diff --git a/.codex/skills/mcaf-adr-writing/SKILL.md b/.codex/skills/mcaf-adr-writing/SKILL.md index 7f547d0..9fb1c75 100644 --- a/.codex/skills/mcaf-adr-writing/SKILL.md +++ b/.codex/skills/mcaf-adr-writing/SKILL.md @@ -65,17 +65,20 @@ compatibility: "Requires repository write access; produces Markdown ADRs with Me Use the Ralph Loop for every task, including docs, architecture, testing, and tooling work. -1. Plan first (mandatory): +1. Brainstorm first (mandatory): - analyze current state - - define target outcome, constraints, and risks - - write a detailed execution plan + - define the problem, target outcome, constraints, and risks + - generate options and think through trade-offs before committing + - capture the recommended direction and open questions +2. Plan second (mandatory): + - write a detailed execution plan from the chosen direction - list final validation skills to run at the end, with order and reason -2. Execute one planned step and produce a concrete delta. -3. Review the result and capture findings with actionable next fixes. -4. Apply fixes in small batches and rerun the relevant checks or review steps. -5. Update the plan after each iteration. -6. Repeat until outcomes are acceptable or only explicit exceptions remain. -7. If a dependency is missing, bootstrap it or return `status: not_applicable` with explicit reason and fallback path. +3. Execute one planned step and produce a concrete delta. +4. Review the result and capture findings with actionable next fixes. +5. Apply fixes in small batches and rerun the relevant checks or review steps. +6. Update the plan after each iteration. +7. Repeat until outcomes are acceptable or only explicit exceptions remain. +8. If a dependency is missing, bootstrap it or return `status: not_applicable` with explicit reason and fallback path. ### Required Result Format diff --git a/.codex/skills/mcaf-agile-delivery/SKILL.md b/.codex/skills/mcaf-agile-delivery/SKILL.md index 15628f3..d7f21b6 100644 --- a/.codex/skills/mcaf-agile-delivery/SKILL.md +++ b/.codex/skills/mcaf-agile-delivery/SKILL.md @@ -62,17 +62,20 @@ compatibility: "Requires repository access only when the repo stores delivery do Use the Ralph Loop for every task, including docs, architecture, testing, and tooling work. -1. Plan first (mandatory): +1. Brainstorm first (mandatory): - analyze current state - - define target outcome, constraints, and risks - - write a detailed execution plan + - define the problem, target outcome, constraints, and risks + - generate options and think through trade-offs before committing + - capture the recommended direction and open questions +2. Plan second (mandatory): + - write a detailed execution plan from the chosen direction - list final validation skills to run at the end, with order and reason -2. Execute one planned step and produce a concrete delta. -3. Review the result and capture findings with actionable next fixes. -4. Apply fixes in small batches and rerun the relevant checks or review steps. -5. Update the plan after each iteration. -6. Repeat until outcomes are acceptable or only explicit exceptions remain. -7. If a dependency is missing, bootstrap it or return `status: not_applicable` with explicit reason and fallback path. +3. Execute one planned step and produce a concrete delta. +4. Review the result and capture findings with actionable next fixes. +5. Apply fixes in small batches and rerun the relevant checks or review steps. +6. Update the plan after each iteration. +7. Repeat until outcomes are acceptable or only explicit exceptions remain. +8. If a dependency is missing, bootstrap it or return `status: not_applicable` with explicit reason and fallback path. ### Required Result Format diff --git a/.codex/skills/mcaf-architecture-overview/SKILL.md b/.codex/skills/mcaf-architecture-overview/SKILL.md index 51cf144..31e8c38 100644 --- a/.codex/skills/mcaf-architecture-overview/SKILL.md +++ b/.codex/skills/mcaf-architecture-overview/SKILL.md @@ -63,17 +63,20 @@ compatibility: "Requires repository write access; produces Markdown docs with Me Use the Ralph Loop for every task, including docs, architecture, testing, and tooling work. -1. Plan first (mandatory): +1. Brainstorm first (mandatory): - analyze current state - - define target outcome, constraints, and risks - - write a detailed execution plan + - define the problem, target outcome, constraints, and risks + - generate options and think through trade-offs before committing + - capture the recommended direction and open questions +2. Plan second (mandatory): + - write a detailed execution plan from the chosen direction - list final validation skills to run at the end, with order and reason -2. Execute one planned step and produce a concrete delta. -3. Review the result and capture findings with actionable next fixes. -4. Apply fixes in small batches and rerun the relevant checks or review steps. -5. Update the plan after each iteration. -6. Repeat until outcomes are acceptable or only explicit exceptions remain. -7. If a dependency is missing, bootstrap it or return `status: not_applicable` with explicit reason and fallback path. +3. Execute one planned step and produce a concrete delta. +4. Review the result and capture findings with actionable next fixes. +5. Apply fixes in small batches and rerun the relevant checks or review steps. +6. Update the plan after each iteration. +7. Repeat until outcomes are acceptable or only explicit exceptions remain. +8. If a dependency is missing, bootstrap it or return `status: not_applicable` with explicit reason and fallback path. ### Required Result Format diff --git a/.codex/skills/mcaf-ci-cd/SKILL.md b/.codex/skills/mcaf-ci-cd/SKILL.md index b34c37f..26d19f7 100644 --- a/.codex/skills/mcaf-ci-cd/SKILL.md +++ b/.codex/skills/mcaf-ci-cd/SKILL.md @@ -72,17 +72,20 @@ compatibility: "Requires repository access; may update CI workflows, pipeline do Use the Ralph Loop for every task, including docs, architecture, testing, and tooling work. -1. Plan first (mandatory): +1. Brainstorm first (mandatory): - analyze current state - - define target outcome, constraints, and risks - - write a detailed execution plan + - define the problem, target outcome, constraints, and risks + - generate options and think through trade-offs before committing + - capture the recommended direction and open questions +2. Plan second (mandatory): + - write a detailed execution plan from the chosen direction - list final validation skills to run at the end, with order and reason -2. Execute one planned step and produce a concrete delta. -3. Review the result and capture findings with actionable next fixes. -4. Apply fixes in small batches and rerun the relevant checks or review steps. -5. Update the plan after each iteration. -6. Repeat until outcomes are acceptable or only explicit exceptions remain. -7. If a dependency is missing, bootstrap it or return `status: not_applicable` with explicit reason and fallback path. +3. Execute one planned step and produce a concrete delta. +4. Review the result and capture findings with actionable next fixes. +5. Apply fixes in small batches and rerun the relevant checks or review steps. +6. Update the plan after each iteration. +7. Repeat until outcomes are acceptable or only explicit exceptions remain. +8. If a dependency is missing, bootstrap it or return `status: not_applicable` with explicit reason and fallback path. ### Required Result Format @@ -98,7 +101,7 @@ For setup-only requests with no execution, return `status: configured` and exact ## Load References - read `references/ci-cd.md` first -- for .NET quality gates, use `mcaf-dotnet-quality-ci` +- for .NET quality gates, use the external `mcaf-dotnet-quality-ci` skill from [managedcode/dotnet-skills](https://github.com/managedcode/dotnet-skills) ## Example Requests diff --git a/.codex/skills/mcaf-code-review/SKILL.md b/.codex/skills/mcaf-code-review/SKILL.md index f595391..85e53da 100644 --- a/.codex/skills/mcaf-code-review/SKILL.md +++ b/.codex/skills/mcaf-code-review/SKILL.md @@ -64,17 +64,20 @@ compatibility: "Requires repository access; may update PR templates, review docs Use the Ralph Loop for every task, including docs, architecture, testing, and tooling work. -1. Plan first (mandatory): +1. Brainstorm first (mandatory): - analyze current state - - define target outcome, constraints, and risks - - write a detailed execution plan + - define the problem, target outcome, constraints, and risks + - generate options and think through trade-offs before committing + - capture the recommended direction and open questions +2. Plan second (mandatory): + - write a detailed execution plan from the chosen direction - list final validation skills to run at the end, with order and reason -2. Execute one planned step and produce a concrete delta. -3. Review the result and capture findings with actionable next fixes. -4. Apply fixes in small batches and rerun the relevant checks or review steps. -5. Update the plan after each iteration. -6. Repeat until outcomes are acceptable or only explicit exceptions remain. -7. If a dependency is missing, bootstrap it or return `status: not_applicable` with explicit reason and fallback path. +3. Execute one planned step and produce a concrete delta. +4. Review the result and capture findings with actionable next fixes. +5. Apply fixes in small batches and rerun the relevant checks or review steps. +6. Update the plan after each iteration. +7. Repeat until outcomes are acceptable or only explicit exceptions remain. +8. If a dependency is missing, bootstrap it or return `status: not_applicable` with explicit reason and fallback path. ### Required Result Format diff --git a/.codex/skills/mcaf-devex/SKILL.md b/.codex/skills/mcaf-devex/SKILL.md index 401a445..b6784c8 100644 --- a/.codex/skills/mcaf-devex/SKILL.md +++ b/.codex/skills/mcaf-devex/SKILL.md @@ -63,17 +63,20 @@ compatibility: "Requires repository access; may update docs, task runners, devco Use the Ralph Loop for every task, including docs, architecture, testing, and tooling work. -1. Plan first (mandatory): +1. Brainstorm first (mandatory): - analyze current state - - define target outcome, constraints, and risks - - write a detailed execution plan + - define the problem, target outcome, constraints, and risks + - generate options and think through trade-offs before committing + - capture the recommended direction and open questions +2. Plan second (mandatory): + - write a detailed execution plan from the chosen direction - list final validation skills to run at the end, with order and reason -2. Execute one planned step and produce a concrete delta. -3. Review the result and capture findings with actionable next fixes. -4. Apply fixes in small batches and rerun the relevant checks or review steps. -5. Update the plan after each iteration. -6. Repeat until outcomes are acceptable or only explicit exceptions remain. -7. If a dependency is missing, bootstrap it or return `status: not_applicable` with explicit reason and fallback path. +3. Execute one planned step and produce a concrete delta. +4. Review the result and capture findings with actionable next fixes. +5. Apply fixes in small batches and rerun the relevant checks or review steps. +6. Update the plan after each iteration. +7. Repeat until outcomes are acceptable or only explicit exceptions remain. +8. If a dependency is missing, bootstrap it or return `status: not_applicable` with explicit reason and fallback path. ### Required Result Format diff --git a/.codex/skills/mcaf-documentation/SKILL.md b/.codex/skills/mcaf-documentation/SKILL.md index 7292f05..1cfdaf4 100644 --- a/.codex/skills/mcaf-documentation/SKILL.md +++ b/.codex/skills/mcaf-documentation/SKILL.md @@ -59,17 +59,20 @@ compatibility: "Requires repository write access; updates docs and documentation Use the Ralph Loop for every task, including docs, architecture, testing, and tooling work. -1. Plan first (mandatory): +1. Brainstorm first (mandatory): - analyze current state - - define target outcome, constraints, and risks - - write a detailed execution plan + - define the problem, target outcome, constraints, and risks + - generate options and think through trade-offs before committing + - capture the recommended direction and open questions +2. Plan second (mandatory): + - write a detailed execution plan from the chosen direction - list final validation skills to run at the end, with order and reason -2. Execute one planned step and produce a concrete delta. -3. Review the result and capture findings with actionable next fixes. -4. Apply fixes in small batches and rerun the relevant checks or review steps. -5. Update the plan after each iteration. -6. Repeat until outcomes are acceptable or only explicit exceptions remain. -7. If a dependency is missing, bootstrap it or return `status: not_applicable` with explicit reason and fallback path. +3. Execute one planned step and produce a concrete delta. +4. Review the result and capture findings with actionable next fixes. +5. Apply fixes in small batches and rerun the relevant checks or review steps. +6. Update the plan after each iteration. +7. Repeat until outcomes are acceptable or only explicit exceptions remain. +8. If a dependency is missing, bootstrap it or return `status: not_applicable` with explicit reason and fallback path. ### Required Result Format diff --git a/.codex/skills/mcaf-feature-spec/SKILL.md b/.codex/skills/mcaf-feature-spec/SKILL.md index 05b4560..39d8be7 100644 --- a/.codex/skills/mcaf-feature-spec/SKILL.md +++ b/.codex/skills/mcaf-feature-spec/SKILL.md @@ -65,17 +65,20 @@ compatibility: "Requires repository write access; produces Markdown docs with Me Use the Ralph Loop for every task, including docs, architecture, testing, and tooling work. -1. Plan first (mandatory): +1. Brainstorm first (mandatory): - analyze current state - - define target outcome, constraints, and risks - - write a detailed execution plan + - define the problem, target outcome, constraints, and risks + - generate options and think through trade-offs before committing + - capture the recommended direction and open questions +2. Plan second (mandatory): + - write a detailed execution plan from the chosen direction - list final validation skills to run at the end, with order and reason -2. Execute one planned step and produce a concrete delta. -3. Review the result and capture findings with actionable next fixes. -4. Apply fixes in small batches and rerun the relevant checks or review steps. -5. Update the plan after each iteration. -6. Repeat until outcomes are acceptable or only explicit exceptions remain. -7. If a dependency is missing, bootstrap it or return `status: not_applicable` with explicit reason and fallback path. +3. Execute one planned step and produce a concrete delta. +4. Review the result and capture findings with actionable next fixes. +5. Apply fixes in small batches and rerun the relevant checks or review steps. +6. Update the plan after each iteration. +7. Repeat until outcomes are acceptable or only explicit exceptions remain. +8. If a dependency is missing, bootstrap it or return `status: not_applicable` with explicit reason and fallback path. ### Required Result Format diff --git a/.codex/skills/mcaf-human-review-planning/SKILL.md b/.codex/skills/mcaf-human-review-planning/SKILL.md index 3b294e6..5e14585 100644 --- a/.codex/skills/mcaf-human-review-planning/SKILL.md +++ b/.codex/skills/mcaf-human-review-planning/SKILL.md @@ -84,17 +84,20 @@ compatibility: "Requires repository read access; may write a `HUMAN_REVIEW_PLAN. Use the Ralph Loop for every task, including docs, architecture, testing, and tooling work. -1. Plan first (mandatory): +1. Brainstorm first (mandatory): - analyze current state - - define target outcome, constraints, and risks - - write a detailed execution plan + - define the problem, target outcome, constraints, and risks + - generate options and think through trade-offs before committing + - capture the recommended direction and open questions +2. Plan second (mandatory): + - write a detailed execution plan from the chosen direction - list final validation skills to run at the end, with order and reason -2. Execute one planned step and produce a concrete delta. -3. Review the result and capture findings with actionable next fixes. -4. Apply fixes in small batches and rerun the relevant checks or review steps. -5. Update the plan after each iteration. -6. Repeat until outcomes are acceptable or only explicit exceptions remain. -7. If a dependency is missing, bootstrap it or return `status: not_applicable` with explicit reason and fallback path. +3. Execute one planned step and produce a concrete delta. +4. Review the result and capture findings with actionable next fixes. +5. Apply fixes in small batches and rerun the relevant checks or review steps. +6. Update the plan after each iteration. +7. Repeat until outcomes are acceptable or only explicit exceptions remain. +8. If a dependency is missing, bootstrap it or return `status: not_applicable` with explicit reason and fallback path. ### Required Result Format diff --git a/.codex/skills/mcaf-ml-ai-delivery/SKILL.md b/.codex/skills/mcaf-ml-ai-delivery/SKILL.md index 0a01e66..f71894a 100644 --- a/.codex/skills/mcaf-ml-ai-delivery/SKILL.md +++ b/.codex/skills/mcaf-ml-ai-delivery/SKILL.md @@ -58,17 +58,20 @@ compatibility: "Requires repository access when ML/AI docs, experiments, or deli Use the Ralph Loop for every task, including docs, architecture, testing, and tooling work. -1. Plan first (mandatory): +1. Brainstorm first (mandatory): - analyze current state - - define target outcome, constraints, and risks - - write a detailed execution plan + - define the problem, target outcome, constraints, and risks + - generate options and think through trade-offs before committing + - capture the recommended direction and open questions +2. Plan second (mandatory): + - write a detailed execution plan from the chosen direction - list final validation skills to run at the end, with order and reason -2. Execute one planned step and produce a concrete delta. -3. Review the result and capture findings with actionable next fixes. -4. Apply fixes in small batches and rerun the relevant checks or review steps. -5. Update the plan after each iteration. -6. Repeat until outcomes are acceptable or only explicit exceptions remain. -7. If a dependency is missing, bootstrap it or return `status: not_applicable` with explicit reason and fallback path. +3. Execute one planned step and produce a concrete delta. +4. Review the result and capture findings with actionable next fixes. +5. Apply fixes in small batches and rerun the relevant checks or review steps. +6. Update the plan after each iteration. +7. Repeat until outcomes are acceptable or only explicit exceptions remain. +8. If a dependency is missing, bootstrap it or return `status: not_applicable` with explicit reason and fallback path. ### Required Result Format diff --git a/.codex/skills/mcaf-nfr/SKILL.md b/.codex/skills/mcaf-nfr/SKILL.md index 245cd4c..675cf1e 100644 --- a/.codex/skills/mcaf-nfr/SKILL.md +++ b/.codex/skills/mcaf-nfr/SKILL.md @@ -58,17 +58,20 @@ compatibility: "Requires repository access when NFRs are documented in feature d Use the Ralph Loop for every task, including docs, architecture, testing, and tooling work. -1. Plan first (mandatory): +1. Brainstorm first (mandatory): - analyze current state - - define target outcome, constraints, and risks - - write a detailed execution plan + - define the problem, target outcome, constraints, and risks + - generate options and think through trade-offs before committing + - capture the recommended direction and open questions +2. Plan second (mandatory): + - write a detailed execution plan from the chosen direction - list final validation skills to run at the end, with order and reason -2. Execute one planned step and produce a concrete delta. -3. Review the result and capture findings with actionable next fixes. -4. Apply fixes in small batches and rerun the relevant checks or review steps. -5. Update the plan after each iteration. -6. Repeat until outcomes are acceptable or only explicit exceptions remain. -7. If a dependency is missing, bootstrap it or return `status: not_applicable` with explicit reason and fallback path. +3. Execute one planned step and produce a concrete delta. +4. Review the result and capture findings with actionable next fixes. +5. Apply fixes in small batches and rerun the relevant checks or review steps. +6. Update the plan after each iteration. +7. Repeat until outcomes are acceptable or only explicit exceptions remain. +8. If a dependency is missing, bootstrap it or return `status: not_applicable` with explicit reason and fallback path. ### Required Result Format diff --git a/.codex/skills/mcaf-observability/SKILL.md b/.codex/skills/mcaf-observability/SKILL.md index 3c047ae..32530f1 100644 --- a/.codex/skills/mcaf-observability/SKILL.md +++ b/.codex/skills/mcaf-observability/SKILL.md @@ -63,17 +63,20 @@ compatibility: "Requires repository access; may update code, dashboards-as-code, Use the Ralph Loop for every task, including docs, architecture, testing, and tooling work. -1. Plan first (mandatory): +1. Brainstorm first (mandatory): - analyze current state - - define target outcome, constraints, and risks - - write a detailed execution plan + - define the problem, target outcome, constraints, and risks + - generate options and think through trade-offs before committing + - capture the recommended direction and open questions +2. Plan second (mandatory): + - write a detailed execution plan from the chosen direction - list final validation skills to run at the end, with order and reason -2. Execute one planned step and produce a concrete delta. -3. Review the result and capture findings with actionable next fixes. -4. Apply fixes in small batches and rerun the relevant checks or review steps. -5. Update the plan after each iteration. -6. Repeat until outcomes are acceptable or only explicit exceptions remain. -7. If a dependency is missing, bootstrap it or return `status: not_applicable` with explicit reason and fallback path. +3. Execute one planned step and produce a concrete delta. +4. Review the result and capture findings with actionable next fixes. +5. Apply fixes in small batches and rerun the relevant checks or review steps. +6. Update the plan after each iteration. +7. Repeat until outcomes are acceptable or only explicit exceptions remain. +8. If a dependency is missing, bootstrap it or return `status: not_applicable` with explicit reason and fallback path. ### Required Result Format diff --git a/.codex/skills/mcaf-security-baseline/SKILL.md b/.codex/skills/mcaf-security-baseline/SKILL.md index 846a067..f3e7c54 100644 --- a/.codex/skills/mcaf-security-baseline/SKILL.md +++ b/.codex/skills/mcaf-security-baseline/SKILL.md @@ -64,17 +64,20 @@ compatibility: "Requires repository access; may update security docs, ADRs, and Use the Ralph Loop for every task, including docs, architecture, testing, and tooling work. -1. Plan first (mandatory): +1. Brainstorm first (mandatory): - analyze current state - - define target outcome, constraints, and risks - - write a detailed execution plan + - define the problem, target outcome, constraints, and risks + - generate options and think through trade-offs before committing + - capture the recommended direction and open questions +2. Plan second (mandatory): + - write a detailed execution plan from the chosen direction - list final validation skills to run at the end, with order and reason -2. Execute one planned step and produce a concrete delta. -3. Review the result and capture findings with actionable next fixes. -4. Apply fixes in small batches and rerun the relevant checks or review steps. -5. Update the plan after each iteration. -6. Repeat until outcomes are acceptable or only explicit exceptions remain. -7. If a dependency is missing, bootstrap it or return `status: not_applicable` with explicit reason and fallback path. +3. Execute one planned step and produce a concrete delta. +4. Review the result and capture findings with actionable next fixes. +5. Apply fixes in small batches and rerun the relevant checks or review steps. +6. Update the plan after each iteration. +7. Repeat until outcomes are acceptable or only explicit exceptions remain. +8. If a dependency is missing, bootstrap it or return `status: not_applicable` with explicit reason and fallback path. ### Required Result Format diff --git a/.codex/skills/mcaf-solid-maintainability/SKILL.md b/.codex/skills/mcaf-solid-maintainability/SKILL.md index 289101e..045c933 100644 --- a/.codex/skills/mcaf-solid-maintainability/SKILL.md +++ b/.codex/skills/mcaf-solid-maintainability/SKILL.md @@ -70,17 +70,20 @@ compatibility: "Requires repository write access; uses maintainability limits fr Use the Ralph Loop for every task, including docs, architecture, testing, and tooling work. -1. Plan first (mandatory): +1. Brainstorm first (mandatory): - analyze current state - - define target outcome, constraints, and risks - - write a detailed execution plan + - define the problem, target outcome, constraints, and risks + - generate options and think through trade-offs before committing + - capture the recommended direction and open questions +2. Plan second (mandatory): + - write a detailed execution plan from the chosen direction - list final validation skills to run at the end, with order and reason -2. Execute one planned step and produce a concrete delta. -3. Review the result and capture findings with actionable next fixes. -4. Apply fixes in small batches and rerun the relevant checks or review steps. -5. Update the plan after each iteration. -6. Repeat until outcomes are acceptable or only explicit exceptions remain. -7. If a dependency is missing, bootstrap it or return `status: not_applicable` with explicit reason and fallback path. +3. Execute one planned step and produce a concrete delta. +4. Review the result and capture findings with actionable next fixes. +5. Apply fixes in small batches and rerun the relevant checks or review steps. +6. Update the plan after each iteration. +7. Repeat until outcomes are acceptable or only explicit exceptions remain. +8. If a dependency is missing, bootstrap it or return `status: not_applicable` with explicit reason and fallback path. ### Required Result Format diff --git a/.codex/skills/mcaf-solution-governance/SKILL.md b/.codex/skills/mcaf-solution-governance/SKILL.md index 47db57d..6f58fff 100644 --- a/.codex/skills/mcaf-solution-governance/SKILL.md +++ b/.codex/skills/mcaf-solution-governance/SKILL.md @@ -76,17 +76,20 @@ compatibility: "Requires repository write access; updates root or local `AGENTS. Use the Ralph Loop for every task, including docs, architecture, testing, and tooling work. -1. Plan first (mandatory): +1. Brainstorm first (mandatory): - analyze current state - - define target outcome, constraints, and risks - - write a detailed execution plan + - define the problem, target outcome, constraints, and risks + - generate options and think through trade-offs before committing + - capture the recommended direction and open questions +2. Plan second (mandatory): + - write a detailed execution plan from the chosen direction - list final validation skills to run at the end, with order and reason -2. Execute one planned step and produce a concrete delta. -3. Review the result and capture findings with actionable next fixes. -4. Apply fixes in small batches and rerun the relevant checks or review steps. -5. Update the plan after each iteration. -6. Repeat until outcomes are acceptable or only explicit exceptions remain. -7. If a dependency is missing, bootstrap it or return `status: not_applicable` with explicit reason and fallback path. +3. Execute one planned step and produce a concrete delta. +4. Review the result and capture findings with actionable next fixes. +5. Apply fixes in small batches and rerun the relevant checks or review steps. +6. Update the plan after each iteration. +7. Repeat until outcomes are acceptable or only explicit exceptions remain. +8. If a dependency is missing, bootstrap it or return `status: not_applicable` with explicit reason and fallback path. ### Required Result Format diff --git a/.codex/skills/mcaf-solution-governance/references/dotnet-agents-pattern.md b/.codex/skills/mcaf-solution-governance/references/dotnet-agents-pattern.md index a5f1fd4..67abfff 100644 --- a/.codex/skills/mcaf-solution-governance/references/dotnet-agents-pattern.md +++ b/.codex/skills/mcaf-solution-governance/references/dotnet-agents-pattern.md @@ -1,6 +1,7 @@ # .NET AGENTS Pattern Use this reference when the solution stack is .NET and the root or local `AGENTS.md` needs concrete command and tooling guidance. +The `.NET` skill bundle itself lives in [managedcode/dotnet-skills](https://github.com/managedcode/dotnet-skills), not in this repository. ## Root AGENTS.md Expectations @@ -27,7 +28,7 @@ Coverage command depends on the runner model: - `coverage`: `dotnet test MySolution.sln --coverlet` ``` -In `Global Skills`, list: +In `Global Skills`, list the installed `.NET` skills from the external repository: - `mcaf-dotnet` - `mcaf-dotnet-features` when modern C# feature choice matters in this repo diff --git a/.codex/skills/mcaf-solution-governance/references/project-agents-template.md b/.codex/skills/mcaf-solution-governance/references/project-agents-template.md index 9d5c067..363b098 100644 --- a/.codex/skills/mcaf-solution-governance/references/project-agents-template.md +++ b/.codex/skills/mcaf-solution-governance/references/project-agents-template.md @@ -41,7 +41,8 @@ For .NET projects also document: - `...` - `...` -For .NET projects this usually includes: +For .NET projects, install the needed `.NET` skills from [managedcode/dotnet-skills](https://github.com/managedcode/dotnet-skills). +The local skill list usually includes: - `mcaf-testing` - exactly one of `mcaf-dotnet-xunit`, `mcaf-dotnet-tunit`, or `mcaf-dotnet-mstest` diff --git a/.codex/skills/mcaf-source-control/SKILL.md b/.codex/skills/mcaf-source-control/SKILL.md index bbcf6af..0529d9a 100644 --- a/.codex/skills/mcaf-source-control/SKILL.md +++ b/.codex/skills/mcaf-source-control/SKILL.md @@ -59,17 +59,20 @@ compatibility: "Requires repository access; may update contribution docs, AGENTS Use the Ralph Loop for every task, including docs, architecture, testing, and tooling work. -1. Plan first (mandatory): +1. Brainstorm first (mandatory): - analyze current state - - define target outcome, constraints, and risks - - write a detailed execution plan + - define the problem, target outcome, constraints, and risks + - generate options and think through trade-offs before committing + - capture the recommended direction and open questions +2. Plan second (mandatory): + - write a detailed execution plan from the chosen direction - list final validation skills to run at the end, with order and reason -2. Execute one planned step and produce a concrete delta. -3. Review the result and capture findings with actionable next fixes. -4. Apply fixes in small batches and rerun the relevant checks or review steps. -5. Update the plan after each iteration. -6. Repeat until outcomes are acceptable or only explicit exceptions remain. -7. If a dependency is missing, bootstrap it or return `status: not_applicable` with explicit reason and fallback path. +3. Execute one planned step and produce a concrete delta. +4. Review the result and capture findings with actionable next fixes. +5. Apply fixes in small batches and rerun the relevant checks or review steps. +6. Update the plan after each iteration. +7. Repeat until outcomes are acceptable or only explicit exceptions remain. +8. If a dependency is missing, bootstrap it or return `status: not_applicable` with explicit reason and fallback path. ### Required Result Format diff --git a/.codex/skills/mcaf-testing/SKILL.md b/.codex/skills/mcaf-testing/SKILL.md index c5788fb..39a40d7 100644 --- a/.codex/skills/mcaf-testing/SKILL.md +++ b/.codex/skills/mcaf-testing/SKILL.md @@ -43,7 +43,7 @@ compatibility: "Requires the repository’s build and test tooling; uses command - new or changed tests - related suite - broader regressions -4. When the stack is .NET, use `mcaf-dotnet` as the orchestration skill when the task spans code, tests, and verification, and route framework mechanics through exactly one matching skill: +4. When the stack is .NET, use the external `.NET` skills from [managedcode/dotnet-skills](https://github.com/managedcode/dotnet-skills), use `mcaf-dotnet` as the orchestration skill when the task spans code, tests, and verification, and route framework mechanics through exactly one matching skill: - `mcaf-dotnet-xunit` - `mcaf-dotnet-tunit` - `mcaf-dotnet-mstest` @@ -72,17 +72,20 @@ compatibility: "Requires the repository’s build and test tooling; uses command Use the Ralph Loop for every task, including docs, architecture, testing, and tooling work. -1. Plan first (mandatory): +1. Brainstorm first (mandatory): - analyze current state - - define target outcome, constraints, and risks - - write a detailed execution plan + - define the problem, target outcome, constraints, and risks + - generate options and think through trade-offs before committing + - capture the recommended direction and open questions +2. Plan second (mandatory): + - write a detailed execution plan from the chosen direction - list final validation skills to run at the end, with order and reason -2. Execute one planned step and produce a concrete delta. -3. Review the result and capture findings with actionable next fixes. -4. Apply fixes in small batches and rerun the relevant checks or review steps. -5. Update the plan after each iteration. -6. Repeat until outcomes are acceptable or only explicit exceptions remain. -7. If a dependency is missing, bootstrap it or return `status: not_applicable` with explicit reason and fallback path. +3. Execute one planned step and produce a concrete delta. +4. Review the result and capture findings with actionable next fixes. +5. Apply fixes in small batches and rerun the relevant checks or review steps. +6. Update the plan after each iteration. +7. Repeat until outcomes are acceptable or only explicit exceptions remain. +8. If a dependency is missing, bootstrap it or return `status: not_applicable` with explicit reason and fallback path. ### Required Result Format @@ -99,8 +102,8 @@ For setup-only requests with no execution, return `status: configured` and exact - read `references/test-planning.md` first - open `references/automated-testing.md` for deeper strategy and trade-offs -- for broader .NET implementation flow, use `mcaf-dotnet` -- for .NET framework-specific mechanics, use exactly one of `mcaf-dotnet-xunit`, `mcaf-dotnet-tunit`, or `mcaf-dotnet-mstest` +- for broader .NET implementation flow, use the external `mcaf-dotnet` skill from [managedcode/dotnet-skills](https://github.com/managedcode/dotnet-skills) +- for .NET framework-specific mechanics, use exactly one external skill from [managedcode/dotnet-skills](https://github.com/managedcode/dotnet-skills): `mcaf-dotnet-xunit`, `mcaf-dotnet-tunit`, or `mcaf-dotnet-mstest` ## Example Requests diff --git a/.codex/skills/mcaf-ui-ux/SKILL.md b/.codex/skills/mcaf-ui-ux/SKILL.md index d10525a..4cc7d21 100644 --- a/.codex/skills/mcaf-ui-ux/SKILL.md +++ b/.codex/skills/mcaf-ui-ux/SKILL.md @@ -57,17 +57,20 @@ compatibility: "Requires repository access when UI docs, component guidance, or Use the Ralph Loop for every task, including docs, architecture, testing, and tooling work. -1. Plan first (mandatory): +1. Brainstorm first (mandatory): - analyze current state - - define target outcome, constraints, and risks - - write a detailed execution plan + - define the problem, target outcome, constraints, and risks + - generate options and think through trade-offs before committing + - capture the recommended direction and open questions +2. Plan second (mandatory): + - write a detailed execution plan from the chosen direction - list final validation skills to run at the end, with order and reason -2. Execute one planned step and produce a concrete delta. -3. Review the result and capture findings with actionable next fixes. -4. Apply fixes in small batches and rerun the relevant checks or review steps. -5. Update the plan after each iteration. -6. Repeat until outcomes are acceptable or only explicit exceptions remain. -7. If a dependency is missing, bootstrap it or return `status: not_applicable` with explicit reason and fallback path. +3. Execute one planned step and produce a concrete delta. +4. Review the result and capture findings with actionable next fixes. +5. Apply fixes in small batches and rerun the relevant checks or review steps. +6. Update the plan after each iteration. +7. Repeat until outcomes are acceptable or only explicit exceptions remain. +8. If a dependency is missing, bootstrap it or return `status: not_applicable` with explicit reason and fallback path. ### Required Result Format diff --git a/.github/workflows/gh-pages-deploy.yml b/.github/workflows/gh-pages-deploy.yml new file mode 100644 index 0000000..4c72d09 --- /dev/null +++ b/.github/workflows/gh-pages-deploy.yml @@ -0,0 +1,89 @@ +name: Deploy GitHub Pages + +on: + release: + types: [published] + push: + branches: + - main + paths: + - 'gh-pages/**' + - '.github/workflows/gh-pages-deploy.yml' + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + name: Build Pages + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Fetch Latest Release Info + id: release + env: + GH_TOKEN: ${{ github.token }} + run: | + release_info=$(gh api repos/${{ github.repository }}/releases/latest 2>/dev/null || echo '{}') + + if [[ "$release_info" == "{}" ]] || [[ -z "$(echo "$release_info" | jq -r '.tag_name // empty')" ]]; then + echo "No releases found, using default values" + echo "version=1.0.0" >> "$GITHUB_OUTPUT" + echo "macos_url=https://github.com/${{ github.repository }}/releases" >> "$GITHUB_OUTPUT" + echo "windows_url=https://github.com/${{ github.repository }}/releases" >> "$GITHUB_OUTPUT" + echo "linux_url=https://github.com/${{ github.repository }}/releases" >> "$GITHUB_OUTPUT" + exit 0 + fi + + version=$(echo "$release_info" | jq -r '.tag_name' | sed 's/^v//') + echo "version=${version}" >> "$GITHUB_OUTPUT" + + macos_url=$(echo "$release_info" | jq -r '.assets[] | select(.name | contains("macos")) | .browser_download_url' | head -n 1) + windows_url=$(echo "$release_info" | jq -r '.assets[] | select(.name | contains("windows")) | .browser_download_url' | head -n 1) + linux_url=$(echo "$release_info" | jq -r '.assets[] | select(.name | contains("linux")) | .browser_download_url' | head -n 1) + + releases_url="https://github.com/${{ github.repository }}/releases/latest" + echo "macos_url=${macos_url:-$releases_url}" >> "$GITHUB_OUTPUT" + echo "windows_url=${windows_url:-$releases_url}" >> "$GITHUB_OUTPUT" + echo "linux_url=${linux_url:-$releases_url}" >> "$GITHUB_OUTPUT" + + - name: Prepare Site + run: | + mkdir -p ./site + + cp ./gh-pages/index.html ./site/index.html + + sed -i "s|{{VERSION}}|${{ steps.release.outputs.version }}|g" ./site/index.html + sed -i "s|{{MACOS_URL}}|${{ steps.release.outputs.macos_url }}|g" ./site/index.html + sed -i "s|{{WINDOWS_URL}}|${{ steps.release.outputs.windows_url }}|g" ./site/index.html + sed -i "s|{{LINUX_URL}}|${{ steps.release.outputs.linux_url }}|g" ./site/index.html + + - name: Setup Pages + uses: actions/configure-pages@v5 + + - name: Upload Artifact + uses: actions/upload-pages-artifact@v3 + with: + path: ./site + + deploy: + name: Deploy to GitHub Pages + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + needs: build + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 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..34a8714 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,9 +22,6 @@ This file defines how AI agents work in this solution. - Projects or modules with local `AGENTS.md` files: - `DotPilot` - `DotPilot.Core` - - `DotPilot.Runtime` - - `DotPilot.Runtime.Host` - - `DotPilot.ReleaseTool` - `DotPilot.Tests` - `DotPilot.UITests` - Shared solution artifacts: @@ -143,24 +140,65 @@ For this app: - prefer the newest stable `.NET 10` and `C#` language features that are supported by the pinned SDK and do not weaken readability, determinism, or analyzability - the repo-root lowercase `.editorconfig` is the source of truth for formatting, naming, style, and analyzer severity - local and CI build commands must pass `-warnaserror`; warnings are not an acceptable "green" build state in this repository +- do not run unit tests, UI tests, or coverage commands unless the user explicitly asks for test execution in the current turn; structural refactors and exploratory work should stop at code organization until the operator requests verification - 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 - `global.json` pins the .NET SDK and Uno SDK version used by the app and tests - `DotPilot/DotPilot.csproj` keeps `GenerateDocumentationFile=true` with `CS1591` suppressed so `IDE0005` stays enforceable in CI across all target frameworks without inventing command-line-only build flags +- solution folders in `DotPilot.slnx` are allowed when they provide stable project categories such as `Libraries` and `Tests`; use them to keep the IDE readable, but keep project directory names, `.csproj` names, and namespaces honest to the real extracted subsystem +- project extraction must stay structurally honest: once a subsystem becomes its own DLL, keep its files, namespaces, local `AGENTS.md`, and direct app references inside that subsystem instead of leaving half of it behind in `DotPilot.Core` +- when a library already names the subsystem, do not add a duplicate same-name root folder inside it; expose its real slices directly from the project root instead of nesting them again under another copy of the subsystem name - architecture work must keep a vertical-slice shape: each feature owns its contracts, orchestration, and tests behind clear boundaries instead of growing a shared horizontal service layer -- keep the Uno app project presentation-only; domain, runtime host, orchestration, integrations, and persistence code must live in separate class-library projects so UI composition does not mix with feature implementation +- `DotPilot.Core` is the default home for non-UI code, but once a feature becomes large enough to deserve an architectural boundary, extract it into its own DLL instead of bloating `DotPilot.Core` +- do not create or reintroduce generic project, folder, namespace, or product language named `Runtime` unless the user explicitly asks for that exact boundary; the default non-UI home is `DotPilot.Core`, and vague runtime naming is considered architectural noise in this repo +- every new large feature DLL must reference `DotPilot.Core` for shared abstractions and contracts, and the desktop app should reference that feature DLL explicitly instead of dragging the feature back into the UI project +- when a feature slice grows beyond a few files, split it into responsibility-based subfolders that mirror the slice's real concerns such as chat, drafting, providers, persistence, settings, or tests; do not leave large flat file dumps that force unrelated code to coexist in one directory +- do not hide multiple real features under one umbrella folder such as `AgentSessions` when the code actually belongs to distinct features like `Chat`, `AgentBuilder`, `Settings`, `Providers`, or `Workspace`; use explicit feature roots and keep logs, models, services, and tests under the feature that owns them +- inside each feature root, keep structural subfolders explicit: models go under `Models`, configuration and defaults under `Configuration` or `Composition`, views under `Views`, view-models under `ViewModels`, diagnostics under `Diagnostics`, and service/runtime types under a responsibility-specific folder; do not leave those file kinds mixed together at the feature root +- when a feature exposes commands, public interfaces, or specialized runtime contracts, put them in visible folders such as `Commands`, `Interfaces`, and other role-specific folders; do not bury them inside a generic `Contracts` folder where their role disappears from the tree +- keep the Uno app project presentation-only; domain, host, orchestration, integrations, and persistence code must live outside the UI project in class-library code so UI composition does not mix with feature implementation +- UI-facing shell and application-configuration types belong in `DotPilot`; `DotPilot.Core` stays the shared non-UI contract/application layer, while any future large subsystem should move into its own DLL only when it earns a clear architectural boundary +- UI-only preferences, keyboard behavior options, and other shell interaction settings belong in `DotPilot`, not in `DotPilot.Core`; `Core` must not own models or contracts that exist only to drive presentation behavior - when the user asks to implement an epic, the delivery branch and PR must cover all of that epic's direct child issues that belong to the requested scope, not just one child issue with a partial close-out - epic implementation PRs must include automated tests for every direct child issue they claim to cover, plus the broader runtime and UI regressions required by the touched flows - do not claim an epic is implemented unless every direct child issue in the requested scope is both realized in code and covered by automated tests; partial coverage is not an acceptable close-out - structure both `DotPilot.Tests` and `DotPilot.UITests` by vertical slice and explicit harness boundaries; do not keep test files in one flat project-root pile -- the first embedded Orleans runtime cut must use `UseLocalhostClustering` together with in-memory Orleans grain storage and in-memory reminders; do not introduce remote clustering or external durable stores until a later backlog item explicitly requires them, and keep durable resume/replay outside Orleans storage until the cluster topology is intentionally upgraded - 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 `DotPilot.Core` services and persistence boundaries, while the Uno UI mainly renders state and forwards operator commands +- Uno controls and page code-behind must not cast `DataContext` to concrete view-model/model types or invoke orchestration methods directly; route framework events through bindable commands, attached behaviors, dependency properties, or other presentation-safe seams so the view stays decoupled from runtime logic - 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 +- Agent creation must be prompt-first: the default operator flow is to describe the desired agent in natural language and let the product generate a draft agent definition, prompt, description, and tool set instead of forcing low-level manual configuration first +- When the product creates or configures agents, workflows, or similar runtime assets from operator intent, route that intent through a built-in system agent or equivalent orchestration tool that understands the available providers, tools, and policies instead of scattering that decision logic across UI forms +- The product must always have a sensible default system agent path: a fresh app state should not leave the operator without an obvious usable agent, and runtime defaults should pick an available provider/model combination or degrade to the deterministic debug agent without blocking the UI +- The deterministic debug provider is an internal fallback, not an operator-facing authoring choice: do not surface it as a selectable provider or suggested model in the `New agent` creation flow; if no real provider is enabled or installed, send the operator to provider settings instead of defaulting the form to debug +- Do not invent agent roles, tool catalogs, skill catalogs, or capability tags in code or UI unless the product has a real backing registry and runtime path for them; absent a real implementation, leave those selections out of the product surface. +- 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 +- 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 +- When the runtime uses `Microsoft Agent Framework`, prefer agent or run-scope middleware for detailed lifecycle logging and correlation instead of scattering ad hoc logging around UI callbacks or provider shims +- 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 `DotPilot.Core` services +- Desktop navigation and tab/menu switching must stay structurally simple: do not introduce background refresh loops or in-memory cache projections as a default optimization path +- Agent-management UX must be proven end-to-end: prompt-first creation, default-agent availability, provider enable/disable or readiness changes, and starting a chat with an agent all require real `DotPilot.UITests` coverage instead of unit-only verification +- The desktop app is one shell, not three unrelated page layouts: keep one stable left navigation rail/app chrome across chat, agents, and providers, and switch the main content state instead of rebuilding different sidebars per screen +- Shell geometry must stay visually stable across primary screens: do not let the left rail width, footer block, nav button sizing, or page chrome jump between `Chat`, `Agents`, and `Providers` +- Do not fill the shell with duplicated explanatory copy or hardcoded decorative text blocks that restate the same information in multiple panes; prefer concise, task-oriented labels and let the main content carry the active workflow +- Do not ship placeholder-looking UI chrome such as ASCII pseudo-icons, swollen capsule buttons, or duplicated provider/agent cards just to make state visible; use consistent desktop controls and one clear source of truth per surface +- 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 @@ -174,6 +212,7 @@ For this app: - prefer MIT-licensed GitHub and NuGet dependencies when they materially accelerate delivery and align with the current architecture - prefer official `.NET` AI evaluation libraries under `Microsoft.Extensions.AI.Evaluation*` for response-quality, tool-usage, and safety evaluation instead of custom or third-party evaluation stacks by default - prefer `Microsoft Agent Framework` telemetry and observability patterns with OpenTelemetry-first instrumentation and optional Azure Monitor or Foundry export later +- Treat the built-in MCP server as the canonical capability surface of `dotPilot`: operator-visible actions and automations should be exposed as properly defined MCP tools on the app-owned server, and agents must discover and invoke those tools through the shared MCP gateway instead of bypassing it with ad hoc internal calls ### Project AGENTS Policy @@ -308,6 +347,7 @@ Local `AGENTS.md` files may tighten these values, but they must not loosen them - Every class, object, module, and service MUST have a clear single responsibility and explicit boundaries. - SOLID is mandatory. - SRP and strong cohesion are mandatory for files, types, and functions. +- project structure must also follow SOLID and cohesion rules: keep related code together, keep unrelated responsibilities apart, and introduce a project boundary when a feature has become too large or too independent to stay clean inside `DotPilot.Core` - Prefer composition over inheritance unless inheritance is explicitly justified. - Large files, types, functions, and deep nesting are design smells. Split them or document a justified exception under `exception_policy`. - Hardcoded values are forbidden. @@ -348,9 +388,11 @@ Ask first: ### Likes +- Keep regression coverage tied to real operator flows: when agent creation changes, tests should cover creating an agent, choosing a valid provider model, and sending at least one message through the resulting session path. - 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. @@ -363,7 +405,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 +420,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..9c70724 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -11,18 +11,24 @@ + + - - - + + + + + + + - + \ No newline at end of file diff --git a/DotPilot.Core/AGENTS.md b/DotPilot.Core/AGENTS.md index 668f8e6..987b72f 100644 --- a/DotPilot.Core/AGENTS.md +++ b/DotPilot.Core/AGENTS.md @@ -1,32 +1,54 @@ # AGENTS.md Project: `DotPilot.Core` -Stack: `.NET 10`, class library, feature-aligned contracts and provider-independent runtime foundations +Stack: `.NET 10`, class library, non-UI contracts, orchestration, persistence, and shared application/domain code ## Purpose -- This project owns non-UI contracts, typed identifiers, and feature slices that must stay independent from the Uno presentation host. -- It provides the stable public shapes for runtime, orchestration, providers, and shell configuration so UI and future runtime implementations can evolve without circular coupling. +- This project is the default non-UI home and must stay independent from the Uno presentation host. +- It owns shared application/domain code for agent-centric features until a slice becomes big enough to earn its own DLL. ## Entry Points - `DotPilot.Core.csproj` -- `Features/ApplicationShell/AppConfig.cs` -- `Features/ControlPlaneDomain/*` -- `Features/RuntimeCommunication/*` -- `Features/RuntimeFoundation/*` +- `AgentBuilder/{Configuration,Models,Services}/*` +- `ChatSessions/{Commands,Configuration,Contracts,Diagnostics,Execution,Interfaces,Models,Persistence}/*` +- `ControlPlaneDomain/{Identifiers,Contracts,Models,Policies}/*` +- `HttpDiagnostics/DebugHttpHandler.cs` +- `Providers/{Configuration,Infrastructure,Interfaces,Models,Services}/*` +- `Workspace/{Interfaces,Services}/*` ## Boundaries - Keep this project free of `Uno Platform`, XAML, brushes, and page/view-model concerns. +- Keep app-shell, app-host, and application-configuration types out of this project; those belong in `DotPilot`. +- Do not keep UI-only preference models or shell interaction settings here; if a setting exists only to control presentation behavior such as composer key handling, it belongs in `DotPilot`. +- Do not add cache-specific abstractions, services, or snapshot layers here by default; `DotPilot.Core` should read from the real source of truth unless the user explicitly asks for a cache boundary. +- Keep provider readiness, workspace/session projections, and similar environment state uncached by default; do not add in-memory mirrors or snapshot caches unless the user explicitly asks for that tradeoff. +- Do not introduce fabricated role enums, hardcoded tool catalogs, skill catalogs, or encoded capability tags for agents unless the product has a real backing registry and runtime implementation for them. - 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. +- `DotPilot.Core` is the default non-UI home, not a permanent dumping ground: when a feature becomes large enough to justify its own architectural boundary, extract it into a dedicated DLL that references `DotPilot.Core` +- do not introduce a generic `Runtime` naming layer inside this project or split code out into a vaguely named runtime assembly unless the user explicitly asks for that boundary; keep non-UI logic in explicit feature slices under `DotPilot.Core` +- when a feature is extracted out of `DotPilot.Core`, keep `DotPilot.Core` as the abstraction/shared-contract layer and make the desktop app reference the new feature DLL explicitly +- do not leave extracted subsystem contracts half-inside `DotPilot.Core`; when a future subsystem is split into its own DLL, its feature-facing interfaces and implementation seams should move with it +- keep feature-specific heavy infrastructure out of this project once it becomes its own subsystem; `DotPilot.Core` should stay cohesive instead of half-owning an extracted runtime +- Do not collect unrelated code under an umbrella directory such as `AgentSessions`; split session, workspace, settings, providers, and host code into explicit feature roots when the surface grows. +- Keep `ControlPlaneDomain` explicit too: identifiers belong under `Identifiers`, participant/provider/session DTOs under `Contracts`, cross-flow state under `Models`, and policy shapes under `Policies` instead of leaving one flat dump. +- Keep contract-centric slices explicit inside each feature root: commands live under `Commands`, public DTO shapes live under `Contracts`, public service seams live under `Interfaces`, state records or enums live under `Models`, diagnostics under `Diagnostics`, and persistence under `Persistence`. +- When a slice exposes `Commands` and `Results`, use the solution-standard `ManagedCode.Communication` primitives instead of hand-rolled command/result record types. +- Keep the top level readable as two kinds of folders: + - shared/domain folders such as `ControlPlaneDomain` and `Workspace` + - operational/system folders such as `AgentBuilder`, `ChatSessions`, `Providers`, and `HttpDiagnostics` +- 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 - `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` ## Applicable Skills @@ -38,5 +60,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. +- This project is now the full non-UI stack, so naming drift or folder chaos will spread quickly across contracts, providers, persistence, and runtime logic. +- Avoid baking UI assumptions into this project, and avoid baking low-level CLI-process details into the contract-facing folders when provider/session terms are enough. diff --git a/DotPilot.Core/AgentBuilder/Configuration/AgentSessionDefaults.cs b/DotPilot.Core/AgentBuilder/Configuration/AgentSessionDefaults.cs new file mode 100644 index 0000000..22440d5 --- /dev/null +++ b/DotPilot.Core/AgentBuilder/Configuration/AgentSessionDefaults.cs @@ -0,0 +1,76 @@ +using System.Globalization; +using System.Text; +using DotPilot.Core.Providers; + +namespace DotPilot.Core.AgentBuilder; + +public static class AgentSessionDefaults +{ + public const string SystemAgentName = "dotPilot System Agent"; + public const string SystemAgentDescription = "Built-in local agent for desktop chat, prompt-to-agent drafting, and deterministic fallback workflows."; + + private const string MissionPrefix = "Mission:"; + private const string StructuredPromptClosingInstruction = + "Be explicit about actions and keep the operator informed."; + + public static string SystemAgentPrompt { get; } = + CreateStructuredSystemPrompt( + SystemAgentName, + SystemAgentDescription); + + public static bool IsSystemAgent(string agentName) + { + return string.Equals(agentName, SystemAgentName, StringComparison.Ordinal); + } + + public static string GetDefaultModel(AgentProviderKind kind) + { + return AgentSessionProviderCatalog.Get(kind).DefaultModelName; + } + + public static string CreateAgentDescription(string systemPrompt) + { + ArgumentException.ThrowIfNullOrWhiteSpace(systemPrompt); + + foreach (var line in systemPrompt.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + if (line.StartsWith(MissionPrefix, StringComparison.OrdinalIgnoreCase)) + { + return line[MissionPrefix.Length..].Trim(); + } + } + + var normalized = string.Join( + ' ', + systemPrompt + .Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)); + + var sentenceEndIndex = normalized.IndexOf('.', StringComparison.Ordinal); + if (sentenceEndIndex <= 0) + { + return normalized; + } + + return normalized[..(sentenceEndIndex + 1)]; + } + + public static string CreateStructuredSystemPrompt( + string name, + string description) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentException.ThrowIfNullOrWhiteSpace(description); + + var normalizedDescription = description.Trim(); + if (!normalizedDescription.EndsWith('.')) + { + normalizedDescription += "."; + } + + StringBuilder builder = new(); + builder.AppendLine(CultureInfo.InvariantCulture, $"You are {name}."); + builder.AppendLine(CultureInfo.InvariantCulture, $"{MissionPrefix} {normalizedDescription}"); + builder.Append(StructuredPromptClosingInstruction); + return builder.ToString().Trim(); + } +} diff --git a/DotPilot.Core/AgentBuilder/Models/AgentPromptDraft.cs b/DotPilot.Core/AgentBuilder/Models/AgentPromptDraft.cs new file mode 100644 index 0000000..e8b7de7 --- /dev/null +++ b/DotPilot.Core/AgentBuilder/Models/AgentPromptDraft.cs @@ -0,0 +1,10 @@ + +namespace DotPilot.Core.AgentBuilder; + +public sealed record AgentPromptDraft( + string Prompt, + string Name, + string Description, + AgentProviderKind ProviderKind, + string ModelName, + string SystemPrompt); diff --git a/DotPilot.Core/AgentBuilder/Services/AgentPromptDraftGenerator.cs b/DotPilot.Core/AgentBuilder/Services/AgentPromptDraftGenerator.cs new file mode 100644 index 0000000..bed989c --- /dev/null +++ b/DotPilot.Core/AgentBuilder/Services/AgentPromptDraftGenerator.cs @@ -0,0 +1,239 @@ +using System.Globalization; +using DotPilot.Core.Providers; +using Microsoft.Extensions.Logging; + +namespace DotPilot.Core.AgentBuilder; + +public sealed class AgentPromptDraftGenerator( + IAgentProviderStatusReader providerStatusReader, + ILogger logger) +{ + private const string ManualPrompt = "Build manually"; + private const string ManualAgentName = "New agent"; + private const string ManualDescription = "Local desktop assistant for reusable chat and workflow sessions."; + private const string PromptPrefixCreate = "create "; + private const string PromptPrefixBuild = "build "; + private const string PromptPrefixMake = "make "; + private const string PromptPrefixIWant = "i want "; + private static readonly string[] PromptPrefixes = + [ + PromptPrefixCreate, + PromptPrefixBuild, + PromptPrefixMake, + PromptPrefixIWant, + ]; + private static readonly char[] NameSplitCharacters = + [ + ' ', + ',', + '.', + ';', + ':', + '-', + '/', + '\\', + ]; + private static readonly string[] NoiseTerms = + [ + "an", + "a", + "agent", + "assistant", + "that", + "which", + "who", + "for", + "to", + "with", + "the", + "and", + "can", + ]; + + public async ValueTask CreateManualDraftAsync(CancellationToken cancellationToken) + { + var provider = await ResolvePreferredProviderAsync(ManualPrompt, cancellationToken); + var draft = new AgentPromptDraft( + ManualPrompt, + ManualAgentName, + ManualDescription, + provider.Kind, + provider.SuggestedModelName, + CreateSystemPrompt(ManualAgentName, ManualDescription)); + + AgentPromptDraftGeneratorLog.ManualDraftCreated(logger, draft.ProviderKind, draft.ModelName); + return draft; + } + + public async ValueTask GenerateAsync(string prompt, CancellationToken cancellationToken) + { + var normalizedPrompt = NormalizePrompt(prompt); + var provider = await ResolvePreferredProviderAsync(normalizedPrompt, cancellationToken); + var description = CreateDescription(normalizedPrompt); + var name = CreateName(normalizedPrompt); + var draft = new AgentPromptDraft( + normalizedPrompt, + name, + description, + provider.Kind, + provider.SuggestedModelName, + CreateSystemPrompt(name, description)); + + AgentPromptDraftGeneratorLog.GeneratedDraft( + logger, + draft.Name, + draft.ProviderKind, + draft.ModelName); + return draft; + } + + private async ValueTask ResolvePreferredProviderAsync(string prompt, CancellationToken cancellationToken) + { + var providers = await providerStatusReader.ReadAsync(cancellationToken); + var creatableProviders = providers + .Where(static provider => provider.CanCreateAgents) + .ToDictionary(static provider => provider.Kind); + + foreach (var candidate in ResolveProviderPreferences(prompt)) + { + if (creatableProviders.TryGetValue(candidate, out var provider)) + { + return provider; + } + } + + return providers.FirstOrDefault(static provider => provider.Kind == AgentProviderKind.Debug) + ?? new ProviderStatusDescriptor( + AgentSessionDeterministicIdentity.CreateProviderId("debug"), + AgentProviderKind.Debug, + "Debug Provider", + "debug", + AgentProviderStatus.Disabled, + "Provider is disabled for local agent creation.", + AgentSessionDefaults.GetDefaultModel(AgentProviderKind.Debug), + [AgentSessionDefaults.GetDefaultModel(AgentProviderKind.Debug)], + AgentSessionDefaults.GetDefaultModel(AgentProviderKind.Debug), + false, + false, + [], + []); + } + + private static IEnumerable ResolveProviderPreferences(string prompt) + { + if (ContainsAny(prompt, "review", "pull request", "repository", "repo", "code", "commit", "branch", "diff")) + { + yield return AgentProviderKind.Codex; + yield return AgentProviderKind.GitHubCopilot; + yield return AgentProviderKind.ClaudeCode; + yield return AgentProviderKind.Debug; + yield break; + } + + if (ContainsAny(prompt, "research", "search", "summarize", "summary", "writing", "content", "docs", "analysis")) + { + yield return AgentProviderKind.ClaudeCode; + yield return AgentProviderKind.Codex; + yield return AgentProviderKind.GitHubCopilot; + yield return AgentProviderKind.Debug; + yield break; + } + + yield return AgentProviderKind.Codex; + yield return AgentProviderKind.ClaudeCode; + yield return AgentProviderKind.GitHubCopilot; + yield return AgentProviderKind.Debug; + } + + private static string NormalizePrompt(string prompt) + { + var normalized = string.Join( + ' ', + (prompt ?? string.Empty) + .Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)); + + if (string.IsNullOrWhiteSpace(normalized)) + { + throw new InvalidOperationException("Describe the agent you want before generating a draft."); + } + + return normalized; + } + + private static string CreateDescription(string prompt) + { + var normalized = prompt.Trim(); + if (!normalized.EndsWith('.')) + { + normalized += "."; + } + + return normalized; + } + + private static string CreateName(string prompt) + { + var normalized = prompt.Trim(); + foreach (var prefix in PromptPrefixes) + { + if (normalized.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + normalized = normalized[prefix.Length..]; + break; + } + } + + var nameWords = normalized + .Split(NameSplitCharacters, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Where(word => !NoiseTerms.Contains(word, StringComparer.OrdinalIgnoreCase)) + .Take(3) + .Select(ToTitleCase) + .ToArray(); + + if (nameWords.Length == 0) + { + return ManualAgentName; + } + + var baseName = string.Join(" ", nameWords); + return baseName.EndsWith("Agent", StringComparison.Ordinal) + ? baseName + : string.Create(CultureInfo.InvariantCulture, $"{baseName} Agent"); + } + + private static string CreateSystemPrompt( + string name, + string description) + { + return AgentSessionDefaults.CreateStructuredSystemPrompt(name, description); + } + + private static bool ContainsAny(string value, params string[] candidates) + { + return candidates.Any(candidate => value.Contains(candidate, StringComparison.OrdinalIgnoreCase)); + } + + private static string ToTitleCase(string value) + { + return CultureInfo.InvariantCulture.TextInfo.ToTitleCase(value.ToLowerInvariant()); + } +} + +internal static partial class AgentPromptDraftGeneratorLog +{ + [LoggerMessage( + EventId = 1400, + Level = LogLevel.Information, + Message = "Created manual agent draft. Provider={ProviderKind} Model={ModelName}.")] + public static partial void ManualDraftCreated(ILogger logger, AgentProviderKind providerKind, string modelName); + + [LoggerMessage( + EventId = 1401, + Level = LogLevel.Information, + Message = "Generated prompt-based agent draft. Name={AgentName} Provider={ProviderKind} Model={ModelName}.")] + public static partial void GeneratedDraft( + ILogger logger, + string agentName, + AgentProviderKind providerKind, + string modelName); +} diff --git a/DotPilot.Core/ChatSessions/Commands/CreateAgentProfileCommand.cs b/DotPilot.Core/ChatSessions/Commands/CreateAgentProfileCommand.cs new file mode 100644 index 0000000..da0c42c --- /dev/null +++ b/DotPilot.Core/ChatSessions/Commands/CreateAgentProfileCommand.cs @@ -0,0 +1,43 @@ +using ManagedCode.Communication.Commands; + +namespace DotPilot.Core.ChatSessions.Commands; + +public sealed class CreateAgentProfileCommand : Command +{ + private readonly Payload payload; + + public CreateAgentProfileCommand( + string name, + AgentProviderKind providerKind, + string modelName, + string systemPrompt, + string description = "") + : this(new Payload(name, providerKind, modelName, systemPrompt, description)) + { + } + + private CreateAgentProfileCommand(Payload payload) + : base(Guid.CreateVersion7(), nameof(CreateAgentProfileCommand), payload) + { + this.payload = payload; + Value = payload; + } + + public string Name => payload.Name; + + public AgentProviderKind ProviderKind => payload.ProviderKind; + + public string ModelName => payload.ModelName; + + public string SystemPrompt => payload.SystemPrompt; + + public string Description => payload.Description; + + [GenerateSerializer] + public sealed record Payload( + [property: Id(0)] string Name, + [property: Id(1)] AgentProviderKind ProviderKind, + [property: Id(2)] string ModelName, + [property: Id(3)] string SystemPrompt, + [property: Id(4)] string Description); +} diff --git a/DotPilot.Core/ChatSessions/Commands/CreateSessionCommand.cs b/DotPilot.Core/ChatSessions/Commands/CreateSessionCommand.cs new file mode 100644 index 0000000..f342ed8 --- /dev/null +++ b/DotPilot.Core/ChatSessions/Commands/CreateSessionCommand.cs @@ -0,0 +1,32 @@ +using DotPilot.Core.ControlPlaneDomain; +using ManagedCode.Communication.Commands; + +namespace DotPilot.Core.ChatSessions.Commands; + +public sealed class CreateSessionCommand : Command +{ + private readonly Payload payload; + + public CreateSessionCommand( + string title, + AgentProfileId agentProfileId) + : this(new Payload(title, agentProfileId)) + { + } + + private CreateSessionCommand(Payload payload) + : base(Guid.CreateVersion7(), nameof(CreateSessionCommand), payload) + { + this.payload = payload; + Value = payload; + } + + public string Title => payload.Title; + + public AgentProfileId AgentProfileId => payload.AgentProfileId; + + [GenerateSerializer] + public sealed record Payload( + [property: Id(0)] string Title, + [property: Id(1)] AgentProfileId AgentProfileId); +} diff --git a/DotPilot.Core/ChatSessions/Commands/SendSessionMessageCommand.cs b/DotPilot.Core/ChatSessions/Commands/SendSessionMessageCommand.cs new file mode 100644 index 0000000..b6ef9d4 --- /dev/null +++ b/DotPilot.Core/ChatSessions/Commands/SendSessionMessageCommand.cs @@ -0,0 +1,34 @@ +using System.Globalization; +using DotPilot.Core.ControlPlaneDomain; +using ManagedCode.Communication.Commands; + +namespace DotPilot.Core.ChatSessions.Commands; + +public sealed class SendSessionMessageCommand : Command +{ + private readonly Payload payload; + + public SendSessionMessageCommand( + SessionId sessionId, + string message) + : this(new Payload(sessionId, message)) + { + } + + private SendSessionMessageCommand(Payload payload) + : base(Guid.CreateVersion7(), nameof(SendSessionMessageCommand), payload) + { + this.payload = payload; + Value = payload; + base.SessionId = payload.SessionId.Value.ToString("N", CultureInfo.InvariantCulture); + } + + public new SessionId SessionId => payload.SessionId; + + public string Message => payload.Message; + + [GenerateSerializer] + public sealed record Payload( + [property: Id(0)] SessionId SessionId, + [property: Id(1)] string Message); +} diff --git a/DotPilot.Core/ChatSessions/Commands/UpdateAgentProfileCommand.cs b/DotPilot.Core/ChatSessions/Commands/UpdateAgentProfileCommand.cs new file mode 100644 index 0000000..a38f8a2 --- /dev/null +++ b/DotPilot.Core/ChatSessions/Commands/UpdateAgentProfileCommand.cs @@ -0,0 +1,48 @@ +using DotPilot.Core.ControlPlaneDomain; +using ManagedCode.Communication.Commands; + +namespace DotPilot.Core.ChatSessions.Commands; + +public sealed class UpdateAgentProfileCommand : Command +{ + private readonly Payload payload; + + public UpdateAgentProfileCommand( + AgentProfileId agentId, + string name, + AgentProviderKind providerKind, + string modelName, + string systemPrompt, + string description = "") + : this(new Payload(agentId, name, providerKind, modelName, systemPrompt, description)) + { + } + + private UpdateAgentProfileCommand(Payload payload) + : base(Guid.CreateVersion7(), nameof(UpdateAgentProfileCommand), payload) + { + this.payload = payload; + Value = payload; + } + + public AgentProfileId AgentId => payload.AgentId; + + public string Name => payload.Name; + + public AgentProviderKind ProviderKind => payload.ProviderKind; + + public string ModelName => payload.ModelName; + + public string SystemPrompt => payload.SystemPrompt; + + public string Description => payload.Description; + + [GenerateSerializer] + public sealed record Payload( + [property: Id(0)] AgentProfileId AgentId, + [property: Id(1)] string Name, + [property: Id(2)] AgentProviderKind ProviderKind, + [property: Id(3)] string ModelName, + [property: Id(4)] string SystemPrompt, + [property: Id(5)] string Description); +} diff --git a/DotPilot.Core/ChatSessions/Commands/UpdateProviderPreferenceCommand.cs b/DotPilot.Core/ChatSessions/Commands/UpdateProviderPreferenceCommand.cs new file mode 100644 index 0000000..488d529 --- /dev/null +++ b/DotPilot.Core/ChatSessions/Commands/UpdateProviderPreferenceCommand.cs @@ -0,0 +1,31 @@ +using ManagedCode.Communication.Commands; + +namespace DotPilot.Core.ChatSessions.Commands; + +public sealed class UpdateProviderPreferenceCommand : Command +{ + private readonly Payload payload; + + public UpdateProviderPreferenceCommand( + AgentProviderKind providerKind, + bool isEnabled) + : this(new Payload(providerKind, isEnabled)) + { + } + + private UpdateProviderPreferenceCommand(Payload payload) + : base(Guid.CreateVersion7(), nameof(UpdateProviderPreferenceCommand), payload) + { + this.payload = payload; + Value = payload; + } + + public AgentProviderKind ProviderKind => payload.ProviderKind; + + public bool IsEnabled => payload.IsEnabled; + + [GenerateSerializer] + public sealed record Payload( + [property: Id(0)] AgentProviderKind ProviderKind, + [property: Id(1)] bool IsEnabled); +} diff --git a/DotPilot.Core/ChatSessions/Configuration/AgentSessionServiceCollectionExtensions.cs b/DotPilot.Core/ChatSessions/Configuration/AgentSessionServiceCollectionExtensions.cs new file mode 100644 index 0000000..8fcba6c --- /dev/null +++ b/DotPilot.Core/ChatSessions/Configuration/AgentSessionServiceCollectionExtensions.cs @@ -0,0 +1,51 @@ +using DotPilot.Core.AgentBuilder; +using DotPilot.Core.Providers; +using DotPilot.Core.Workspace; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace DotPilot.Core.ChatSessions; + +public static class AgentSessionServiceCollectionExtensions +{ + 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(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + 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 = AgentSessionStoragePaths.ResolveDatabasePath(storageOptions); + var databaseDirectory = Path.GetDirectoryName(databasePath); + if (!string.IsNullOrWhiteSpace(databaseDirectory)) + { + Directory.CreateDirectory(databaseDirectory); + } + + builder.UseSqlite($"Data Source={databasePath}"); + } +} diff --git a/DotPilot.Core/ChatSessions/Contracts/AgentSessionContracts.cs b/DotPilot.Core/ChatSessions/Contracts/AgentSessionContracts.cs new file mode 100644 index 0000000..1d91973 --- /dev/null +++ b/DotPilot.Core/ChatSessions/Contracts/AgentSessionContracts.cs @@ -0,0 +1,68 @@ +using DotPilot.Core.ControlPlaneDomain; + +namespace DotPilot.Core.ChatSessions.Contracts; + +public sealed record ProviderActionDescriptor( + string Label, + string Summary, + string Command); + +public sealed record ProviderDetailDescriptor( + string Label, + string Value); + +public sealed record ProviderStatusDescriptor( + ProviderId Id, + AgentProviderKind Kind, + string DisplayName, + string CommandName, + AgentProviderStatus Status, + string StatusSummary, + string SuggestedModelName, + IReadOnlyList SupportedModelNames, + string? InstalledVersion, + bool IsEnabled, + bool CanCreateAgents, + IReadOnlyList Details, + IReadOnlyList Actions); + +public sealed record AgentProfileSummary( + AgentProfileId Id, + string Name, + string Description, + AgentProviderKind ProviderKind, + string ProviderDisplayName, + string ModelName, + string SystemPrompt, + 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/ChatSessions/Contracts/SessionActivityContracts.cs b/DotPilot.Core/ChatSessions/Contracts/SessionActivityContracts.cs new file mode 100644 index 0000000..1a43e25 --- /dev/null +++ b/DotPilot.Core/ChatSessions/Contracts/SessionActivityContracts.cs @@ -0,0 +1,20 @@ +using DotPilot.Core.ControlPlaneDomain; + +namespace DotPilot.Core.ChatSessions.Contracts; + +public sealed record SessionActivityDescriptor( + SessionId SessionId, + string SessionTitle, + AgentProfileId AgentProfileId, + string AgentName, + string ProviderDisplayName); + +public sealed record SessionActivitySnapshot( + bool HasActiveSessions, + int ActiveSessionCount, + IReadOnlyList ActiveSessions, + SessionId? SessionId, + string SessionTitle, + AgentProfileId? AgentProfileId, + string AgentName, + string ProviderDisplayName); diff --git a/DotPilot.Core/ChatSessions/Diagnostics/AgentExecutionLoggingMiddleware.ChatClient.cs b/DotPilot.Core/ChatSessions/Diagnostics/AgentExecutionLoggingMiddleware.ChatClient.cs new file mode 100644 index 0000000..a6b549e --- /dev/null +++ b/DotPilot.Core/ChatSessions/Diagnostics/AgentExecutionLoggingMiddleware.ChatClient.cs @@ -0,0 +1,186 @@ +using System.Diagnostics; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; + +namespace DotPilot.Core.ChatSessions; + +internal sealed partial class AgentExecutionLoggingMiddleware +{ + private IChatClient WrapChatClient(IChatClient chatClient, AgentRunLogContext runContext) + { + ArgumentNullException.ThrowIfNull(chatClient); + + return chatClient + .AsBuilder() + .Use( + getResponseFunc: (messages, options, innerChatClient, cancellationToken) => + LogChatResponseAsync( + runContext, + messages, + options, + innerChatClient, + cancellationToken), + getStreamingResponseFunc: (messages, options, innerChatClient, cancellationToken) => + LogStreamingChatResponseAsync( + runContext, + messages, + options, + innerChatClient, + cancellationToken)) + .Build(); + } + + private async Task LogChatResponseAsync( + AgentRunLogContext runContext, + IEnumerable messages, + ChatOptions? options, + IChatClient innerChatClient, + CancellationToken cancellationToken) + { + var materializedMessages = MaterializeMessages(messages); + using var scope = BeginScope(runContext); + var stopwatch = Stopwatch.StartNew(); + var isInfoEnabled = logger.IsEnabled(LogLevel.Information); + + if (isInfoEnabled) + { + var toolCount = CountTools(options); + AgentRuntimeConversationFactoryLog.ChatClientRequestStarted( + logger, + runContext.RunId, + runContext.SessionId, + runContext.AgentId, + false, + runContext.ModelName, + materializedMessages.Count, + toolCount); + } + + try + { + var response = await innerChatClient.GetResponseAsync( + materializedMessages, + options, + cancellationToken); + + if (isInfoEnabled) + { + var outputCount = response.Messages.Count; + var characterCount = CountMessageCharacters(response.Messages); + AgentRuntimeConversationFactoryLog.ChatClientRequestCompleted( + logger, + runContext.RunId, + runContext.SessionId, + runContext.AgentId, + false, + outputCount, + characterCount, + stopwatch.Elapsed.TotalMilliseconds); + } + + return response; + } + catch (Exception exception) + { + AgentRuntimeConversationFactoryLog.ChatClientRequestFailed( + logger, + exception, + runContext.RunId, + runContext.SessionId, + runContext.AgentId, + false); + throw; + } + } + + private async IAsyncEnumerable LogStreamingChatResponseAsync( + AgentRunLogContext runContext, + IEnumerable messages, + ChatOptions? options, + IChatClient innerChatClient, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) + { + var materializedMessages = MaterializeMessages(messages); + using var scope = BeginScope(runContext); + var stopwatch = Stopwatch.StartNew(); + var updateCount = 0; + var totalCharacters = 0; + var loggedMessageId = false; + var isInfoEnabled = logger.IsEnabled(LogLevel.Information); + + if (isInfoEnabled) + { + var toolCount = CountTools(options); + AgentRuntimeConversationFactoryLog.ChatClientRequestStarted( + logger, + runContext.RunId, + runContext.SessionId, + runContext.AgentId, + true, + runContext.ModelName, + materializedMessages.Count, + toolCount); + } + + await using var enumerator = innerChatClient.GetStreamingResponseAsync( + materializedMessages, + options, + cancellationToken) + .GetAsyncEnumerator(cancellationToken); + + while (true) + { + ChatResponseUpdate update; + try + { + if (!await enumerator.MoveNextAsync()) + { + break; + } + + update = enumerator.Current; + } + catch (Exception exception) + { + AgentRuntimeConversationFactoryLog.ChatClientRequestFailed( + logger, + exception, + runContext.RunId, + runContext.SessionId, + runContext.AgentId, + true); + throw; + } + + updateCount++; + totalCharacters += update.Text?.Length ?? 0; + + if (isInfoEnabled && !loggedMessageId && !string.IsNullOrWhiteSpace(update.MessageId)) + { + loggedMessageId = true; + AgentRuntimeConversationFactoryLog.ChatClientFirstUpdateObserved( + logger, + runContext.RunId, + runContext.SessionId, + runContext.AgentId, + update.MessageId, + update.Text?.Length ?? 0); + } + + yield return update; + } + + if (isInfoEnabled) + { + AgentRuntimeConversationFactoryLog.ChatClientRequestCompleted( + logger, + runContext.RunId, + runContext.SessionId, + runContext.AgentId, + true, + updateCount, + totalCharacters, + stopwatch.Elapsed.TotalMilliseconds); + } + } +} diff --git a/DotPilot.Core/ChatSessions/Diagnostics/AgentExecutionLoggingMiddleware.cs b/DotPilot.Core/ChatSessions/Diagnostics/AgentExecutionLoggingMiddleware.cs new file mode 100644 index 0000000..5955f9c --- /dev/null +++ b/DotPilot.Core/ChatSessions/Diagnostics/AgentExecutionLoggingMiddleware.cs @@ -0,0 +1,237 @@ +using System.Diagnostics; +using System.Globalization; +using DotPilot.Core.ControlPlaneDomain; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; + +namespace DotPilot.Core.ChatSessions; + +internal sealed partial class AgentExecutionLoggingMiddleware( + ILogger logger) +{ + public AIAgent AttachAgentRunLogging( + AIAgent agent, + AgentExecutionDescriptor descriptor) + { + ArgumentNullException.ThrowIfNull(agent); + ArgumentNullException.ThrowIfNull(descriptor); + + AgentRuntimeConversationFactoryLog.AgentMiddlewareConfigured( + logger, + descriptor.AgentId, + descriptor.AgentName, + descriptor.ProviderKind); + + return agent + .AsBuilder() + .Use( + runFunc: (messages, session, options, innerAgent, cancellationToken) => + LogAgentRunAsync( + descriptor, + messages, + session, + options, + innerAgent, + cancellationToken), + runStreamingFunc: (messages, session, options, innerAgent, cancellationToken) => + LogAgentRunStreamingAsync( + descriptor, + messages, + session, + options, + innerAgent, + cancellationToken)) + .Build(); + } + + public AgentExecutionRunConfiguration CreateRunConfiguration( + AgentExecutionDescriptor descriptor, + SessionId sessionId) + { + ArgumentNullException.ThrowIfNull(descriptor); + + var runContext = new AgentRunLogContext( + Guid.CreateVersion7().ToString("N", CultureInfo.InvariantCulture), + sessionId.Value.ToString("N", CultureInfo.InvariantCulture), + descriptor.AgentId, + descriptor.AgentName, + descriptor.ProviderKind, + descriptor.ProviderDisplayName, + descriptor.ModelName); + + var options = new ChatClientAgentRunOptions(chatOptions: null) + { + AdditionalProperties = CreateAdditionalProperties(runContext), + ChatClientFactory = chatClient => WrapChatClient(chatClient, runContext), + }; + + AgentRuntimeConversationFactoryLog.RunScopedChatLoggingConfigured( + logger, + runContext.RunId, + sessionId, + descriptor.AgentId, + descriptor.ProviderKind, + descriptor.ModelName); + + return new AgentExecutionRunConfiguration(runContext, options); + } + + private async Task LogAgentRunAsync( + AgentExecutionDescriptor descriptor, + IEnumerable messages, + AgentSession? session, + AgentRunOptions? options, + AIAgent innerAgent, + CancellationToken cancellationToken) + { + var materializedMessages = MaterializeMessages(messages); + var runContext = ResolveRunContext(descriptor, options); + using var scope = BeginScope(runContext); + var stopwatch = Stopwatch.StartNew(); + var isInfoEnabled = logger.IsEnabled(LogLevel.Information); + + if (isInfoEnabled) + { + AgentRuntimeConversationFactoryLog.AgentRunStarted( + logger, + runContext.RunId, + runContext.SessionId, + runContext.AgentId, + runContext.AgentName, + runContext.ProviderKind, + false, + materializedMessages.Count); + } + + try + { + var response = await innerAgent.RunAsync( + materializedMessages, + session, + options, + cancellationToken); + + if (isInfoEnabled) + { + var outputCount = response.Messages.Count; + var characterCount = CountMessageCharacters(response.Messages); + AgentRuntimeConversationFactoryLog.AgentRunCompleted( + logger, + runContext.RunId, + runContext.SessionId, + runContext.AgentId, + false, + outputCount, + characterCount, + stopwatch.Elapsed.TotalMilliseconds); + } + + return response; + } + catch (Exception exception) + { + AgentRuntimeConversationFactoryLog.AgentRunFailed( + logger, + exception, + runContext.RunId, + runContext.SessionId, + runContext.AgentId, + false); + throw; + } + } + + private async IAsyncEnumerable LogAgentRunStreamingAsync( + AgentExecutionDescriptor descriptor, + IEnumerable messages, + AgentSession? session, + AgentRunOptions? options, + AIAgent innerAgent, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) + { + var materializedMessages = MaterializeMessages(messages); + var runContext = ResolveRunContext(descriptor, options); + using var scope = BeginScope(runContext); + var stopwatch = Stopwatch.StartNew(); + var updateCount = 0; + var totalCharacters = 0; + var loggedMessageId = false; + var isInfoEnabled = logger.IsEnabled(LogLevel.Information); + + if (isInfoEnabled) + { + AgentRuntimeConversationFactoryLog.AgentRunStarted( + logger, + runContext.RunId, + runContext.SessionId, + runContext.AgentId, + runContext.AgentName, + runContext.ProviderKind, + true, + materializedMessages.Count); + } + + await using var enumerator = innerAgent.RunStreamingAsync( + materializedMessages, + session, + options, + cancellationToken) + .GetAsyncEnumerator(cancellationToken); + + while (true) + { + AgentResponseUpdate update; + try + { + if (!await enumerator.MoveNextAsync()) + { + break; + } + + update = enumerator.Current; + } + catch (Exception exception) + { + AgentRuntimeConversationFactoryLog.AgentRunFailed( + logger, + exception, + runContext.RunId, + runContext.SessionId, + runContext.AgentId, + true); + throw; + } + + updateCount++; + totalCharacters += update.Text?.Length ?? 0; + + if (isInfoEnabled && !loggedMessageId && !string.IsNullOrWhiteSpace(update.MessageId)) + { + loggedMessageId = true; + AgentRuntimeConversationFactoryLog.AgentRunFirstUpdateObserved( + logger, + runContext.RunId, + runContext.SessionId, + runContext.AgentId, + update.MessageId, + update.Text?.Length ?? 0); + } + + yield return update; + } + + if (isInfoEnabled) + { + AgentRuntimeConversationFactoryLog.AgentRunCompleted( + logger, + runContext.RunId, + runContext.SessionId, + runContext.AgentId, + true, + updateCount, + totalCharacters, + stopwatch.Elapsed.TotalMilliseconds); + } + } +} diff --git a/DotPilot.Core/ChatSessions/Diagnostics/AgentExecutionLoggingRuntimeLog.cs b/DotPilot.Core/ChatSessions/Diagnostics/AgentExecutionLoggingRuntimeLog.cs new file mode 100644 index 0000000..d77582a --- /dev/null +++ b/DotPilot.Core/ChatSessions/Diagnostics/AgentExecutionLoggingRuntimeLog.cs @@ -0,0 +1,148 @@ +using DotPilot.Core.ControlPlaneDomain; +using Microsoft.Extensions.Logging; + +namespace DotPilot.Core.ChatSessions; + +internal static partial class AgentRuntimeConversationFactoryLog +{ + [LoggerMessage( + EventId = 1106, + Level = LogLevel.Information, + Message = "Configured agent run middleware. AgentId={AgentId} AgentName={AgentName} Provider={ProviderKind}.")] + public static partial void AgentMiddlewareConfigured( + ILogger logger, + Guid agentId, + string agentName, + AgentProviderKind providerKind); + + [LoggerMessage( + EventId = 1107, + Level = LogLevel.Information, + Message = "Configured run-scoped chat logging. RunId={RunId} SessionId={SessionId} AgentId={AgentId} Provider={ProviderKind} Model={ModelName}.")] + public static partial void RunScopedChatLoggingConfigured( + ILogger logger, + string runId, + SessionId sessionId, + Guid agentId, + AgentProviderKind providerKind, + string modelName); + + [LoggerMessage( + EventId = 1108, + Level = LogLevel.Information, + Message = "Agent run started. RunId={RunId} SessionId={SessionId} AgentId={AgentId} AgentName={AgentName} Provider={ProviderKind} IsStreaming={IsStreaming} MessageCount={MessageCount}.")] + public static partial void AgentRunStarted( + ILogger logger, + string runId, + string sessionId, + Guid agentId, + string agentName, + AgentProviderKind providerKind, + bool isStreaming, + int messageCount); + + [LoggerMessage( + EventId = 1109, + Level = LogLevel.Information, + Message = "Agent run completed. RunId={RunId} SessionId={SessionId} AgentId={AgentId} IsStreaming={IsStreaming} OutputCount={OutputCount} CharacterCount={CharacterCount} ElapsedMilliseconds={ElapsedMilliseconds}.")] + public static partial void AgentRunCompleted( + ILogger logger, + string runId, + string sessionId, + Guid agentId, + bool isStreaming, + int outputCount, + int characterCount, + double elapsedMilliseconds); + + [LoggerMessage( + EventId = 1110, + Level = LogLevel.Error, + Message = "Agent run failed. RunId={RunId} SessionId={SessionId} AgentId={AgentId} IsStreaming={IsStreaming}.")] + public static partial void AgentRunFailed( + ILogger logger, + Exception exception, + string runId, + string sessionId, + Guid agentId, + bool isStreaming); + + [LoggerMessage( + EventId = 1111, + Level = LogLevel.Information, + Message = "Observed first agent streaming update. RunId={RunId} SessionId={SessionId} AgentId={AgentId} MessageId={MessageId} CharacterCount={CharacterCount}.")] + public static partial void AgentRunFirstUpdateObserved( + ILogger logger, + string runId, + string sessionId, + Guid agentId, + string messageId, + int characterCount); + + [LoggerMessage( + EventId = 1112, + Level = LogLevel.Information, + Message = "Chat client request started. RunId={RunId} SessionId={SessionId} AgentId={AgentId} IsStreaming={IsStreaming} Model={ModelName} MessageCount={MessageCount} ToolCount={ToolCount}.")] + public static partial void ChatClientRequestStarted( + ILogger logger, + string runId, + string sessionId, + Guid agentId, + bool isStreaming, + string modelName, + int messageCount, + int toolCount); + + [LoggerMessage( + EventId = 1113, + Level = LogLevel.Information, + Message = "Chat client request completed. RunId={RunId} SessionId={SessionId} AgentId={AgentId} IsStreaming={IsStreaming} OutputCount={OutputCount} CharacterCount={CharacterCount} ElapsedMilliseconds={ElapsedMilliseconds}.")] + public static partial void ChatClientRequestCompleted( + ILogger logger, + string runId, + string sessionId, + Guid agentId, + bool isStreaming, + int outputCount, + int characterCount, + double elapsedMilliseconds); + + [LoggerMessage( + EventId = 1114, + Level = LogLevel.Error, + Message = "Chat client request failed. RunId={RunId} SessionId={SessionId} AgentId={AgentId} IsStreaming={IsStreaming}.")] + public static partial void ChatClientRequestFailed( + ILogger logger, + Exception exception, + string runId, + string sessionId, + Guid agentId, + bool isStreaming); + + [LoggerMessage( + EventId = 1115, + Level = LogLevel.Information, + Message = "Observed first chat client streaming update. RunId={RunId} SessionId={SessionId} AgentId={AgentId} MessageId={MessageId} CharacterCount={CharacterCount}.")] + public static partial void ChatClientFirstUpdateObserved( + ILogger logger, + string runId, + string sessionId, + Guid agentId, + string messageId, + int characterCount); +} + +internal static partial class AgentSessionServiceLog +{ + [LoggerMessage( + EventId = 1219, + Level = LogLevel.Information, + Message = "Prepared correlated agent run. SessionId={SessionId} AgentId={AgentId} RunId={RunId} Provider={ProviderKind} Model={ModelName}.")] + public static partial void SendRunPrepared( + ILogger logger, + SessionId sessionId, + Guid agentId, + string runId, + AgentProviderKind providerKind, + string modelName); +} diff --git a/DotPilot.Core/ChatSessions/Diagnostics/AgentSessionRuntimeLog.cs b/DotPilot.Core/ChatSessions/Diagnostics/AgentSessionRuntimeLog.cs new file mode 100644 index 0000000..4566644 --- /dev/null +++ b/DotPilot.Core/ChatSessions/Diagnostics/AgentSessionRuntimeLog.cs @@ -0,0 +1,345 @@ +using DotPilot.Core.ControlPlaneDomain; +using Microsoft.Extensions.Logging; + +namespace DotPilot.Core.ChatSessions; + +internal static partial class AgentProviderStatusReaderLog +{ + [LoggerMessage( + EventId = 1000, + Level = LogLevel.Information, + Message = "Reading provider readiness state from local sources.")] + public static partial void ReadStarted(ILogger logger); + + [LoggerMessage( + EventId = 1001, + Level = LogLevel.Information, + Message = "Provider readiness state read for {ProviderCount} providers in {ElapsedMilliseconds} ms.")] + public static partial void ReadCompleted(ILogger logger, int providerCount, double elapsedMilliseconds); + + [LoggerMessage( + EventId = 1002, + 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 = 1003, + Level = LogLevel.Error, + Message = "Provider readiness state read failed.")] + public static partial void ReadFailed(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); + + [LoggerMessage( + EventId = 1105, + Level = LogLevel.Information, + Message = "Using transient runtime conversation path. SessionId={SessionId} AgentId={AgentId}.")] + public static partial void TransientRuntimeConversation(ILogger logger, SessionId sessionId, Guid agentId); +} + +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 = 1217, + Level = LogLevel.Information, + Message = "Enabled default provider preference. Provider={ProviderKind}.")] + public static partial void DefaultProviderEnabled(ILogger logger, AgentProviderKind providerKind); + + [LoggerMessage( + EventId = 1218, + Level = LogLevel.Information, + Message = "Seeded default system agent. AgentId={AgentId} Provider={ProviderKind} Model={ModelName}.")] + public static partial void DefaultAgentSeeded( + ILogger logger, + Guid agentId, + AgentProviderKind providerKind, + string modelName); + + [LoggerMessage( + EventId = 1219, + Level = LogLevel.Information, + Message = "Normalized legacy agent profile. AgentId={AgentId} Provider={ProviderKind} Model={ModelName}.")] + public static partial void LegacyAgentProfileNormalized( + ILogger logger, + Guid agentId, + AgentProviderKind providerKind, + string modelName); + + [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 = 1220, + Level = LogLevel.Error, + Message = "Workspace snapshot load failed.")] + public static partial void WorkspaceLoadFailed(ILogger logger, Exception exception); + + [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 = 1221, + Level = LogLevel.Error, + Message = "Session transcript load failed. SessionId={SessionId}.")] + public static partial void SessionLoadFailed(ILogger logger, Exception exception, SessionId sessionId); + + [LoggerMessage( + EventId = 1205, + Level = LogLevel.Information, + Message = "Creating agent profile. Name={AgentName} Provider={ProviderKind}.")] + public static partial void AgentCreationStarted( + ILogger logger, + string agentName, + AgentProviderKind providerKind); + + [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 = 1224, + Level = LogLevel.Information, + Message = "Updating agent profile. AgentId={AgentId} Name={AgentName} Provider={ProviderKind}.")] + public static partial void AgentUpdateStarted( + ILogger logger, + AgentProfileId agentId, + string agentName, + AgentProviderKind providerKind); + + [LoggerMessage( + EventId = 1225, + Level = LogLevel.Information, + Message = "Updated agent profile. AgentId={AgentId} Name={AgentName} Provider={ProviderKind}.")] + public static partial void AgentUpdated( + ILogger logger, + Guid agentId, + string agentName, + AgentProviderKind providerKind); + + [LoggerMessage( + EventId = 1226, + Level = LogLevel.Error, + Message = "Agent profile update failed. AgentId={AgentId} Name={AgentName} Provider={ProviderKind}.")] + public static partial void AgentUpdateFailed( + ILogger logger, + Exception exception, + AgentProfileId agentId, + 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 = 1222, + Level = LogLevel.Error, + Message = "Session creation failed. Title={SessionTitle} AgentId={AgentId}.")] + public static partial void SessionCreationFailed( + ILogger logger, + Exception exception, + 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); +} + +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); +} + +internal static partial class SessionActivityMonitorLog +{ + [LoggerMessage( + EventId = 1303, + Level = LogLevel.Information, + Message = "Marked session activity as live. SessionId={SessionId} AgentId={AgentId} ActiveSessionCount={ActiveSessionCount}.")] + public static partial void ActivityStarted( + ILogger logger, + Guid sessionId, + Guid agentId, + int activeSessionCount); + + [LoggerMessage( + EventId = 1304, + Level = LogLevel.Information, + Message = "Released session live activity. SessionId={SessionId} AgentId={AgentId} ActiveSessionCount={ActiveSessionCount}.")] + public static partial void ActivityCompleted( + ILogger logger, + Guid sessionId, + Guid agentId, + int activeSessionCount); +} diff --git a/DotPilot.Core/ChatSessions/Execution/AgentRuntimeConversationFactory.cs b/DotPilot.Core/ChatSessions/Execution/AgentRuntimeConversationFactory.cs new file mode 100644 index 0000000..f7322b9 --- /dev/null +++ b/DotPilot.Core/ChatSessions/Execution/AgentRuntimeConversationFactory.cs @@ -0,0 +1,194 @@ +using System.Globalization; +using DotPilot.Core.ControlPlaneDomain; +using DotPilot.Core.Providers; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace DotPilot.Core.ChatSessions; + +internal sealed class AgentRuntimeConversationFactory( + AgentSessionStorageOptions storageOptions, + AgentExecutionLoggingMiddleware executionLoggingMiddleware, + LocalCodexThreadStateStore codexThreadStateStore, + LocalAgentSessionStateStore sessionStateStore, + IServiceProvider serviceProvider, + TimeProvider timeProvider, + ILogger logger) +{ + public async ValueTask InitializeAsync( + AgentProfileRecord agentRecord, + SessionId sessionId, + CancellationToken cancellationToken) + { + AgentRuntimeConversationFactoryLog.InitializeStarted(logger, sessionId, agentRecord.Id); + if (ShouldUseTransientRuntimeConversation(agentRecord)) + { + AgentRuntimeConversationFactoryLog.TransientRuntimeConversation(logger, sessionId, agentRecord.Id); + return; + } + + 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( + AgentProfileRecord agentRecord, + SessionId sessionId, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(agentRecord); + + var useTransientConversation = ShouldUseTransientRuntimeConversation(agentRecord); + var historyProvider = new FolderChatHistoryProvider( + serviceProvider.GetRequiredService()); + var descriptor = CreateExecutionDescriptor(agentRecord); + var agent = CreateAgent(agentRecord, descriptor, historyProvider, sessionId); + if (useTransientConversation) + { + var transientSession = await CreateNewSessionAsync(agent, sessionId, cancellationToken); + AgentRuntimeConversationFactoryLog.TransientRuntimeConversation(logger, sessionId, agentRecord.Id); + return new RuntimeConversationContext(agent, transientSession, descriptor, IsTransient: true); + } + + 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); + AgentRuntimeConversationFactoryLog.SessionCreated(logger, sessionId, agentRecord.Id); + } + else + { + AgentRuntimeConversationFactoryLog.SessionLoaded(logger, sessionId, agentRecord.Id); + } + + FolderChatHistoryProvider.BindToSession(session, sessionId); + return new RuntimeConversationContext(agent, session, descriptor); + } + + public ValueTask SaveAsync( + RuntimeConversationContext runtimeContext, + SessionId sessionId, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(runtimeContext); + if (runtimeContext.IsTransient) + { + return ValueTask.CompletedTask; + } + + AgentRuntimeConversationFactoryLog.SessionSaved(logger, sessionId, runtimeContext.Agent.Id); + return sessionStateStore.SaveAsync(runtimeContext.Agent, runtimeContext.Session, sessionId, cancellationToken); + } + + private bool ShouldUseTransientRuntimeConversation(AgentProfileRecord agentRecord) + { + ArgumentNullException.ThrowIfNull(agentRecord); + + var providerKind = (AgentProviderKind)agentRecord.ProviderKind; + return storageOptions.PreferTransientRuntimeConversation || + (OperatingSystem.IsBrowser() && providerKind == AgentProviderKind.Debug); + } + + private static async ValueTask CreateNewSessionAsync( + AIAgent agent, + SessionId sessionId, + CancellationToken cancellationToken) + { + var session = await agent.CreateSessionAsync(cancellationToken); + FolderChatHistoryProvider.BindToSession(session, sessionId); + return session; + } + + private AIAgent CreateAgent( + AgentProfileRecord agentRecord, + AgentExecutionDescriptor descriptor, + FolderChatHistoryProvider historyProvider, + SessionId sessionId) + { + var loggerFactory = serviceProvider.GetService(); + var options = new ChatClientAgentOptions + { + Id = agentRecord.Id.ToString("N", CultureInfo.InvariantCulture), + Name = agentRecord.Name, + Description = descriptor.ProviderDisplayName, + ChatHistoryProvider = historyProvider, + UseProvidedChatClientAsIs = true, + ChatOptions = new ChatOptions + { + Instructions = agentRecord.SystemPrompt, + ModelId = agentRecord.ModelName, + }, + }; + + AgentRuntimeConversationFactoryLog.AgentRuntimeCreated( + logger, + agentRecord.Id, + agentRecord.Name, + descriptor.ProviderKind); + + var agent = CreateChatClient( + descriptor.ProviderKind, + descriptor.ProviderDisplayName, + agentRecord.Name, + sessionId, + agentRecord.ModelName) + .AsAIAgent(options, loggerFactory, serviceProvider); + + return executionLoggingMiddleware.AttachAgentRunLogging(agent, descriptor); + } + + private static AgentExecutionDescriptor CreateExecutionDescriptor(AgentProfileRecord agentRecord) + { + var providerProfile = AgentSessionProviderCatalog.Get((AgentProviderKind)agentRecord.ProviderKind); + return new AgentExecutionDescriptor( + agentRecord.Id, + agentRecord.Name, + providerProfile.Kind, + providerProfile.DisplayName, + agentRecord.ModelName); + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Performance", + "CA1859:Use concrete types when possible for improved performance", + Justification = "The runtime conversation factory intentionally preserves the IChatClient abstraction across provider-backed chat clients.")] + private IChatClient CreateChatClient( + AgentProviderKind providerKind, + string providerDisplayName, + string agentName, + SessionId sessionId, + string modelName) + { + if (providerKind == AgentProviderKind.Debug) + { + return new DebugChatClient(agentName, timeProvider); + } + + if (providerKind == AgentProviderKind.Codex) + { + return new CodexChatClient( + sessionId, + agentName, + modelName, + codexThreadStateStore, + timeProvider); + } + + throw new InvalidOperationException( + string.Format( + CultureInfo.InvariantCulture, + AgentSessionService.LiveExecutionUnavailableCompositeFormat, + providerDisplayName)); + } +} diff --git a/DotPilot.Core/ChatSessions/Execution/AgentSessionService.cs b/DotPilot.Core/ChatSessions/Execution/AgentSessionService.cs new file mode 100644 index 0000000..5d053f6 --- /dev/null +++ b/DotPilot.Core/ChatSessions/Execution/AgentSessionService.cs @@ -0,0 +1,1070 @@ +using DotPilot.Core.AgentBuilder; +using DotPilot.Core.ControlPlaneDomain; +using DotPilot.Core.Providers; +using ManagedCode.Communication; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace DotPilot.Core.ChatSessions; + +internal sealed class AgentSessionService( + IDbContextFactory dbContextFactory, + AgentExecutionLoggingMiddleware executionLoggingMiddleware, + ISessionActivityMonitor sessionActivityMonitor, + IAgentProviderStatusReader providerStatusReader, + AgentRuntimeConversationFactory runtimeConversationFactory, + TimeProvider timeProvider, + ILogger logger) + : IAgentSessionService, IDisposable +{ + 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 ToolAccentLabel = "tool"; + private const string StatusAccentLabel = "status"; + private const string ErrorAccentLabel = "error"; + internal static readonly System.Text.CompositeFormat LiveExecutionUnavailableCompositeFormat = + System.Text.CompositeFormat.Parse(NotYetImplementedFormat); + private readonly SemaphoreSlim _initializationGate = new(1, 1); + private bool _initialized; + + public async ValueTask> GetWorkspaceAsync(CancellationToken cancellationToken) + { + return await LoadWorkspaceAsync(forceRefreshProviders: false, cancellationToken); + } + + public async ValueTask> RefreshWorkspaceAsync(CancellationToken cancellationToken) + { + return await LoadWorkspaceAsync(forceRefreshProviders: true, cancellationToken); + } + + private async ValueTask> LoadWorkspaceAsync( + bool forceRefreshProviders, + CancellationToken cancellationToken) + { + try + { + await EnsureInitializedAsync(cancellationToken); + + await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); + var agents = await dbContext.AgentProfiles + .ToListAsync(cancellationToken); + agents = OrderAgents(agents); + var sessions = (await dbContext.Sessions + .ToListAsync(cancellationToken)) + .OrderByDescending(record => record.UpdatedAt) + .ToList(); + var entries = (await dbContext.SessionEntries + .ToListAsync(cancellationToken)) + .OrderBy(record => record.Timestamp) + .ToList(); + + var agentsById = agents.ToDictionary(record => record.Id); + var sessionItems = sessions + .Select(record => MapSessionListItem(record, agentsById, entries)) + .ToArray(); + var providers = await GetProviderStatusesAsync(forceRefreshProviders, cancellationToken); + + AgentSessionServiceLog.WorkspaceLoaded( + logger, + sessionItems.Length, + agents.Count, + providers.Count); + + return Result.Succeed(new AgentWorkspaceSnapshot( + sessionItems, + agents.Select(MapAgentSummary).ToArray(), + providers, + sessionItems.Length > 0 ? sessionItems[0].Id : null)); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) + { + AgentSessionServiceLog.WorkspaceLoadFailed(logger, exception); + return Result.Fail(exception); + } + } + + public async ValueTask> GetSessionAsync(SessionId sessionId, CancellationToken cancellationToken) + { + try + { + 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) + { + AgentSessionServiceLog.SessionNotFound(logger, sessionId); + return Result.FailNotFound($"Session '{sessionId}' was not found."); + } + + 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) + .ToListAsync(cancellationToken)) + .OrderBy(record => record.Timestamp) + .ToList(); + + 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 Result.Succeed(snapshot); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) + { + AgentSessionServiceLog.SessionLoadFailed(logger, exception, sessionId); + return Result.Fail(exception); + } + } + + public async ValueTask> CreateAgentAsync( + CreateAgentProfileCommand command, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(command); + await EnsureInitializedAsync(cancellationToken); + var agentName = command.Name.Trim(); + var modelName = command.ModelName.Trim(); + var systemPrompt = command.SystemPrompt.Trim(); + var description = ResolveAgentDescription(command.Description, systemPrompt); + AgentSessionServiceLog.AgentCreationStarted(logger, agentName, command.ProviderKind); + + try + { + await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); + var providers = await GetProviderStatusesAsync(forceRefresh: false, cancellationToken); + var provider = providers.First(status => status.Kind == command.ProviderKind); + if (!provider.CanCreateAgents) + { + return Result.FailForbidden(provider.StatusSummary); + } + + var createdAt = timeProvider.GetUtcNow(); + var record = new AgentProfileRecord + { + Id = Guid.CreateVersion7(), + Name = agentName, + Description = description, + Role = AgentProfileSchemaDefaults.DefaultRole, + ProviderKind = (int)command.ProviderKind, + ModelName = modelName, + SystemPrompt = systemPrompt, + CapabilitiesJson = AgentProfileSchemaDefaults.EmptyCapabilitiesJson, + CreatedAt = createdAt, + }; + + dbContext.AgentProfiles.Add(record); + await dbContext.SaveChangesAsync(cancellationToken); + + AgentSessionServiceLog.AgentCreated( + logger, + record.Id, + record.Name, + command.ProviderKind); + + return Result.Succeed(MapAgentSummary(record)); + } + catch (Exception exception) + { + AgentSessionServiceLog.AgentCreationFailed(logger, exception, agentName, command.ProviderKind); + return Result.Fail(exception); + } + } + + public async ValueTask> UpdateAgentAsync( + UpdateAgentProfileCommand command, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(command); + await EnsureInitializedAsync(cancellationToken); + var agentName = command.Name.Trim(); + var modelName = command.ModelName.Trim(); + var systemPrompt = command.SystemPrompt.Trim(); + var description = ResolveAgentDescription(command.Description, systemPrompt); + AgentSessionServiceLog.AgentUpdateStarted(logger, command.AgentId, agentName, command.ProviderKind); + + try + { + await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); + var record = await dbContext.AgentProfiles + .FirstOrDefaultAsync(agent => agent.Id == command.AgentId.Value, cancellationToken); + if (record is null) + { + return Result.FailNotFound($"Agent '{command.AgentId}' was not found."); + } + + var providers = await GetProviderStatusesAsync(forceRefresh: false, cancellationToken); + var provider = providers.First(status => status.Kind == command.ProviderKind); + if (!provider.CanCreateAgents) + { + return Result.FailForbidden(provider.StatusSummary); + } + + record.Name = agentName; + record.Description = description; + record.ProviderKind = (int)command.ProviderKind; + record.ModelName = modelName; + record.SystemPrompt = systemPrompt; + + await dbContext.SaveChangesAsync(cancellationToken); + + AgentSessionServiceLog.AgentUpdated( + logger, + record.Id, + record.Name, + command.ProviderKind); + + return Result.Succeed(MapAgentSummary(record)); + } + catch (Exception exception) + { + AgentSessionServiceLog.AgentUpdateFailed(logger, exception, command.AgentId, agentName, command.ProviderKind); + return Result.Fail(exception); + } + } + + public async ValueTask> CreateSessionAsync( + CreateSessionCommand command, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(command); + try + { + 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 + .FirstOrDefaultAsync(record => record.Id == command.AgentProfileId.Value, cancellationToken); + if (agent is null) + { + return Result.FailNotFound($"Agent '{command.AgentProfileId}' was not found."); + } + + var now = timeProvider.GetUtcNow(); + var sessionId = SessionId.New(); + var session = new SessionRecord + { + Id = sessionId.Value, + Title = sessionTitle, + 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); + + AgentSessionServiceLog.SessionCreated(logger, sessionId, session.Title, command.AgentProfileId); + + var reloaded = await GetSessionAsync(sessionId, cancellationToken); + return reloaded.IsSuccess + ? reloaded + : Result.Fail("SessionCreationFailed", "Created session could not be reloaded."); + } + catch (Exception exception) + { + AgentSessionServiceLog.SessionCreationFailed(logger, exception, command.Title, command.AgentProfileId); + return Result.Fail(exception); + } + } + + public async ValueTask> UpdateProviderAsync( + UpdateProviderPreferenceCommand command, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(command); + await EnsureInitializedAsync(cancellationToken); + try + { + 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); + + InvalidateProviderStatusSnapshot(); + var providers = await GetProviderStatusesAsync(forceRefresh: true, cancellationToken); + var provider = providers.First(status => status.Kind == command.ProviderKind); + + AgentSessionServiceLog.ProviderPreferenceUpdated(logger, command.ProviderKind, command.IsEnabled); + + return Result.Succeed(provider); + } + catch (Exception exception) + { + AgentSessionServiceLog.ProviderPreferenceUpdateFailed( + logger, + exception, + command.ProviderKind, + command.IsEnabled); + return Result.Fail(exception); + } + } + + public async IAsyncEnumerable> SendMessageAsync( + SendSessionMessageCommand command, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(command); + Result initializeResult; + try + { + await EnsureInitializedAsync(cancellationToken); + initializeResult = Result.Succeed(); + } + catch (Exception exception) + { + AgentSessionServiceLog.SendFailed(logger, exception, command.SessionId, Guid.Empty); + initializeResult = Result.Fail(exception); + } + + if (initializeResult.IsFailed) + { + yield return Result.Fail(initializeResult.Problem!); + yield break; + } + + LocalAgentSessionDbContext? dbContext = null; + Result dbContextResult; + try + { + dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); + dbContextResult = Result.Succeed(); + } + catch (Exception exception) + { + AgentSessionServiceLog.SendFailed(logger, exception, command.SessionId, Guid.Empty); + dbContextResult = Result.Fail(exception); + } + + if (dbContextResult.IsFailed || dbContext is null) + { + yield return Result.Fail(dbContextResult.Problem!); + yield break; + } + + await using (dbContext) + { + Result<(SessionRecord Session, AgentProfileRecord Agent, AgentSessionProviderProfile ProviderProfile, DateTimeOffset Timestamp)> contextResult; + try + { + var sessionRecord = await dbContext.Sessions + .FirstOrDefaultAsync(record => record.Id == command.SessionId.Value, cancellationToken); + if (sessionRecord is null) + { + contextResult = Result<(SessionRecord, AgentProfileRecord, AgentSessionProviderProfile, DateTimeOffset)>.FailNotFound( + $"Session '{command.SessionId}' was not found."); + } + else + { + var agentRecord = await dbContext.AgentProfiles + .FirstOrDefaultAsync(record => record.Id == sessionRecord.PrimaryAgentProfileId, cancellationToken); + if (agentRecord is null) + { + contextResult = Result<(SessionRecord, AgentProfileRecord, AgentSessionProviderProfile, DateTimeOffset)>.FailNotFound( + $"Agent '{sessionRecord.PrimaryAgentProfileId}' was not found."); + } + else + { + contextResult = Result<(SessionRecord, AgentProfileRecord, AgentSessionProviderProfile, DateTimeOffset)>.Succeed(( + sessionRecord, + agentRecord, + AgentSessionProviderCatalog.Get((AgentProviderKind)agentRecord.ProviderKind), + timeProvider.GetUtcNow())); + } + } + } + catch (Exception exception) + { + AgentSessionServiceLog.SendFailed(logger, exception, command.SessionId, Guid.Empty); + contextResult = Result<(SessionRecord, AgentProfileRecord, AgentSessionProviderProfile, DateTimeOffset)>.Fail(exception); + } + + if (contextResult.IsFailed) + { + yield return Result.Fail(contextResult.Problem!); + yield break; + } + + var (session, agent, providerProfile, now) = contextResult.Value; + AgentSessionServiceLog.SendStarted( + logger, + command.SessionId, + agent.Id, + providerProfile.Kind); + + Result userEntryResult; + try + { + var userEntry = CreateEntryRecord(command.SessionId, SessionStreamEntryKind.UserMessage, UserAuthor, command.Message.Trim(), now); + dbContext.SessionEntries.Add(userEntry); + session.UpdatedAt = now; + await dbContext.SaveChangesAsync(cancellationToken); + userEntryResult = Result.Succeed(userEntry); + } + catch (Exception exception) + { + AgentSessionServiceLog.SendFailed(logger, exception, command.SessionId, agent.Id); + userEntryResult = Result.Fail(exception); + } + + if (userEntryResult.IsFailed) + { + yield return Result.Fail(userEntryResult.Problem!); + yield break; + } + + yield return Result.Succeed(MapEntry(userEntryResult.Value)); + + var statusEntry = CreateEntryRecord( + command.SessionId, + SessionStreamEntryKind.Status, + StatusAuthor, + $"Running {agent.Name} with {providerProfile.DisplayName}.", + timeProvider.GetUtcNow(), + accentLabel: StatusAccentLabel); + Result statusEntryResult; + try + { + dbContext.SessionEntries.Add(statusEntry); + session.UpdatedAt = statusEntry.Timestamp; + await dbContext.SaveChangesAsync(cancellationToken); + statusEntryResult = Result.Succeed(MapEntry(statusEntry)); + } + catch (Exception exception) + { + AgentSessionServiceLog.SendFailed(logger, exception, command.SessionId, agent.Id); + statusEntryResult = Result.Fail(exception); + } + + if (statusEntryResult.IsFailed) + { + yield return statusEntryResult; + yield break; + } + + yield return statusEntryResult; + + Result providerStatusResult; + try + { + var providerStatuses = await GetProviderStatusesAsync(forceRefresh: false, cancellationToken); + providerStatusResult = Result.Succeed( + providerStatuses.First(status => status.Kind == providerProfile.Kind)); + } + catch (Exception exception) + { + AgentSessionServiceLog.SendFailed(logger, exception, command.SessionId, agent.Id); + providerStatusResult = Result.Fail(exception); + } + + if (providerStatusResult.IsFailed) + { + yield return Result.Fail(providerStatusResult.Problem!); + yield break; + } + + var providerStatus = providerStatusResult.Value; + if (!providerStatus.IsEnabled) + { + AgentSessionServiceLog.SendBlockedDisabled(logger, command.SessionId, providerProfile.Kind); + 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 Result.Succeed(MapEntry(disabledEntry)); + yield break; + } + + if (!providerStatus.CanCreateAgents) + { + var unavailableEntry = CreateEntryRecord( + command.SessionId, + SessionStreamEntryKind.Error, + StatusAuthor, + providerStatus.StatusSummary, + timeProvider.GetUtcNow(), + accentLabel: ErrorAccentLabel); + dbContext.SessionEntries.Add(unavailableEntry); + session.UpdatedAt = unavailableEntry.Timestamp; + await dbContext.SaveChangesAsync(cancellationToken); + + yield return Result.Succeed(MapEntry(unavailableEntry)); + yield break; + } + + if (!providerProfile.SupportsLiveExecution) + { + AgentSessionServiceLog.SendBlockedNotWired(logger, command.SessionId, providerProfile.Kind); + var notImplementedEntry = CreateEntryRecord( + command.SessionId, + SessionStreamEntryKind.Error, + StatusAuthor, + string.Format( + System.Globalization.CultureInfo.InvariantCulture, + LiveExecutionUnavailableCompositeFormat, + providerProfile.DisplayName), + timeProvider.GetUtcNow(), + accentLabel: ErrorAccentLabel); + dbContext.SessionEntries.Add(notImplementedEntry); + session.UpdatedAt = notImplementedEntry.Timestamp; + await dbContext.SaveChangesAsync(cancellationToken); + + yield return Result.Succeed(MapEntry(notImplementedEntry)); + yield break; + } + + using var liveActivity = sessionActivityMonitor.BeginActivity( + new SessionActivityDescriptor( + command.SessionId, + session.Title, + new AgentProfileId(agent.Id), + agent.Name, + providerProfile.DisplayName)); + + Result runtimeConversationResult; + try + { + runtimeConversationResult = Result.Succeed( + await runtimeConversationFactory.LoadOrCreateAsync(agent, command.SessionId, cancellationToken)); + } + catch (Exception exception) + { + AgentSessionServiceLog.SendFailed(logger, exception, command.SessionId, agent.Id); + runtimeConversationResult = Result.Fail(exception); + } + + if (runtimeConversationResult.IsFailed) + { + yield return Result.Fail(runtimeConversationResult.Problem!); + yield break; + } + + var runtimeConversation = runtimeConversationResult.Value; + var toolStartEntry = CreateEntryRecord( + command.SessionId, + SessionStreamEntryKind.ToolStarted, + ToolAuthor, + CreateToolStartText(providerProfile), + timeProvider.GetUtcNow(), + agentProfileId: new AgentProfileId(agent.Id), + accentLabel: ToolAccentLabel); + Result toolStartEntryResult; + try + { + dbContext.SessionEntries.Add(toolStartEntry); + session.UpdatedAt = toolStartEntry.Timestamp; + await dbContext.SaveChangesAsync(cancellationToken); + toolStartEntryResult = Result.Succeed(MapEntry(toolStartEntry)); + } + catch (Exception exception) + { + AgentSessionServiceLog.SendFailed(logger, exception, command.SessionId, agent.Id); + toolStartEntryResult = Result.Fail(exception); + } + + if (toolStartEntryResult.IsFailed) + { + yield return toolStartEntryResult; + yield break; + } + + yield return toolStartEntryResult; + + string? streamedMessageId = null; + SessionEntryRecord? streamedAssistantEntry = null; + var accumulated = new System.Text.StringBuilder(); + var runConfiguration = executionLoggingMiddleware.CreateRunConfiguration( + runtimeConversation.Descriptor, + command.SessionId); + AgentSessionServiceLog.SendRunPrepared( + logger, + command.SessionId, + agent.Id, + runConfiguration.Context.RunId, + providerProfile.Kind, + agent.ModelName); + + await using var updateEnumerator = runtimeConversation.Agent.RunStreamingAsync( + command.Message.Trim(), + runtimeConversation.Session, + runConfiguration.Options, + cancellationToken) + .GetAsyncEnumerator(cancellationToken); + + while (true) + { + Microsoft.Agents.AI.AgentResponseUpdate? update = null; + Result nextUpdateResult; + try + { + var hasNext = await updateEnumerator.MoveNextAsync(); + if (hasNext) + { + update = updateEnumerator.Current; + } + + nextUpdateResult = Result.Succeed(hasNext); + } + catch (Exception exception) + { + AgentSessionServiceLog.SendFailed(logger, exception, command.SessionId, agent.Id); + nextUpdateResult = Result.Fail(exception); + } + + if (nextUpdateResult.IsFailed) + { + yield return Result.Fail(nextUpdateResult.Problem!); + yield break; + } + + if (!nextUpdateResult.Value) + { + break; + } + + 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); + Result streamedEntryResult; + try + { + var timestamp = update.CreatedAt ?? timeProvider.GetUtcNow(); + if (streamedAssistantEntry is null) + { + streamedAssistantEntry = new SessionEntryRecord + { + Id = streamedMessageId, + SessionId = command.SessionId.Value, + AgentProfileId = agent.Id, + Kind = (int)SessionStreamEntryKind.AssistantMessage, + Author = agent.Name, + Text = accumulated.ToString(), + Timestamp = timestamp, + }; + dbContext.SessionEntries.Add(streamedAssistantEntry); + } + else + { + streamedAssistantEntry.Text = accumulated.ToString(); + streamedAssistantEntry.Timestamp = timestamp; + } + + session.UpdatedAt = timestamp; + await dbContext.SaveChangesAsync(cancellationToken); + streamedEntryResult = Result.Succeed(MapEntry(streamedAssistantEntry)); + } + catch (Exception exception) + { + AgentSessionServiceLog.SendFailed(logger, exception, command.SessionId, agent.Id); + streamedEntryResult = Result.Fail(exception); + } + + if (streamedEntryResult.IsFailed) + { + yield return streamedEntryResult; + yield break; + } + + yield return streamedEntryResult; + } + + Result completionResult; + try + { + await runtimeConversationFactory.SaveAsync(runtimeConversation, command.SessionId, cancellationToken); + + if (streamedAssistantEntry is null) + { + streamedAssistantEntry = 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(), + }; + dbContext.SessionEntries.Add(streamedAssistantEntry); + } + else + { + streamedAssistantEntry.Text = accumulated.ToString(); + streamedAssistantEntry.Timestamp = timeProvider.GetUtcNow(); + } + + var toolDoneEntry = CreateEntryRecord( + command.SessionId, + SessionStreamEntryKind.ToolCompleted, + ToolAuthor, + CreateToolDoneText(providerProfile), + timeProvider.GetUtcNow(), + agentProfileId: new AgentProfileId(agent.Id), + accentLabel: ToolAccentLabel); + + dbContext.SessionEntries.Add(toolDoneEntry); + session.UpdatedAt = toolDoneEntry.Timestamp; + await dbContext.SaveChangesAsync(cancellationToken); + + AgentSessionServiceLog.SendCompleted(logger, command.SessionId, agent.Id, accumulated.Length); + completionResult = Result.Succeed(MapEntry(toolDoneEntry)); + } + catch (Exception exception) + { + AgentSessionServiceLog.SendFailed(logger, exception, command.SessionId, agent.Id); + completionResult = Result.Fail(exception); + } + + yield return completionResult; + } + } + + private async Task EnsureInitializedAsync(CancellationToken cancellationToken) + { + if (_initialized) + { + return; + } + + await _initializationGate.WaitAsync(cancellationToken); + try + { + if (_initialized) + { + return; + } + + AgentSessionServiceLog.InitializationStarted(logger); + await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); + await dbContext.Database.EnsureCreatedAsync(cancellationToken); + await AgentProfileSchemaCompatibilityEnsurer.EnsureAsync(dbContext, cancellationToken); + await EnsureDefaultProviderAndAgentAsync(dbContext, cancellationToken); + _initialized = true; + AgentSessionServiceLog.InitializationCompleted(logger); + } + finally + { + _initializationGate.Release(); + } + } + + private async Task EnsureDefaultProviderAndAgentAsync( + LocalAgentSessionDbContext dbContext, + CancellationToken cancellationToken) + { + var hasEnabledProvider = await dbContext.ProviderPreferences + .AnyAsync(record => record.IsEnabled, cancellationToken); + if (!hasEnabledProvider) + { + await EnsureProviderEnabledAsync(dbContext, AgentProviderKind.Debug, cancellationToken); + } + + await NormalizeLegacyAgentProfilesAsync(dbContext, cancellationToken); + + var hasAgents = await dbContext.AgentProfiles.AnyAsync(cancellationToken); + if (hasAgents) + { + return; + } + + var providerKind = await ResolveSeedProviderKindAsync(dbContext, cancellationToken); + if (providerKind == AgentProviderKind.Debug) + { + await EnsureProviderEnabledAsync(dbContext, providerKind, cancellationToken); + } + + var record = new AgentProfileRecord + { + Id = Guid.CreateVersion7(), + Name = AgentSessionDefaults.SystemAgentName, + Description = AgentSessionDefaults.SystemAgentDescription, + Role = AgentProfileSchemaDefaults.DefaultRole, + ProviderKind = (int)providerKind, + ModelName = AgentSessionDefaults.GetDefaultModel(providerKind), + SystemPrompt = AgentSessionDefaults.SystemAgentPrompt, + CapabilitiesJson = AgentProfileSchemaDefaults.EmptyCapabilitiesJson, + CreatedAt = timeProvider.GetUtcNow(), + }; + + dbContext.AgentProfiles.Add(record); + await dbContext.SaveChangesAsync(cancellationToken); + AgentSessionServiceLog.DefaultAgentSeeded(logger, record.Id, providerKind, record.ModelName); + } + + private async Task EnsureProviderEnabledAsync( + LocalAgentSessionDbContext dbContext, + AgentProviderKind providerKind, + CancellationToken cancellationToken) + { + var preference = await dbContext.ProviderPreferences + .FirstOrDefaultAsync( + record => record.ProviderKind == (int)providerKind, + cancellationToken); + + if (preference is null) + { + preference = new ProviderPreferenceRecord + { + ProviderKind = (int)providerKind, + }; + dbContext.ProviderPreferences.Add(preference); + } + + if (preference.IsEnabled) + { + return; + } + + preference.IsEnabled = true; + preference.UpdatedAt = timeProvider.GetUtcNow(); + AgentSessionServiceLog.DefaultProviderEnabled(logger, providerKind); + await dbContext.SaveChangesAsync(cancellationToken); + } + + private static async ValueTask ResolveSeedProviderKindAsync( + LocalAgentSessionDbContext dbContext, + CancellationToken cancellationToken) + { + var enabledProviderKinds = await dbContext.ProviderPreferences + .Where(record => record.IsEnabled) + .Select(record => (AgentProviderKind)record.ProviderKind) + .ToArrayAsync(cancellationToken); + + foreach (var providerKind in enabledProviderKinds) + { + var profile = AgentSessionProviderCatalog.Get(providerKind); + if (!profile.SupportsLiveExecution || profile.IsBuiltIn) + { + continue; + } + + if (!string.IsNullOrWhiteSpace(AgentSessionCommandProbe.ResolveExecutablePath(profile.CommandName))) + { + return providerKind; + } + } + + return AgentProviderKind.Debug; + } + + private async ValueTask> GetProviderStatusesAsync( + bool forceRefresh, + CancellationToken cancellationToken) + { + return forceRefresh + ? await providerStatusReader.RefreshAsync(cancellationToken) + : await providerStatusReader.ReadAsync(cancellationToken); + } + + private void InvalidateProviderStatusSnapshot() + { + providerStatusReader.Invalidate(); + } + + private async Task NormalizeLegacyAgentProfilesAsync( + LocalAgentSessionDbContext dbContext, + CancellationToken cancellationToken) + { + var legacyDebugModel = AgentSessionDefaults.GetDefaultModel(AgentProviderKind.Debug); + var legacyAgents = await dbContext.AgentProfiles + .Where(record => + (record.ProviderKind != (int)AgentProviderKind.Debug && + record.ModelName == legacyDebugModel) || + string.IsNullOrWhiteSpace(record.Description)) + .ToListAsync(cancellationToken); + if (legacyAgents.Count == 0) + { + return; + } + + foreach (var agent in legacyAgents) + { + var providerKind = (AgentProviderKind)agent.ProviderKind; + if (agent.ProviderKind != (int)AgentProviderKind.Debug && + agent.ModelName == legacyDebugModel) + { + agent.ModelName = AgentSessionDefaults.GetDefaultModel(providerKind); + AgentSessionServiceLog.LegacyAgentProfileNormalized(logger, agent.Id, providerKind, agent.ModelName); + } + + if (string.IsNullOrWhiteSpace(agent.Description)) + { + agent.Description = ResolveAgentDescription(string.Empty, agent.SystemPrompt); + } + } + + await dbContext.SaveChangesAsync(cancellationToken); + } + + 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 string CreateToolStartText(AgentSessionProviderProfile providerProfile) + { + return providerProfile.Kind == AgentProviderKind.Debug + ? "Preparing local debug workflow." + : string.Create( + System.Globalization.CultureInfo.InvariantCulture, + $"Launching {providerProfile.DisplayName} in the local playground."); + } + + private static string CreateToolDoneText(AgentSessionProviderProfile providerProfile) + { + return providerProfile.Kind == AgentProviderKind.Debug + ? "Debug workflow finished." + : string.Create( + System.Globalization.CultureInfo.InvariantCulture, + $"{providerProfile.DisplayName} turn finished."); + } + + 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, + string.IsNullOrWhiteSpace(record.Description) + ? ResolveAgentDescription(string.Empty, record.SystemPrompt) + : record.Description, + (AgentProviderKind)record.ProviderKind, + providerProfile.DisplayName, + record.ModelName, + record.SystemPrompt, + record.CreatedAt); + } + + private static List OrderAgents(IReadOnlyList agents) + { + var hasNonSystemAgents = agents.Any(record => !AgentSessionDefaults.IsSystemAgent(record.Name)); + + return agents + .OrderBy(record => hasNonSystemAgents && AgentSessionDefaults.IsSystemAgent(record.Name) ? 1 : 0) + .ThenByDescending(record => record.CreatedAt) + .ThenBy(record => record.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + public void Dispose() + { + _initializationGate.Dispose(); + } + + private static string ResolveAgentDescription(string description, string systemPrompt) + { + var normalizedDescription = description.Trim(); + if (!string.IsNullOrWhiteSpace(normalizedDescription)) + { + return normalizedDescription; + } + + return string.IsNullOrWhiteSpace(systemPrompt) + ? string.Empty + : AgentSessionDefaults.CreateAgentDescription(systemPrompt); + } +} diff --git a/DotPilot.Core/ChatSessions/Execution/CodexChatClient.cs b/DotPilot.Core/ChatSessions/Execution/CodexChatClient.cs new file mode 100644 index 0000000..30c4c28 --- /dev/null +++ b/DotPilot.Core/ChatSessions/Execution/CodexChatClient.cs @@ -0,0 +1,326 @@ +using System.Globalization; +using System.Runtime.CompilerServices; +using System.Security.Cryptography; +using System.Text; +using DotPilot.Core.ControlPlaneDomain; +using ManagedCode.CodexSharpSDK.Client; +using ManagedCode.CodexSharpSDK.Configuration; +using ManagedCode.CodexSharpSDK.Models; +using Microsoft.Extensions.AI; + +namespace DotPilot.Core.ChatSessions; + +internal sealed class CodexChatClient( + SessionId sessionId, + string agentName, + string fallbackModelName, + LocalCodexThreadStateStore threadStateStore, + TimeProvider timeProvider) : IChatClient +{ + private const string ContinuePrompt = "Continue the conversation."; + private readonly SemaphoreSlim _initializationGate = new(1, 1); + private CodexClient? _client; + private CodexThread? _thread; + private LocalCodexThreadState? _threadState; + private bool _disposed; + + public async Task GetResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + var prompt = ResolveLatestUserPrompt(messages); + var instructions = options?.Instructions ?? string.Empty; + var turnContext = await EnsureThreadAsync(options?.ModelId, instructions, cancellationToken); + var result = await turnContext.Thread.RunAsync( + BuildInputs(prompt, instructions, turnContext.State.InstructionsSeeded), + new TurnOptions + { + CancellationToken = cancellationToken, + }); + + await MarkInstructionsSeededAsync(turnContext.State, cancellationToken); + + var timestamp = timeProvider.GetUtcNow(); + var message = new ChatMessage(ChatRole.Assistant, ResolveResponseText(result)) + { + AuthorName = agentName, + CreatedAt = timestamp, + MessageId = Guid.CreateVersion7().ToString("N", CultureInfo.InvariantCulture), + }; + + return new ChatResponse(message) + { + CreatedAt = timestamp, + }; + } + + public async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + var prompt = ResolveLatestUserPrompt(messages); + var instructions = options?.Instructions ?? string.Empty; + var turnContext = await EnsureThreadAsync(options?.ModelId, instructions, cancellationToken); + var streamed = await turnContext.Thread.RunStreamedAsync( + BuildInputs(prompt, instructions, turnContext.State.InstructionsSeeded), + new TurnOptions + { + CancellationToken = cancellationToken, + }); + + await MarkInstructionsSeededAsync(turnContext.State, cancellationToken); + + var messageId = Guid.CreateVersion7().ToString("N", CultureInfo.InvariantCulture); + Dictionary observedTextLengths = []; + + await foreach (var evt in streamed.Events.WithCancellation(cancellationToken)) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (evt is TurnFailedEvent failed) + { + throw new InvalidOperationException(failed.Error.Message); + } + + if (!TryCreateAssistantUpdate(evt, observedTextLengths, messageId, out var update)) + { + continue; + } + + update.AuthorName = agentName; + update.CreatedAt = timeProvider.GetUtcNow(); + yield return update; + } + } + + public object? GetService(Type serviceType, object? serviceKey = null) + { + ArgumentNullException.ThrowIfNull(serviceType); + return serviceType == typeof(IChatClient) ? this : null; + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _thread?.Dispose(); + _client?.Dispose(); + _initializationGate.Dispose(); + _disposed = true; + } + + private async Task EnsureThreadAsync( + string? requestedModelName, + string systemInstructions, + CancellationToken cancellationToken) + { + var normalizedModelName = string.IsNullOrWhiteSpace(requestedModelName) + ? fallbackModelName + : requestedModelName.Trim(); + var normalizedPromptHash = ComputeHash(systemInstructions); + + await _initializationGate.WaitAsync(cancellationToken); + try + { + ThrowIfDisposed(); + + if (_thread is not null && _threadState is not null) + { + return new CodexTurnContext(_thread, _threadState); + } + + _client ??= new CodexClient(new CodexOptions()); + + var persistedState = await threadStateStore.TryLoadAsync(sessionId, cancellationToken); + var workingDirectory = ResolveWorkingDirectory(persistedState); + Directory.CreateDirectory(workingDirectory); + + if (CanResumeThread(persistedState, normalizedModelName, normalizedPromptHash)) + { + var resumableState = persistedState!; + try + { + _thread = _client.ResumeThread( + resumableState.ThreadId, + CreateThreadOptions(normalizedModelName, workingDirectory)); + _threadState = resumableState; + return new CodexTurnContext(_thread, _threadState); + } + catch + { + } + } + + _thread = _client.StartThread(CreateThreadOptions(normalizedModelName, workingDirectory)); + _threadState = new LocalCodexThreadState( + _thread.Id ?? Guid.CreateVersion7().ToString("N", CultureInfo.InvariantCulture), + workingDirectory, + normalizedModelName, + normalizedPromptHash, + InstructionsSeeded: false); + await threadStateStore.SaveAsync(sessionId, _threadState, cancellationToken); + return new CodexTurnContext(_thread, _threadState); + } + finally + { + _initializationGate.Release(); + } + } + + private async Task MarkInstructionsSeededAsync( + LocalCodexThreadState state, + CancellationToken cancellationToken) + { + if (state.InstructionsSeeded) + { + return; + } + + var updatedState = state with + { + InstructionsSeeded = true, + }; + + await _initializationGate.WaitAsync(cancellationToken); + try + { + _threadState = updatedState; + await threadStateStore.SaveAsync(sessionId, updatedState, cancellationToken); + } + finally + { + _initializationGate.Release(); + } + } + + private string ResolveWorkingDirectory(LocalCodexThreadState? persistedState) + { + return string.IsNullOrWhiteSpace(persistedState?.WorkingDirectory) + ? threadStateStore.ResolvePlaygroundDirectory(sessionId) + : persistedState.WorkingDirectory; + } + + private static bool CanResumeThread( + LocalCodexThreadState? persistedState, + string modelName, + string systemPromptHash) + { + return persistedState is not null && + !string.IsNullOrWhiteSpace(persistedState.ThreadId) && + string.Equals(persistedState.ModelName, modelName, StringComparison.Ordinal) && + string.Equals(persistedState.SystemPromptHash, systemPromptHash, StringComparison.Ordinal); + } + + private static ThreadOptions CreateThreadOptions(string modelName, string workingDirectory) + { + return new ThreadOptions + { + ApprovalPolicy = ApprovalMode.Never, + Model = modelName, + SandboxMode = SandboxMode.WorkspaceWrite, + SkipGitRepoCheck = true, + WorkingDirectory = workingDirectory, + }; + } + + private static IReadOnlyList BuildInputs( + string prompt, + string systemInstructions, + bool instructionsSeeded) + { + var text = instructionsSeeded || string.IsNullOrWhiteSpace(systemInstructions) + ? prompt + : string.Create( + CultureInfo.InvariantCulture, + $$""" + Follow these system instructions for this entire session: + {{systemInstructions.Trim()}} + + Operator request: + {{prompt}} + """); + + return [new TextInput(text)]; + } + + private static string ResolveLatestUserPrompt(IEnumerable messages) + { + var prompt = messages + .LastOrDefault(static message => message.Role == ChatRole.User) + ?.Text + ?.Trim(); + + return string.IsNullOrWhiteSpace(prompt) ? ContinuePrompt : prompt; + } + + private static string ResolveResponseText(RunResult result) + { + if (!string.IsNullOrWhiteSpace(result.FinalResponse)) + { + return result.FinalResponse; + } + + return result.Items + .OfType() + .Select(static item => item.Text) + .LastOrDefault(static text => !string.IsNullOrWhiteSpace(text)) + ?? string.Empty; + } + + private static bool TryCreateAssistantUpdate( + ThreadEvent evt, + Dictionary observedTextLengths, + string messageId, + out ChatResponseUpdate update) + { + update = null!; + var item = evt switch + { + ItemUpdatedEvent updated => updated.Item, + ItemCompletedEvent completed => completed.Item, + _ => null, + }; + + if (item is not AgentMessageItem assistantMessageItem) + { + return false; + } + + var textKey = string.IsNullOrWhiteSpace(assistantMessageItem.Id) + ? messageId + : assistantMessageItem.Id; + var currentText = assistantMessageItem.Text ?? string.Empty; + observedTextLengths.TryGetValue(textKey, out var knownLength); + if (currentText.Length <= knownLength) + { + return false; + } + + observedTextLengths[textKey] = currentText.Length; + update = new ChatResponseUpdate(ChatRole.Assistant, currentText[knownLength..]) + { + MessageId = messageId, + }; + return true; + } + + private static string ComputeHash(string value) + { + var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(value ?? string.Empty)); + return Convert.ToHexString(bytes); + } + + private void ThrowIfDisposed() + { + ObjectDisposedException.ThrowIf(_disposed, this); + } + + private readonly record struct CodexTurnContext(CodexThread Thread, LocalCodexThreadState State); +} diff --git a/DotPilot.Core/ChatSessions/Execution/DebugChatClient.cs b/DotPilot.Core/ChatSessions/Execution/DebugChatClient.cs new file mode 100644 index 0000000..53d1443 --- /dev/null +++ b/DotPilot.Core/ChatSessions/Execution/DebugChatClient.cs @@ -0,0 +1,115 @@ +using System.Runtime.CompilerServices; +using Microsoft.Extensions.AI; + +namespace DotPilot.Core.ChatSessions; + +internal sealed class DebugChatClient(string agentName, TimeProvider timeProvider) : IChatClient +{ + private const int DefaultChunkDelayMilliseconds = 45; + private const int BrowserChunkDelayMilliseconds = 400; + 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(GetChunkDelayMilliseconds(), 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.", + ]); + } + + private static int GetChunkDelayMilliseconds() + { + return OperatingSystem.IsBrowser() + ? BrowserChunkDelayMilliseconds + : DefaultChunkDelayMilliseconds; + } +} diff --git a/DotPilot.Core/ChatSessions/Execution/SessionActivityMonitor.cs b/DotPilot.Core/ChatSessions/Execution/SessionActivityMonitor.cs new file mode 100644 index 0000000..954e57e --- /dev/null +++ b/DotPilot.Core/ChatSessions/Execution/SessionActivityMonitor.cs @@ -0,0 +1,150 @@ +using DotPilot.Core.ControlPlaneDomain; +using Microsoft.Extensions.Logging; + +namespace DotPilot.Core.ChatSessions; + +internal sealed class SessionActivityMonitor(ILogger logger) : ISessionActivityMonitor +{ + private static readonly SessionActivitySnapshot EmptySnapshot = new( + false, + 0, + [], + null, + string.Empty, + null, + string.Empty, + string.Empty); + + private readonly Lock _gate = new(); + private readonly List _leases = []; + private SessionActivitySnapshot _current = EmptySnapshot; + + public SessionActivitySnapshot Current + { + get + { + lock (_gate) + { + return _current; + } + } + } + + public event EventHandler? StateChanged; + + public IDisposable BeginActivity(SessionActivityDescriptor descriptor) + { + ArgumentNullException.ThrowIfNull(descriptor); + + ActivityLease lease; + int activeSessionCount; + lock (_gate) + { + lease = new ActivityLease(this, descriptor); + _leases.Add(lease); + activeSessionCount = UpdateSnapshotUnsafe().ActiveSessionCount; + } + + SessionActivityMonitorLog.ActivityStarted( + logger, + descriptor.SessionId.Value, + descriptor.AgentProfileId.Value, + activeSessionCount); + StateChanged?.Invoke(this, EventArgs.Empty); + return lease; + } + + private void EndActivity(ActivityLease lease) + { + ArgumentNullException.ThrowIfNull(lease); + + int activeSessionCount; + lock (_gate) + { + var index = _leases.IndexOf(lease); + if (index < 0) + { + return; + } + + _leases.RemoveAt(index); + activeSessionCount = UpdateSnapshotUnsafe().ActiveSessionCount; + } + + SessionActivityMonitorLog.ActivityCompleted( + logger, + lease.Descriptor.SessionId.Value, + lease.Descriptor.AgentProfileId.Value, + activeSessionCount); + StateChanged?.Invoke(this, EventArgs.Empty); + } + + private SessionActivitySnapshot UpdateSnapshotUnsafe() + { + if (_leases.Count == 0) + { + _current = EmptySnapshot; + return _current; + } + + var activeSessions = GetActiveSessionsUnsafe(); + _current = CreateSnapshot(activeSessions, _leases[^1].Descriptor); + return _current; + } + + private List GetActiveSessionsUnsafe() + { + if (_leases.Count == 0) + { + return []; + } + + HashSet seen = []; + List descriptors = []; + for (var index = _leases.Count - 1; index >= 0; index--) + { + var descriptor = _leases[index].Descriptor; + if (!seen.Add(descriptor.SessionId)) + { + continue; + } + + descriptors.Add(descriptor); + } + + descriptors.Reverse(); + return descriptors; + } + + private static SessionActivitySnapshot CreateSnapshot( + List activeSessions, + SessionActivityDescriptor latestSession) + { + return new SessionActivitySnapshot( + true, + activeSessions.Count, + activeSessions, + latestSession.SessionId, + latestSession.SessionTitle, + latestSession.AgentProfileId, + latestSession.AgentName, + latestSession.ProviderDisplayName); + } + + private sealed class ActivityLease(SessionActivityMonitor owner, SessionActivityDescriptor descriptor) : IDisposable + { + private int _disposed; + + public SessionActivityDescriptor Descriptor { get; } = descriptor; + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + owner.EndActivity(this); + } + } +} diff --git a/DotPilot.Core/ChatSessions/Interfaces/IAgentSessionService.cs b/DotPilot.Core/ChatSessions/Interfaces/IAgentSessionService.cs new file mode 100644 index 0000000..c27be34 --- /dev/null +++ b/DotPilot.Core/ChatSessions/Interfaces/IAgentSessionService.cs @@ -0,0 +1,33 @@ +using DotPilot.Core.ControlPlaneDomain; +using ManagedCode.Communication; + +namespace DotPilot.Core.ChatSessions.Interfaces; + +public interface IAgentSessionService +{ + ValueTask> GetWorkspaceAsync(CancellationToken cancellationToken); + + ValueTask> RefreshWorkspaceAsync(CancellationToken cancellationToken); + + ValueTask> GetSessionAsync(SessionId sessionId, CancellationToken cancellationToken); + + ValueTask> CreateAgentAsync( + CreateAgentProfileCommand command, + CancellationToken cancellationToken); + + ValueTask> UpdateAgentAsync( + UpdateAgentProfileCommand 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/ChatSessions/Interfaces/ISessionActivityMonitor.cs b/DotPilot.Core/ChatSessions/Interfaces/ISessionActivityMonitor.cs new file mode 100644 index 0000000..5df5bd6 --- /dev/null +++ b/DotPilot.Core/ChatSessions/Interfaces/ISessionActivityMonitor.cs @@ -0,0 +1,10 @@ +namespace DotPilot.Core.ChatSessions; + +public interface ISessionActivityMonitor +{ + SessionActivitySnapshot Current { get; } + + event EventHandler? StateChanged; + + IDisposable BeginActivity(SessionActivityDescriptor descriptor); +} diff --git a/DotPilot.Core/ChatSessions/Models/AgentExecutionLoggingModels.cs b/DotPilot.Core/ChatSessions/Models/AgentExecutionLoggingModels.cs new file mode 100644 index 0000000..a25565f --- /dev/null +++ b/DotPilot.Core/ChatSessions/Models/AgentExecutionLoggingModels.cs @@ -0,0 +1,118 @@ +using System.Globalization; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; + +namespace DotPilot.Core.ChatSessions; + +internal sealed partial class AgentExecutionLoggingMiddleware +{ + private const string SessionIdPropertyName = "dotpilot.session.id"; + private const string RunIdPropertyName = "dotpilot.run.id"; + private const string AgentIdPropertyName = "dotpilot.agent.id"; + private const string AgentNamePropertyName = "dotpilot.agent.name"; + private const string ProviderKindPropertyName = "dotpilot.provider.kind"; + private const string ProviderDisplayNamePropertyName = "dotpilot.provider.display_name"; + private const string ModelNamePropertyName = "dotpilot.model.name"; + private const string UnknownValue = ""; + private const string NoneValue = ""; + + private static IReadOnlyList MaterializeMessages(IEnumerable messages) + { + ArgumentNullException.ThrowIfNull(messages); + return messages as IReadOnlyList ?? messages.ToArray(); + } + + private static int CountMessageCharacters(IEnumerable messages) + { + var total = 0; + foreach (var message in messages) + { + total += message.Text?.Length ?? 0; + } + + return total; + } + + private static int CountTools(ChatOptions? options) + { + return options?.Tools?.Count ?? 0; + } + + private static AdditionalPropertiesDictionary CreateAdditionalProperties(AgentRunLogContext runContext) + { + return new AdditionalPropertiesDictionary + { + [RunIdPropertyName] = runContext.RunId, + [SessionIdPropertyName] = runContext.SessionId, + [AgentIdPropertyName] = runContext.AgentId.ToString("N", CultureInfo.InvariantCulture), + [AgentNamePropertyName] = runContext.AgentName, + [ProviderKindPropertyName] = runContext.ProviderKind.ToString(), + [ProviderDisplayNamePropertyName] = runContext.ProviderDisplayName, + [ModelNamePropertyName] = runContext.ModelName, + }; + } + + private static AgentRunLogContext ResolveRunContext( + AgentExecutionDescriptor descriptor, + AgentRunOptions? options) + { + var properties = options?.AdditionalProperties; + return new AgentRunLogContext( + GetAdditionalProperty(properties, RunIdPropertyName, NoneValue), + GetAdditionalProperty(properties, SessionIdPropertyName, UnknownValue), + descriptor.AgentId, + GetAdditionalProperty(properties, AgentNamePropertyName, descriptor.AgentName), + descriptor.ProviderKind, + GetAdditionalProperty(properties, ProviderDisplayNamePropertyName, descriptor.ProviderDisplayName), + GetAdditionalProperty(properties, ModelNamePropertyName, descriptor.ModelName)); + } + + private IDisposable? BeginScope(AgentRunLogContext runContext) + { + return logger.BeginScope(new Dictionary + { + [RunIdPropertyName] = runContext.RunId, + [SessionIdPropertyName] = runContext.SessionId, + [AgentIdPropertyName] = runContext.AgentId.ToString("N", CultureInfo.InvariantCulture), + [AgentNamePropertyName] = runContext.AgentName, + [ProviderKindPropertyName] = runContext.ProviderKind.ToString(), + [ProviderDisplayNamePropertyName] = runContext.ProviderDisplayName, + [ModelNamePropertyName] = runContext.ModelName, + }); + } + + private static string GetAdditionalProperty( + AdditionalPropertiesDictionary? properties, + string key, + string fallback) + { + if (properties is not null && + properties.TryGetValue(key, out var value) && + value is not null) + { + return Convert.ToString(value, CultureInfo.InvariantCulture) ?? fallback; + } + + return fallback; + } +} + +internal sealed record AgentExecutionDescriptor( + Guid AgentId, + string AgentName, + AgentProviderKind ProviderKind, + string ProviderDisplayName, + string ModelName); + +internal sealed record AgentRunLogContext( + string RunId, + string SessionId, + Guid AgentId, + string AgentName, + AgentProviderKind ProviderKind, + string ProviderDisplayName, + string ModelName); + +internal sealed record AgentExecutionRunConfiguration( + AgentRunLogContext Context, + ChatClientAgentRunOptions Options); diff --git a/DotPilot.Core/ChatSessions/Models/AgentSessionStates.cs b/DotPilot.Core/ChatSessions/Models/AgentSessionStates.cs new file mode 100644 index 0000000..97ff9d1 --- /dev/null +++ b/DotPilot.Core/ChatSessions/Models/AgentSessionStates.cs @@ -0,0 +1,28 @@ +namespace DotPilot.Core.ChatSessions.Models; + +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/ChatSessions/Models/RuntimeConversationContext.cs b/DotPilot.Core/ChatSessions/Models/RuntimeConversationContext.cs new file mode 100644 index 0000000..95ffd9c --- /dev/null +++ b/DotPilot.Core/ChatSessions/Models/RuntimeConversationContext.cs @@ -0,0 +1,9 @@ +using Microsoft.Agents.AI; + +namespace DotPilot.Core.ChatSessions; + +internal sealed record RuntimeConversationContext( + AIAgent Agent, + AgentSession Session, + AgentExecutionDescriptor Descriptor, + bool IsTransient = false); diff --git a/DotPilot.Core/ChatSessions/Persistence/Configuration/AgentProfileSchemaDefaults.cs b/DotPilot.Core/ChatSessions/Persistence/Configuration/AgentProfileSchemaDefaults.cs new file mode 100644 index 0000000..f09d3ca --- /dev/null +++ b/DotPilot.Core/ChatSessions/Persistence/Configuration/AgentProfileSchemaDefaults.cs @@ -0,0 +1,9 @@ +namespace DotPilot.Core.ChatSessions; + +internal static class AgentProfileSchemaDefaults +{ + // Legacy SQLite stores still require these columns even though the current UI no longer edits them. + public const int DefaultRole = 4; + + public const string EmptyCapabilitiesJson = "[]"; +} diff --git a/DotPilot.Core/ChatSessions/Persistence/Configuration/AgentSessionJsonSerializerContext.cs b/DotPilot.Core/ChatSessions/Persistence/Configuration/AgentSessionJsonSerializerContext.cs new file mode 100644 index 0000000..263d5c9 --- /dev/null +++ b/DotPilot.Core/ChatSessions/Persistence/Configuration/AgentSessionJsonSerializerContext.cs @@ -0,0 +1,8 @@ +using System.Text.Json.Serialization; + +namespace DotPilot.Core.ChatSessions; + +[JsonSerializable(typeof(string[]))] +internal sealed partial class AgentSessionJsonSerializerContext : JsonSerializerContext +{ +} diff --git a/DotPilot.Core/ChatSessions/Persistence/Configuration/AgentSessionSerialization.cs b/DotPilot.Core/ChatSessions/Persistence/Configuration/AgentSessionSerialization.cs new file mode 100644 index 0000000..45e221f --- /dev/null +++ b/DotPilot.Core/ChatSessions/Persistence/Configuration/AgentSessionSerialization.cs @@ -0,0 +1,18 @@ +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; + +namespace DotPilot.Core.ChatSessions; + +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.Core/ChatSessions/Persistence/Configuration/AgentSessionStorageOptions.cs b/DotPilot.Core/ChatSessions/Persistence/Configuration/AgentSessionStorageOptions.cs new file mode 100644 index 0000000..28892b5 --- /dev/null +++ b/DotPilot.Core/ChatSessions/Persistence/Configuration/AgentSessionStorageOptions.cs @@ -0,0 +1,18 @@ +namespace DotPilot.Core.ChatSessions; + +public sealed class AgentSessionStorageOptions +{ + public bool UseInMemoryDatabase { get; init; } + + public bool PreferTransientRuntimeConversation { get; init; } + + public string InMemoryDatabaseName { get; init; } = "DotPilotAgentSessions"; + + public string? DatabasePath { get; init; } + + public string? RuntimeSessionDirectoryPath { get; init; } + + public string? ChatHistoryDirectoryPath { get; init; } + + public string? PlaygroundDirectoryPath { get; init; } +} diff --git a/DotPilot.Core/ChatSessions/Persistence/Configuration/AgentSessionStoragePaths.cs b/DotPilot.Core/ChatSessions/Persistence/Configuration/AgentSessionStoragePaths.cs new file mode 100644 index 0000000..5c27981 --- /dev/null +++ b/DotPilot.Core/ChatSessions/Persistence/Configuration/AgentSessionStoragePaths.cs @@ -0,0 +1,67 @@ +using DotPilot.Core.ControlPlaneDomain; + +namespace DotPilot.Core.ChatSessions; + +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"; + private const string PlaygroundFolderName = "agent-playgrounds"; + + 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; + } + + public static string ResolvePlaygroundRootDirectory(AgentSessionStorageOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + return string.IsNullOrWhiteSpace(options.PlaygroundDirectoryPath) + ? Path.Combine(GetAppStorageRoot(), PlaygroundFolderName) + : options.PlaygroundDirectoryPath; + } + + public static string ResolvePlaygroundDirectory( + AgentSessionStorageOptions options, + SessionId sessionId) + { + return Path.Combine( + ResolvePlaygroundRootDirectory(options), + sessionId.Value.ToString("N", System.Globalization.CultureInfo.InvariantCulture)); + } + + private static string GetAppStorageRoot() + { + return Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + AppFolderName); + } +} diff --git a/DotPilot.Core/ChatSessions/Persistence/Models/FolderChatHistoryState.cs b/DotPilot.Core/ChatSessions/Persistence/Models/FolderChatHistoryState.cs new file mode 100644 index 0000000..f757f6e --- /dev/null +++ b/DotPilot.Core/ChatSessions/Persistence/Models/FolderChatHistoryState.cs @@ -0,0 +1,6 @@ +namespace DotPilot.Core.ChatSessions; + +internal sealed class FolderChatHistoryState +{ + public string? StorageKey { get; set; } +} diff --git a/DotPilot.Core/ChatSessions/Persistence/Models/LocalAgentSessionRecords.cs b/DotPilot.Core/ChatSessions/Persistence/Models/LocalAgentSessionRecords.cs new file mode 100644 index 0000000..7aa2f1e --- /dev/null +++ b/DotPilot.Core/ChatSessions/Persistence/Models/LocalAgentSessionRecords.cs @@ -0,0 +1,63 @@ +namespace DotPilot.Core.ChatSessions; + +internal sealed class AgentProfileRecord +{ + public Guid Id { get; set; } + + public string Name { get; set; } = string.Empty; + + public string Description { 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; } = AgentProfileSchemaDefaults.EmptyCapabilitiesJson; + + 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.Core/ChatSessions/Persistence/Models/LocalCodexThreadState.cs b/DotPilot.Core/ChatSessions/Persistence/Models/LocalCodexThreadState.cs new file mode 100644 index 0000000..77c8954 --- /dev/null +++ b/DotPilot.Core/ChatSessions/Persistence/Models/LocalCodexThreadState.cs @@ -0,0 +1,8 @@ +namespace DotPilot.Core.ChatSessions; + +internal sealed record LocalCodexThreadState( + string ThreadId, + string WorkingDirectory, + string ModelName, + string SystemPromptHash, + bool InstructionsSeeded); diff --git a/DotPilot.Core/ChatSessions/Persistence/Services/AgentProfileSchemaCompatibilityEnsurer.cs b/DotPilot.Core/ChatSessions/Persistence/Services/AgentProfileSchemaCompatibilityEnsurer.cs new file mode 100644 index 0000000..55fe86b --- /dev/null +++ b/DotPilot.Core/ChatSessions/Persistence/Services/AgentProfileSchemaCompatibilityEnsurer.cs @@ -0,0 +1,88 @@ +using System.Data; +using Microsoft.EntityFrameworkCore; + +namespace DotPilot.Core.ChatSessions; + +internal static class AgentProfileSchemaCompatibilityEnsurer +{ + private const string AgentProfilesTableName = "AgentProfiles"; + private const string DescriptionColumnName = "Description"; + private const string RoleColumnName = "Role"; + private const string CapabilitiesJsonColumnName = "CapabilitiesJson"; + + public static async Task EnsureAsync(LocalAgentSessionDbContext dbContext, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(dbContext); + + if (!dbContext.Database.IsSqlite()) + { + return; + } + + var existingColumns = await ReadColumnNamesAsync(dbContext, cancellationToken); + if (!existingColumns.Contains(DescriptionColumnName, StringComparer.OrdinalIgnoreCase)) + { + await dbContext.Database.ExecuteSqlRawAsync( + $""" + ALTER TABLE "{AgentProfilesTableName}" + ADD COLUMN "{DescriptionColumnName}" TEXT NOT NULL DEFAULT ''; + """, + cancellationToken); + } + + if (!existingColumns.Contains(RoleColumnName, StringComparer.OrdinalIgnoreCase)) + { + await dbContext.Database.ExecuteSqlRawAsync( + $""" + ALTER TABLE "{AgentProfilesTableName}" + ADD COLUMN "{RoleColumnName}" INTEGER NOT NULL DEFAULT {AgentProfileSchemaDefaults.DefaultRole}; + """, + cancellationToken); + } + + if (!existingColumns.Contains(CapabilitiesJsonColumnName, StringComparer.OrdinalIgnoreCase)) + { + await dbContext.Database.ExecuteSqlRawAsync( + $""" + ALTER TABLE "{AgentProfilesTableName}" + ADD COLUMN "{CapabilitiesJsonColumnName}" TEXT NOT NULL DEFAULT '{AgentProfileSchemaDefaults.EmptyCapabilitiesJson}'; + """, + cancellationToken); + } + } + + private static async Task> ReadColumnNamesAsync( + LocalAgentSessionDbContext dbContext, + CancellationToken cancellationToken) + { + var connection = dbContext.Database.GetDbConnection(); + var shouldCloseConnection = connection.State != ConnectionState.Open; + if (shouldCloseConnection) + { + await connection.OpenAsync(cancellationToken); + } + + try + { + await using var command = connection.CreateCommand(); + command.CommandText = $"""PRAGMA table_info("{AgentProfilesTableName}")"""; + + HashSet columns = new(StringComparer.OrdinalIgnoreCase); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + var nameOrdinal = reader.GetOrdinal("name"); + while (await reader.ReadAsync(cancellationToken)) + { + columns.Add(reader.GetString(nameOrdinal)); + } + + return columns; + } + finally + { + if (shouldCloseConnection) + { + await connection.CloseAsync(); + } + } + } +} diff --git a/DotPilot.Core/ChatSessions/Persistence/Services/FolderChatHistoryProvider.cs b/DotPilot.Core/ChatSessions/Persistence/Services/FolderChatHistoryProvider.cs new file mode 100644 index 0000000..d818dc7 --- /dev/null +++ b/DotPilot.Core/ChatSessions/Persistence/Services/FolderChatHistoryProvider.cs @@ -0,0 +1,101 @@ +using System.Globalization; +using DotPilot.Core.ControlPlaneDomain; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; + +namespace DotPilot.Core.ChatSessions; + +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 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, + newMessages, + cancellationToken); + } + + private static string? GetStorageKey(AgentSession session) + { + ArgumentNullException.ThrowIfNull(session); + + 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; + } +} diff --git a/DotPilot.Core/ChatSessions/Persistence/Services/LocalAgentChatHistoryStore.cs b/DotPilot.Core/ChatSessions/Persistence/Services/LocalAgentChatHistoryStore.cs new file mode 100644 index 0000000..6cad909 --- /dev/null +++ b/DotPilot.Core/ChatSessions/Persistence/Services/LocalAgentChatHistoryStore.cs @@ -0,0 +1,91 @@ +using System.Text.Json; +using Microsoft.Extensions.AI; + +namespace DotPilot.Core.ChatSessions; + +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.Core/ChatSessions/Persistence/Services/LocalAgentSessionStateStore.cs b/DotPilot.Core/ChatSessions/Persistence/Services/LocalAgentSessionStateStore.cs new file mode 100644 index 0000000..4032c52 --- /dev/null +++ b/DotPilot.Core/ChatSessions/Persistence/Services/LocalAgentSessionStateStore.cs @@ -0,0 +1,101 @@ +using System.Globalization; +using System.Text.Json; +using DotPilot.Core.ControlPlaneDomain; +using Microsoft.Agents.AI; + +namespace DotPilot.Core.ChatSessions; + +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.Core/ChatSessions/Persistence/Services/LocalCodexThreadStateStore.cs b/DotPilot.Core/ChatSessions/Persistence/Services/LocalCodexThreadStateStore.cs new file mode 100644 index 0000000..c3c8160 --- /dev/null +++ b/DotPilot.Core/ChatSessions/Persistence/Services/LocalCodexThreadStateStore.cs @@ -0,0 +1,82 @@ +using System.Text.Json; +using DotPilot.Core.ControlPlaneDomain; + +namespace DotPilot.Core.ChatSessions; + +internal sealed class LocalCodexThreadStateStore(AgentSessionStorageOptions storageOptions) +{ + private const string StateFileName = "codex-thread.json"; + private const string TempSuffix = ".tmp"; + private readonly Dictionary _memoryStates = []; + + public async ValueTask TryLoadAsync( + SessionId sessionId, + CancellationToken cancellationToken) + { + var path = GetPath(sessionId); + if (UseTransientStore()) + { + return _memoryStates.GetValueOrDefault(path); + } + + if (!File.Exists(path)) + { + return null; + } + + await using var stream = File.OpenRead(path); + return await JsonSerializer.DeserializeAsync( + stream, + AgentSessionSerialization.Options, + cancellationToken); + } + + public async ValueTask SaveAsync( + SessionId sessionId, + LocalCodexThreadState state, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(state); + + var path = GetPath(sessionId); + if (UseTransientStore()) + { + _memoryStates[path] = state; + return; + } + + 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, + state, + AgentSessionSerialization.Options, + cancellationToken); + } + + File.Move(tempPath, path, overwrite: true); + } + + public string ResolvePlaygroundDirectory(SessionId sessionId) + { + return AgentSessionStoragePaths.ResolvePlaygroundDirectory(storageOptions, sessionId); + } + + private string GetPath(SessionId sessionId) + { + var directory = ResolvePlaygroundDirectory(sessionId); + return Path.Combine(directory, StateFileName); + } + + private bool UseTransientStore() + { + return storageOptions.UseInMemoryDatabase || OperatingSystem.IsBrowser(); + } +} diff --git a/DotPilot.Core/ChatSessions/Persistence/Storage/LocalAgentSessionDbContext.cs b/DotPilot.Core/ChatSessions/Persistence/Storage/LocalAgentSessionDbContext.cs new file mode 100644 index 0000000..0a88d7b --- /dev/null +++ b/DotPilot.Core/ChatSessions/Persistence/Storage/LocalAgentSessionDbContext.cs @@ -0,0 +1,55 @@ +using Microsoft.EntityFrameworkCore; + +namespace DotPilot.Core.ChatSessions; + +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.Description) + .HasDefaultValue(string.Empty) + .IsRequired(); + entity.Property(record => record.Role) + .HasDefaultValue(AgentProfileSchemaDefaults.DefaultRole); + entity.Property(record => record.ModelName).IsRequired(); + entity.Property(record => record.SystemPrompt).IsRequired(); + entity.Property(record => record.CapabilitiesJson) + .HasDefaultValue(AgentProfileSchemaDefaults.EmptyCapabilitiesJson) + .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(); + }); + } +} diff --git a/DotPilot.Core/Features/ControlPlaneDomain/ParticipantContracts.cs b/DotPilot.Core/ControlPlaneDomain/Contracts/ParticipantContracts.cs similarity index 91% rename from DotPilot.Core/Features/ControlPlaneDomain/ParticipantContracts.cs rename to DotPilot.Core/ControlPlaneDomain/Contracts/ParticipantContracts.cs index 4bb177c..1466999 100644 --- a/DotPilot.Core/Features/ControlPlaneDomain/ParticipantContracts.cs +++ b/DotPilot.Core/ControlPlaneDomain/Contracts/ParticipantContracts.cs @@ -1,4 +1,4 @@ -namespace DotPilot.Core.Features.ControlPlaneDomain; +namespace DotPilot.Core.ControlPlaneDomain; [GenerateSerializer] public sealed record WorkspaceDescriptor @@ -26,18 +26,15 @@ public sealed record AgentProfileDescriptor public string Name { get; init; } = string.Empty; [Id(2)] - public AgentRoleKind Role { get; init; } - - [Id(3)] public ProviderId? ProviderId { get; init; } - [Id(4)] + [Id(3)] public ModelRuntimeId? ModelRuntimeId { get; init; } - [Id(5)] + [Id(4)] public ToolCapabilityId[] ToolCapabilityIds { get; init; } = []; - [Id(6)] + [Id(5)] public string[] Tags { get; init; } = []; } diff --git a/DotPilot.Core/Features/ControlPlaneDomain/ProviderAndToolContracts.cs b/DotPilot.Core/ControlPlaneDomain/Contracts/ProviderAndToolContracts.cs similarity index 96% rename from DotPilot.Core/Features/ControlPlaneDomain/ProviderAndToolContracts.cs rename to DotPilot.Core/ControlPlaneDomain/Contracts/ProviderAndToolContracts.cs index 8aa0232..1da6ab9 100644 --- a/DotPilot.Core/Features/ControlPlaneDomain/ProviderAndToolContracts.cs +++ b/DotPilot.Core/ControlPlaneDomain/Contracts/ProviderAndToolContracts.cs @@ -1,4 +1,4 @@ -namespace DotPilot.Core.Features.ControlPlaneDomain; +namespace DotPilot.Core.ControlPlaneDomain; [GenerateSerializer] public sealed record ToolCapabilityDescriptor diff --git a/DotPilot.Core/Features/ControlPlaneDomain/SessionExecutionContracts.cs b/DotPilot.Core/ControlPlaneDomain/Contracts/SessionExecutionContracts.cs similarity index 98% rename from DotPilot.Core/Features/ControlPlaneDomain/SessionExecutionContracts.cs rename to DotPilot.Core/ControlPlaneDomain/Contracts/SessionExecutionContracts.cs index 6a3e83d..f3671a7 100644 --- a/DotPilot.Core/Features/ControlPlaneDomain/SessionExecutionContracts.cs +++ b/DotPilot.Core/ControlPlaneDomain/Contracts/SessionExecutionContracts.cs @@ -1,4 +1,4 @@ -namespace DotPilot.Core.Features.ControlPlaneDomain; +namespace DotPilot.Core.ControlPlaneDomain; [GenerateSerializer] public sealed record SessionDescriptor diff --git a/DotPilot.Core/Features/ControlPlaneDomain/ControlPlaneIdentifiers.cs b/DotPilot.Core/ControlPlaneDomain/Identifiers/ControlPlaneIdentifiers.cs similarity index 98% rename from DotPilot.Core/Features/ControlPlaneDomain/ControlPlaneIdentifiers.cs rename to DotPilot.Core/ControlPlaneDomain/Identifiers/ControlPlaneIdentifiers.cs index 3799537..3e42123 100644 --- a/DotPilot.Core/Features/ControlPlaneDomain/ControlPlaneIdentifiers.cs +++ b/DotPilot.Core/ControlPlaneDomain/Identifiers/ControlPlaneIdentifiers.cs @@ -1,6 +1,6 @@ using System.Globalization; -namespace DotPilot.Core.Features.ControlPlaneDomain; +namespace DotPilot.Core.ControlPlaneDomain; [GenerateSerializer] public readonly record struct WorkspaceId([property: Id(0)] Guid Value) diff --git a/DotPilot.Core/Features/ControlPlaneDomain/ControlPlaneStates.cs b/DotPilot.Core/ControlPlaneDomain/Models/ControlPlaneStates.cs similarity index 86% rename from DotPilot.Core/Features/ControlPlaneDomain/ControlPlaneStates.cs rename to DotPilot.Core/ControlPlaneDomain/Models/ControlPlaneStates.cs index 5318f6a..907ceed 100644 --- a/DotPilot.Core/Features/ControlPlaneDomain/ControlPlaneStates.cs +++ b/DotPilot.Core/ControlPlaneDomain/Models/ControlPlaneStates.cs @@ -1,4 +1,4 @@ -namespace DotPilot.Core.Features.ControlPlaneDomain; +namespace DotPilot.Core.ControlPlaneDomain; public enum SessionPhase { @@ -27,16 +27,6 @@ public enum ApprovalState Rejected, } -public enum AgentRoleKind -{ - Coding, - Research, - Analyst, - Reviewer, - Operator, - Orchestrator, -} - public enum FleetExecutionMode { SingleAgent, diff --git a/DotPilot.Core/Features/ControlPlaneDomain/PolicyContracts.cs b/DotPilot.Core/ControlPlaneDomain/Policies/PolicyContracts.cs similarity index 90% rename from DotPilot.Core/Features/ControlPlaneDomain/PolicyContracts.cs rename to DotPilot.Core/ControlPlaneDomain/Policies/PolicyContracts.cs index c66747e..f443cfe 100644 --- a/DotPilot.Core/Features/ControlPlaneDomain/PolicyContracts.cs +++ b/DotPilot.Core/ControlPlaneDomain/Policies/PolicyContracts.cs @@ -1,4 +1,4 @@ -namespace DotPilot.Core.Features.ControlPlaneDomain; +namespace DotPilot.Core.ControlPlaneDomain; [GenerateSerializer] public sealed record PolicyDescriptor diff --git a/DotPilot.Core/DotPilot.Core.csproj b/DotPilot.Core/DotPilot.Core.csproj index af712af..60719cc 100644 --- a/DotPilot.Core/DotPilot.Core.csproj +++ b/DotPilot.Core/DotPilot.Core.csproj @@ -7,7 +7,18 @@ - + + + + + + + + + + + + diff --git a/DotPilot.Core/Features/ApplicationShell/AppConfig.cs b/DotPilot.Core/Features/ApplicationShell/AppConfig.cs deleted file mode 100644 index 36188ea..0000000 --- a/DotPilot.Core/Features/ApplicationShell/AppConfig.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace DotPilot.Core.Features.ApplicationShell; - -public sealed record AppConfig -{ - public string? Environment { get; init; } -} 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.Core/GlobalUsings.cs b/DotPilot.Core/GlobalUsings.cs new file mode 100644 index 0000000..cb5c6e1 --- /dev/null +++ b/DotPilot.Core/GlobalUsings.cs @@ -0,0 +1,6 @@ +global using DotPilot.Core.ChatSessions.Commands; +global using DotPilot.Core.ChatSessions.Contracts; +global using DotPilot.Core.ChatSessions.Interfaces; +global using DotPilot.Core.ChatSessions.Models; +global using DotPilot.Core.Providers.Interfaces; +global using DotPilot.Core.Workspace.Interfaces; diff --git a/DotPilot.Core/Providers/Configuration/AgentSessionProviderCatalog.cs b/DotPilot.Core/Providers/Configuration/AgentSessionProviderCatalog.cs new file mode 100644 index 0000000..87c8577 --- /dev/null +++ b/DotPilot.Core/Providers/Configuration/AgentSessionProviderCatalog.cs @@ -0,0 +1,85 @@ + +namespace DotPilot.Core.Providers; + +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 IReadOnlyList DebugModels = + [ + DebugModelName, + ]; + + private static readonly IReadOnlyList CodexModels = + [ + CodexModelName, + ]; + + private static readonly IReadOnlyList ClaudeModels = + [ + "claude-opus-4-6", + "claude-opus-4-5", + ClaudeModelName, + "claude-haiku-4-5", + "claude-sonnet-4", + ]; + + private static readonly IReadOnlyList CopilotModels = + [ + "claude-sonnet-4.6", + "claude-sonnet-4.5", + "claude-haiku-4.5", + "claude-opus-4.6", + "claude-opus-4.6-fast", + "claude-opus-4.5", + "claude-sonnet-4", + "gemini-3-pro-preview", + "gpt-5.4", + "gpt-5.3-codex", + "gpt-5.2-codex", + "gpt-5.2", + "gpt-5.1-codex-max", + "gpt-5.1-codex", + "gpt-5.1", + "gpt-5.1-codex-mini", + "gpt-5-mini", + "gpt-4.1", + ]; + + 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, DebugModels, DebugInstallCommand, true, true), + new(AgentProviderKind.Codex, CodexDisplayName, CodexCommandName, CodexModelName, CodexModels, CodexInstallCommand, false, true), + new(AgentProviderKind.ClaudeCode, ClaudeDisplayName, ClaudeCommandName, ClaudeModelName, ClaudeModels, ClaudeInstallCommand, false, false), + new(AgentProviderKind.GitHubCopilot, CopilotDisplayName, CopilotCommandName, CopilotModelName, CopilotModels, CopilotInstallCommand, false, false), + ]; + } +} diff --git a/DotPilot.Runtime/Features/ToolchainCenter/ToolchainCommandProbe.cs b/DotPilot.Core/Providers/Infrastructure/AgentSessionCommandProbe.cs similarity index 72% rename from DotPilot.Runtime/Features/ToolchainCenter/ToolchainCommandProbe.cs rename to DotPilot.Core/Providers/Infrastructure/AgentSessionCommandProbe.cs index e2e7468..d9f5c2d 100644 --- a/DotPilot.Runtime/Features/ToolchainCenter/ToolchainCommandProbe.cs +++ b/DotPilot.Core/Providers/Infrastructure/AgentSessionCommandProbe.cs @@ -1,62 +1,75 @@ using System.Diagnostics; +using System.Runtime.InteropServices; -namespace DotPilot.Runtime.Features.ToolchainCenter; +namespace DotPilot.Core.Providers; -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 ReadVersion(string executablePath, IReadOnlyList arguments) - => ProbeVersion(executablePath, arguments).Version; - - public static ToolchainVersionProbeResult ProbeVersion(string executablePath, IReadOnlyList arguments) + public static string? ResolveExecutablePath(string commandName) { - ArgumentException.ThrowIfNullOrWhiteSpace(executablePath); - ArgumentNullException.ThrowIfNull(arguments); + if (OperatingSystem.IsBrowser()) + { + return null; + } - var execution = Execute(executablePath, arguments); - if (!execution.Succeeded) + var searchPaths = (Environment.GetEnvironmentVariable("PATH") ?? string.Empty) + .Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + foreach (var searchPath in searchPaths) { - return ToolchainVersionProbeResult.Missing with { Launched = execution.Launched }; + foreach (var candidate in EnumerateCandidates(searchPath, commandName)) + { + if (File.Exists(candidate)) + { + return candidate; + } + } } - var output = string.IsNullOrWhiteSpace(execution.StandardOutput) - ? execution.StandardError - : execution.StandardOutput; + return null; + } + public static string ReadVersion(string executablePath, IReadOnlyList arguments) + { + var output = ReadOutput(executablePath, arguments); 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) + public static string ReadOutput(string executablePath, IReadOnlyList arguments) { - ArgumentException.ThrowIfNullOrWhiteSpace(executablePath); - ArgumentNullException.ThrowIfNull(arguments); + var execution = Execute(executablePath, arguments); + if (!execution.Succeeded) + { + return EmptyOutput; + } - return Execute(executablePath, arguments).Succeeded; + return string.IsNullOrWhiteSpace(execution.StandardOutput) + ? execution.StandardError + : execution.StandardOutput; } private static ToolchainCommandExecution Execute(string executablePath, IReadOnlyList arguments) { + ArgumentException.ThrowIfNullOrWhiteSpace(executablePath); + ArgumentNullException.ThrowIfNull(arguments); + var startInfo = new ProcessStartInfo { FileName = executablePath, @@ -111,6 +124,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 +179,6 @@ private static void TryTerminate(Process process) } catch { - // Best-effort cleanup only. } } @@ -165,15 +193,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.Core/Providers/Infrastructure/AgentSessionDeterministicIdentity.cs similarity index 50% rename from DotPilot.Runtime/Features/RuntimeFoundation/RuntimeFoundationDeterministicIdentity.cs rename to DotPilot.Core/Providers/Infrastructure/AgentSessionDeterministicIdentity.cs index a06f8a4..48dc273 100644 --- a/DotPilot.Runtime/Features/RuntimeFoundation/RuntimeFoundationDeterministicIdentity.cs +++ b/DotPilot.Core/Providers/Infrastructure/AgentSessionDeterministicIdentity.cs @@ -1,22 +1,14 @@ using System.Security.Cryptography; using System.Text; -using DotPilot.Core.Features.ControlPlaneDomain; +using DotPilot.Core.ControlPlaneDomain; -namespace DotPilot.Runtime.Features.RuntimeFoundation; +namespace DotPilot.Core.Providers; -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))); diff --git a/DotPilot.Core/Providers/Interfaces/IAgentProviderStatusReader.cs b/DotPilot.Core/Providers/Interfaces/IAgentProviderStatusReader.cs new file mode 100644 index 0000000..25ae3e4 --- /dev/null +++ b/DotPilot.Core/Providers/Interfaces/IAgentProviderStatusReader.cs @@ -0,0 +1,10 @@ +namespace DotPilot.Core.Providers.Interfaces; + +public interface IAgentProviderStatusReader +{ + ValueTask> ReadAsync(CancellationToken cancellationToken); + + ValueTask> RefreshAsync(CancellationToken cancellationToken); + + void Invalidate(); +} diff --git a/DotPilot.Core/Providers/Models/AgentSessionProviderProfile.cs b/DotPilot.Core/Providers/Models/AgentSessionProviderProfile.cs new file mode 100644 index 0000000..9e93e5e --- /dev/null +++ b/DotPilot.Core/Providers/Models/AgentSessionProviderProfile.cs @@ -0,0 +1,12 @@ + +namespace DotPilot.Core.Providers; + +internal sealed record AgentSessionProviderProfile( + AgentProviderKind Kind, + string DisplayName, + string CommandName, + string DefaultModelName, + IReadOnlyList SupportedModelNames, + string InstallCommand, + bool IsBuiltIn, + bool SupportsLiveExecution); diff --git a/DotPilot.Core/Providers/Models/ProviderCliMetadataSnapshot.cs b/DotPilot.Core/Providers/Models/ProviderCliMetadataSnapshot.cs new file mode 100644 index 0000000..9b3ba79 --- /dev/null +++ b/DotPilot.Core/Providers/Models/ProviderCliMetadataSnapshot.cs @@ -0,0 +1,6 @@ +namespace DotPilot.Core.Providers; + +internal sealed record ProviderCliMetadataSnapshot( + string? InstalledVersion, + string? SuggestedModelName, + IReadOnlyList SupportedModels); diff --git a/DotPilot.Core/Providers/Models/ProviderStatusProbeResult.cs b/DotPilot.Core/Providers/Models/ProviderStatusProbeResult.cs new file mode 100644 index 0000000..d848994 --- /dev/null +++ b/DotPilot.Core/Providers/Models/ProviderStatusProbeResult.cs @@ -0,0 +1,6 @@ + +namespace DotPilot.Core.Providers; + +internal sealed record ProviderStatusProbeResult( + ProviderStatusDescriptor Descriptor, + string? ExecutablePath); diff --git a/DotPilot.Core/Providers/Services/AgentProviderStatusReader.cs b/DotPilot.Core/Providers/Services/AgentProviderStatusReader.cs new file mode 100644 index 0000000..c3e5c94 --- /dev/null +++ b/DotPilot.Core/Providers/Services/AgentProviderStatusReader.cs @@ -0,0 +1,168 @@ +using System.Diagnostics; +using DotPilot.Core.ChatSessions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace DotPilot.Core.Providers; + +internal sealed class AgentProviderStatusReader( + IDbContextFactory dbContextFactory, + ILogger logger) + : IAgentProviderStatusReader +{ + 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(forceRefresh: false); + return await readTask.WaitAsync(cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) + { + AgentProviderStatusReaderLog.ReadFailed(logger, exception); + throw; + } + } + + public async ValueTask> RefreshAsync(CancellationToken cancellationToken) + { + try + { + var readTask = GetOrStartActiveRead(forceRefresh: true); + return await readTask.WaitAsync(cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) + { + AgentProviderStatusReaderLog.ReadFailed(logger, exception); + throw; + } + } + + public void Invalidate() + { + lock (activeReadSync) + { + 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; + } + + if (forceRefresh) + { + snapshotGeneration++; + cachedSnapshot = null; + } + + generation = snapshotGeneration; + activeReadGeneration = generation; + activeReadTask = CreateReadTask(generation); + activeTask = activeReadTask; + } + + return activeTask; + } + + private Task> CreateReadTask(long generation) + { + Task>? readTask = null; + readTask = ReadAndCacheAsync(); + return readTask; + + async Task> ReadAndCacheAsync() + { + try + { + var snapshot = await ReadFromCurrentSourcesAsync(); + lock (activeReadSync) + { + if (generation == snapshotGeneration) + { + cachedSnapshot = snapshot; + } + } + + return snapshot; + } + finally + { + lock (activeReadSync) + { + if (ReferenceEquals(activeReadTask, readTask)) + { + activeReadTask = null; + activeReadGeneration = -1; + } + } + } + } + } + + private async Task> ReadFromCurrentSourcesAsync() + { + var startedAt = Stopwatch.GetTimestamp(); + AgentProviderStatusReaderLog.ReadStarted(logger); + + await using var dbContext = await dbContextFactory.CreateDbContextAsync(CancellationToken.None); + var probeResults = await AgentProviderStatusSnapshotReader.BuildAsync(dbContext, CancellationToken.None); + var providers = probeResults + .Select(result => result.Descriptor) + .ToArray(); + + foreach (var probeResult in probeResults) + { + AgentProviderStatusReaderLog.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; + AgentProviderStatusReaderLog.ReadCompleted( + logger, + providers.Length, + elapsedMilliseconds); + } + + return providers; + } +} diff --git a/DotPilot.Core/Providers/Services/AgentProviderStatusSnapshotReader.cs b/DotPilot.Core/Providers/Services/AgentProviderStatusSnapshotReader.cs new file mode 100644 index 0000000..03013a5 --- /dev/null +++ b/DotPilot.Core/Providers/Services/AgentProviderStatusSnapshotReader.cs @@ -0,0 +1,291 @@ +using DotPilot.Core.ChatSessions; +using Microsoft.EntityFrameworkCore; + +namespace DotPilot.Core.Providers; + +internal static class AgentProviderStatusSnapshotReader +{ + private const string BrowserStatusSummary = + "Desktop CLI probing is unavailable in the browser automation head. Enable the provider to author its profile here."; + 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 ready for local desktop execution."; + private const string ProfileAuthoringAvailableSummaryFormat = + "{0} profile authoring is available, but live desktop execution is not available in this app yet."; + 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 ProfileAuthoringAvailableCompositeFormat = + System.Text.CompositeFormat.Parse(ProfileAuthoringAvailableSummaryFormat); + + public static async Task> BuildAsync( + LocalAgentSessionDbContext dbContext, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(dbContext); + + var preferences = await dbContext.ProviderPreferences + .ToDictionaryAsync( + preference => (AgentProviderKind)preference.ProviderKind, + cancellationToken); + var profiles = AgentSessionProviderCatalog.All; + + return await Task.Run( + async () => + { + List results = new(profiles.Count); + foreach (var profile in profiles) + { + cancellationToken.ThrowIfCancellationRequested(); + results.Add(await BuildProviderStatusAsync( + profile, + GetProviderPreference(profile.Kind, preferences), + cancellationToken).ConfigureAwait(false)); + } + + return (IReadOnlyList)results; + }, + cancellationToken).ConfigureAwait(false); + } + + 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 async ValueTask BuildProviderStatusAsync( + AgentSessionProviderProfile profile, + ProviderPreferenceRecord preference, + CancellationToken cancellationToken) + { + var providerId = AgentSessionDeterministicIdentity.CreateProviderId(profile.CommandName); + var actions = new List(); + var details = new List(); + string? executablePath = null; + var installedVersion = profile.IsBuiltIn ? profile.DefaultModelName : (string?)null; + var suggestedModelName = profile.DefaultModelName; + var supportedModelNames = ResolveSupportedModels( + profile.DefaultModelName, + profile.DefaultModelName, + profile.SupportedModelNames, + []); + var status = AgentProviderStatus.Ready; + var statusSummary = BuiltInStatusSummary; + var canCreateAgents = profile.IsBuiltIn; + + if (OperatingSystem.IsBrowser() && !profile.IsBuiltIn) + { + details.Add(new ProviderDetailDescriptor("Install command", profile.InstallCommand)); + actions.Add(new ProviderActionDescriptor("Install", "Run this on desktop.", profile.InstallCommand)); + status = AgentProviderStatus.Unsupported; + statusSummary = BrowserStatusSummary; + canCreateAgents = preference.IsEnabled; + } + else if (!profile.IsBuiltIn) + { + executablePath = AgentSessionCommandProbe.ResolveExecutablePath(profile.CommandName); + if (string.IsNullOrWhiteSpace(executablePath)) + { + details.Add(new ProviderDetailDescriptor("Install command", profile.InstallCommand)); + 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 + { + var metadata = await ResolveMetadataAsync(profile, executablePath, cancellationToken).ConfigureAwait(false); + installedVersion = metadata.InstalledVersion; + if (!LooksLikeInstalledVersion(installedVersion, profile.CommandName)) + { + installedVersion = AgentSessionCommandProbe.ReadVersion(executablePath, ["--version"]); + } + + actions.Add(new ProviderActionDescriptor("Open CLI", "CLI detected on PATH.", $"{profile.CommandName} --version")); + suggestedModelName = ResolveSuggestedModel(profile.DefaultModelName, metadata.SuggestedModelName); + supportedModelNames = ResolveSupportedModels( + profile.DefaultModelName, + suggestedModelName, + profile.SupportedModelNames, + metadata.SupportedModels); + details.AddRange(CreateProviderDetails(installedVersion, suggestedModelName, supportedModelNames)); + + if (profile.SupportsLiveExecution) + { + statusSummary = string.Format(System.Globalization.CultureInfo.InvariantCulture, ReadySummaryCompositeFormat, profile.DisplayName); + canCreateAgents = true; + } + else + { + status = AgentProviderStatus.Unsupported; + statusSummary = string.Format( + System.Globalization.CultureInfo.InvariantCulture, + ProfileAuthoringAvailableCompositeFormat, + profile.DisplayName); + canCreateAgents = true; + } + } + } + + if (!preference.IsEnabled) + { + status = AgentProviderStatus.Disabled; + statusSummary = $"{DisabledStatusSummary} {statusSummary}"; + canCreateAgents = false; + } + + return new ProviderStatusProbeResult( + new ProviderStatusDescriptor( + providerId, + profile.Kind, + profile.DisplayName, + profile.CommandName, + status, + statusSummary, + suggestedModelName, + supportedModelNames, + installedVersion, + preference.IsEnabled, + canCreateAgents, + details, + actions), + executablePath); + } + + private static async ValueTask ResolveMetadataAsync( + AgentSessionProviderProfile profile, + string executablePath, + CancellationToken cancellationToken) + { + return profile.Kind switch + { + AgentProviderKind.Codex => CreateCodexSnapshot(CodexCliMetadataReader.TryRead(executablePath)), + AgentProviderKind.ClaudeCode => ClaudeCodeCliMetadataReader.TryRead(executablePath, profile), + AgentProviderKind.GitHubCopilot => await CopilotCliMetadataReader.TryReadAsync( + executablePath, + profile, + cancellationToken).ConfigureAwait(false), + _ => new ProviderCliMetadataSnapshot(null, null, []), + }; + } + + private static ProviderCliMetadataSnapshot CreateCodexSnapshot(CodexCliMetadataSnapshot? metadata) + { + return new ProviderCliMetadataSnapshot( + metadata?.InstalledVersion, + metadata?.DefaultModel, + metadata?.AvailableModels ?? []); + } + + private static string ResolveSuggestedModel(string defaultModelName, string? suggestedModelName) + { + return string.IsNullOrWhiteSpace(suggestedModelName) + ? defaultModelName + : suggestedModelName; + } + + private static bool LooksLikeInstalledVersion(string? installedVersion, string commandName) + { + if (string.IsNullOrWhiteSpace(installedVersion)) + { + return false; + } + + if (string.Equals(installedVersion, commandName, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + return installedVersion.Any(char.IsDigit); + } + + private static IReadOnlyList ResolveSupportedModels( + string defaultModelName, + string suggestedModelName, + IReadOnlyList fallbackModels, + IReadOnlyList discoveredModels) + { + return [.. EnumerateSupportedModels(defaultModelName, suggestedModelName, fallbackModels, discoveredModels)]; + } + + private static IEnumerable EnumerateSupportedModels( + string defaultModelName, + string suggestedModelName, + IReadOnlyList fallbackModels, + IReadOnlyList discoveredModels) + { + HashSet seen = new(StringComparer.OrdinalIgnoreCase); + foreach (var model in new[] { suggestedModelName, defaultModelName } + .Concat(discoveredModels) + .Concat(fallbackModels)) + { + if (string.IsNullOrWhiteSpace(model) || !seen.Add(model)) + { + continue; + } + + yield return model; + } + } + + private static List CreateProviderDetails( + string? installedVersion, + string suggestedModelName, + IReadOnlyList supportedModelNames) + { + List details = []; + if (!string.IsNullOrWhiteSpace(installedVersion)) + { + details.Add(new ProviderDetailDescriptor("Installed version", installedVersion)); + } + + details.Add(new ProviderDetailDescriptor("Suggested model", suggestedModelName)); + + var supportedModels = FormatSupportedModels(supportedModelNames); + if (!string.IsNullOrWhiteSpace(supportedModels)) + { + details.Add(new ProviderDetailDescriptor("Supported models", supportedModels)); + } + + return details; + } + + private static string FormatSupportedModels(IReadOnlyList models) + { + if (models.Count == 0) + { + return string.Empty; + } + + const int limit = 8; + var visibleModels = models + .Where(static model => !string.IsNullOrWhiteSpace(model)) + .Distinct(StringComparer.Ordinal) + .Take(limit) + .ToArray(); + if (visibleModels.Length == 0) + { + return string.Empty; + } + + var remaining = models + .Where(static model => !string.IsNullOrWhiteSpace(model)) + .Distinct(StringComparer.Ordinal) + .Count() - visibleModels.Length; + var summary = string.Join(", ", visibleModels); + return remaining > 0 + ? $"{summary} (+{remaining} more)" + : summary; + } +} diff --git a/DotPilot.Core/Providers/Services/ClaudeCodeCliMetadataReader.cs b/DotPilot.Core/Providers/Services/ClaudeCodeCliMetadataReader.cs new file mode 100644 index 0000000..02962d5 --- /dev/null +++ b/DotPilot.Core/Providers/Services/ClaudeCodeCliMetadataReader.cs @@ -0,0 +1,76 @@ +using System.Text.Json; +using ManagedCode.ClaudeCodeSharpSDK.Client; +using ManagedCode.ClaudeCodeSharpSDK.Configuration; + +namespace DotPilot.Core.Providers; + +internal static class ClaudeCodeCliMetadataReader +{ + private const string SettingsFileName = "settings.json"; + private const string SuggestedModelPropertyName = "model"; + + public static ProviderCliMetadataSnapshot TryRead(string executablePath, AgentSessionProviderProfile profile) + { + ArgumentException.ThrowIfNullOrWhiteSpace(executablePath); + ArgumentNullException.ThrowIfNull(profile); + + var configuredModel = ReadSuggestedModelFromSettings(); + try + { + using var client = new ClaudeClient(new ClaudeOptions + { + ClaudeExecutablePath = executablePath, + }); + + var metadata = client.GetCliMetadata(); + return new ProviderCliMetadataSnapshot( + metadata.InstalledVersion, + ResolveSuggestedModel(configuredModel, metadata.DefaultModel), + metadata.Models + .Where(static model => model.IsListed) + .Select(static model => model.Slug) + .ToArray()); + } + catch + { + return new ProviderCliMetadataSnapshot( + InstalledVersion: null, + configuredModel, + profile.SupportedModelNames); + } + } + + private static string? ResolveSuggestedModel(string? configuredModel, string? defaultModel) + { + return string.IsNullOrWhiteSpace(configuredModel) + ? defaultModel + : configuredModel; + } + + private static string? ReadSuggestedModelFromSettings() + { + var settingsPath = GetSettingsPath(); + if (string.IsNullOrWhiteSpace(settingsPath) || !File.Exists(settingsPath)) + { + return null; + } + + try + { + using var stream = File.OpenRead(settingsPath); + using var document = JsonDocument.Parse(stream); + return document.RootElement.TryGetProperty(SuggestedModelPropertyName, out var property) + ? property.GetString() + : null; + } + catch + { + return null; + } + } + + private static string GetSettingsPath() + { + return ProviderCliHomeDirectory.GetFilePath(".claude", SettingsFileName); + } +} diff --git a/DotPilot.Core/Providers/Services/CodexCliMetadataReader.cs b/DotPilot.Core/Providers/Services/CodexCliMetadataReader.cs new file mode 100644 index 0000000..3333adb --- /dev/null +++ b/DotPilot.Core/Providers/Services/CodexCliMetadataReader.cs @@ -0,0 +1,193 @@ +using System.Text.Json; +using ManagedCode.CodexSharpSDK.Client; +using ManagedCode.CodexSharpSDK.Configuration; + +namespace DotPilot.Core.Providers; + +internal static class CodexCliMetadataReader +{ + private const string ConfigDirectoryName = ".codex"; + private const string ConfigFileName = "config.toml"; + private const string ModelsCacheFileName = "models_cache.json"; + private const string DefaultModelPropertyName = "model"; + private const string VersionSeparator = "version"; + + public static CodexCliMetadataSnapshot? TryRead(string executablePath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(executablePath); + + var fallbackSnapshot = TryReadFromLocalFiles(); + try + { + using var client = new CodexClient(new CodexOptions + { + CodexExecutablePath = executablePath, + }); + var metadata = client.GetCliMetadata(); + return new CodexCliMetadataSnapshot( + NormalizeInstalledVersion(metadata.InstalledVersion), + ResolveDefaultModel(fallbackSnapshot?.DefaultModel, metadata.DefaultModel), + MergeAvailableModels( + fallbackSnapshot?.AvailableModels ?? [], + metadata.Models + .Where(static model => model.IsListed) + .Select(static model => model.Slug))); + } + catch + { + return fallbackSnapshot; + } + } + + private static CodexCliMetadataSnapshot? TryReadFromLocalFiles() + { + var defaultModel = TryReadDefaultModelFromConfig(); + var models = TryReadAvailableModelsFromCache(); + return string.IsNullOrWhiteSpace(defaultModel) && models.Length == 0 + ? null + : new CodexCliMetadataSnapshot(null, defaultModel, models); + } + + private static string? NormalizeInstalledVersion(string? installedVersion) + { + if (string.IsNullOrWhiteSpace(installedVersion)) + { + return installedVersion; + } + + var firstLine = installedVersion + .Split(Environment.NewLine, StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries) + .FirstOrDefault(); + if (string.IsNullOrWhiteSpace(firstLine)) + { + return string.Empty; + } + + var separatorIndex = firstLine.IndexOf(VersionSeparator, StringComparison.OrdinalIgnoreCase); + return separatorIndex >= 0 + ? firstLine[(separatorIndex + VersionSeparator.Length)..].Trim(' ', ':') + : firstLine.Trim(); + } + + private static string? ResolveDefaultModel(string? configuredModel, string? discoveredModel) + { + return string.IsNullOrWhiteSpace(configuredModel) + ? discoveredModel + : configuredModel; + } + + private static string[] MergeAvailableModels( + IReadOnlyList configuredModels, + IEnumerable discoveredModels) + { + HashSet seen = new(StringComparer.OrdinalIgnoreCase); + List models = []; + + foreach (var model in configuredModels.Concat(discoveredModels)) + { + if (string.IsNullOrWhiteSpace(model) || !seen.Add(model)) + { + continue; + } + + models.Add(model); + } + + return [.. models]; + } + + private static string? TryReadDefaultModelFromConfig() + { + var configPath = ProviderCliHomeDirectory.GetFilePath(ConfigDirectoryName, ConfigFileName); + if (string.IsNullOrWhiteSpace(configPath) || !File.Exists(configPath)) + { + return null; + } + + try + { + foreach (var line in File.ReadLines(configPath)) + { + if (!TryParseConfigValue(line, DefaultModelPropertyName, out var value)) + { + continue; + } + + return value; + } + } + catch + { + } + + return null; + } + + private static string[] TryReadAvailableModelsFromCache() + { + var cachePath = ProviderCliHomeDirectory.GetFilePath(ConfigDirectoryName, ModelsCacheFileName); + if (string.IsNullOrWhiteSpace(cachePath) || !File.Exists(cachePath)) + { + return []; + } + + try + { + using var stream = File.OpenRead(cachePath); + using var document = JsonDocument.Parse(stream); + if (!document.RootElement.TryGetProperty("models", out var modelsElement) || + modelsElement.ValueKind != JsonValueKind.Array) + { + return []; + } + + return modelsElement.EnumerateArray() + .Select(static model => model.TryGetProperty("slug", out var slugProperty) + ? slugProperty.GetString() + : null) + .Where(static slug => !string.IsNullOrWhiteSpace(slug)) + .Cast() + .ToArray(); + } + catch + { + return []; + } + } + + private static bool TryParseConfigValue(string line, string propertyName, out string value) + { + value = string.Empty; + if (string.IsNullOrWhiteSpace(line)) + { + return false; + } + + var trimmedLine = line.Trim(); + if (!trimmedLine.StartsWith(propertyName, StringComparison.Ordinal) || + !trimmedLine.Contains('=', StringComparison.Ordinal)) + { + return false; + } + + var separatorIndex = trimmedLine.IndexOf('=', StringComparison.Ordinal); + var candidate = trimmedLine[(separatorIndex + 1)..].Trim(); + if (candidate.StartsWith('"') && candidate.EndsWith('"') && candidate.Length >= 2) + { + candidate = candidate[1..^1]; + } + + if (string.IsNullOrWhiteSpace(candidate)) + { + return false; + } + + value = candidate; + return true; + } +} + +internal sealed record CodexCliMetadataSnapshot( + string? InstalledVersion, + string? DefaultModel, + IReadOnlyList AvailableModels); diff --git a/DotPilot.Core/Providers/Services/CopilotCliMetadataReader.cs b/DotPilot.Core/Providers/Services/CopilotCliMetadataReader.cs new file mode 100644 index 0000000..af7fc2d --- /dev/null +++ b/DotPilot.Core/Providers/Services/CopilotCliMetadataReader.cs @@ -0,0 +1,128 @@ +using System.Text.Json; +using GitHub.Copilot.SDK; + +namespace DotPilot.Core.Providers; + +internal static class CopilotCliMetadataReader +{ + private const string ConfigFileName = "config.json"; + private const string SuggestedModelPropertyName = "model"; + private const string EnabledPolicyState = "enabled"; + private const string ModelSettingHeader = "`model`:"; + + public static async ValueTask TryReadAsync( + string executablePath, + AgentSessionProviderProfile profile, + CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(executablePath); + ArgumentNullException.ThrowIfNull(profile); + + var configuredModel = ReadConfiguredModel(); + try + { + return await ReadViaSdkAsync(executablePath, configuredModel, cancellationToken).ConfigureAwait(false); + } + catch + { + return new ProviderCliMetadataSnapshot( + InstalledVersion: null, + configuredModel, + ReadSupportedModelsFromHelp(executablePath, profile.SupportedModelNames)); + } + } + + private static async ValueTask ReadViaSdkAsync( + string executablePath, + string? configuredModel, + CancellationToken cancellationToken) + { + await using var client = new CopilotClient(new CopilotClientOptions + { + CliPath = executablePath, + AutoStart = false, + UseStdio = true, + }); + + await client.StartAsync(cancellationToken).ConfigureAwait(false); + var status = await client.GetStatusAsync(cancellationToken).ConfigureAwait(false); + var models = await client.ListModelsAsync(cancellationToken).ConfigureAwait(false); + + return new ProviderCliMetadataSnapshot( + status.Version, + configuredModel, + models + .Where(static model => string.IsNullOrWhiteSpace(model.Policy?.State) || + string.Equals(model.Policy.State, EnabledPolicyState, StringComparison.OrdinalIgnoreCase)) + .Select(static model => model.Id) + .ToArray()); + } + + private static string? ReadConfiguredModel() + { + var configPath = GetConfigPath(); + if (string.IsNullOrWhiteSpace(configPath) || !File.Exists(configPath)) + { + return null; + } + + try + { + using var stream = File.OpenRead(configPath); + using var document = JsonDocument.Parse(stream); + return document.RootElement.TryGetProperty(SuggestedModelPropertyName, out var property) + ? property.GetString() + : null; + } + catch + { + return null; + } + } + + private static IReadOnlyList ReadSupportedModelsFromHelp(string executablePath, IReadOnlyList fallbackModels) + { + var helpOutput = AgentSessionCommandProbe.ReadOutput(executablePath, ["help", "config"]); + if (string.IsNullOrWhiteSpace(helpOutput)) + { + return fallbackModels; + } + + List models = []; + var inModelSection = false; + foreach (var rawLine in helpOutput.Split(Environment.NewLine, StringSplitOptions.TrimEntries)) + { + var line = rawLine.Trim(); + if (!inModelSection) + { + inModelSection = string.Equals(line, ModelSettingHeader, StringComparison.Ordinal); + continue; + } + + if (line.StartsWith('`')) + { + break; + } + + if (!line.StartsWith('-') && + !line.StartsWith('`')) + { + continue; + } + + var model = line.TrimStart('-', ' ') + .Trim('"'); + if (!string.IsNullOrWhiteSpace(model)) + { + models.Add(model); + } + } + + return models.Count == 0 ? fallbackModels : models; + } + + private static string GetConfigPath() + { + return ProviderCliHomeDirectory.GetFilePath(".copilot", ConfigFileName); + } +} diff --git a/DotPilot.Core/Providers/Services/ProviderCliHomeDirectory.cs b/DotPilot.Core/Providers/Services/ProviderCliHomeDirectory.cs new file mode 100644 index 0000000..36b50cf --- /dev/null +++ b/DotPilot.Core/Providers/Services/ProviderCliHomeDirectory.cs @@ -0,0 +1,35 @@ +namespace DotPilot.Core.Providers; + +internal static class ProviderCliHomeDirectory +{ + public static string GetPath() + { + foreach (var variableName in VariableNames) + { + var value = Environment.GetEnvironmentVariable(variableName); + if (!string.IsNullOrWhiteSpace(value)) + { + return value; + } + } + + return Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + } + + public static string GetFilePath(string directoryName, string fileName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(directoryName); + ArgumentException.ThrowIfNullOrWhiteSpace(fileName); + + var homePath = GetPath(); + return string.IsNullOrWhiteSpace(homePath) + ? string.Empty + : Path.Combine(homePath, directoryName, fileName); + } + + private static readonly string[] VariableNames = + [ + "HOME", + "USERPROFILE", + ]; +} 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/IAgentWorkspaceState.cs b/DotPilot.Core/Workspace/Interfaces/IAgentWorkspaceState.cs new file mode 100644 index 0000000..2ba52a8 --- /dev/null +++ b/DotPilot.Core/Workspace/Interfaces/IAgentWorkspaceState.cs @@ -0,0 +1,33 @@ +using DotPilot.Core.ControlPlaneDomain; +using ManagedCode.Communication; + +namespace DotPilot.Core.Workspace.Interfaces; + +public interface IAgentWorkspaceState +{ + ValueTask> GetWorkspaceAsync(CancellationToken cancellationToken); + + ValueTask> RefreshWorkspaceAsync(CancellationToken cancellationToken); + + ValueTask> GetSessionAsync(SessionId sessionId, CancellationToken cancellationToken); + + ValueTask> CreateAgentAsync( + CreateAgentProfileCommand command, + CancellationToken cancellationToken); + + ValueTask> UpdateAgentAsync( + UpdateAgentProfileCommand 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/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/AgentWorkspaceState.cs b/DotPilot.Core/Workspace/Services/AgentWorkspaceState.cs new file mode 100644 index 0000000..b89aa42 --- /dev/null +++ b/DotPilot.Core/Workspace/Services/AgentWorkspaceState.cs @@ -0,0 +1,57 @@ +using DotPilot.Core.ControlPlaneDomain; +using ManagedCode.Communication; + +namespace DotPilot.Core.Workspace; + +internal sealed class AgentWorkspaceState(IAgentSessionService agentSessionService) : IAgentWorkspaceState +{ + public ValueTask> GetWorkspaceAsync(CancellationToken cancellationToken) + { + return agentSessionService.GetWorkspaceAsync(cancellationToken); + } + + public ValueTask> RefreshWorkspaceAsync(CancellationToken cancellationToken) + { + return agentSessionService.RefreshWorkspaceAsync(cancellationToken); + } + + public ValueTask> GetSessionAsync(SessionId sessionId, CancellationToken cancellationToken) + { + return agentSessionService.GetSessionAsync(sessionId, cancellationToken); + } + + public ValueTask> CreateAgentAsync( + CreateAgentProfileCommand command, + CancellationToken cancellationToken) + { + return agentSessionService.CreateAgentAsync(command, cancellationToken); + } + + public ValueTask> UpdateAgentAsync( + UpdateAgentProfileCommand command, + CancellationToken cancellationToken) + { + return agentSessionService.UpdateAgentAsync(command, cancellationToken); + } + + public ValueTask> CreateSessionAsync( + CreateSessionCommand command, + CancellationToken cancellationToken) + { + return agentSessionService.CreateSessionAsync(command, cancellationToken); + } + + public ValueTask> UpdateProviderAsync( + UpdateProviderPreferenceCommand command, + CancellationToken cancellationToken) + { + return agentSessionService.UpdateProviderAsync(command, cancellationToken); + } + + public IAsyncEnumerable> SendMessageAsync( + SendSessionMessageCommand command, + CancellationToken cancellationToken) + { + return agentSessionService.SendMessageAsync(command, cancellationToken); + } +} diff --git a/DotPilot.Core/Workspace/Services/StartupWorkspaceHydration.cs b/DotPilot.Core/Workspace/Services/StartupWorkspaceHydration.cs new file mode 100644 index 0000000..1ebb75a --- /dev/null +++ b/DotPilot.Core/Workspace/Services/StartupWorkspaceHydration.cs @@ -0,0 +1,124 @@ +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; + } + + var hydrationSucceeded = false; + UpdateState(isHydrating: true, isReady: false); + StartupWorkspaceHydrationLog.HydrationStarted(logger); + + try + { + hydrationSucceeded = await TryHydrateWorkspaceAsync(cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) + { + StartupWorkspaceHydrationLog.HydrationFailed(logger, exception); + } + finally + { + UpdateState(isHydrating: false, isReady: hydrationSucceeded); + } + } + finally + { + hydrationGate.Release(); + } + } + + private async ValueTask TryHydrateWorkspaceAsync(CancellationToken cancellationToken) + { + var workspace = await workspaceState.GetWorkspaceAsync(cancellationToken); + if (workspace.IsFailed) + { + StartupWorkspaceHydrationLog.HydrationFailed( + logger, + new InvalidOperationException("Startup workspace hydration failed.")); + return false; + } + + StartupWorkspaceHydrationLog.HydrationCompleted(logger); + return true; + } + + 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.Runtime.Host/AGENTS.md b/DotPilot.Runtime.Host/AGENTS.md deleted file mode 100644 index 63971a2..0000000 --- a/DotPilot.Runtime.Host/AGENTS.md +++ /dev/null @@ -1,39 +0,0 @@ -# AGENTS.md - -Project: `DotPilot.Runtime.Host` -Stack: `.NET 10`, class library, embedded Orleans host and local runtime-host services - -## Purpose - -- This project owns the desktop-embedded Orleans host for `dotPilot`. -- It keeps cluster hosting, grain registration, and host lifecycle code out of the Uno app and away from browser-targeted runtime libraries. - -## Entry Points - -- `DotPilot.Runtime.Host.csproj` -- `Features/RuntimeFoundation/*` - -## Boundaries - -- 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. -- Do not add remote clustering, external durable stores, or provider-specific orchestration here unless a later issue explicitly requires them. - -## 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` - -## Applicable Skills - -- `mcaf-dotnet` -- `mcaf-dotnet-features` -- `mcaf-testing` -- `mcaf-solid-maintainability` -- `mcaf-architecture-overview` - -## Local Risks Or Protected Areas - -- 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. diff --git a/DotPilot.Runtime.Host/DotPilot.Runtime.Host.csproj b/DotPilot.Runtime.Host/DotPilot.Runtime.Host.csproj deleted file mode 100644 index 6f8f53a..0000000 --- a/DotPilot.Runtime.Host/DotPilot.Runtime.Host.csproj +++ /dev/null @@ -1,19 +0,0 @@ - - - - net10.0 - true - $(NoWarn);CS1591 - - - - - - - - - - - - - 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 deleted file mode 100644 index c53f5f1..0000000 --- a/DotPilot.Runtime/AGENTS.md +++ /dev/null @@ -1,41 +0,0 @@ -# AGENTS.md - -Project: `DotPilot.Runtime` -Stack: `.NET 10`, class library, provider-independent runtime services and diagnostics - -## Purpose - -- This project owns non-UI runtime implementations that sit behind the contracts in `DotPilot.Core`. -- It is the first landing zone for deterministic test clients, runtime probes, and later embedded-host integrations so the Uno app stays focused on presentation and startup composition. - -## Entry Points - -- `DotPilot.Runtime.csproj` -- `Features/RuntimeFoundation/*` -- `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. -- 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` - -## Applicable Skills - -- `mcaf-dotnet` -- `mcaf-dotnet-features` -- `mcaf-testing` -- `mcaf-solid-maintainability` -- `mcaf-architecture-overview` - -## 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. diff --git a/DotPilot.Runtime/DotPilot.Runtime.csproj b/DotPilot.Runtime/DotPilot.Runtime.csproj deleted file mode 100644 index 729ab80..0000000 --- a/DotPilot.Runtime/DotPilot.Runtime.csproj +++ /dev/null @@ -1,18 +0,0 @@ - - - - net10.0 - true - $(NoWarn);CS1591 - - - - - - - - - - - - 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/AGENTS.md b/DotPilot.Tests/AGENTS.md index 31a3c9b..39f3e31 100644 --- a/DotPilot.Tests/AGENTS.md +++ b/DotPilot.Tests/AGENTS.md @@ -11,7 +11,12 @@ Stack: `.NET 10`, `NUnit`, `FluentAssertions`, `coverlet.collector` ## Entry Points - `DotPilot.Tests.csproj` -- `Features/*` +- `AgentBuilder/*` +- `Chat/*` +- `ChatSessions/*` +- `Providers/*` +- `Settings/*` +- `Workspace/*` ## Boundaries @@ -22,6 +27,7 @@ Stack: `.NET 10`, `NUnit`, `FluentAssertions`, `coverlet.collector` - Keep a deterministic in-repo test AI client available for CI so provider-independent agent flows remain testable even when Codex, Claude Code, or GitHub Copilot are unavailable. - Tests that require real provider CLIs or auth must detect availability and run only when the corresponding external toolchain is present. - Organize test files by feature slice, with shared helpers living next to the slice that owns them instead of a flat project root. +- Keep test layout mirroring the production slice shape: feature-specific helpers live inside that feature folder, and configuration/model/view-model related assertions should sit under matching subfolders when a slice grows. ## Local Commands diff --git a/DotPilot.Tests/AgentBuilder/Services/AgentPromptDraftGeneratorTests.cs b/DotPilot.Tests/AgentBuilder/Services/AgentPromptDraftGeneratorTests.cs new file mode 100644 index 0000000..331332d --- /dev/null +++ b/DotPilot.Tests/AgentBuilder/Services/AgentPromptDraftGeneratorTests.cs @@ -0,0 +1,79 @@ +using DotPilot.Core.AgentBuilder; +using DotPilot.Core.ChatSessions; +using DotPilot.Tests.Providers; +using Microsoft.Extensions.DependencyInjection; + +namespace DotPilot.Tests.AgentBuilder; + +[NonParallelizable] +public sealed class AgentPromptDraftGeneratorTests +{ + [Test] + public async Task GenerateAsyncCreatesRepositoryReviewDraftUsingEnabledRealProvider() + { + using var commandScope = CodexCliTestScope.Create(nameof(AgentPromptDraftGeneratorTests)); + commandScope.WriteVersionCommand("codex", "codex version 1.0.0"); + commandScope.WriteCodexMetadata("gpt-5.4", "gpt-5.4", "gpt-5"); + + await using var fixture = CreateFixture(); + (await fixture.WorkspaceState.UpdateProviderAsync( + new UpdateProviderPreferenceCommand(AgentProviderKind.Codex, true), + CancellationToken.None)).ShouldSucceed(); + + var draft = await fixture.Generator.GenerateAsync( + "Create a repository reviewer that checks git diff before answering.", + CancellationToken.None); + + draft.Name.Should().Be("Repository Reviewer Checks Agent"); + draft.ProviderKind.Should().Be(AgentProviderKind.Codex); + draft.ModelName.Should().Be("gpt-5.4"); + draft.SystemPrompt.Should().Contain("Mission:"); + draft.SystemPrompt.Should().NotContain("Primary tools:"); + draft.SystemPrompt.Should().NotContain("Preferred skills:"); + draft.SystemPrompt.Should().NotContain("Role:"); + } + + [Test] + public async Task CreateManualDraftAsyncUsesDefaultDebugFallback() + { + await using var fixture = CreateFixture(); + + var draft = await fixture.Generator.CreateManualDraftAsync(CancellationToken.None); + + draft.Name.Should().Be("New agent"); + draft.ProviderKind.Should().Be(AgentProviderKind.Debug); + draft.ModelName.Should().Be("debug-echo"); + } + + 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(); + return new TestFixture( + provider, + provider.GetRequiredService(), + provider.GetRequiredService()); + } + + private sealed class TestFixture( + ServiceProvider provider, + AgentPromptDraftGenerator generator, + IAgentWorkspaceState workspaceState) : IAsyncDisposable + { + public AgentPromptDraftGenerator Generator { get; } = generator; + + public IAgentWorkspaceState WorkspaceState { get; } = workspaceState; + + public ValueTask DisposeAsync() + { + return provider.DisposeAsync(); + } + } +} diff --git a/DotPilot.Tests/AgentBuilder/ViewModels/AgentBuilderModelTests.cs b/DotPilot.Tests/AgentBuilder/ViewModels/AgentBuilderModelTests.cs new file mode 100644 index 0000000..250f58c --- /dev/null +++ b/DotPilot.Tests/AgentBuilder/ViewModels/AgentBuilderModelTests.cs @@ -0,0 +1,434 @@ +using DotPilot.Core.ChatSessions; +using DotPilot.Tests.Providers; +using Microsoft.Extensions.DependencyInjection; + +namespace DotPilot.Tests.AgentBuilder; + +[NonParallelizable] +public sealed class AgentBuilderModelTests +{ + [Test] + public async Task GenerateDraftAndSaveAgentUsesEnabledProviderModelWhenModelOverrideIsBlank() + { + using var commandScope = CodexCliTestScope.Create(nameof(AgentBuilderModelTests)); + commandScope.WriteVersionCommand("codex", "codex version 1.0.0"); + commandScope.WriteCodexMetadata("gpt-5.4", "gpt-5.4", "gpt-5"); + + await using var fixture = await CreateFixtureAsync(); + (await fixture.WorkspaceState.UpdateProviderAsync( + new UpdateProviderPreferenceCommand(AgentProviderKind.Codex, true), + CancellationToken.None)).ShouldSucceed(); + var model = ActivatorUtilities.CreateInstance(fixture.Provider); + + await model.OpenCreateAgent(CancellationToken.None); + await model.AgentRequest.SetAsync("Create a repository reviewer", CancellationToken.None); + await model.GenerateAgentDraft(CancellationToken.None); + + (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"); + + await model.SaveAgent(CancellationToken.None); + + var workspace = (await fixture.WorkspaceState.GetWorkspaceAsync(CancellationToken.None)).ShouldSucceed(); + workspace.Agents.Should().Contain(agent => + agent.Name == "Repository Reviewer Agent" && + agent.ProviderKind == AgentProviderKind.Codex && + agent.ModelName == "gpt-5.4"); + workspace.Sessions.Should().Contain(session => session.Title == "Session with Repository Reviewer Agent"); + workspace.SelectedSessionId.Should().NotBeNull(); + fixture.RequestedRoutes.Should().Contain(ShellRoute.Chat); + (await model.BuilderStatusMessage).Should().Contain("ready for local desktop execution"); + } + + [Test] + public async Task BuilderProjectionReflectsSelectedProviderSuggestionAndVersion() + { + await using var fixture = await CreateFixtureAsync(); + var model = ActivatorUtilities.CreateInstance(fixture.Provider); + + await model.HandleSelectedProviderChanged( + new AgentProviderOption( + AgentProviderKind.GitHubCopilot, + "GitHub Copilot", + "copilot", + "GitHub Copilot CLI is available on PATH.", + "gpt-5", + ["gpt-5", "claude-opus-4.6"], + "0.0.421", + false), + CancellationToken.None); + + (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] + public async Task BuildManuallyUsesEnabledProviderDefaults() + { + using var commandScope = CodexCliTestScope.Create(nameof(AgentBuilderModelTests)); + commandScope.WriteVersionCommand("codex", "codex version 1.0.0"); + commandScope.WriteCodexMetadata("gpt-5.4", "gpt-5.4", "gpt-5"); + + await using var fixture = await CreateFixtureAsync(); + (await fixture.WorkspaceState.UpdateProviderAsync( + new UpdateProviderPreferenceCommand(AgentProviderKind.Codex, true), + CancellationToken.None)).ShouldSucceed(); + var model = ActivatorUtilities.CreateInstance(fixture.Provider); + + await model.OpenCreateAgent(CancellationToken.None); + await model.BuildManually(CancellationToken.None); + + var surface = await model.Surface; + surface!.ShowEditor.Should().BeTrue(); + (await model.AgentName).Should().Be("New agent"); + (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."); + } + + [Test] + public async Task BuildManuallyWithoutEnabledRealProviderFallsBackToTheFirstProviderChoice() + { + using var commandScope = CodexCliTestScope.Create(nameof(AgentBuilderModelTests)); + await using var fixture = await CreateFixtureAsync(); + var model = ActivatorUtilities.CreateInstance(fixture.Provider); + + await model.OpenCreateAgent(CancellationToken.None); + await model.BuildManually(CancellationToken.None); + + (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"); + } + + [Test] + public async Task HandleSelectedProviderChangedUpdatesModelSuggestionToTheChosenProvider() + { + using var commandScope = CodexCliTestScope.Create(nameof(AgentBuilderModelTests)); + await using var fixture = await CreateFixtureAsync(); + var model = ActivatorUtilities.CreateInstance(fixture.Provider); + + await model.BuildManually(CancellationToken.None); + (await model.ModelName).Should().Be("gpt-5"); + await model.SelectedProvider.UpdateAsync( + _ => new AgentProviderOption( + AgentProviderKind.Codex, + "Codex", + "codex", + "Codex CLI is ready for local desktop execution.", + "gpt-5", + ["gpt-5", "gpt-5.4"], + "1.0.0", + true), + CancellationToken.None); + await model.SelectedProviderKind.SetAsync(AgentProviderKind.Codex, CancellationToken.None); + + await model.HandleSelectedProviderChanged( + new AgentProviderOption( + AgentProviderKind.Codex, + "Codex", + "codex", + "Codex CLI is ready for local desktop execution.", + "gpt-5.4", + ["gpt-5.4", "gpt-5"], + "1.0.0", + true), + CancellationToken.None); + + (await model.ModelName).Should().Be("gpt-5.4"); + (await model.SelectedProvider)!.Kind.Should().Be(AgentProviderKind.Codex); + } + + [Test] + public async Task HandleSelectedProviderChangedResetsModelThatIsNotSupportedByTheNextProvider() + { + await using var fixture = await CreateFixtureAsync(); + var model = ActivatorUtilities.CreateInstance(fixture.Provider); + + await model.ModelName.SetAsync("gpt-5.4", CancellationToken.None); + await model.SelectedProvider.UpdateAsync( + _ => new AgentProviderOption( + AgentProviderKind.Codex, + "Codex", + "codex", + "Codex CLI is ready for local desktop execution.", + "gpt-5.4", + ["gpt-5.4", "gpt-5.2"], + "1.0.0", + true), + CancellationToken.None); + await model.SelectedProviderKind.SetAsync(AgentProviderKind.Codex, CancellationToken.None); + + await model.HandleSelectedProviderChanged( + new AgentProviderOption( + AgentProviderKind.GitHubCopilot, + "GitHub Copilot", + "copilot", + "GitHub Copilot profile authoring is available.", + "claude-opus-4.6", + ["claude-opus-4.6", "gpt-5"], + "1.0.3", + true), + CancellationToken.None); + + (await model.ModelName).Should().Be("claude-opus-4.6"); + (await model.SelectedProviderKind).Should().Be(AgentProviderKind.GitHubCopilot); + } + + [Test] + public async Task HandleProviderSelectionChangedUsesProviderKindParameterWhenNoProviderOptionIsProvided() + { + using var commandScope = CodexCliTestScope.Create(nameof(AgentBuilderModelTests)); + commandScope.WriteVersionCommand("codex", "codex version 1.0.0"); + commandScope.WriteCodexMetadata("gpt-5.4", "gpt-5.4", "gpt-5"); + commandScope.WriteVersionCommand("claude", "2.0.75 (Claude Code)"); + commandScope.WriteClaudeSettings("claude-opus-4-6"); + + await using var fixture = await CreateFixtureAsync(); + (await fixture.WorkspaceState.UpdateProviderAsync( + new UpdateProviderPreferenceCommand(AgentProviderKind.Codex, true), + CancellationToken.None)).ShouldSucceed(); + (await fixture.WorkspaceState.UpdateProviderAsync( + new UpdateProviderPreferenceCommand(AgentProviderKind.ClaudeCode, true), + CancellationToken.None)).ShouldSucceed(); + + var model = ActivatorUtilities.CreateInstance(fixture.Provider); + + await model.BuildManually(CancellationToken.None); + await model.HandleProviderSelectionChanged(AgentProviderKind.ClaudeCode, CancellationToken.None); + + (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 HandleProviderSelectionChangedUsesTheProvidedProviderWhenThePreviousProviderRemainsPopulated() + { + using var commandScope = CodexCliTestScope.Create(nameof(AgentBuilderModelTests)); + commandScope.WriteVersionCommand("codex", "codex version 1.0.0"); + commandScope.WriteCodexMetadata("gpt-5.4", "gpt-5.4", "gpt-5"); + commandScope.WriteVersionCommand("claude", "2.0.75 (Claude Code)"); + commandScope.WriteClaudeSettings("claude-opus-4-6"); + + await using var fixture = await CreateFixtureAsync(); + (await fixture.WorkspaceState.UpdateProviderAsync( + new UpdateProviderPreferenceCommand(AgentProviderKind.Codex, true), + CancellationToken.None)).ShouldSucceed(); + (await fixture.WorkspaceState.UpdateProviderAsync( + new UpdateProviderPreferenceCommand(AgentProviderKind.ClaudeCode, true), + CancellationToken.None)).ShouldSucceed(); + + var model = ActivatorUtilities.CreateInstance(fixture.Provider); + + await model.BuildManually(CancellationToken.None); + (await model.BuilderProviderDisplayName).Should().Be("Codex"); + + await model.HandleProviderSelectionChanged( + new AgentProviderOption( + AgentProviderKind.ClaudeCode, + "Claude Code", + "claude", + "Claude Code profile authoring is available.", + "claude-opus-4-6", + ["claude-opus-4-6", "claude-sonnet-4-5"], + "2.0.75", + true), + CancellationToken.None); + + (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"); + + (await model.BuilderProviderDisplayName).Should().Be("Claude Code"); + (await model.BuilderSuggestedModelName).Should().Be("claude-opus-4-6"); + } + + [Test] + public async Task BuildManuallyAllowsChoosingASupportedModelBeforeSavingAgent() + { + using var commandScope = CodexCliTestScope.Create(nameof(AgentBuilderModelTests)); + commandScope.WriteVersionCommand("codex", "codex version 1.0.0"); + commandScope.WriteCodexMetadata("gpt-5.4", "gpt-5.4", "gpt-5"); + + await using var fixture = await CreateFixtureAsync(); + (await fixture.WorkspaceState.UpdateProviderAsync( + new UpdateProviderPreferenceCommand(AgentProviderKind.Codex, true), + CancellationToken.None)).ShouldSucceed(); + var model = ActivatorUtilities.CreateInstance(fixture.Provider); + + await model.OpenCreateAgent(CancellationToken.None); + await model.BuildManually(CancellationToken.None); + await model.AgentName.SetAsync("Codex Custom Model Agent", CancellationToken.None); + await model.ModelName.SetAsync("gpt-5", CancellationToken.None); + await model.SystemPrompt.SetAsync("Use the chosen Codex model for this session.", CancellationToken.None); + + await model.SaveAgent(CancellationToken.None); + + var workspace = (await fixture.WorkspaceState.GetWorkspaceAsync(CancellationToken.None)).ShouldSucceed(); + workspace.Agents.Should().Contain(agent => + agent.Name == "Codex Custom Model Agent" && + agent.ProviderKind == AgentProviderKind.Codex && + agent.ModelName == "gpt-5"); + } + + [Test] + public async Task OpenEditAgentLoadsAnExistingProfileAndSaveUpdatesItInPlace() + { + using var commandScope = CodexCliTestScope.Create(nameof(AgentBuilderModelTests)); + commandScope.WriteVersionCommand("codex", "codex version 1.0.0"); + commandScope.WriteCodexMetadata("gpt-5.4", "gpt-5.4", "gpt-5"); + + await using var fixture = await CreateFixtureAsync(); + (await fixture.WorkspaceState.UpdateProviderAsync( + new UpdateProviderPreferenceCommand(AgentProviderKind.Codex, true), + CancellationToken.None)).ShouldSucceed(); + + var existing = (await fixture.WorkspaceState.CreateAgentAsync( + new CreateAgentProfileCommand( + "Editable Codex Agent", + AgentProviderKind.Codex, + "gpt-5.4", + "Answer clearly.", + "Editable Codex profile."), + CancellationToken.None)).ShouldSucceed(); + + var model = ActivatorUtilities.CreateInstance(fixture.Provider); + + await model.OpenEditAgent(existing.Id, CancellationToken.None); + + (await model.Surface).Should().NotBeNull(); + (await model.Surface)!.Title.Should().Be("Edit agent"); + (await model.Surface)!.PrimaryActionLabel.Should().Be("Save changes"); + (await model.AgentName).Should().Be("Editable Codex Agent"); + (await model.AgentDescription).Should().Be("Editable Codex profile."); + (await model.ModelName).Should().Be("gpt-5.4"); + + await model.AgentName.SetAsync("Edited Codex Agent", CancellationToken.None); + await model.AgentDescription.SetAsync("Edited Codex profile.", CancellationToken.None); + await model.SystemPrompt.SetAsync("Answer even more clearly.", CancellationToken.None); + + await model.SaveAgent(CancellationToken.None); + + var workspace = (await fixture.WorkspaceState.GetWorkspaceAsync(CancellationToken.None)).ShouldSucceed(); + workspace.Agents.Should().ContainSingle(agent => + agent.Id == existing.Id && + agent.Name == "Edited Codex Agent" && + agent.Description == "Edited Codex profile." && + agent.SystemPrompt == "Answer even more clearly."); + workspace.Sessions.Should().BeEmpty(); + fixture.RequestedRoutes.Should().BeEmpty(); + (await model.Surface)!.ShowCatalog.Should().BeTrue(); + (await model.OperationMessage).Should().Be("Saved changes to Edited Codex Agent using Codex."); + } + + [Test] + public async Task StartChatForAgentCreatesAndSelectsSessionForChosenCatalogAgent() + { + using var commandScope = CodexCliTestScope.Create(nameof(AgentBuilderModelTests)); + commandScope.WriteVersionCommand("codex", "codex version 1.0.0"); + commandScope.WriteCodexMetadata("gpt-5.4", "gpt-5.4", "gpt-5"); + + await using var fixture = await CreateFixtureAsync(); + (await fixture.WorkspaceState.UpdateProviderAsync( + new UpdateProviderPreferenceCommand(AgentProviderKind.Codex, true), + CancellationToken.None)).ShouldSucceed(); + var model = ActivatorUtilities.CreateInstance(fixture.Provider); + + await model.OpenCreateAgent(CancellationToken.None); + await model.AgentRequest.SetAsync("Create a repository reviewer", CancellationToken.None); + await model.GenerateAgentDraft(CancellationToken.None); + await model.SaveAgent(CancellationToken.None); + + var workspace = (await fixture.WorkspaceState.GetWorkspaceAsync(CancellationToken.None)).ShouldSucceed(); + var createdAgentSummary = workspace.Agents + .Should() + .ContainSingle(agent => agent.Name == "Repository Reviewer Agent") + .Which; + var createdAgent = new AgentCatalogItem( + createdAgentSummary.Id, + "R", + createdAgentSummary.Name, + createdAgentSummary.Description, + createdAgentSummary.ProviderDisplayName, + createdAgentSummary.ModelName, + false, + "AgentCatalogEditButton_RepositoryReviewerAgent", + new AgentCatalogEditRequest(createdAgentSummary.Id, createdAgentSummary.Name), + null, + "AgentCatalogStartChatButton_RepositoryReviewerAgent", + new AgentCatalogStartChatRequest(createdAgentSummary.Id, createdAgentSummary.Name), + null); + + await model.StartChatForAgent(createdAgent, CancellationToken.None); + + workspace = (await fixture.WorkspaceState.GetWorkspaceAsync(CancellationToken.None)).ShouldSucceed(); + workspace.Sessions.Should().Contain(session => session.Title == "Session with Repository Reviewer Agent"); + workspace.SelectedSessionId.Should().NotBeNull(); + fixture.RequestedRoutes.Should().Contain(ShellRoute.Chat); + (await model.OperationMessage).Should().Be("Started a session with Repository Reviewer Agent."); + } + + private static async Task CreateFixtureAsync() + { + var services = new ServiceCollection(); + services.AddSingleton(TimeProvider.System); + services.AddAgentSessions(new AgentSessionStorageOptions + { + UseInMemoryDatabase = true, + InMemoryDatabaseName = Guid.NewGuid().ToString("N"), + }); + services.AddSingleton(); + services.AddSingleton(); + + var provider = services.BuildServiceProvider(); + var workspaceState = provider.GetRequiredService(); + var navigationNotifier = provider.GetRequiredService(); + return new TestFixture(provider, workspaceState, navigationNotifier); + } + + private sealed class TestFixture : IAsyncDisposable + { + private readonly ShellNavigationNotifier navigationNotifier; + private readonly List requestedRoutes = []; + + public TestFixture(ServiceProvider provider, IAgentWorkspaceState workspaceState, ShellNavigationNotifier navigationNotifier) + { + Provider = provider; + WorkspaceState = workspaceState; + this.navigationNotifier = navigationNotifier; + this.navigationNotifier.Requested += OnNavigationRequested; + } + + public ServiceProvider Provider { get; } + + public IAgentWorkspaceState WorkspaceState { get; } + + public IReadOnlyList RequestedRoutes => requestedRoutes; + + public ValueTask DisposeAsync() + { + navigationNotifier.Requested -= OnNavigationRequested; + return Provider.DisposeAsync(); + } + + private void OnNavigationRequested(object? sender, ShellNavigationRequestedEventArgs e) + { + requestedRoutes.Add(e.Route); + } + } +} diff --git a/DotPilot.Tests/Features/ApplicationShell/AppConfigTests.cs b/DotPilot.Tests/ApplicationConfiguration/AppConfigTests.cs similarity index 65% rename from DotPilot.Tests/Features/ApplicationShell/AppConfigTests.cs rename to DotPilot.Tests/ApplicationConfiguration/AppConfigTests.cs index f9b9b0f..3ecd6fe 100644 --- a/DotPilot.Tests/Features/ApplicationShell/AppConfigTests.cs +++ b/DotPilot.Tests/ApplicationConfiguration/AppConfigTests.cs @@ -1,4 +1,4 @@ -namespace DotPilot.Tests.Features.ApplicationShell; +namespace DotPilot.Tests.ApplicationConfiguration; public class AppConfigTests { @@ -10,7 +10,7 @@ public void Setup() [Test] public void AppInfoCreation() { - var appInfo = new AppConfig { Environment = "Test" }; + var appInfo = new DotPilot.AppConfig { Environment = "Test" }; appInfo.Should().NotBeNull(); appInfo.Environment.Should().Be("Test"); diff --git a/DotPilot.Tests/Chat/Configuration/ChatComposerKeyboardPolicyTests.cs b/DotPilot.Tests/Chat/Configuration/ChatComposerKeyboardPolicyTests.cs new file mode 100644 index 0000000..4e40797 --- /dev/null +++ b/DotPilot.Tests/Chat/Configuration/ChatComposerKeyboardPolicyTests.cs @@ -0,0 +1,123 @@ +namespace DotPilot.Tests.Chat; + +public sealed class ChatComposerKeyboardPolicyTests +{ + [Test] + public void ResolveReturnsSendMessageForPlainEnter() + { + var action = ChatComposerKeyboardPolicy.Resolve( + behavior: ComposerSendBehavior.EnterSends, + isEnterKey: true, + hasModifier: false); + + action.Should().Be(ChatComposerKeyboardAction.SendMessage); + } + + [Test] + public void ResolveReturnsInsertNewLineForModifiedEnterWhenEnterSends() + { + var action = ChatComposerKeyboardPolicy.Resolve( + behavior: ComposerSendBehavior.EnterSends, + isEnterKey: true, + hasModifier: true); + + action.Should().Be(ChatComposerKeyboardAction.InsertNewLine); + } + + [Test] + public void ResolveReturnsInsertNewLineForPlainEnterWhenEnterAddsNewLine() + { + var action = ChatComposerKeyboardPolicy.Resolve( + behavior: ComposerSendBehavior.EnterInsertsNewLine, + isEnterKey: true, + hasModifier: false); + + action.Should().Be(ChatComposerKeyboardAction.InsertNewLine); + } + + [Test] + public void ResolveReturnsSendMessageForModifiedEnterWhenEnterAddsNewLine() + { + var action = ChatComposerKeyboardPolicy.Resolve( + behavior: ComposerSendBehavior.EnterInsertsNewLine, + isEnterKey: true, + hasModifier: true); + + action.Should().Be(ChatComposerKeyboardAction.SendMessage); + } + + [Test] + public void ResolveReturnsNoneWhenKeyIsNotEnter() + { + var action = ChatComposerKeyboardPolicy.Resolve( + behavior: ComposerSendBehavior.EnterSends, + isEnterKey: false, + hasModifier: true); + + action.Should().Be(ChatComposerKeyboardAction.None); + } + + [Test] + public void ShouldHandleInComposerReturnsTrueForPlainEnterWhenEnterSends() + { + var action = ChatComposerKeyboardPolicy.Resolve( + behavior: ComposerSendBehavior.EnterSends, + isEnterKey: true, + hasModifier: false); + + var shouldHandle = ChatComposerKeyboardPolicy.ShouldHandleInComposer( + ComposerSendBehavior.EnterSends, + action, + hasModifier: false); + + shouldHandle.Should().BeTrue(); + } + + [Test] + public void ShouldHandleInComposerReturnsTrueForModifierEnterWhenEnterSends() + { + var action = ChatComposerKeyboardPolicy.Resolve( + behavior: ComposerSendBehavior.EnterSends, + isEnterKey: true, + hasModifier: true); + + var shouldHandle = ChatComposerKeyboardPolicy.ShouldHandleInComposer( + ComposerSendBehavior.EnterSends, + action, + hasModifier: true); + + shouldHandle.Should().BeTrue(); + } + + [Test] + public void ShouldHandleInComposerReturnsFalseForPlainEnterWhenEnterAddsNewLine() + { + var action = ChatComposerKeyboardPolicy.Resolve( + behavior: ComposerSendBehavior.EnterInsertsNewLine, + isEnterKey: true, + hasModifier: false); + + var shouldHandle = ChatComposerKeyboardPolicy.ShouldHandleInComposer( + ComposerSendBehavior.EnterInsertsNewLine, + action, + hasModifier: false); + + shouldHandle.Should().BeFalse(); + } + + [Test] + public void ShouldHandleInComposerReturnsTrueForModifierEnterWhenEnterAddsNewLine() + { + var action = ChatComposerKeyboardPolicy.Resolve( + behavior: ComposerSendBehavior.EnterInsertsNewLine, + isEnterKey: true, + hasModifier: true); + + var shouldHandle = ChatComposerKeyboardPolicy.ShouldHandleInComposer( + ComposerSendBehavior.EnterInsertsNewLine, + action, + hasModifier: true); + + shouldHandle.Should().BeTrue(); + } +} diff --git a/DotPilot.Tests/Chat/Configuration/ChatComposerModifierStateTests.cs b/DotPilot.Tests/Chat/Configuration/ChatComposerModifierStateTests.cs new file mode 100644 index 0000000..de8ab11 --- /dev/null +++ b/DotPilot.Tests/Chat/Configuration/ChatComposerModifierStateTests.cs @@ -0,0 +1,112 @@ +using Windows.System; + +namespace DotPilot.Tests.Chat; + +public sealed class ChatComposerModifierStateTests +{ + [TestCase(VirtualKey.Shift)] + [TestCase(VirtualKey.LeftShift)] + [TestCase(VirtualKey.RightShift)] + [TestCase(VirtualKey.Control)] + [TestCase(VirtualKey.LeftControl)] + [TestCase(VirtualKey.RightControl)] + [TestCase(VirtualKey.Menu)] + [TestCase(VirtualKey.LeftMenu)] + [TestCase(VirtualKey.RightMenu)] + [TestCase(VirtualKey.LeftWindows)] + [TestCase(VirtualKey.RightWindows)] + public void RegisterKeyDownMarksSupportedModifierKeysAsPressed(VirtualKey key) + { + var state = new ChatComposerModifierState(); + + state.RegisterKeyDown(key); + + state.HasPressedModifier.Should().BeTrue(); + } + + [Test] + public void RegisterKeyDownIgnoresNonModifierKeys() + { + var state = new ChatComposerModifierState(); + + state.RegisterKeyDown(VirtualKey.A); + + state.HasPressedModifier.Should().BeFalse(); + } + + [Test] + public void RegisterKeyUpClearsTrackedModifier() + { + var state = new ChatComposerModifierState(); + state.RegisterKeyDown(VirtualKey.LeftShift); + + state.RegisterKeyUp(VirtualKey.LeftShift); + + state.HasPressedModifier.Should().BeFalse(); + } + + [Test] + public void RegisterKeyUpKeepsModifierPressedWhileSameModifierFamilyIsStillHeld() + { + var state = new ChatComposerModifierState(); + state.RegisterKeyDown(VirtualKey.LeftShift); + state.RegisterKeyDown(VirtualKey.RightShift); + + state.RegisterKeyUp(VirtualKey.LeftShift); + + state.HasPressedModifier.Should().BeTrue(); + } + + [Test] + public void IsPressedNormalizesModifierFamilies() + { + var state = new ChatComposerModifierState(); + state.RegisterKeyDown(VirtualKey.RightMenu); + + state.IsPressed(VirtualKey.Menu).Should().BeTrue(); + state.IsPressed(VirtualKey.LeftMenu).Should().BeTrue(); + } + + [Test] + public void ResetClearsTrackedModifiers() + { + var state = new ChatComposerModifierState(); + state.RegisterKeyDown(VirtualKey.LeftShift); + state.RegisterKeyDown(VirtualKey.LeftControl); + + state.Reset(); + + 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 new file mode 100644 index 0000000..d1fe857 --- /dev/null +++ b/DotPilot.Tests/Chat/ViewModels/ChatModelTests.cs @@ -0,0 +1,338 @@ +using DotPilot.Core.AgentBuilder; +using DotPilot.Core.ChatSessions; +using DotPilot.Tests.Providers; +using Microsoft.Extensions.DependencyInjection; + +namespace DotPilot.Tests.Chat; + +public sealed class ChatModelTests +{ + [Test] + public async Task StartNewSessionUsesSeededDefaultSystemAgentWhenNoCustomAgentExists() + { + await using var fixture = await CreateFixtureAsync(); + var model = ActivatorUtilities.CreateInstance(fixture.Provider); + + await model.StartNewSession(CancellationToken.None); + + var activeSession = await model.ActiveSession; + activeSession.Should().NotBeNull(); + activeSession!.Title.Should().Be($"Session with {AgentSessionDefaults.SystemAgentName}"); + activeSession.StatusSummary.Should().Contain("Debug Provider"); + } + + [Test] + public async Task SendMessageStreamsDebugTranscriptForAnActiveSession() + { + await using var fixture = await CreateFixtureAsync(); + (await fixture.WorkspaceState.CreateAgentAsync( + new CreateAgentProfileCommand( + "Debug Agent", + AgentProviderKind.Debug, + "debug-echo", + "Be deterministic for automated verification."), + CancellationToken.None)).ShouldSucceed(); + 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.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"); + } + + [Test] + public async Task StartNewSessionUsesNewestCustomAgentWhenCustomNameSortsAfterSystemAgent() + { + await using var fixture = await CreateFixtureAsync(); + (await fixture.WorkspaceState.CreateAgentAsync( + new CreateAgentProfileCommand( + "Repository Reviewer Agent", + AgentProviderKind.Debug, + "debug-echo", + "Review repository changes and explain the diff."), + CancellationToken.None)).ShouldSucceed(); + var model = ActivatorUtilities.CreateInstance(fixture.Provider); + + await model.StartNewSession(CancellationToken.None); + + var activeSession = await model.ActiveSession; + activeSession.Should().NotBeNull(); + activeSession!.Title.Should().Be("Session with Repository Reviewer Agent"); + 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(); + } + + [Test] + public async Task RefreshInvalidatesTheFleetProviderSnapshotAfterProviderChanges() + { + 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); + + var initialBoard = await model.FleetBoard; + initialBoard.Should().NotBeNull(); + initialBoard!.Providers.Should().Contain(provider => + provider.DisplayName == "Codex" && + provider.StatusLabel == "Disabled"); + + (await fixture.WorkspaceState.UpdateProviderAsync( + new UpdateProviderPreferenceCommand(AgentProviderKind.Codex, true), + CancellationToken.None)).ShouldSucceed(); + + await model.Refresh(CancellationToken.None); + + var refreshedBoard = await model.FleetBoard; + refreshedBoard.Should().NotBeNull(); + refreshedBoard!.Providers.Should().Contain(provider => + provider.DisplayName == "Codex" && + provider.StatusLabel == "Ready"); + } + + [Test] + public async Task FleetBoardShowsTheActiveSessionWhileStreamingAndClearsAfterCompletion() + { + await using var fixture = await CreateFixtureAsync(); + (await fixture.WorkspaceState.CreateAgentAsync( + new CreateAgentProfileCommand( + "Fleet Agent", + AgentProviderKind.Debug, + "debug-echo", + "Stay deterministic for fleet board verification."), + CancellationToken.None)).ShouldSucceed(); + var model = ActivatorUtilities.CreateInstance(fixture.Provider); + + await model.StartNewSession(CancellationToken.None); + var selectedChat = await model.SelectedChat; + + await using var enumerator = fixture.WorkspaceState.SendMessageAsync( + new SendSessionMessageCommand(selectedChat!.Id, "fleet activity"), + CancellationToken.None) + .GetAsyncEnumerator(CancellationToken.None); + + var observedLiveBoard = false; + while (await enumerator.MoveNextAsync()) + { + _ = enumerator.Current.ShouldSucceed(); + var board = await model.FleetBoard; + board.Should().NotBeNull(); + if (board!.ActiveSessions.Count == 0) + { + continue; + } + + observedLiveBoard = true; + board.Metrics.Should().Contain(metric => + metric.Label == "Live sessions" && + metric.Value == "1"); + board.ActiveSessions.Should().Contain(item => + item.Title == selectedChat.Title && + item.Summary.Contains("Fleet Agent", StringComparison.Ordinal)); + break; + } + + observedLiveBoard.Should().BeTrue(); + + while (await enumerator.MoveNextAsync()) + { + _ = enumerator.Current.ShouldSucceed(); + } + + FleetBoardView? completedBoard = null; + var timeoutAt = DateTimeOffset.UtcNow.AddSeconds(2); + while (DateTimeOffset.UtcNow < timeoutAt) + { + completedBoard = await model.FleetBoard; + completedBoard.Should().NotBeNull(); + if (completedBoard!.ActiveSessions.Count == 0) + { + break; + } + + await Task.Delay(50); + } + + completedBoard.Should().NotBeNull(); + completedBoard!.ActiveSessions.Should().BeEmpty(); + completedBoard.ShowActiveSessionsEmptyState.Should().BeTrue(); + } + + [Test] + public async Task FleetBoardReusesTheWarmProviderSnapshotDuringLiveStreaming() + { + using var commandScope = CodexCliTestScope.Create(nameof(ChatModelTests)); + commandScope.WriteCountingVersionCommand("codex", "codex version 1.0.0", delayMilliseconds: 300); + commandScope.WriteCodexMetadata("gpt-5.4", "gpt-5.4"); + await using var fixture = await CreateFixtureAsync(); + (await fixture.WorkspaceState.CreateAgentAsync( + new CreateAgentProfileCommand( + "Fleet Agent", + AgentProviderKind.Debug, + "debug-echo", + "Stay deterministic for fleet board verification."), + CancellationToken.None)).ShouldSucceed(); + var model = ActivatorUtilities.CreateInstance(fixture.Provider); + + var initialBoard = await model.FleetBoard; + initialBoard.Should().NotBeNull(); + var warmInvocationCount = commandScope.ReadInvocationCount("codex"); + + await model.StartNewSession(CancellationToken.None); + var postStartInvocationCount = commandScope.ReadInvocationCount("codex"); + postStartInvocationCount.Should().BeGreaterThanOrEqualTo(warmInvocationCount); + + var selectedChat = await model.SelectedChat; + + await using var enumerator = fixture.WorkspaceState.SendMessageAsync( + new SendSessionMessageCommand(selectedChat!.Id, "fleet activity"), + CancellationToken.None) + .GetAsyncEnumerator(CancellationToken.None); + + var observedLiveBoard = false; + while (await enumerator.MoveNextAsync()) + { + _ = enumerator.Current.ShouldSucceed(); + var board = await model.FleetBoard; + board.Should().NotBeNull(); + if (board!.ActiveSessions.Count == 0) + { + continue; + } + + observedLiveBoard = true; + break; + } + + observedLiveBoard.Should().BeTrue(); + commandScope.ReadInvocationCount("codex").Should().Be(postStartInvocationCount); + + while (await enumerator.MoveNextAsync()) + { + _ = enumerator.Current.ShouldSucceed(); + } + } + + [Test] + public async Task OpenFleetSessionSelectsTheRequestedActiveSession() + { + await using var fixture = await CreateFixtureAsync(); + var agent = (await fixture.WorkspaceState.CreateAgentAsync( + new CreateAgentProfileCommand( + "Navigator Agent", + AgentProviderKind.Debug, + "debug-echo", + "Stay deterministic for fleet navigation verification."), + CancellationToken.None)).ShouldSucceed(); + var firstSession = (await fixture.WorkspaceState.CreateSessionAsync( + new CreateSessionCommand("Fleet Session One", agent.Id), + CancellationToken.None)).ShouldSucceed(); + var secondSession = (await fixture.WorkspaceState.CreateSessionAsync( + new CreateSessionCommand("Fleet Session Two", agent.Id), + CancellationToken.None)).ShouldSucceed(); + var model = ActivatorUtilities.CreateInstance(fixture.Provider); + await model.SelectedChat.UpdateAsync( + _ => new SessionSidebarItem(secondSession.Session.Id, secondSession.Session.Title, secondSession.Session.Preview), + CancellationToken.None); + + await using var enumerator = fixture.WorkspaceState.SendMessageAsync( + new SendSessionMessageCommand(firstSession.Session.Id, "jump back to this"), + CancellationToken.None) + .GetAsyncEnumerator(CancellationToken.None); + + FleetBoardSessionItem? activeSession = null; + while (await enumerator.MoveNextAsync()) + { + _ = enumerator.Current.ShouldSucceed(); + var board = await model.FleetBoard; + board.Should().NotBeNull(); + activeSession = board!.ActiveSessions.FirstOrDefault(item => + item.Title == firstSession.Session.Title); + if (activeSession is not null) + { + break; + } + } + + activeSession.Should().NotBeNull(); + await model.OpenFleetSession(activeSession!.OpenRequest, CancellationToken.None); + + var selectedChat = await model.SelectedChat; + selectedChat.Should().NotBeNull(); + selectedChat!.Id.Should().Be(firstSession.Session.Id); + selectedChat.Title.Should().Be(firstSession.Session.Title); + + while (await enumerator.MoveNextAsync()) + { + _ = enumerator.Current.ShouldSucceed(); + } + } + + private static async Task CreateFixtureAsync() + { + var services = new ServiceCollection(); + services.AddSingleton(TimeProvider.System); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + 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.Tests/ChatSessions/Execution/AgentSessionLoggingTests.cs b/DotPilot.Tests/ChatSessions/Execution/AgentSessionLoggingTests.cs new file mode 100644 index 0000000..c9f26b6 --- /dev/null +++ b/DotPilot.Tests/ChatSessions/Execution/AgentSessionLoggingTests.cs @@ -0,0 +1,133 @@ +using System.Collections.Concurrent; +using DotPilot.Core.ChatSessions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace DotPilot.Tests.ChatSessions; + +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)).ShouldSucceed(); + + var agent = (await fixture.Service.CreateAgentAsync( + new CreateAgentProfileCommand( + "Logged Agent", + AgentProviderKind.Debug, + "debug-echo", + "Be explicit in tests."), + CancellationToken.None)).ShouldSucceed(); + + var session = (await fixture.Service.CreateSessionAsync( + new CreateSessionCommand("Logged session", agent.Id), + CancellationToken.None)).ShouldSucceed(); + + await foreach (var result in fixture.Service.SendMessageAsync( + new SendSessionMessageCommand(session.Session.Id, "hello logs"), + CancellationToken.None)) + { + result.ShouldSucceed(); + } + + 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("Configured agent run middleware.", StringComparison.Ordinal)); + messages.Should().Contain(message => message.Contains("Configured run-scoped chat logging.", StringComparison.Ordinal)); + messages.Should().Contain(message => message.Contains("Prepared correlated agent run.", StringComparison.Ordinal)); + messages.Should().Contain(message => message.Contains("Agent run started.", StringComparison.Ordinal)); + messages.Should().Contain(message => message.Contains("Agent run completed.", StringComparison.Ordinal)); + messages.Should().Contain(message => message.Contains("Chat client request started.", StringComparison.Ordinal)); + messages.Should().Contain(message => message.Contains("Chat client request completed.", StringComparison.Ordinal)); + messages.Should().Contain(message => message.Contains("RunId=", StringComparison.Ordinal)); + messages.Should().Contain(message => message.Contains("Logged Agent", 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.Tests/ChatSessions/Execution/AgentSessionServiceTests.cs b/DotPilot.Tests/ChatSessions/Execution/AgentSessionServiceTests.cs new file mode 100644 index 0000000..8caba7e --- /dev/null +++ b/DotPilot.Tests/ChatSessions/Execution/AgentSessionServiceTests.cs @@ -0,0 +1,674 @@ +using DotPilot.Core.AgentBuilder; +using DotPilot.Core.ChatSessions; +using DotPilot.Core.ControlPlaneDomain; +using DotPilot.Tests.Providers; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace DotPilot.Tests.ChatSessions; + +[NonParallelizable] +public sealed class AgentSessionServiceTests +{ + private const int LegacyDefaultRole = 4; + private const string LegacyEmptyCapabilitiesJson = "[]"; + private const int DeleteRetryCount = 40; + private static readonly TimeSpan DeleteRetryDelay = TimeSpan.FromMilliseconds(250); + + [Test] + public async Task GetWorkspaceAsyncSeedsDefaultSystemAgentForANewStore() + { + await using var fixture = CreateFixture(); + + var workspace = (await fixture.Service.GetWorkspaceAsync(CancellationToken.None)).ShouldSucceed(); + + workspace.Sessions.Should().BeEmpty(); + workspace.Agents.Should().ContainSingle(agent => + agent.Name == AgentSessionDefaults.SystemAgentName && + agent.ProviderKind == AgentProviderKind.Debug && + agent.ModelName == AgentSessionDefaults.GetDefaultModel(AgentProviderKind.Debug)); + workspace.Providers.Should().HaveCount(4); + workspace.Providers.Should().ContainSingle(provider => provider.Kind == AgentProviderKind.Debug); + workspace.Providers.Should().ContainSingle(provider => + provider.Kind == AgentProviderKind.Debug && + provider.IsEnabled && + provider.CanCreateAgents); + } + + [Test] + public async Task CreateAgentAsyncPersistsLegacyCompatibilityColumnsInSqliteStore() + { + var tempRoot = CreateTempRootDirectory(); + var databasePath = Path.Combine(tempRoot, "legacy-store.db"); + await CreateSchemaAsync(databasePath, includeLegacyColumns: true); + await using var fixture = CreateFixture(CreateSqliteOptions(tempRoot, databasePath), tempRoot); + + var created = (await fixture.Service.CreateAgentAsync( + new CreateAgentProfileCommand( + "SQLite Agent", + AgentProviderKind.Debug, + "debug-echo", + "Use the persisted SQLite path.", + "SQLite-backed debug agent."), + CancellationToken.None)).ShouldSucceed(); + + created.Name.Should().Be("SQLite Agent"); + created.Description.Should().Be("SQLite-backed debug agent."); + + await using var connection = new SqliteConnection($"Data Source={databasePath}"); + await connection.OpenAsync(CancellationToken.None); + + await using var command = connection.CreateCommand(); + command.CommandText = + """ + SELECT "Description", "Role", "CapabilitiesJson" + FROM "AgentProfiles" + WHERE "Name" = 'SQLite Agent'; + """; + + await using var reader = await command.ExecuteReaderAsync(CancellationToken.None); + (await reader.ReadAsync(CancellationToken.None)).Should().BeTrue(); + reader.GetString(0).Should().Be("SQLite-backed debug agent."); + reader.GetInt32(1).Should().Be(LegacyDefaultRole); + reader.GetString(2).Should().Be(LegacyEmptyCapabilitiesJson); + } + + [Test] + public async Task GetWorkspaceAsyncUpgradesRegressedSqliteSchemaMissingLegacyColumns() + { + var tempRoot = CreateTempRootDirectory(); + var databasePath = Path.Combine(tempRoot, "regressed-store.db"); + await CreateSchemaAsync(databasePath, includeLegacyColumns: false); + await using var fixture = CreateFixture(CreateSqliteOptions(tempRoot, databasePath), tempRoot); + + var workspace = (await fixture.Service.GetWorkspaceAsync(CancellationToken.None)).ShouldSucceed(); + + workspace.Agents.Should().ContainSingle(agent => agent.Name == AgentSessionDefaults.SystemAgentName); + + await using var connection = new SqliteConnection($"Data Source={databasePath}"); + await connection.OpenAsync(CancellationToken.None); + + await using var command = connection.CreateCommand(); + command.CommandText = """PRAGMA table_info("AgentProfiles")"""; + + List columns = []; + await using var reader = await command.ExecuteReaderAsync(CancellationToken.None); + var nameOrdinal = reader.GetOrdinal("name"); + while (await reader.ReadAsync(CancellationToken.None)) + { + columns.Add(reader.GetString(nameOrdinal)); + } + + columns.Should().Contain("Role"); + columns.Should().Contain("CapabilitiesJson"); + columns.Should().Contain("Description"); + } + + [Test] + public async Task CreateAgentAsyncPersistsAnEnabledDebugProviderProfile() + { + await using var fixture = CreateFixture(); + (await fixture.Service.UpdateProviderAsync( + new UpdateProviderPreferenceCommand(AgentProviderKind.Debug, true), + CancellationToken.None)).ShouldSucceed(); + + var created = (await fixture.Service.CreateAgentAsync( + new CreateAgentProfileCommand( + "Debug Agent", + AgentProviderKind.Debug, + "debug-echo", + "Act as a deterministic local test agent.", + "Deterministic local test agent."), + CancellationToken.None)).ShouldSucceed(); + + var workspace = (await fixture.Service.GetWorkspaceAsync(CancellationToken.None)).ShouldSucceed(); + + created.Name.Should().Be("Debug Agent"); + created.Description.Should().Be("Deterministic local test 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 UpdateAgentAsyncUpdatesAnExistingProfileWithoutCreatingADuplicate() + { + await using var fixture = CreateFixture(); + var created = await EnableDebugAndCreateAgentAsync(fixture.Service, "Editable Agent"); + + var updated = (await fixture.Service.UpdateAgentAsync( + new UpdateAgentProfileCommand( + created.Id, + "Edited Agent", + AgentProviderKind.Debug, + "debug-echo", + "Stay deterministic after edit.", + "Updated deterministic profile."), + CancellationToken.None)).ShouldSucceed(); + + var workspace = (await fixture.Service.GetWorkspaceAsync(CancellationToken.None)).ShouldSucceed(); + + updated.Id.Should().Be(created.Id); + updated.Name.Should().Be("Edited Agent"); + updated.Description.Should().Be("Updated deterministic profile."); + workspace.Agents.Should().ContainSingle(agent => + agent.Id == created.Id && + agent.Name == "Edited Agent" && + agent.Description == "Updated deterministic profile." && + agent.SystemPrompt == "Stay deterministic after edit."); + } + + [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)).ShouldSucceed(); + + 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 CreateAgentAndSendMessageFlowPreservesSelectedModelAcrossConversation() + { + await using var fixture = CreateFixture(); + (await fixture.Service.UpdateProviderAsync( + new UpdateProviderPreferenceCommand(AgentProviderKind.Debug, true), + CancellationToken.None)).ShouldSucceed(); + + var agent = (await fixture.Service.CreateAgentAsync( + new CreateAgentProfileCommand( + "Operator Flow Agent", + AgentProviderKind.Debug, + "debug-echo", + "Stay deterministic for operator-flow tests."), + CancellationToken.None)).ShouldSucceed(); + + var session = (await fixture.Service.CreateSessionAsync( + new CreateSessionCommand("Operator flow session", agent.Id), + CancellationToken.None)).ShouldSucceed(); + + List streamedEntries = []; + await foreach (var entry in fixture.Service.SendMessageAsync( + new SendSessionMessageCommand(session.Session.Id, "what model are you using?"), + CancellationToken.None)) + { + streamedEntries.Add(entry.ShouldSucceed()); + } + + var transcript = (await fixture.Service.GetSessionAsync(session.Session.Id, CancellationToken.None)).ShouldSucceed(); + + transcript.Participants.Should().ContainSingle(participant => + participant.Id == agent.Id && + participant.ModelName == "debug-echo"); + streamedEntries.Should().Contain(entry => + entry.Kind == SessionStreamEntryKind.AssistantMessage && + entry.Text.Contains("Debug provider received: what model are you using?", 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)).ShouldSucceed(); + + List streamedEntries = []; + await foreach (var entry in fixture.Service.SendMessageAsync( + new SendSessionMessageCommand(session.Session.Id, "hello from tests"), + CancellationToken.None)) + { + streamedEntries.Add(entry.ShouldSucceed()); + } + + var reloaded = (await fixture.Service.GetSessionAsync(session.Session.Id, CancellationToken.None)).ShouldSucceed(); + + 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.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)); + } + + [Test] + public async Task SendMessageAsyncMarksTheSessionAsLiveWhileStreamingIsActive() + { + await using var fixture = CreateFixture(); + var agent = await EnableDebugAndCreateAgentAsync(fixture.Service, "Live Agent"); + var session = (await fixture.Service.CreateSessionAsync( + new CreateSessionCommand("Live session", agent.Id), + CancellationToken.None)).ShouldSucceed(); + + fixture.SessionActivityMonitor.Current.HasActiveSessions.Should().BeFalse(); + + await using var enumerator = fixture.Service.SendMessageAsync( + new SendSessionMessageCommand(session.Session.Id, "hello from live monitor"), + CancellationToken.None) + .GetAsyncEnumerator(CancellationToken.None); + + var observedLiveState = false; + while (await enumerator.MoveNextAsync()) + { + _ = enumerator.Current.ShouldSucceed(); + if (!fixture.SessionActivityMonitor.Current.HasActiveSessions) + { + continue; + } + + observedLiveState = true; + fixture.SessionActivityMonitor.Current.SessionId.Should().Be(session.Session.Id); + fixture.SessionActivityMonitor.Current.AgentProfileId.Should().Be(agent.Id); + fixture.SessionActivityMonitor.Current.AgentName.Should().Be("Live Agent"); + break; + } + + observedLiveState.Should().BeTrue(); + + while (await enumerator.MoveNextAsync()) + { + _ = enumerator.Current.ShouldSucceed(); + } + + fixture.SessionActivityMonitor.Current.HasActiveSessions.Should().BeFalse(); + } + + [Test] + public async Task SendMessageAsyncStreamsDebugEntriesWhenTransientRuntimeConversationIsPreferred() + { + await using var fixture = CreateFixture(new AgentSessionStorageOptions + { + UseInMemoryDatabase = true, + InMemoryDatabaseName = Guid.NewGuid().ToString("N"), + PreferTransientRuntimeConversation = true, + }); + var agent = await EnableDebugAndCreateAgentAsync(fixture.Service, "Transient Agent"); + var session = (await fixture.Service.CreateSessionAsync( + new CreateSessionCommand("Transient session", agent.Id), + CancellationToken.None)).ShouldSucceed(); + + List streamedEntries = []; + await foreach (var entry in fixture.Service.SendMessageAsync( + new SendSessionMessageCommand(session.Session.Id, "hello from transient tests"), + CancellationToken.None)) + { + streamedEntries.Add(entry.ShouldSucceed()); + } + + streamedEntries.Should().Contain(entry => + entry.Kind == SessionStreamEntryKind.AssistantMessage && + entry.Text.Contains("Debug provider received: hello from transient tests", StringComparison.Ordinal)); + streamedEntries.Should().Contain(entry => + entry.Kind == SessionStreamEntryKind.ToolCompleted && + entry.Text.Contains("Debug workflow finished", StringComparison.Ordinal)); + } + + [Test] + public async Task SendMessageAsyncReturnsProviderReadinessErrorWhenCodexCliIsMissing() + { + using var commandScope = CodexCliTestScope.Create(nameof(AgentSessionServiceTests)); + await using var fixture = CreateFixture(); + (await fixture.Service.UpdateProviderAsync( + new UpdateProviderPreferenceCommand(AgentProviderKind.Codex, true), + CancellationToken.None)).ShouldSucceed(); + + var legacyAgentId = Guid.CreateVersion7(); + await SeedLegacyAgentAsync(fixture.Provider, legacyAgentId); + + var session = (await fixture.Service.CreateSessionAsync( + new CreateSessionCommand("Legacy session", new AgentProfileId(legacyAgentId)), + CancellationToken.None)).ShouldSucceed(); + + List streamedEntries = []; + await foreach (var entry in fixture.Service.SendMessageAsync( + new SendSessionMessageCommand(session.Session.Id, "hello legacy"), + CancellationToken.None)) + { + streamedEntries.Add(entry.ShouldSucceed()); + } + + var reloaded = (await fixture.Service.GetSessionAsync(session.Session.Id, CancellationToken.None)).ShouldSucceed(); + + streamedEntries.Should().Contain(entry => entry.Kind == SessionStreamEntryKind.UserMessage); + streamedEntries.Should().Contain(entry => + entry.Kind == SessionStreamEntryKind.Error && + entry.Text.Contains("Codex CLI is not installed.", StringComparison.Ordinal)); + streamedEntries.Should().NotContain(entry => entry.Kind == SessionStreamEntryKind.ToolStarted); + streamedEntries.Should().NotContain(entry => entry.Kind == SessionStreamEntryKind.ToolCompleted); + streamedEntries.Should().NotContain(entry => entry.Kind == SessionStreamEntryKind.AssistantMessage); + + reloaded.Entries.Should().Contain(entry => + entry.Kind == SessionStreamEntryKind.Error && + entry.Text.Contains("Codex CLI is not installed.", StringComparison.Ordinal)); + } + + [Test] + public async Task GetWorkspaceAsyncNormalizesLegacyDebugModelAssignedToANonDebugProvider() + { + using var commandScope = CodexCliTestScope.Create(nameof(AgentSessionServiceTests)); + await using var fixture = CreateFixture(); + var legacyAgentId = Guid.CreateVersion7(); + await SeedLegacyAgentAsync( + fixture.Provider, + legacyAgentId, + AgentProviderKind.Codex, + "Debug Agent", + "debug-echo"); + (await fixture.Service.UpdateProviderAsync( + new UpdateProviderPreferenceCommand(AgentProviderKind.Codex, true), + CancellationToken.None)).ShouldSucceed(); + + var workspace = (await fixture.Service.GetWorkspaceAsync(CancellationToken.None)).ShouldSucceed(); + + workspace.Agents.Should().ContainSingle(agent => + agent.Id == new AgentProfileId(legacyAgentId) && + agent.ProviderKind == AgentProviderKind.Codex && + !string.Equals( + agent.ModelName, + AgentSessionDefaults.GetDefaultModel(AgentProviderKind.Debug), + 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) + { + (await service.UpdateProviderAsync( + new UpdateProviderPreferenceCommand(AgentProviderKind.Debug, true), + CancellationToken.None)).ShouldSucceed(); + + return (await service.CreateAgentAsync( + new CreateAgentProfileCommand( + name, + AgentProviderKind.Debug, + "debug-echo", + "Be deterministic for automated verification.", + "Deterministic local test agent."), + CancellationToken.None)).ShouldSucceed(); + } + + private static TestFixture CreateFixture(AgentSessionStorageOptions? options = null) + { + var services = new ServiceCollection(); + services.AddSingleton(TimeProvider.System); + services.AddAgentSessions(options ?? new AgentSessionStorageOptions + { + UseInMemoryDatabase = true, + InMemoryDatabaseName = Guid.NewGuid().ToString("N"), + }); + + var provider = services.BuildServiceProvider(); + var service = provider.GetRequiredService(); + return new TestFixture(provider, service); + } + + private static TestFixture CreateFixture(AgentSessionStorageOptions options, string tempRootPath) + { + var services = new ServiceCollection(); + services.AddSingleton(TimeProvider.System); + services.AddAgentSessions(options); + + var provider = services.BuildServiceProvider(); + var service = provider.GetRequiredService(); + return new TestFixture(provider, service, tempRootPath); + } + + private static async Task SeedLegacyAgentAsync( + ServiceProvider provider, + Guid agentId, + AgentProviderKind providerKind = AgentProviderKind.Codex, + string agentName = "Legacy Codex Agent", + string modelName = "gpt-5") + { + ArgumentNullException.ThrowIfNull(provider); + + var serviceAssembly = provider.GetRequiredService().GetType().Assembly; + var dbContextType = serviceAssembly.GetType("DotPilot.Core.ChatSessions.LocalAgentSessionDbContext") + ?? throw new InvalidOperationException("LocalAgentSessionDbContext type was not found."); + var agentProfileRecordType = serviceAssembly.GetType("DotPilot.Core.ChatSessions.AgentProfileRecord") + ?? throw new InvalidOperationException("AgentProfileRecord type was not found."); + var dbContextFactoryType = typeof(IDbContextFactory<>).MakeGenericType(dbContextType); + var dbContextFactory = provider.GetRequiredService(dbContextFactoryType); + var createDbContextMethod = dbContextFactoryType.GetMethod("CreateDbContext", Type.EmptyTypes) + ?? throw new InvalidOperationException("CreateDbContext method was not found."); + + await using var dbContext = (DbContext)(createDbContextMethod.Invoke(dbContextFactory, []) ?? + throw new InvalidOperationException("CreateDbContext returned null.")); + + var record = Activator.CreateInstance(agentProfileRecordType) + ?? throw new InvalidOperationException("AgentProfileRecord could not be created."); + SetProperty(record, "Id", agentId); + SetProperty(record, "Name", agentName); + TrySetProperty(record, "Description", string.Empty); + SetProperty(record, "ProviderKind", (int)providerKind); + SetProperty(record, "ModelName", modelName); + SetProperty(record, "SystemPrompt", "Use Codex when available."); + TrySetProperty(record, "Role", LegacyDefaultRole); + TrySetProperty(record, "CapabilitiesJson", LegacyEmptyCapabilitiesJson); + SetProperty(record, "CreatedAt", DateTimeOffset.UtcNow); + + dbContext.Add(record); + _ = await dbContext.SaveChangesAsync(CancellationToken.None); + } + + private static void SetProperty(object instance, string propertyName, object value) + { + ArgumentNullException.ThrowIfNull(instance); + ArgumentException.ThrowIfNullOrWhiteSpace(propertyName); + + var property = instance.GetType().GetProperty(propertyName) + ?? throw new InvalidOperationException($"Property '{propertyName}' was not found on '{instance.GetType().FullName}'."); + property.SetValue(instance, value); + } + + private static void TrySetProperty(object instance, string propertyName, object value) + { + ArgumentNullException.ThrowIfNull(instance); + ArgumentException.ThrowIfNullOrWhiteSpace(propertyName); + + var property = instance.GetType().GetProperty(propertyName); + property?.SetValue(instance, value); + } + + private static AgentSessionStorageOptions CreateSqliteOptions(string tempRootPath, string databasePath) + { + return new AgentSessionStorageOptions + { + DatabasePath = databasePath, + RuntimeSessionDirectoryPath = Path.Combine(tempRootPath, "runtime"), + ChatHistoryDirectoryPath = Path.Combine(tempRootPath, "history"), + PlaygroundDirectoryPath = Path.Combine(tempRootPath, "playgrounds"), + }; + } + + private static string CreateTempRootDirectory() + { + var path = Path.Combine(Path.GetTempPath(), "dotpilot-tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(path); + return path; + } + + private static async Task CreateSchemaAsync(string databasePath, bool includeLegacyColumns) + { + ArgumentException.ThrowIfNullOrWhiteSpace(databasePath); + + var directory = Path.GetDirectoryName(databasePath); + if (!string.IsNullOrWhiteSpace(directory)) + { + Directory.CreateDirectory(directory); + } + + await using var connection = new SqliteConnection($"Data Source={databasePath}"); + await connection.OpenAsync(CancellationToken.None); + + var agentProfilesTableDefinition = includeLegacyColumns + ? """ + CREATE TABLE "AgentProfiles" ( + "Id" TEXT NOT NULL CONSTRAINT "PK_AgentProfiles" PRIMARY KEY, + "Name" TEXT NOT NULL, + "Description" TEXT NOT NULL, + "Role" INTEGER NOT NULL, + "ProviderKind" INTEGER NOT NULL, + "ModelName" TEXT NOT NULL, + "SystemPrompt" TEXT NOT NULL, + "CapabilitiesJson" TEXT NOT NULL, + "CreatedAt" TEXT NOT NULL + ); + """ + : """ + CREATE TABLE "AgentProfiles" ( + "Id" TEXT NOT NULL CONSTRAINT "PK_AgentProfiles" PRIMARY KEY, + "Name" TEXT NOT NULL, + "ProviderKind" INTEGER NOT NULL, + "ModelName" TEXT NOT NULL, + "SystemPrompt" TEXT NOT NULL, + "CreatedAt" TEXT NOT NULL + ); + """; + + await using var command = connection.CreateCommand(); + command.CommandText = + $""" + {agentProfilesTableDefinition} + CREATE TABLE "Sessions" ( + "Id" TEXT NOT NULL CONSTRAINT "PK_Sessions" PRIMARY KEY, + "Title" TEXT NOT NULL, + "PrimaryAgentProfileId" TEXT NOT NULL, + "CreatedAt" TEXT NOT NULL, + "UpdatedAt" TEXT NOT NULL + ); + CREATE TABLE "SessionEntries" ( + "Id" TEXT NOT NULL CONSTRAINT "PK_SessionEntries" PRIMARY KEY, + "SessionId" TEXT NOT NULL, + "AgentProfileId" TEXT NULL, + "Kind" INTEGER NOT NULL, + "Author" TEXT NOT NULL, + "Text" TEXT NOT NULL, + "AccentLabel" TEXT NULL, + "Timestamp" TEXT NOT NULL + ); + CREATE TABLE "ProviderPreferences" ( + "ProviderKind" INTEGER NOT NULL CONSTRAINT "PK_ProviderPreferences" PRIMARY KEY, + "IsEnabled" INTEGER NOT NULL, + "UpdatedAt" TEXT NOT NULL + ); + CREATE INDEX "IX_Sessions_UpdatedAt" ON "Sessions" ("UpdatedAt"); + CREATE INDEX "IX_SessionEntries_SessionId_Timestamp" ON "SessionEntries" ("SessionId", "Timestamp"); + """; + _ = await command.ExecuteNonQueryAsync(CancellationToken.None); + } + + private sealed class TestFixture(ServiceProvider provider, IAgentSessionService service, string? tempRootPath = null) : IAsyncDisposable + { + private readonly ServiceProvider _provider = provider; + private readonly string? _tempRootPath = tempRootPath; + + public ServiceProvider Provider { get; } = provider; + + public IAgentSessionService Service { get; } = service; + + public ISessionActivityMonitor SessionActivityMonitor { get; } = + provider.GetRequiredService(); + + public ValueTask DisposeAsync() + { + return DisposeAsyncCore(); + } + + private async ValueTask DisposeAsyncCore() + { + await _provider.DisposeAsync(); + if (!string.IsNullOrWhiteSpace(_tempRootPath) && Directory.Exists(_tempRootPath)) + { + await DeleteDirectoryWithRetryAsync(_tempRootPath); + } + } + } + + private static async Task DeleteDirectoryWithRetryAsync(string path) + { + for (var attempt = 0; attempt < DeleteRetryCount; attempt++) + { + SqliteConnection.ClearAllPools(); + if (!Directory.Exists(path)) + { + return; + } + + try + { + Directory.Delete(path, recursive: true); + return; + } + catch (IOException) when (attempt < DeleteRetryCount - 1) + { + await Task.Delay(DeleteRetryDelay); + } + catch (UnauthorizedAccessException) when (attempt < DeleteRetryCount - 1) + { + await Task.Delay(DeleteRetryDelay); + } + } + } +} diff --git a/DotPilot.Tests/ChatSessions/Execution/SessionActivityMonitorTests.cs b/DotPilot.Tests/ChatSessions/Execution/SessionActivityMonitorTests.cs new file mode 100644 index 0000000..6b1c2e0 --- /dev/null +++ b/DotPilot.Tests/ChatSessions/Execution/SessionActivityMonitorTests.cs @@ -0,0 +1,62 @@ +using DotPilot.Core.ChatSessions; +using DotPilot.Core.ControlPlaneDomain; +using Microsoft.Extensions.DependencyInjection; + +namespace DotPilot.Tests.ChatSessions.Execution; + +public sealed class SessionActivityMonitorTests +{ + [Test] + public void BeginActivityOrdersActiveSessionsByLatestDistinctLeaseAndUsesTheMostRecentLeaseForTheSnapshot() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(TimeProvider.System); + services.AddAgentSessions(new AgentSessionStorageOptions + { + UseInMemoryDatabase = true, + InMemoryDatabaseName = Guid.NewGuid().ToString("N"), + }); + + using var provider = services.BuildServiceProvider(); + var monitor = provider.GetRequiredService(); + var firstSessionId = SessionId.New(); + var secondSessionId = SessionId.New(); + var firstAgentId = AgentProfileId.New(); + var secondAgentId = AgentProfileId.New(); + + using var firstLease = monitor.BeginActivity( + new SessionActivityDescriptor( + firstSessionId, + "First session", + firstAgentId, + "First agent", + "Codex")); + using var secondLease = monitor.BeginActivity( + new SessionActivityDescriptor( + secondSessionId, + "Second session", + secondAgentId, + "Second agent", + "Claude Code")); + using var refreshedFirstLease = monitor.BeginActivity( + new SessionActivityDescriptor( + firstSessionId, + "First session", + firstAgentId, + "First agent (latest)", + "GitHub Copilot")); + + var snapshot = monitor.Current; + + snapshot.HasActiveSessions.Should().BeTrue(); + snapshot.ActiveSessionCount.Should().Be(2); + snapshot.ActiveSessions.Select(session => session.SessionId) + .Should() + .Equal(secondSessionId, firstSessionId); + snapshot.SessionId.Should().Be(firstSessionId); + snapshot.AgentProfileId.Should().Be(firstAgentId); + snapshot.AgentName.Should().Be("First agent (latest)"); + snapshot.ProviderDisplayName.Should().Be("GitHub Copilot"); + } +} diff --git a/DotPilot.Tests/ChatSessions/Persistence/AgentSessionPersistenceTests.cs b/DotPilot.Tests/ChatSessions/Persistence/AgentSessionPersistenceTests.cs new file mode 100644 index 0000000..3e3e3fa --- /dev/null +++ b/DotPilot.Tests/ChatSessions/Persistence/AgentSessionPersistenceTests.cs @@ -0,0 +1,193 @@ +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; +using DotPilot.Core.ChatSessions; +using DotPilot.Core.ControlPlaneDomain; +using Microsoft.Data.Sqlite; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; + +namespace DotPilot.Tests.ChatSessions; + +public sealed class AgentSessionPersistenceTests +{ + private const int DeleteRetryCount = 40; + private static readonly TimeSpan DeleteRetryDelay = TimeSpan.FromMilliseconds(250); + private static readonly JsonSerializerOptions HistorySerializerOptions = new() + { + TypeInfoResolver = new DefaultJsonTypeInfoResolver(), + }; + + [Test] + public async Task SendMessageAsyncPersistsFolderBackedAgentSessionAndHistoryAcrossServiceRestart() + { + var root = CreateRootPath(); + + try + { + SessionId sessionId; + var 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)).ShouldSucceed(); + 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)).ShouldSucceed(); + 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 + { + await DeleteDirectoryAsync(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 result in stream) + { + result.ShouldSucceed(); + } + } + + 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)).ShouldSucceed(); + + return (await service.CreateAgentAsync( + new CreateAgentProfileCommand( + name, + AgentProviderKind.Debug, + "debug-echo", + "Be deterministic for automated verification."), + CancellationToken.None)).ShouldSucceed(); + } + + private static string CreateRootPath() + { + return Path.Combine( + Path.GetTempPath(), + "DotPilot.Tests", + nameof(AgentSessionPersistenceTests), + Guid.NewGuid().ToString("N", System.Globalization.CultureInfo.InvariantCulture)); + } + + private static async Task DeleteDirectoryAsync(string path) + { + for (var attempt = 0; attempt < DeleteRetryCount; attempt++) + { + SqliteConnection.ClearAllPools(); + if (!Directory.Exists(path)) + { + return; + } + + try + { + Directory.Delete(path, recursive: true); + return; + } + catch (IOException) when (attempt < DeleteRetryCount - 1) + { + await Task.Delay(DeleteRetryDelay); + } + catch (UnauthorizedAccessException) when (attempt < DeleteRetryCount - 1) + { + await Task.Delay(DeleteRetryDelay); + } + } + } + + 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/ControlPlaneDomain/ControlPlaneDomainContractsTests.cs similarity index 97% rename from DotPilot.Tests/Features/ControlPlaneDomain/ControlPlaneDomainContractsTests.cs rename to DotPilot.Tests/ControlPlaneDomain/ControlPlaneDomainContractsTests.cs index 9e81cb0..1fcd5b3 100644 --- a/DotPilot.Tests/Features/ControlPlaneDomain/ControlPlaneDomainContractsTests.cs +++ b/DotPilot.Tests/ControlPlaneDomain/ControlPlaneDomainContractsTests.cs @@ -1,6 +1,7 @@ using System.Text.Json; +using DotPilot.Core.ControlPlaneDomain; -namespace DotPilot.Tests.Features.ControlPlaneDomain; +namespace DotPilot.Tests.ControlPlaneDomain; public class ControlPlaneDomainContractsTests { @@ -108,7 +109,6 @@ private static ControlPlaneDomainEnvelope CreateEnvelope() { Id = AgentProfileId.New(), Name = "Implementation Agent", - Role = AgentRoleKind.Coding, ProviderId = provider.Id, ToolCapabilityIds = [tool.Id], Tags = ["implementation", "provider"], @@ -118,7 +118,6 @@ private static ControlPlaneDomainEnvelope CreateEnvelope() { Id = AgentProfileId.New(), Name = "Runtime Reviewer", - Role = AgentRoleKind.Reviewer, ModelRuntimeId = localRuntime.Id, ToolCapabilityIds = [tool.Id], Tags = ["review", "local"], @@ -172,8 +171,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/ControlPlaneDomain/ControlPlaneIdentifierContractTests.cs b/DotPilot.Tests/ControlPlaneDomain/ControlPlaneIdentifierContractTests.cs similarity index 97% rename from DotPilot.Tests/Features/ControlPlaneDomain/ControlPlaneIdentifierContractTests.cs rename to DotPilot.Tests/ControlPlaneDomain/ControlPlaneIdentifierContractTests.cs index a9f4ed0..4e77e80 100644 --- a/DotPilot.Tests/Features/ControlPlaneDomain/ControlPlaneIdentifierContractTests.cs +++ b/DotPilot.Tests/ControlPlaneDomain/ControlPlaneIdentifierContractTests.cs @@ -1,4 +1,6 @@ -namespace DotPilot.Tests.Features.ControlPlaneDomain; +using DotPilot.Core.ControlPlaneDomain; + +namespace DotPilot.Tests.ControlPlaneDomain; public sealed class ControlPlaneIdentifierContractTests { diff --git a/DotPilot.Tests/DotPilot.Tests.csproj b/DotPilot.Tests/DotPilot.Tests.csproj index 22f9d44..5623ba9 100644 --- a/DotPilot.Tests/DotPilot.Tests.csproj +++ b/DotPilot.Tests/DotPilot.Tests.csproj @@ -22,8 +22,6 @@ - - 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..45cbff7 100644 --- a/DotPilot.Tests/GlobalUsings.cs +++ b/DotPilot.Tests/GlobalUsings.cs @@ -1,11 +1,8 @@ -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 DotPilot.Core.ChatSessions.Commands; +global using DotPilot.Core.ChatSessions.Contracts; +global using DotPilot.Core.ChatSessions.Interfaces; +global using DotPilot.Core.ChatSessions.Models; +global using DotPilot.Core.Workspace.Interfaces; +global using DotPilot.Presentation; global using FluentAssertions; global using NUnit.Framework; diff --git a/DotPilot.Tests/Host/Power/DesktopSleepPreventionServiceTests.cs b/DotPilot.Tests/Host/Power/DesktopSleepPreventionServiceTests.cs new file mode 100644 index 0000000..4dd839d --- /dev/null +++ b/DotPilot.Tests/Host/Power/DesktopSleepPreventionServiceTests.cs @@ -0,0 +1,99 @@ +using System.Diagnostics; +using DotPilot.Core.ChatSessions; +using DotPilot.Core.ControlPlaneDomain; +using Microsoft.Extensions.DependencyInjection; + +namespace DotPilot.Tests.Host.Power; + +public sealed class DesktopSleepPreventionServiceTests +{ + [Test] + public async Task ServiceTracksSessionActivityLifecycle() + { + if (OperatingSystem.IsLinux() && !CommandExists("systemd-inhibit")) + { + Assert.Ignore("systemd-inhibit is not available on this machine."); + } + + if (OperatingSystem.IsMacOS() && !CommandExists("caffeinate")) + { + Assert.Ignore("caffeinate is not available on this machine."); + } + + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(TimeProvider.System); + services.AddAgentSessions(new AgentSessionStorageOptions + { + UseInMemoryDatabase = true, + InMemoryDatabaseName = Guid.NewGuid().ToString("N"), + }); + services.AddSingleton(); + + await using var provider = services.BuildServiceProvider(); + var monitor = provider.GetRequiredService(); + var sleepPrevention = provider.GetRequiredService(); + + using var lease = monitor.BeginActivity( + new SessionActivityDescriptor( + SessionId.New(), + "Sleep prevention session", + AgentProfileId.New(), + "Sleep agent", + "Debug Provider")); + + await WaitForAsync(static service => service.IsSleepPreventionActive, sleepPrevention); + + lease.Dispose(); + + await WaitForAsync(static service => !service.IsSleepPreventionActive, sleepPrevention); + } + + private static async Task WaitForAsync( + Func predicate, + DesktopSleepPreventionService service) + { + var timeoutAt = DateTimeOffset.UtcNow.AddSeconds(5); + while (DateTimeOffset.UtcNow < timeoutAt) + { + if (predicate(service)) + { + return; + } + + await Task.Delay(50); + } + + predicate(service).Should().BeTrue(); + } + + private static bool CommandExists(string commandName) + { + try + { + var startInfo = new ProcessStartInfo + { + FileName = "sh", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + startInfo.ArgumentList.Add("-c"); + startInfo.ArgumentList.Add($"command -v {commandName}"); + + using var process = Process.Start(startInfo); + if (process is null) + { + return false; + } + + process.WaitForExit(2000); + return process.ExitCode == 0; + } + catch + { + return false; + } + } +} diff --git a/DotPilot.Runtime/Features/HttpDiagnostics/DebugHttpHandler.cs b/DotPilot.Tests/HttpDiagnostics/DebugHttpHandler.cs similarity index 97% rename from DotPilot.Runtime/Features/HttpDiagnostics/DebugHttpHandler.cs rename to DotPilot.Tests/HttpDiagnostics/DebugHttpHandler.cs index ad2eaa4..3f59a1e 100644 --- a/DotPilot.Runtime/Features/HttpDiagnostics/DebugHttpHandler.cs +++ b/DotPilot.Tests/HttpDiagnostics/DebugHttpHandler.cs @@ -4,7 +4,7 @@ using System.Text; #endif -namespace DotPilot.Runtime.Features.HttpDiagnostics; +namespace DotPilot.Core.HttpDiagnostics; public sealed class DebugHttpHandler(HttpMessageHandler? innerHandler = null) : DelegatingHandler(innerHandler ?? new HttpClientHandler()) diff --git a/DotPilot.Tests/Features/HttpDiagnostics/DebugHttpHandlerTests.cs b/DotPilot.Tests/HttpDiagnostics/DebugHttpHandlerTests.cs similarity index 96% rename from DotPilot.Tests/Features/HttpDiagnostics/DebugHttpHandlerTests.cs rename to DotPilot.Tests/HttpDiagnostics/DebugHttpHandlerTests.cs index 2c40652..7bfd8a8 100644 --- a/DotPilot.Tests/Features/HttpDiagnostics/DebugHttpHandlerTests.cs +++ b/DotPilot.Tests/HttpDiagnostics/DebugHttpHandlerTests.cs @@ -1,7 +1,7 @@ using System.Net; -using DotPilot.Runtime.Features.HttpDiagnostics; +using DotPilot.Core.HttpDiagnostics; -namespace DotPilot.Tests.Features.HttpDiagnostics; +namespace DotPilot.Tests.HttpDiagnostics; public class DebugHttpHandlerTests { diff --git a/DotPilot.Tests/Providers/Services/AgentProviderStatusReaderTests.cs b/DotPilot.Tests/Providers/Services/AgentProviderStatusReaderTests.cs new file mode 100644 index 0000000..b2f36f7 --- /dev/null +++ b/DotPilot.Tests/Providers/Services/AgentProviderStatusReaderTests.cs @@ -0,0 +1,227 @@ +using DotPilot.Core.ChatSessions; +using DotPilot.Core.Providers.Interfaces; +using Microsoft.Extensions.DependencyInjection; + +namespace DotPilot.Tests.Providers; + +[NonParallelizable] +public sealed class AgentProviderStatusReaderTests +{ + [Test] + public async Task RefreshWorkspaceAsyncReadsProviderStatusFromCurrentSourceOfTruth() + { + using var commandScope = CodexCliTestScope.Create(nameof(AgentProviderStatusReaderTests)); + commandScope.WriteVersionCommand("codex", "codex version 1.0.0"); + commandScope.WriteCodexMetadata("gpt-5.4", "gpt-5.4", "gpt-5", "gpt-5-mini"); + + await using var fixture = CreateFixture(); + _ = (await fixture.Service.UpdateProviderAsync( + new UpdateProviderPreferenceCommand(AgentProviderKind.Codex, true), + CancellationToken.None)).ShouldSucceed(); + var initialWorkspace = (await fixture.WorkspaceState.GetWorkspaceAsync(CancellationToken.None)).ShouldSucceed(); + + initialWorkspace.Providers + .Single(provider => provider.Kind == AgentProviderKind.Codex) + .InstalledVersion + .Should() + .Be("1.0.0"); + + commandScope.WriteVersionCommand("codex", "codex version 2.0.0"); + + var workspace = (await fixture.WorkspaceState.RefreshWorkspaceAsync(CancellationToken.None)).ShouldSucceed(); + workspace.Providers + .Single(provider => provider.Kind == AgentProviderKind.Codex) + .InstalledVersion + .Should() + .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() + { + using var commandScope = CodexCliTestScope.Create(nameof(AgentProviderStatusReaderTests)); + commandScope.WriteVersionCommand("codex", "codex version 1.0.0"); + commandScope.WriteCodexMetadata("gpt-5.4", "gpt-5.4", "gpt-5", "gpt-5-mini"); + + await using var fixture = CreateFixture(); + var provider = (await fixture.Service.UpdateProviderAsync( + new UpdateProviderPreferenceCommand(AgentProviderKind.Codex, true), + CancellationToken.None)).ShouldSucceed(); + + provider.IsEnabled.Should().BeTrue(); + provider.CanCreateAgents.Should().BeTrue(); + provider.Status.Should().Be(AgentProviderStatus.Ready); + provider.StatusSummary.Should().Contain("ready for local desktop execution"); + provider.SuggestedModelName.Should().Be("gpt-5.4"); + provider.SupportedModelNames.Should().Contain("gpt-5-mini"); + provider.InstalledVersion.Should().Be("1.0.0"); + provider.Details.Should().Contain(detail => detail.Label == "Suggested model" && detail.Value == "gpt-5.4"); + provider.Details.Should().Contain(detail => detail.Label == "Supported models" && detail.Value.Contains("gpt-5-mini", StringComparison.Ordinal)); + } + + [Test] + public async Task EnabledExternalProviderWithoutLiveRuntimeStillAllowsProfileAuthoring() + { + using var commandScope = CodexCliTestScope.Create(nameof(AgentProviderStatusReaderTests)); + commandScope.WriteVersionCommand("copilot", "copilot version 0.0.421"); + commandScope.WriteCopilotConfig("claude-opus-4.6"); + + await using var fixture = CreateFixture(); + var provider = (await fixture.Service.UpdateProviderAsync( + new UpdateProviderPreferenceCommand(AgentProviderKind.GitHubCopilot, true), + CancellationToken.None)).ShouldSucceed(); + + provider.IsEnabled.Should().BeTrue(); + provider.CanCreateAgents.Should().BeTrue(); + provider.Status.Should().Be(AgentProviderStatus.Unsupported); + provider.StatusSummary.Should().Contain("profile authoring is available"); + provider.InstalledVersion.Should().Be("0.0.421"); + provider.SuggestedModelName.Should().Be("claude-opus-4.6"); + provider.SupportedModelNames.Should().Contain("gpt-5"); + provider.SupportedModelNames.Should().Contain("claude-opus-4.6"); + provider.Details.Should().Contain(detail => detail.Label == "Suggested model" && detail.Value == "claude-opus-4.6"); + provider.Details.Should().Contain(detail => detail.Label == "Supported models" && detail.Value.Contains("gpt-5", StringComparison.Ordinal)); + } + + [Test] + public async Task EnabledClaudeProviderWithoutLiveRuntimeProjectsSuggestedAndSupportedModels() + { + using var commandScope = CodexCliTestScope.Create(nameof(AgentProviderStatusReaderTests)); + commandScope.WriteVersionCommand("claude", "claude version 2.0.75"); + commandScope.WriteClaudeSettings("claude-opus-4-6"); + + await using var fixture = CreateFixture(); + var provider = (await fixture.Service.UpdateProviderAsync( + new UpdateProviderPreferenceCommand(AgentProviderKind.ClaudeCode, true), + CancellationToken.None)).ShouldSucceed(); + + provider.IsEnabled.Should().BeTrue(); + provider.CanCreateAgents.Should().BeTrue(); + provider.Status.Should().Be(AgentProviderStatus.Unsupported); + provider.StatusSummary.Should().Contain("profile authoring is available"); + provider.InstalledVersion.Should().Be("2.0.75"); + provider.SuggestedModelName.Should().Be("claude-opus-4-6"); + provider.SupportedModelNames.Should().Contain("claude-sonnet-4-5"); + provider.SupportedModelNames.Should().Contain("claude-opus-4-6"); + provider.Details.Should().Contain(detail => detail.Label == "Suggested model" && detail.Value == "claude-opus-4-6"); + provider.Details.Should().Contain(detail => detail.Label == "Supported models" && detail.Value.Contains("claude-sonnet-4-5", StringComparison.Ordinal)); + } + + [Test] + public async Task ConcurrentReadsShareOneInFlightProviderProbe() + { + 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(); + + await Task.WhenAll( + reader.ReadAsync(CancellationToken.None).AsTask(), + reader.ReadAsync(CancellationToken.None).AsTask(), + reader.ReadAsync(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, IAgentSessionService service) : IAsyncDisposable + { + private readonly ServiceProvider provider = provider; + + public ServiceProvider Provider { get; } = provider; + + public IAgentSessionService Service { get; } = service; + + public IAgentWorkspaceState WorkspaceState { get; } = provider.GetRequiredService(); + + public ValueTask DisposeAsync() + { + return provider.DisposeAsync(); + } + } +} diff --git a/DotPilot.Tests/Providers/Services/CodexCliTestScope.cs b/DotPilot.Tests/Providers/Services/CodexCliTestScope.cs new file mode 100644 index 0000000..939d8d9 --- /dev/null +++ b/DotPilot.Tests/Providers/Services/CodexCliTestScope.cs @@ -0,0 +1,259 @@ +using System.Globalization; +using System.Text.Json; + +namespace DotPilot.Tests.Providers; + +internal sealed class CodexCliTestScope : IDisposable +{ + private const int DeleteRetryCount = 20; + private static readonly TimeSpan DeleteRetryDelay = TimeSpan.FromMilliseconds(250); + private readonly string rootPath; + private readonly string? originalPath; + private readonly string? originalHome; + private readonly string? originalUserProfile; + private bool disposed; + + private CodexCliTestScope( + string rootPath, + string? originalPath, + string? originalHome, + string? originalUserProfile) + { + this.rootPath = rootPath; + this.originalPath = originalPath; + this.originalHome = originalHome; + this.originalUserProfile = originalUserProfile; + } + + public static CodexCliTestScope Create(string testName) + { + var originalPath = Environment.GetEnvironmentVariable("PATH"); + var originalHome = Environment.GetEnvironmentVariable("HOME"); + var originalUserProfile = Environment.GetEnvironmentVariable("USERPROFILE"); + var rootPath = Path.Combine( + Path.GetTempPath(), + "DotPilot.Tests", + testName, + Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture)); + Directory.CreateDirectory(rootPath); + Environment.SetEnvironmentVariable("PATH", rootPath); + + var homePath = Path.Combine(rootPath, "home"); + Directory.CreateDirectory(homePath); + Environment.SetEnvironmentVariable("HOME", homePath); + Environment.SetEnvironmentVariable("USERPROFILE", homePath); + + return new CodexCliTestScope(rootPath, originalPath, originalHome, originalUserProfile); + } + + public void Dispose() + { + if (disposed) + { + return; + } + + Environment.SetEnvironmentVariable("PATH", originalPath); + Environment.SetEnvironmentVariable("HOME", originalHome); + Environment.SetEnvironmentVariable("USERPROFILE", originalUserProfile); + DeleteDirectoryWithRetry(rootPath); + + disposed = true; + } + + public void WriteVersionCommand(string commandName, string output) + { + WriteCommand( + commandName, + OperatingSystem.IsWindows() + ? $"@echo off{Environment.NewLine}echo {output}{Environment.NewLine}" + : $"#!/bin/sh{Environment.NewLine}echo \"{output}\"{Environment.NewLine}"); + } + + public void WriteCountingVersionCommand(string commandName, string output, int delayMilliseconds) + { + var counterPath = GetCounterFilePath(commandName); + if (OperatingSystem.IsWindows()) + { + WriteCommand( + commandName, + string.Join( + Environment.NewLine, + "@echo off", + $"set \"COUNTER_FILE={counterPath}\"", + "set /a COUNT=0", + "if exist \"%COUNTER_FILE%\" set /p COUNT=<\"%COUNTER_FILE%\"", + "set /a COUNT=%COUNT%+1", + ">\"%COUNTER_FILE%\" echo %COUNT%", + $"if not \"{delayMilliseconds}\"==\"0\" powershell -NoProfile -Command \"Start-Sleep -Milliseconds {delayMilliseconds}\"", + $"echo {output}", + string.Empty)); + return; + } + + WriteCommand( + commandName, + string.Join( + Environment.NewLine, + "#!/bin/sh", + $"counter_file='{counterPath.Replace("'", "'\\''", StringComparison.Ordinal)}'", + "count=0", + "if [ -f \"$counter_file\" ]; then count=$(cat \"$counter_file\"); fi", + "count=$((count + 1))", + "printf '%s' \"$count\" > \"$counter_file\"", + delayMilliseconds > 0 + ? $"sleep {(delayMilliseconds / 1000d).ToString(CultureInfo.InvariantCulture)}" + : string.Empty, + $"echo \"{output}\"", + string.Empty)); + } + + public int ReadInvocationCount(string commandName) + { + var counterPath = GetCounterFilePath(commandName); + if (!File.Exists(counterPath)) + { + return 0; + } + + var content = File.ReadAllText(counterPath).Trim(); + return int.TryParse(content, NumberStyles.Integer, CultureInfo.InvariantCulture, out var count) + ? count + : 0; + } + + public void WriteCodexMetadata( + string defaultModel, + params string[] models) + { + var configDirectory = Path.Combine( + Environment.GetEnvironmentVariable("HOME") ?? rootPath, + ".codex"); + Directory.CreateDirectory(configDirectory); + File.WriteAllText( + Path.Combine(configDirectory, "config.toml"), + string.Join( + Environment.NewLine, + $"model = \"{defaultModel}\"", + "model_reasoning_effort = \"medium\"", + string.Empty)); + + var payload = new + { + fetched_at = "2026-03-15T15:52:07.329647Z", + etag = "test", + client_version = "1.0.0", + models = models.Select(model => new + { + slug = model, + display_name = model, + description = $"Test model {model}", + default_reasoning_level = "medium", + supported_reasoning_levels = new[] + { + new + { + effort = "low", + description = "Fast responses with lighter reasoning", + }, + new + { + effort = "medium", + description = "Balances speed and reasoning depth for everyday tasks", + }, + }, + shell_type = "shell_command", + visibility = "list", + supported_in_api = true, + priority = 0, + availability_nux = (string?)null, + upgrade = (string?)null, + }), + }; + + File.WriteAllText( + Path.Combine(configDirectory, "models_cache.json"), + JsonSerializer.Serialize(payload)); + } + + public void WriteClaudeSettings(string model) + { + var configDirectory = Path.Combine( + Environment.GetEnvironmentVariable("HOME") ?? rootPath, + ".claude"); + Directory.CreateDirectory(configDirectory); + File.WriteAllText( + Path.Combine(configDirectory, "settings.json"), + JsonSerializer.Serialize(new + { + model, + })); + } + + public void WriteCopilotConfig(string model) + { + var configDirectory = Path.Combine( + Environment.GetEnvironmentVariable("HOME") ?? rootPath, + ".copilot"); + Directory.CreateDirectory(configDirectory); + File.WriteAllText( + Path.Combine(configDirectory, "config.json"), + JsonSerializer.Serialize(new + { + model, + })); + } + + private string GetCounterFilePath(string commandName) + { + return Path.Combine(rootPath, commandName + ".count"); + } + + private void WriteCommand(string commandName, string commandBody) + { + var commandPath = OperatingSystem.IsWindows() + ? Path.Combine(rootPath, commandName + ".cmd") + : Path.Combine(rootPath, commandName); + 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 static void DeleteDirectoryWithRetry(string path) + { + for (var attempt = 0; attempt < DeleteRetryCount; attempt++) + { + if (!Directory.Exists(path)) + { + return; + } + + try + { + Directory.Delete(path, recursive: true); + return; + } + catch (IOException) when (attempt < DeleteRetryCount - 1) + { + System.Threading.Thread.Sleep(DeleteRetryDelay); + } + catch (UnauthorizedAccessException) when (attempt < DeleteRetryCount - 1) + { + System.Threading.Thread.Sleep(DeleteRetryDelay); + } + } + } +} diff --git a/DotPilot.Tests/Settings/ViewModels/SettingsModelTests.cs b/DotPilot.Tests/Settings/ViewModels/SettingsModelTests.cs new file mode 100644 index 0000000..45eb461 --- /dev/null +++ b/DotPilot.Tests/Settings/ViewModels/SettingsModelTests.cs @@ -0,0 +1,165 @@ +using DotPilot.Core.ChatSessions; +using DotPilot.Tests.Providers; +using Microsoft.Extensions.DependencyInjection; + +namespace DotPilot.Tests.Settings; + +[NonParallelizable] +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("Disable provider"); + (await model.CanToggleSelectedProvider).Should().BeTrue(); + + await model.ToggleSelectedProvider(CancellationToken.None); + + (await model.SelectedProviderTitle).Should().Be("Debug Provider"); + (await model.ToggleActionLabel).Should().Be("Enable provider"); + (await model.SelectedProvider).Should().NotBeNull(); + (await model.SelectedProvider)!.IsEnabled.Should().BeFalse(); + + var workspace = (await fixture.WorkspaceState.GetWorkspaceAsync(CancellationToken.None)).ShouldSucceed(); + workspace.Providers.Should().ContainSingle(provider => + provider.Kind == AgentProviderKind.Debug && + !provider.IsEnabled && + !provider.CanCreateAgents); + } + + [Test] + public async Task SelectProviderUpdatesProjectionToChosenProvider() + { + await using var fixture = CreateFixture(); + var model = ActivatorUtilities.CreateInstance(fixture.Provider); + + var providers = await model.Providers; + var selectedProvider = providers.First(provider => provider.Kind == AgentProviderKind.Codex); + + await model.SelectProvider(selectedProvider, CancellationToken.None); + + (await model.SelectedProviderTitle).Should().Be(selectedProvider.DisplayName); + (await model.SelectedProvider).Should().NotBeNull(); + (await model.SelectedProvider)!.Kind.Should().Be(AgentProviderKind.Codex); + } + + [Test] + public async Task SelectProviderSurfacesCopilotSuggestedAndSupportedModels() + { + using var commandScope = CodexCliTestScope.Create(nameof(SettingsModelTests)); + commandScope.WriteVersionCommand("copilot", "copilot version 1.0.3"); + commandScope.WriteCopilotConfig("claude-opus-4.6"); + await using var fixture = CreateFixture(); + (await fixture.WorkspaceState.UpdateProviderAsync( + new UpdateProviderPreferenceCommand(AgentProviderKind.GitHubCopilot, true), + CancellationToken.None)).ShouldSucceed(); + var model = ActivatorUtilities.CreateInstance(fixture.Provider); + + var providers = await model.Providers; + var selectedProvider = providers.First(provider => provider.Kind == AgentProviderKind.GitHubCopilot); + + await model.SelectProvider(selectedProvider, CancellationToken.None); + + var details = await model.SelectedProviderDetails; + details.Should().Contain(detail => detail.Label == "Installed version" && detail.Value == "1.0.3"); + details.Should().Contain(detail => detail.Label == "Suggested model" && detail.Value == "claude-opus-4.6"); + details.Should().Contain(detail => detail.Label == "Supported models" && detail.Value.Contains("gpt-5", StringComparison.Ordinal)); + } + + [Test] + public async Task SelectProviderSurfacesClaudeSuggestedAndSupportedModels() + { + using var commandScope = CodexCliTestScope.Create(nameof(SettingsModelTests)); + commandScope.WriteVersionCommand("claude", "claude version 2.0.75"); + commandScope.WriteClaudeSettings("claude-opus-4-6"); + await using var fixture = CreateFixture(); + (await fixture.WorkspaceState.UpdateProviderAsync( + new UpdateProviderPreferenceCommand(AgentProviderKind.ClaudeCode, true), + CancellationToken.None)).ShouldSucceed(); + var model = ActivatorUtilities.CreateInstance(fixture.Provider); + + var providers = await model.Providers; + var selectedProvider = providers.First(provider => provider.Kind == AgentProviderKind.ClaudeCode); + + await model.SelectProvider(selectedProvider, CancellationToken.None); + + var details = await model.SelectedProviderDetails; + details.Should().Contain(detail => detail.Label == "Installed version" && detail.Value == "2.0.75"); + details.Should().Contain(detail => detail.Label == "Suggested model" && detail.Value == "claude-opus-4-6"); + details.Should().Contain(detail => detail.Label == "Supported models" && detail.Value.Contains("claude-sonnet-4-5", StringComparison.Ordinal)); + } + + [Test] + public async Task SelectComposerSendBehaviorUpdatesProjectionAndPreferenceStore() + { + await using var fixture = CreateFixture(); + var model = ActivatorUtilities.CreateInstance(fixture.Provider); + + (await model.IsEnterSendsSelected).Should().BeTrue(); + (await model.IsEnterInsertsNewLineSelected).Should().BeFalse(); + + await model.SelectComposerSendBehavior("EnterInsertsNewLine", CancellationToken.None); + + (await model.IsEnterSendsSelected).Should().BeFalse(); + (await model.IsEnterInsertsNewLineSelected).Should().BeTrue(); + (await model.ComposerSendBehaviorHint).Should().Be("Enter adds a new line. Enter with a modifier sends."); + + var preferencesStore = fixture.Provider.GetRequiredService(); + (await preferencesStore.GetAsync(CancellationToken.None)).ComposerSendBehavior + .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(); + services.AddSingleton(TimeProvider.System); + services.AddSingleton(); + services.AddSingleton(); + 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.Tests/Shell/ViewModels/ShellViewModelTests.cs b/DotPilot.Tests/Shell/ViewModels/ShellViewModelTests.cs new file mode 100644 index 0000000..adebe92 --- /dev/null +++ b/DotPilot.Tests/Shell/ViewModels/ShellViewModelTests.cs @@ -0,0 +1,111 @@ +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); + } + + [Test] + public async Task LiveSessionIndicatorAppearsWhileStreamingAndCollapsesAfterCompletion() + { + await using var fixture = CreateFixture(); + var viewModel = fixture.Provider.GetRequiredService(); + + var agent = (await fixture.Service.CreateAgentAsync( + new CreateAgentProfileCommand( + "Sleep Agent", + AgentProviderKind.Debug, + "debug-echo", + "Stay deterministic while testing the shell indicator.", + "Shell indicator test agent."), + CancellationToken.None)).ShouldSucceed(); + var session = (await fixture.Service.CreateSessionAsync( + new CreateSessionCommand("Session with Sleep Agent", agent.Id), + CancellationToken.None)).ShouldSucceed(); + + await using var enumerator = fixture.Service.SendMessageAsync( + new SendSessionMessageCommand(session.Session.Id, "hello from shell test"), + CancellationToken.None) + .GetAsyncEnumerator(CancellationToken.None); + + var observedIndicator = false; + while (await enumerator.MoveNextAsync()) + { + _ = enumerator.Current.ShouldSucceed(); + if (viewModel.LiveSessionIndicatorVisibility != Visibility.Visible) + { + continue; + } + + observedIndicator = true; + viewModel.LiveSessionIndicatorTitle.Should().Be("Live session active"); + viewModel.LiveSessionIndicatorSummary.Should().Contain("Sleep Agent"); + viewModel.LiveSessionIndicatorSummary.Should().Contain("Session with Sleep Agent"); + break; + } + + observedIndicator.Should().BeTrue(); + + while (await enumerator.MoveNextAsync()) + { + _ = enumerator.Current.ShouldSucceed(); + } + + viewModel.LiveSessionIndicatorVisibility.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(); + services.AddSingleton(); + services.AddSingleton(); + + var provider = services.BuildServiceProvider(); + return new TestFixture(provider); + } + + private sealed class TestFixture(ServiceProvider provider) : IAsyncDisposable + { + public ServiceProvider Provider { get; } = provider; + + public IAgentSessionService Service { get; } = provider.GetRequiredService(); + + public ValueTask DisposeAsync() + { + return Provider.DisposeAsync(); + } + } +} diff --git a/DotPilot.Tests/TestResultExtensions.cs b/DotPilot.Tests/TestResultExtensions.cs new file mode 100644 index 0000000..bb23d49 --- /dev/null +++ b/DotPilot.Tests/TestResultExtensions.cs @@ -0,0 +1,16 @@ +using ManagedCode.Communication; +namespace DotPilot.Tests; + +internal static class TestResultExtensions +{ + public static T ShouldSucceed(this Result result) + { + result.IsSuccess.Should().BeTrue(result.ToDisplayMessage("Operation should succeed.")); + return result.Value!; + } + + public static void ShouldSucceed(this Result result) + { + result.IsSuccess.Should().BeTrue(result.ToDisplayMessage("Operation should succeed.")); + } +} diff --git a/DotPilot.Tests/Workspace/Services/AgentWorkspaceStateTests.cs b/DotPilot.Tests/Workspace/Services/AgentWorkspaceStateTests.cs new file mode 100644 index 0000000..aa28b02 --- /dev/null +++ b/DotPilot.Tests/Workspace/Services/AgentWorkspaceStateTests.cs @@ -0,0 +1,69 @@ +using DotPilot.Core.ChatSessions; +using DotPilot.Tests.Providers; +using Microsoft.Extensions.DependencyInjection; + +namespace DotPilot.Tests.Workspace; + +[NonParallelizable] +public sealed class AgentWorkspaceStateTests +{ + [Test] + public async Task RepeatedWorkspaceReadsReuseTheHydratedProviderSnapshotUntilManualRefresh() + { + using var commandScope = CodexCliTestScope.Create(nameof(AgentWorkspaceStateTests)); + commandScope.WriteCountingVersionCommand("codex", "codex version 1.0.0", delayMilliseconds: 0); + commandScope.WriteCodexMetadata("gpt-5.4", "gpt-5.4"); + + await using var fixture = CreateFixture(); + + var initialWorkspace = (await fixture.WorkspaceState.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: 0); + commandScope.WriteCodexMetadata("gpt-5.1", "gpt-5.1"); + + 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 + .Should() + .Be("2.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()); + } + + private sealed class TestFixture(ServiceProvider provider, IAgentWorkspaceState workspaceState) : IAsyncDisposable + { + public IAgentWorkspaceState WorkspaceState { get; } = workspaceState; + + public ValueTask DisposeAsync() + { + return provider.DisposeAsync(); + } + } +} diff --git a/DotPilot.Tests/Workspace/Services/StartupWorkspaceHydrationTests.cs b/DotPilot.Tests/Workspace/Services/StartupWorkspaceHydrationTests.cs new file mode 100644 index 0000000..4e371b7 --- /dev/null +++ b/DotPilot.Tests/Workspace/Services/StartupWorkspaceHydrationTests.cs @@ -0,0 +1,137 @@ +using DotPilot.Core.ChatSessions; +using DotPilot.Tests.Providers; +using Microsoft.Data.Sqlite; +using Microsoft.Extensions.DependencyInjection; + +namespace DotPilot.Tests.Workspace; + +[NonParallelizable] +public sealed class StartupWorkspaceHydrationTests +{ + private const int DeleteRetryCount = 40; + private static readonly TimeSpan DeleteRetryDelay = TimeSpan.FromMilliseconds(250); + + [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); + } + + [Test] + public async Task EnsureHydratedAsyncKeepsHydrationRetryableAfterATransientWorkspaceFailure() + { + var rootPath = Path.Combine( + Path.GetTempPath(), + "DotPilot.Tests", + nameof(StartupWorkspaceHydrationTests), + Guid.NewGuid().ToString("N")); + var databasePath = Path.Combine(rootPath, "dotpilot-agent-sessions.db"); + + Directory.CreateDirectory(rootPath); + Directory.CreateDirectory(databasePath); + + try + { + await using var fixture = CreateFixture(new AgentSessionStorageOptions + { + DatabasePath = databasePath, + }); + var hydration = fixture.Provider.GetRequiredService(); + + await hydration.EnsureHydratedAsync(CancellationToken.None); + + hydration.IsHydrating.Should().BeFalse(); + hydration.IsReady.Should().BeFalse(); + + await DeleteDirectoryWithRetryAsync(databasePath); + + await hydration.EnsureHydratedAsync(CancellationToken.None); + + hydration.IsHydrating.Should().BeFalse(); + hydration.IsReady.Should().BeTrue(); + } + finally + { + if (Directory.Exists(rootPath)) + { + await DeleteDirectoryWithRetryAsync(rootPath); + } + } + } + + private static TestFixture CreateFixture(AgentSessionStorageOptions? storageOptions = null) + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(TimeProvider.System); + services.AddAgentSessions(storageOptions ?? 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(); + } + } + + private static async Task DeleteDirectoryWithRetryAsync(string path) + { + for (var attempt = 0; attempt < DeleteRetryCount; attempt++) + { + SqliteConnection.ClearAllPools(); + if (!Directory.Exists(path)) + { + return; + } + + try + { + Directory.Delete(path, recursive: true); + return; + } + catch (IOException) when (attempt < DeleteRetryCount - 1) + { + await Task.Delay(DeleteRetryDelay); + } + catch (UnauthorizedAccessException) when (attempt < DeleteRetryCount - 1) + { + await Task.Delay(DeleteRetryDelay); + } + } + } +} diff --git a/DotPilot.UITests/AGENTS.md b/DotPilot.UITests/AGENTS.md index be7fa1e..c0b0b8b 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` +- `ChatSessions/Flows/GivenChatSessionsShell.cs` ## Boundaries @@ -22,10 +22,11 @@ Stack: `.NET 10`, `NUnit`, `Uno.UITest`, browser-driven UI tests - Treat browser-driver setup and app-launch prerequisites as part of the harness, not as assumptions inside individual tests. - The harness must make `dotnet test DotPilot.UITests/DotPilot.UITests.csproj` runnable without manual driver-path export and must fail loudly instead of silently skipping coverage. - Keep the harness direct and minimal; prefer the smallest deterministic setup needed to run the suite and return a real result. -- Keep the file layout explicit: browser harness code belongs under `Harness/`, harness self-tests under `Harness/Tests/`, end-to-end slice coverage under `Features//`, and cross-slice operator flows under `Journeys/`. +- Keep the file layout explicit: browser harness code belongs under `Harness/`, harness self-tests under `Harness/Tests/`, end-to-end slice coverage under `/`, and cross-slice operator flows under `Journeys/`. - Use the official `Uno` MCP documentation as the source of truth for `Uno.UITest` browser behavior, and align selectors with the documented WebAssembly automation mapping before changing the harness. - Do not manually launch the app or a standalone `browserwasm` host while working on this project; browser-path reproduction and debugging must go through `dotnet test` and the real `DotPilot.UITests` harness only. - UI tests must cover each feature's interactive elements, expected behaviors, and full operator flows instead of only a top-level smoke path. +- Agent UI coverage is mandatory: keep end-to-end tests for prompt-first agent creation, default-agent visibility, provider readiness or enable/disable interactions, and starting or resuming a chat with an agent. ## Local Commands diff --git a/DotPilot.UITests/ChatSessions/Flows/GivenChatSessionsShell.cs b/DotPilot.UITests/ChatSessions/Flows/GivenChatSessionsShell.cs new file mode 100644 index 0000000..b882378 --- /dev/null +++ b/DotPilot.UITests/ChatSessions/Flows/GivenChatSessionsShell.cs @@ -0,0 +1,634 @@ +using DotPilot.UITests.Harness; +using FluentAssertions; +using OpenQA.Selenium; +using UITestPlatform = Uno.UITest.Helpers.Queries.Platform; + +namespace DotPilot.UITests.ChatSessions; + +[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 ChatPageTitleAutomationId = "ChatPageTitle"; + private const string AgentsPageTitleAutomationId = "AgentsPageTitle"; + private const string ProvidersPageTitleAutomationId = "ProvidersPageTitle"; + private const string AppSidebarAutomationId = "AppSidebar"; + private const string AppSidebarBrandAutomationId = "AppSidebarBrand"; + private const string AppSidebarNavigationAutomationId = "AppSidebarNavigation"; + private const string AppSidebarProfileAutomationId = "AppSidebarProfile"; + private const string AppSidebarLiveSessionIndicatorAutomationId = "AppSidebarLiveSessionIndicator"; + private const string AppSidebarLiveSessionTitleAutomationId = "AppSidebarLiveSessionTitle"; + private const string ChatNavButtonAutomationId = "ChatNavButton"; + private const string ProvidersNavButtonAutomationId = "ProvidersNavButton"; + private const string AgentsNavButtonAutomationId = "AgentsNavButton"; + private const string ProviderListAutomationId = "ProviderList"; + private const string SelectedProviderTitleAutomationId = "SelectedProviderTitle"; + private const string SettingsSectionMessagesButtonAutomationId = "SettingsSectionMessagesButton"; + private const string CodexProviderEntryAutomationId = "ProviderEntry_Codex"; + private const string AgentCatalogSectionAutomationId = "AgentCatalogSection"; + private const string AgentCatalogListAutomationId = "AgentCatalogList"; + private const string AgentCatalogItemAutomationId = "AgentCatalogItem"; + private const string OpenCreateAgentButtonAutomationId = "OpenCreateAgentButton"; + private const string BuildManuallyButtonAutomationId = "BuildManuallyButton"; + private const string AgentBasicInfoSectionAutomationId = "AgentBasicInfoSection"; + private const string AgentProviderClaudeCodeOptionAutomationId = "AgentProviderOption_ClaudeCode"; + private const string AgentProviderCodexOptionAutomationId = "AgentProviderOption_Codex"; + private const string AgentSelectedProviderTextAutomationId = "AgentSelectedProviderText"; + private const string ChatComposerInputAutomationId = "ChatComposerInput"; + private const string ChatComposerHintAutomationId = "ChatComposerHint"; + private const string ChatComposerSendButtonAutomationId = "ChatComposerSendButton"; + private const string ChatFleetBoardSectionAutomationId = "ChatFleetBoardSection"; + private const string ChatFleetMetricItemAutomationId = "ChatFleetMetricItem"; + private const string ChatFleetSessionItemAutomationId = "ChatFleetSessionItem"; + private const string ChatFleetProviderItemAutomationId = "ChatFleetProviderItem"; + private const string ChatFleetEmptyStateAutomationId = "ChatFleetEmptyState"; + private const string ComposerBehaviorSectionAutomationId = "ComposerBehaviorSection"; + private const string ComposerBehaviorCurrentHintAutomationId = "ComposerBehaviorCurrentHint"; + private const string ComposerBehaviorEnterInsertsNewLineButtonAutomationId = "ComposerBehaviorEnterInsertsNewLineButton"; + 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"; + private const string DefaultSystemAgentName = "dotPilot System Agent"; + private const string DefaultSessionTitle = "Session with dotPilot System Agent"; + private const string DefaultSystemAgentStartChatButtonAutomationId = "AgentCatalogStartChatButton_dotPilotSystemAgent"; + private const string EditableCodexAgentName = "UI Editable Agent"; + private const string EditedCodexAgentName = "UI Edited Agent"; + private const string EditableCodexAgentDescription = "UI-created editable agent."; + private const string EditedCodexAgentDescription = "Updated UI-edited agent."; + private const string EditableCodexAgentEditButtonAutomationId = "AgentCatalogEditButton_UIEditableAgent"; + private const string UserPrompt = "hello from ui"; + private const string DebugResponsePrefix = "Debug provider received: hello from ui"; + private const string DebugToolFinishedText = "Debug workflow finished."; + + [Test] + public async Task WhenSwitchingBetweenPrimaryScreensThenOneStableShellChromeRemainsVisible() + { + await Task.CompletedTask; + + EnsureOnChatScreen(); + WaitForTextContains(ChatPageTitleAutomationId, "Chat", ScreenTransitionTimeout); + AssertSingleShellChrome(); + + TapAutomationElement(AgentsNavButtonAutomationId); + WaitForElement(AgentBuilderScreenAutomationId); + WaitForTextContains(AgentsPageTitleAutomationId, "All agents", ScreenTransitionTimeout); + AssertSingleShellChrome(); + + TapAutomationElement(ProvidersNavButtonAutomationId); + WaitForElement(SettingsScreenAutomationId); + WaitForTextContains(ProvidersPageTitleAutomationId, "Settings", ScreenTransitionTimeout); + AssertSingleShellChrome(); + + TapAutomationElement(ChatNavButtonAutomationId); + WaitForElement(ChatScreenAutomationId); + WaitForTextContains(ChatPageTitleAutomationId, "Chat", ScreenTransitionTimeout); + AssertSingleShellChrome(); + + TakeScreenshot("stable_shell_chrome"); + } + + [Test] + public async Task WhenOpeningTheAppThenDefaultSystemAgentCanStartAChat() + { + await Task.CompletedTask; + + EnsureOnChatScreen(); + WaitForElement(ChatTitleTextAutomationId); + WaitForElement(ChatComposerInputAutomationId); + WaitForElement(ChatComposerSendButtonAutomationId); + WaitForTextContains(ChatComposerHintAutomationId, "Enter sends.", ScreenTransitionTimeout); + ClickActionAutomationElement(ChatStartNewButtonAutomationId); + WaitForElement(ChatRecentChatItemAutomationId); + WaitForTextContains(ChatTitleTextAutomationId, DefaultSessionTitle, ScreenTransitionTimeout); + + ReplaceTextAutomationElement(ChatComposerInputAutomationId, UserPrompt); + PressEnterAutomationElement(ChatComposerInputAutomationId); + WaitForElement(ChatActivityItemAutomationId); + WaitForTextContains(ChatActivityLabelAutomationId, "status", ScreenTransitionTimeout); + WaitForTextContains(ChatActivityLabelAutomationId, "tool", ScreenTransitionTimeout); + WaitForTextContains(ChatMessageTextAutomationId, DebugResponsePrefix, ScreenTransitionTimeout); + WaitForTextContains(ChatMessageTextAutomationId, DebugToolFinishedText, ScreenTransitionTimeout); + + TakeScreenshot("chat_default_system_agent_flow"); + } + + [Test] + public async Task WhenLiveGenerationStartsThenSidebarShowsTheLiveSessionIndicator() + { + await Task.CompletedTask; + + EnsureOnChatScreen(); + ClickActionAutomationElement(ChatStartNewButtonAutomationId); + WaitForTextContains(ChatTitleTextAutomationId, DefaultSessionTitle, ScreenTransitionTimeout); + + ReplaceTextAutomationElement(ChatComposerInputAutomationId, UserPrompt); + PressEnterAutomationElement(ChatComposerInputAutomationId); + + WaitForElement(AppSidebarLiveSessionIndicatorAutomationId); + WaitForTextContains(AppSidebarLiveSessionTitleAutomationId, "Live session active", ScreenTransitionTimeout); + WaitForTextContains(ChatMessageTextAutomationId, DebugResponsePrefix, ScreenTransitionTimeout); + WaitForTextContains(ChatMessageTextAutomationId, DebugToolFinishedText, ScreenTransitionTimeout); + WaitForAutomationElementToDisappearById(AppSidebarLiveSessionIndicatorAutomationId, ScreenTransitionTimeout); + + TakeScreenshot("sidebar_live_session_indicator"); + } + + [Test] + public async Task WhenLiveGenerationStartsThenFleetBoardShowsTheActiveSessionAndProviderHealth() + { + await Task.CompletedTask; + + EnsureOnChatScreen(); + WaitForElement(ChatFleetBoardSectionAutomationId); + WaitForTextContains(ChatFleetEmptyStateAutomationId, "No live sessions right now.", ScreenTransitionTimeout); + ClickActionAutomationElement(ChatStartNewButtonAutomationId); + WaitForTextContains(ChatTitleTextAutomationId, DefaultSessionTitle, ScreenTransitionTimeout); + + ReplaceTextAutomationElement(ChatComposerInputAutomationId, UserPrompt); + PressEnterAutomationElement(ChatComposerInputAutomationId); + + WaitForTextContains(ChatFleetMetricItemAutomationId, "Live sessions", ScreenTransitionTimeout); + WaitForTextContains(ChatFleetSessionItemAutomationId, DefaultSessionTitle, ScreenTransitionTimeout); + WaitForTextContains(ChatFleetProviderItemAutomationId, "Debug Provider", ScreenTransitionTimeout); + WaitForTextContains(ChatMessageTextAutomationId, DebugResponsePrefix, ScreenTransitionTimeout); + WaitForTextContains(ChatMessageTextAutomationId, DebugToolFinishedText, ScreenTransitionTimeout); + WaitForAutomationElementToDisappearById(AppSidebarLiveSessionIndicatorAutomationId, ScreenTransitionTimeout); + WaitForAutomationElementToDisappearById(ChatFleetSessionItemAutomationId, ScreenTransitionTimeout); + WaitForTextContains(ChatFleetEmptyStateAutomationId, "No live sessions right now.", ScreenTransitionTimeout); + + TakeScreenshot("chat_fleet_board_live_session"); + } + + [Test] + public async Task WhenOpeningAgentsThenCatalogIsVisible() + { + await Task.CompletedTask; + + EnsureOnChatScreen(); + TapAutomationElement(AgentsNavButtonAutomationId); + WaitForElement(AgentBuilderScreenAutomationId); + WaitForElement(AgentCatalogSectionAutomationId); + WaitForElement(AgentCatalogListAutomationId); + WaitForTextContains(AgentCatalogItemAutomationId, DefaultSystemAgentName, ScreenTransitionTimeout); + + TakeScreenshot("agent_catalog"); + } + + [Test] + public async Task WhenStartingChatFromTheAgentCatalogThenTheChatScreenOpensForThatAgent() + { + await Task.CompletedTask; + + EnsureOnChatScreen(); + TapAutomationElement(AgentsNavButtonAutomationId); + WaitForElement(AgentBuilderScreenAutomationId); + WaitForElement(AgentCatalogSectionAutomationId); + WaitForTextContains(AgentCatalogItemAutomationId, DefaultSystemAgentName, ScreenTransitionTimeout); + + ClickActionAutomationElement(DefaultSystemAgentStartChatButtonAutomationId, expectElementToDisappear: true); + + WaitForElement(ChatScreenAutomationId); + WaitForTextContains(ChatPageTitleAutomationId, "Chat", ScreenTransitionTimeout); + WaitForTextContains(ChatTitleTextAutomationId, DefaultSessionTitle, ScreenTransitionTimeout); + + TakeScreenshot("agent_catalog_start_chat_opens_chat"); + } + + [Test] + public async Task WhenSelectingCodexFromProvidersThenDetailsUpdateAndAgentsNavigationStillWorks() + { + await Task.CompletedTask; + + EnsureOnChatScreen(); + TapAutomationElement(ProvidersNavButtonAutomationId); + WaitForElement(SettingsScreenAutomationId); + WaitForElement(ProviderListAutomationId); + + TapAutomationElement(CodexProviderEntryAutomationId); + WaitForTextContains(SelectedProviderTitleAutomationId, "Codex", ScreenTransitionTimeout); + + TapAutomationElement(AgentsNavButtonAutomationId); + WaitForElement(AgentBuilderScreenAutomationId); + + TakeScreenshot("settings_select_provider_then_agents_navigation"); + } + + [Test] + public async Task WhenSelectingClaudeCodeWhileAuthoringAnAgentThenTheProviderSummaryFollowsTheSelection() + { + await Task.CompletedTask; + + EnsureOnChatScreen(); + TapAutomationElement(AgentsNavButtonAutomationId); + WaitForElement(AgentBuilderScreenAutomationId); + ClickActionAutomationElement(OpenCreateAgentButtonAutomationId, expectElementToDisappear: true); + WaitForElement(BuildManuallyButtonAutomationId); + ClickActionAutomationElement(BuildManuallyButtonAutomationId, expectElementToDisappear: true); + WaitForElement(AgentBasicInfoSectionAutomationId); + WaitForTextContains(AgentSelectedProviderTextAutomationId, "Codex", ScreenTransitionTimeout); + + ClickActionAutomationElement( + AgentProviderClaudeCodeOptionAutomationId, + () => HasTextContaining(AgentSelectedProviderTextAutomationId, "Claude Code")); + + WaitForTextContains(AgentSelectedProviderTextAutomationId, "Claude Code", ScreenTransitionTimeout); + + TakeScreenshot("agent_builder_provider_selection_follows_combo"); + } + + [Test] + public async Task WhenSavingANewAgentThenANewChatSessionOpensForThatAgent() + { + await Task.CompletedTask; + + EnsureProviderEnabled(CodexProviderEntryAutomationId, "Codex"); + + TapAutomationElement(AgentsNavButtonAutomationId); + WaitForElement(AgentBuilderScreenAutomationId); + ClickActionAutomationElement(OpenCreateAgentButtonAutomationId, expectElementToDisappear: true); + WaitForElement(BuildManuallyButtonAutomationId); + ClickActionAutomationElement(BuildManuallyButtonAutomationId, expectElementToDisappear: true); + WaitForElement(AgentBasicInfoSectionAutomationId); + + ReplaceTextAutomationElement("AgentNameInput", "UI Codex Agent"); + ClickActionAutomationElement( + AgentProviderCodexOptionAutomationId, + () => HasTextContaining(AgentSelectedProviderTextAutomationId, "Codex")); + ReplaceTextAutomationElement("AgentDescriptionInput", "UI-created Codex agent."); + ReplaceTextAutomationElement("AgentSystemPromptInput", "Answer briefly."); + + ClickActionAutomationElement(SaveAgentButtonAutomationId); + + WaitForElement(ChatScreenAutomationId); + WaitForTextContains(ChatTitleTextAutomationId, "Session with UI Codex Agent", ScreenTransitionTimeout); + + TakeScreenshot("saving_new_agent_opens_chat"); + } + + [Test] + public async Task WhenEditingASavedAgentThenTheCatalogReflectsTheUpdatedProfile() + { + await Task.CompletedTask; + + EnsureProviderEnabled(CodexProviderEntryAutomationId, "Codex"); + + TapAutomationElement(AgentsNavButtonAutomationId); + WaitForElement(AgentBuilderScreenAutomationId); + ClickActionAutomationElement(OpenCreateAgentButtonAutomationId, expectElementToDisappear: true); + WaitForElement(BuildManuallyButtonAutomationId); + ClickActionAutomationElement(BuildManuallyButtonAutomationId, expectElementToDisappear: true); + WaitForElement(AgentBasicInfoSectionAutomationId); + + ReplaceTextAutomationElement("AgentNameInput", EditableCodexAgentName); + ClickActionAutomationElement( + AgentProviderCodexOptionAutomationId, + () => HasTextContaining(AgentSelectedProviderTextAutomationId, "Codex")); + ReplaceTextAutomationElement("AgentDescriptionInput", EditableCodexAgentDescription); + ReplaceTextAutomationElement("AgentSystemPromptInput", "Answer briefly."); + + ClickActionAutomationElement(SaveAgentButtonAutomationId); + WaitForElement(ChatScreenAutomationId); + WaitForTextContains(ChatTitleTextAutomationId, $"Session with {EditableCodexAgentName}", ScreenTransitionTimeout); + + TapAutomationElement(AgentsNavButtonAutomationId); + WaitForElement(AgentBuilderScreenAutomationId); + WaitForElement(AgentCatalogSectionAutomationId); + ClickActionAutomationElement(EditableCodexAgentEditButtonAutomationId, expectElementToDisappear: true); + WaitForElement(AgentBasicInfoSectionAutomationId); + WaitForTextContains(AgentsPageTitleAutomationId, "Edit agent", ScreenTransitionTimeout); + + ReplaceTextAutomationElement("AgentNameInput", EditedCodexAgentName); + ReplaceTextAutomationElement("AgentDescriptionInput", EditedCodexAgentDescription); + ReplaceTextAutomationElement("AgentSystemPromptInput", "Answer concisely after edit."); + + ClickActionAutomationElement(SaveAgentButtonAutomationId); + + WaitForElement(AgentCatalogSectionAutomationId); + WaitForTextContains(AgentsPageTitleAutomationId, "All agents", ScreenTransitionTimeout); + WaitForTextContains("AgentCatalogStatusMessage", "Saved changes to UI Edited Agent using Codex.", ScreenTransitionTimeout); + WaitForTextContains(AgentCatalogItemAutomationId, EditedCodexAgentName, ScreenTransitionTimeout); + WaitForTextContains(AgentCatalogItemAutomationId, EditedCodexAgentDescription, ScreenTransitionTimeout); + + TakeScreenshot("editing_saved_agent_updates_catalog"); + } + + [Test] + public async Task WhenChangingMessageSendBehaviorThenChatHintReflectsTheSelection() + { + await Task.CompletedTask; + + EnsureOnChatScreen(); + TapAutomationElement(ProvidersNavButtonAutomationId); + WaitForElement(SettingsScreenAutomationId); + WaitForTextContains(ProvidersPageTitleAutomationId, "Settings", ScreenTransitionTimeout); + ClickActionAutomationElement(SettingsSectionMessagesButtonAutomationId); + WaitForElement(ComposerBehaviorSectionAutomationId); + WaitForTextContains(ComposerBehaviorCurrentHintAutomationId, "Enter sends.", ScreenTransitionTimeout); + + ClickActionAutomationElement(ComposerBehaviorEnterInsertsNewLineButtonAutomationId); + WaitForTextContains(ComposerBehaviorCurrentHintAutomationId, "Enter adds a new line.", ScreenTransitionTimeout); + + TapAutomationElement(ChatNavButtonAutomationId); + EnsureOnChatScreen(); + WaitForTextContains(ChatComposerHintAutomationId, "Enter adds a new line.", ScreenTransitionTimeout); + + TakeScreenshot("chat_message_send_behavior"); + } + + [TestCase(BrowserEnterModifier.Shift)] + [TestCase(BrowserEnterModifier.Control)] + [TestCase(BrowserEnterModifier.Alt)] + [TestCase(BrowserEnterModifier.Command)] + public async Task WhenEnterSendsThenModifierEnterInsertsANewLine(BrowserEnterModifier modifier) + { + await Task.CompletedTask; + + EnsureOnChatScreen(); + ClickActionAutomationElement(ChatStartNewButtonAutomationId); + WaitForTextContains(ChatTitleTextAutomationId, DefaultSessionTitle, ScreenTransitionTimeout); + + ReplaceTextAutomationElement(ChatComposerInputAutomationId, UserPrompt); + PressModifierEnterAutomationElement(ChatComposerInputAutomationId, modifier); + + TryReadBrowserInputValue(ChatComposerInputAutomationId, out var composerValue).Should().BeTrue(); + NormalizeText(composerValue).Should().Be(UserPrompt); + composerValue.Replace("\r\n", "\n", StringComparison.Ordinal).Should().EndWith("\n"); + await Task.Delay(TimeSpan.FromSeconds(1)); + HasTextContaining(ChatMessageTextAutomationId, DebugResponsePrefix).Should().BeFalse(); + } + + [Test] + public async Task WhenEnterAddsNewLineThenPlainEnterInsertsANewLine() + { + 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); + PressEnterAutomationElement(ChatComposerInputAutomationId); + + TryReadBrowserInputValue(ChatComposerInputAutomationId, out var composerValue).Should().BeTrue(); + NormalizeText(composerValue).Should().Be(UserPrompt); + composerValue.Replace("\r\n", "\n", StringComparison.Ordinal).Should().EndWith("\n"); + await Task.Delay(TimeSpan.FromSeconds(1)); + HasTextContaining(ChatMessageTextAutomationId, DebugResponsePrefix).Should().BeFalse(); + } + + [TestCase(BrowserEnterModifier.Shift)] + [TestCase(BrowserEnterModifier.Control)] + [TestCase(BrowserEnterModifier.Alt)] + [TestCase(BrowserEnterModifier.Command)] + public async Task WhenEnterAddsNewLineThenModifierEnterSendsTheMessage(BrowserEnterModifier modifier) + { + 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, modifier); + WaitForElement(ChatActivityItemAutomationId); + WaitForTextContains(ChatActivityLabelAutomationId, "status", ScreenTransitionTimeout); + WaitForTextContains(ChatActivityLabelAutomationId, "tool", ScreenTransitionTimeout); + WaitForTextContains(ChatMessageTextAutomationId, DebugResponsePrefix, ScreenTransitionTimeout); + WaitForTextContains(ChatMessageTextAutomationId, DebugToolFinishedText, ScreenTransitionTimeout); + + TakeScreenshot($"chat_{modifier.ToString().ToLowerInvariant()}_enter_send_behavior"); + } + + private void EnsureOnChatScreen() + { + if (TryWaitForElement(ChatScreenAutomationId, InitialScreenProbeTimeout)) + { + return; + } + + TapAutomationElement(ChatNavButtonAutomationId); + + WaitForElement(ChatScreenAutomationId, "Timed out returning to the chat screen.", ScreenTransitionTimeout); + WaitForElement(ChatComposerInputAutomationId); + } + + private void EnsureProviderEnabled(string providerEntryAutomationId, string providerDisplayName) + { + EnsureOnChatScreen(); + TapAutomationElement(ProvidersNavButtonAutomationId); + WaitForElement(SettingsScreenAutomationId); + WaitForElement(ProviderListAutomationId); + ClickActionAutomationElement( + providerEntryAutomationId, + () => HasTextContaining(SelectedProviderTitleAutomationId, providerDisplayName)); + WaitForElement(ToggleProviderButtonAutomationId); + + var toggleText = ReadPrimaryText(ToggleProviderButtonAutomationId); + if (toggleText.Contains("Enable provider", StringComparison.Ordinal)) + { + 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) + .Select(result => NormalizeText(result.Text)) + .Where(text => !string.IsNullOrWhiteSpace(text)) + .ToArray(); + if (texts.Length > 0) + { + return texts[0]; + } + + return TryReadBrowserInputValue(automationId, out var inputValue) + ? NormalizeText(inputValue) + : string.Empty; + } + + 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; + } + + if (TryReadBrowserInputValue(automationId, out var inputValue) && + NormalizeText(inputValue).Contains(expectedText, StringComparison.Ordinal)) + { + return; + } + + if (TryReadBrowserAutomationTexts(automationId, out var browserTexts) && + browserTexts.Any(text => NormalizeText(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) + { + if (Constants.CurrentPlatform == UITestPlatform.Browser) + { + var effectiveTimeout = timeout ?? ScreenTransitionTimeout; + var timeoutAt = DateTimeOffset.UtcNow.Add(effectiveTimeout); + + while (DateTimeOffset.UtcNow < timeoutAt) + { + try + { + var matches = App.Query(automationId); + if (matches.Length > 0) + { + return matches; + } + } + catch (StaleElementReferenceException) + { + } + catch (InvalidOperationException) + { + } + + if (BrowserHasAutomationElement(automationId)) + { + return []; + } + + Task.Delay(QueryRetryFrequency).GetAwaiter().GetResult(); + } + + WriteBrowserAutomationDiagnostics(automationId); + WriteBrowserSystemLogs($"wait-timeout:{automationId}"); + WriteBrowserDomSnapshot($"wait-timeout:{automationId}", automationId); + throw new TimeoutException(timeoutMessage ?? $"Timed out waiting for automation id '{automationId}'."); + } + + 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); + } + + private void AssertSingleShellChrome() + { + WaitForElement(AppSidebarAutomationId); + WaitForElement(AppSidebarBrandAutomationId); + WaitForElement(AppSidebarNavigationAutomationId); + WaitForElement(AppSidebarProfileAutomationId); + + Assert.Multiple(() => + { + Assert.That(App.Query(AppSidebarAutomationId), Has.Length.EqualTo(1)); + Assert.That(App.Query(AppSidebarBrandAutomationId), Has.Length.EqualTo(1)); + Assert.That(App.Query(AppSidebarNavigationAutomationId), Has.Length.EqualTo(1)); + Assert.That(App.Query(AppSidebarProfileAutomationId), Has.Length.EqualTo(1)); + }); + } +} 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/BrowserEnterModifier.cs b/DotPilot.UITests/Harness/BrowserEnterModifier.cs new file mode 100644 index 0000000..6b1d128 --- /dev/null +++ b/DotPilot.UITests/Harness/BrowserEnterModifier.cs @@ -0,0 +1,10 @@ +namespace DotPilot.UITests.Harness; + +public enum BrowserEnterModifier +{ + None, + Shift, + Control, + Alt, + Command, +} diff --git a/DotPilot.UITests/Harness/TestBase.cs b/DotPilot.UITests/Harness/TestBase.cs index 64d3ab5..880cb20 100644 --- a/DotPilot.UITests/Harness/TestBase.cs +++ b/DotPilot.UITests/Harness/TestBase.cs @@ -1,3 +1,6 @@ +using OpenQA.Selenium; +using OpenQA.Selenium.Interactions; +using UITestPlatform = Uno.UITest.Helpers.Queries.Platform; namespace DotPilot.UITests.Harness; @@ -14,10 +17,12 @@ public class TestBase private const string BrowserWindowSizeArgumentPrefix = "--window-size="; private const int BrowserWindowWidth = 1440; private const int BrowserWindowHeight = 960; + private static readonly TimeSpan QueryRetryFrequency = TimeSpan.FromMilliseconds(250); private static readonly TimeSpan AppCleanupTimeout = TimeSpan.FromSeconds(15); + private static readonly TimeSpan PostClickTransitionProbeTimeout = TimeSpan.FromSeconds(1); private static readonly BrowserAutomationSettings? _browserAutomation = - Constants.CurrentPlatform == Platform.Browser + Constants.CurrentPlatform == UITestPlatform.Browser ? BrowserAutomationBootstrap.Resolve() : null; private static readonly bool _browserHeadless = ResolveBrowserHeadless(); @@ -25,7 +30,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}'."); @@ -43,7 +48,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. @@ -65,7 +70,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}'."); @@ -82,7 +87,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(), @@ -117,7 +122,7 @@ public void TearDownFixture() { TryCleanup( () => _app.Dispose(), - Constants.CurrentPlatform == Platform.Browser + Constants.CurrentPlatform == UITestPlatform.Browser ? BrowserAppCleanupOperationName : AttachedAppCleanupOperationName, cleanupFailures); @@ -125,7 +130,7 @@ public void TearDownFixture() _app = null; - if (Constants.CurrentPlatform == Platform.Browser) + if (Constants.CurrentPlatform == UITestPlatform.Browser) { TryCleanup( BrowserTestHost.Stop, @@ -183,7 +188,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; } @@ -205,11 +210,38 @@ protected void WriteBrowserSystemLogs(string context, int maxEntries = 50) { HarnessLog.Write($"Browser system log dump failed for '{context}': {exception.Message}"); } + + try + { + if (_app + .GetType() + .GetField("_driver", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic) + ?.GetValue(_app) is not IWebDriver driver) + { + return; + } + + var browserLogs = driver.Manage() + .Logs + .GetLog("browser") + .TakeLast(maxEntries) + .ToArray(); + + HarnessLog.Write($"Selenium browser log dump for '{context}' contains {browserLogs.Length} entries."); + foreach (var entry in browserLogs) + { + HarnessLog.Write($"SeleniumBrowserLog {entry.Timestamp:O} {entry.Level}: {entry.Message}"); + } + } + catch (Exception exception) + { + HarnessLog.Write($"Selenium browser log dump failed for '{context}': {exception.Message}"); + } } 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; } @@ -252,6 +284,52 @@ static string Normalize(object? value) var readyState = Normalize(ExecuteScript("return document.readyState;")); var location = Normalize(ExecuteScript("return window.location.href;")); var automationCount = Normalize(ExecuteScript("return document.querySelectorAll('[xamlautomationid]').length;")); + var composerInteropState = Normalize(ExecuteScript( + "return JSON.stringify(globalThis.dotPilotComposerInterop?.getDebugState?.() ?? null);")); + var composerInputValue = Normalize(ExecuteScript( + """ + return (() => { + const host = document.querySelector('[xamlautomationid="ChatComposerInput"], [aria-label="ChatComposerInput"]'); + const input = host?.matches('textarea, input:not([type="hidden"]), [contenteditable="true"], [contenteditable="plaintext-only"]') + ? host + : host?.querySelector('textarea, input:not([type="hidden"]), [contenteditable="true"], [contenteditable="plaintext-only"]') + ?? (host?.matches('[role="textbox"]') ? host : host?.querySelector('[role="textbox"]')); + if (!input) { + return ''; + } + + if ('value' in input) { + return input.value ?? ''; + } + + return input.textContent ?? ''; + })(); + """)); + var sendButtonState = Normalize(ExecuteScript( + """ + return (() => { + const automationId = 'ChatComposerSendButton'; + const selector = `[xamlautomationid="${automationId}"], [aria-label="${automationId}"]`; + const host = document.querySelector(selector); + if (!host) { + return 'missing'; + } + + const clickableSelector = 'button, [role="button"], input[type="button"], input[type="submit"], input[type="checkbox"], input[type="radio"], a[href]'; + const element = host.matches(clickableSelector) + ? host + : host.closest(clickableSelector) ?? host.querySelector(clickableSelector) ?? host; + + return JSON.stringify({ + hostTag: host.tagName, + hostType: host.getAttribute('xamltype') ?? '', + elementTag: element?.tagName ?? '', + elementRole: element?.getAttribute('role') ?? '', + elementType: element?.getAttribute('type') ?? '', + text: element?.innerText ?? element?.textContent ?? '' + }); + })(); + """)); var automationIds = Normalize(ExecuteScript( "return Array.from(document.querySelectorAll('[xamlautomationid]')).slice(0, 25).map(e => e.getAttribute('xamlautomationid')).join(' | ');")); var ariaLabels = Normalize(ExecuteScript( @@ -300,6 +378,9 @@ static string Normalize(object? value) """))); HarnessLog.Write($"Browser DOM snapshot for '{context}': readyState='{readyState}', location='{location}', xamlautomationid-count='{automationCount}'."); + HarnessLog.Write($"Browser DOM snapshot composer interop for '{context}': {composerInteropState}"); + HarnessLog.Write($"Browser DOM snapshot composer input value for '{context}': {composerInputValue}"); + HarnessLog.Write($"Browser DOM snapshot send button state for '{context}': {sendButtonState}"); HarnessLog.Write($"Browser DOM snapshot automation ids for '{context}': {automationIds}"); HarnessLog.Write($"Browser DOM snapshot aria-labels for '{context}': {ariaLabels}"); HarnessLog.Write($"Browser DOM snapshot target hit test for '{context}' and automation id '{inspectedAutomationId}': {targetHitTest}"); @@ -317,6 +398,51 @@ 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 == UITestPlatform.Browser) + { + TryScrollBrowserAutomationElementIntoView(automationId); + + if (TryActivateBrowserAutomationElement(automationId)) + { + return; + } + + try + { + App.Tap(automationId); + HarnessLog.Write($"Uno.UITest tap outcome for '{automationId}': tapped"); + return; + } + catch (Exception exception) + { + HarnessLog.Write($"Uno.UITest tap failed for '{automationId}': {exception.Message}"); + } + + if (TryClickBrowserAutomationElementAtCenter(automationId)) + { + return; + } + + try + { + App.TapCoordinates(target.Rect.CenterX, target.Rect.CenterY); + HarnessLog.Write($"Coordinate tap outcome for '{automationId}': tapped"); + return; + } + catch (Exception exception) + { + HarnessLog.Write($"Coordinate tap failed for '{automationId}': {exception.Message}"); + } + } + } + App.Tap(automationId); } catch (InvalidOperationException exception) @@ -340,19 +466,434 @@ protected void TapAutomationElement(string automationId) HarnessLog.Write($"Tap selector diagnostics failed for '{automationId}': {diagnosticException.Message}"); } + if (Constants.CurrentPlatform == UITestPlatform.Browser) + { + var fallbackMatches = App.Query(automationId); + if (fallbackMatches.Length > 0) + { + var fallbackTarget = fallbackMatches[0]; + TryScrollBrowserAutomationElementIntoView(automationId); + 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 (TryTypeBrowserInputValue(automationId, text)) + { + LogAutomationQueryState(automationId, "browser-typed"); + if (TryReadBrowserInputValue(automationId, out var browserValue)) + { + HarnessLog.Write($"Browser input readback for '{automationId}' after typing: '{browserValue}'."); + if (string.Equals(browserValue, text, StringComparison.Ordinal)) + { + return; + } + } + } + + WriteBrowserAutomationDiagnostics(automationId); + + if (TrySetBrowserInputValue(automationId, text)) + { + LogAutomationQueryState(automationId, "browser-set"); + if (TryReadBrowserInputValue(automationId, out var browserValue)) + { + HarnessLog.Write($"Browser input readback for '{automationId}' after replacement: '{browserValue}'."); + } + + if (DoesAutomationElementReflectText(automationId, text)) + { + return; + } + + HarnessLog.Write($"Browser replacement did not update the automation-visible state for '{automationId}'. Falling back to Uno.UITest input."); + } + + App.ClearText(automationId); + App.EnterText(automationId, 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); + + if (Constants.CurrentPlatform == UITestPlatform.Browser) + { + 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 (DidBrowserActionTakeEffect(automationId, effectObserved, expectElementToDisappear)) + { + return; + } + + HarnessLog.Write($"Action '{automationId}' remained visible after Uno.UITest tap; trying stronger browser fallbacks."); + } + catch (Exception exception) + { + HarnessLog.Write($"Uno.UITest action tap failed for '{automationId}': {exception.Message}"); + } + + if (TryPerformBrowserClickAction(automationId)) + { + if (DidBrowserActionTakeEffect(automationId, effectObserved, expectElementToDisappear)) + { + return; + } + + HarnessLog.Write($"Action '{automationId}' remained visible after DOM click; trying the next browser fallback."); + } + + if (TryClickBrowserAutomationElement(automationId)) + { + if (DidBrowserActionTakeEffect(automationId, effectObserved, expectElementToDisappear)) + { + return; + } + + HarnessLog.Write($"Action '{automationId}' remained visible after browser element click; trying the next fallback."); + } + + if (TryClickBrowserAutomationElementAtCenter(automationId)) + { + if (DidBrowserActionTakeEffect(automationId, effectObserved, expectElementToDisappear)) + { + return; + } + + HarnessLog.Write($"Action '{automationId}' remained visible after center-point click; trying keyboard activation."); + } + + try + { + var matches = App.Query(automationId); + if (matches.Length > 0) + { + var target = matches[0]; + App.TapCoordinates(target.Rect.CenterX, target.Rect.CenterY); + HarnessLog.Write($"Coordinate action tap outcome for '{automationId}': tapped"); + if (DidBrowserActionTakeEffect(automationId, effectObserved, expectElementToDisappear)) + { + return; + } + } + } + catch (Exception exception) + { + HarnessLog.Write($"Coordinate action tap failed for '{automationId}': {exception.Message}"); + } + } + + 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, PostClickTransitionProbeTimeout); + } + + 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, TimeSpan timeout) + { + var timeoutAt = DateTimeOffset.UtcNow.Add(timeout); + while (DateTimeOffset.UtcNow < timeoutAt) + { + if (!BrowserHasAutomationElement(automationId)) + { + return true; + } + + Task.Delay(QueryRetryFrequency).GetAwaiter().GetResult(); + } + + return !BrowserHasAutomationElement(automationId); + } + + protected void PressEnterAutomationElement(string automationId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(automationId); + + if (TryPressEnterBrowserInput(automationId, BrowserEnterModifier.None)) + { + return; + } + + App.EnterText(automationId, Keys.Enter); + } + + protected void PressModifierEnterAutomationElement(string automationId) + { + PressModifierEnterAutomationElement(automationId, BrowserEnterModifier.Control); + } + + protected void PressModifierEnterAutomationElement(string automationId, BrowserEnterModifier modifier) + { + ArgumentException.ThrowIfNullOrWhiteSpace(automationId); + + if (modifier is BrowserEnterModifier.None) + { + PressEnterAutomationElement(automationId); + return; + } + + if (TryPressEnterBrowserInput(automationId, modifier)) + { + return; + } + + App.EnterText(automationId, ComposeEnterSequence(modifier)); + } + + protected void WaitForAutomationElementToDisappearById(string automationId) + { + WaitForAutomationElementToDisappearById(automationId, PostClickTransitionProbeTimeout); + } + + protected void WaitForAutomationElementToDisappearById(string automationId, TimeSpan timeout) + { + ArgumentException.ThrowIfNullOrWhiteSpace(automationId); + + if (WaitForAutomationElementToDisappear(automationId, timeout)) + { + return; + } + + WriteBrowserAutomationDiagnostics(automationId); + WriteBrowserDomSnapshot($"disappear-timeout:{automationId}", automationId); + throw new TimeoutException($"Timed out waiting for automation id '{automationId}' to disappear."); + } + + protected void SelectComboBoxAutomationElementOption(string automationId, string optionText) { - if (Constants.CurrentPlatform != Platform.Browser || _app is null) + ArgumentException.ThrowIfNullOrWhiteSpace(automationId); + ArgumentException.ThrowIfNullOrWhiteSpace(optionText); + + if (TrySelectBrowserComboBoxOption(automationId, optionText)) { return; } + WriteBrowserAutomationDiagnostics(automationId); + WriteBrowserDomSnapshot($"select-combo:{automationId}", automationId); + throw new InvalidOperationException( + $"Could not select combo-box option '{optionText}' for automation id '{automationId}'."); + } + + private bool TryActivateBrowserAutomationElement(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 || + driver is not IJavaScriptExecutor javaScriptExecutor) + { + return false; + } + + var escapedAutomationId = automationId.Replace("'", "\\'", StringComparison.Ordinal); + if (javaScriptExecutor.ExecuteScript( + string.Concat( + """ + return (() => { + const automationId = ' + """, + escapedAutomationId, + """ + '; + const selector = `[xamlautomationid="${automationId}"], [aria-label="${automationId}"]`; + const matches = Array.from(document.querySelectorAll(selector)); + const visibleMatches = matches.filter(element => { + const rect = element.getBoundingClientRect(); + return rect.width > 0 && + rect.height > 0 && + rect.right > 0 && + rect.bottom > 0 && + rect.left < window.innerWidth && + rect.top < window.innerHeight; + }); + const host = visibleMatches.find(element => element.getAttribute('xamlautomationid') === automationId) + ?? visibleMatches[0] + ?? matches.find(element => element.getAttribute('xamlautomationid') === automationId) + ?? matches[0]; + if (!host) { + return null; + } + + const clickableSelector = 'button, [role="button"], input[type="button"], input[type="submit"], input[type="checkbox"], input[type="radio"], a[href]'; + const element = host.matches(clickableSelector) + ? host + : host.closest(clickableSelector) ?? host.querySelector(clickableSelector) ?? host; + + element.scrollIntoView({ block: 'center', inline: 'center' }); + if (!element.hasAttribute('tabindex')) { + element.setAttribute('tabindex', '0'); + } + + if (typeof host.focus === 'function') { + host.focus({ preventScroll: true }); + } + + if (typeof element.focus === 'function') { + element.focus({ preventScroll: true }); + } + + return automationId; + })(); + """)) is null) + { + return false; + } + + static string ReadActiveAutomationId(IJavaScriptExecutor executor) + { + return Convert.ToString( + executor.ExecuteScript( + """ + const active = document.activeElement; + return active?.getAttribute('xamlautomationid') + ?? active?.getAttribute('aria-label') + ?? ''; + """), + System.Globalization.CultureInfo.InvariantCulture) + ?? string.Empty; + } + + var activeAutomationId = ReadActiveAutomationId(javaScriptExecutor); + for (var index = 0; index < 6 && !string.Equals(activeAutomationId, automationId, StringComparison.Ordinal); index++) + { + var activeElement = driver.SwitchTo().ActiveElement(); + activeElement.SendKeys(Keys.Tab); + activeAutomationId = ReadActiveAutomationId(javaScriptExecutor); + } + + var focusedElement = driver.SwitchTo().ActiveElement(); + focusedElement.SendKeys(Keys.Space); + HarnessLog.Write( + $"Browser keyboard activation outcome for '{automationId}': space via active '{ReadActiveAutomationId(javaScriptExecutor)}'"); + return true; + } + catch (Exception exception) + { + HarnessLog.Write($"Browser keyboard activation failed for '{automationId}' with space: {exception.Message}"); + } + + try + { + if (_app + .GetType() + .GetField("_driver", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic) + ?.GetValue(_app) is not IWebDriver driver) + { + return false; + } + + var activeElement = driver.SwitchTo().ActiveElement(); + activeElement.SendKeys(Keys.Enter); + HarnessLog.Write($"Browser keyboard activation outcome for '{automationId}': enter"); + return true; + } + catch (Exception exception) + { + HarnessLog.Write($"Browser keyboard activation failed for '{automationId}' with enter: {exception.Message}"); + return false; + } + } + private bool TryClickBrowserAutomationElement(string automationId) + { + if (Constants.CurrentPlatform != UITestPlatform.Browser || _app is null) + { + return false; + } + try { var driver = _app @@ -362,8 +903,7 @@ private void WriteBrowserAutomationDiagnostics(string automationId) if (driver is null) { - HarnessLog.Write($"Browser automation diagnostics skipped for '{automationId}': Selenium driver field was not found."); - return; + return false; } var executeScriptMethod = driver.GetType().GetMethod( @@ -372,13 +912,1433 @@ private void WriteBrowserAutomationDiagnostics(string automationId) if (executeScriptMethod is null) { - HarnessLog.Write($"Browser automation diagnostics skipped for '{automationId}': ExecuteScript was not found."); - return; + return false; } - var script = string.Concat( - """ - return (() => { + 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 matches = Array.from(document.querySelectorAll(selector)); + const visibleMatches = matches.filter(element => { + const rect = element.getBoundingClientRect(); + return rect.width > 0 && + rect.height > 0 && + rect.right > 0 && + rect.bottom > 0 && + rect.left < window.innerWidth && + rect.top < window.innerHeight; + }); + const host = visibleMatches.find(element => element.getAttribute('xamlautomationid') === automationId) + ?? visibleMatches[0] + ?? matches.find(element => element.getAttribute('xamlautomationid') === automationId) + ?? matches[0]; + if (!host) { + return 'missing'; + } + + const clickableSelector = 'button, [role="button"], input[type="button"], input[type="submit"], input[type="checkbox"], input[type="radio"], a[href]'; + const element = host.matches(clickableSelector) + ? host + : host.closest(clickableSelector) ?? host.querySelector(clickableSelector) ?? host; + element.scrollIntoView({ block: 'center', inline: 'center' }); + if (typeof host.focus === 'function') { + host.focus({ preventScroll: true }); + } + if (typeof element.focus === 'function') { + element.focus({ preventScroll: true }); + } + + if (typeof element.click === 'function') { + element.click(); + return 'clicked'; + } + + 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 TryClickBrowserAutomationElementAtCenter(string automationId) + { + if (Constants.CurrentPlatform != UITestPlatform.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 matches = Array.from(document.querySelectorAll(selector)); + const visibleMatches = matches.filter(element => { + const rect = element.getBoundingClientRect(); + return rect.width > 0 && + rect.height > 0 && + rect.right > 0 && + rect.bottom > 0 && + rect.left < window.innerWidth && + rect.top < window.innerHeight; + }); + const host = visibleMatches.find(element => element.getAttribute('xamlautomationid') === automationId) + ?? visibleMatches[0] + ?? matches.find(element => element.getAttribute('xamlautomationid') === automationId) + ?? matches[0]; + if (!host) { + return 'missing'; + } + + host.scrollIntoView({ block: 'center', inline: 'center' }); + const rect = host.getBoundingClientRect(); + const clientX = rect.left + (rect.width / 2); + const clientY = rect.top + (rect.height / 2); + const pointTarget = document.elementFromPoint(clientX, clientY) ?? host; + const clickableSelector = 'button, [role="button"], input[type="button"], input[type="submit"], input[type="checkbox"], input[type="radio"], a[href], .uno-button, .uno-buttonbase'; + const target = pointTarget?.closest?.(clickableSelector) + ?? (host.matches(clickableSelector) + ? host + : host.closest(clickableSelector) ?? host.querySelector(clickableSelector)) + ?? host; + const eventBase = { + bubbles: true, + cancelable: true, + composed: true, + view: window, + clientX, + clientY, + pointerId: 1, + pointerType: 'mouse', + isPrimary: true + }; + + target.dispatchEvent(new PointerEvent('pointerdown', { ...eventBase, button: 0, buttons: 1, pressure: 0.5 })); + target.dispatchEvent(new MouseEvent('mousedown', { ...eventBase, button: 0, buttons: 1 })); + target.dispatchEvent(new PointerEvent('pointerup', { ...eventBase, button: 0, buttons: 0, pressure: 0 })); + target.dispatchEvent(new MouseEvent('mouseup', { ...eventBase, button: 0, buttons: 0 })); + target.dispatchEvent(new MouseEvent('click', { ...eventBase, button: 0, buttons: 0 })); + return `clicked:${target.tagName}:${target.getAttribute('xamlautomationid') ?? ''}:${target.getAttribute('aria-label') ?? ''}`; + })(); + """), + Array.Empty(), + ]); + + HarnessLog.Write($"Browser center-point click outcome for '{automationId}': {outcome}"); + return Convert.ToString(outcome, System.Globalization.CultureInfo.InvariantCulture) + ?.StartsWith("clicked:", StringComparison.Ordinal) is true; + } + catch (Exception exception) + { + HarnessLog.Write($"Browser center-point click failed for '{automationId}': {exception.Message}"); + return false; + } + } + + private bool TryPerformBrowserClickAction(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 || + driver is not IJavaScriptExecutor javaScriptExecutor) + { + return false; + } + + var selector = string.Concat( + "[xamlautomationid=\"", + automationId, + "\"], [aria-label=\"", + automationId, + "\"]"); + var matches = driver.FindElements(By.CssSelector(selector)); + var resolvedElement = matches.FirstOrDefault(element => + { + try + { + if (!element.Displayed) + { + return false; + } + + var inViewport = javaScriptExecutor.ExecuteScript( + """ + const element = arguments[0]; + if (!element) { + return false; + } + + const rect = element.getBoundingClientRect(); + return rect.width > 0 && + rect.height > 0 && + rect.right > 0 && + rect.bottom > 0 && + rect.left < window.innerWidth && + rect.top < window.innerHeight; + """, + element); + + return inViewport is true; + } + catch + { + return false; + } + }) ?? matches.FirstOrDefault(); + + if (resolvedElement is null) + { + return false; + } + + javaScriptExecutor.ExecuteScript( + """ + const element = arguments[0]; + if (!element) { + return; + } + + element.scrollIntoView({ block: 'center', inline: 'center' }); + """, + resolvedElement); + + var directClickOutcome = javaScriptExecutor.ExecuteScript( + """ + const host = arguments[0]; + if (!host) { + return false; + } + + const clickableSelector = 'button, [role="button"], input[type="button"], input[type="submit"], input[type="checkbox"], input[type="radio"], a[href], .uno-button, .uno-buttonbase'; + const element = host.matches(clickableSelector) + ? host + : host.closest(clickableSelector) ?? host.querySelector(clickableSelector) ?? host; + if (typeof element.click !== 'function') { + return false; + } + + element.scrollIntoView({ block: 'center', inline: 'center' }); + element.click(); + return true; + """, + resolvedElement); + if (directClickOutcome is true) + { + HarnessLog.Write($"Browser native click outcome for '{automationId}': clicked via DOM click"); + return true; + } + + try + { + if (javaScriptExecutor.ExecuteScript( + """ + const element = arguments[0]; + if (!element) { + return null; + } + + const rect = element.getBoundingClientRect(); + return [ + Math.round(rect.left + (rect.width / 2)), + Math.round(rect.top + (rect.height / 2)) + ]; + """, + resolvedElement) is System.Collections.ObjectModel.ReadOnlyCollection pointerTarget && + pointerTarget.Count == 2 && + driver is IActionExecutor actionExecutor) + { + var pointerX = Convert.ToInt32(pointerTarget[0], System.Globalization.CultureInfo.InvariantCulture); + var pointerY = Convert.ToInt32(pointerTarget[1], System.Globalization.CultureInfo.InvariantCulture); + var pointer = new PointerInputDevice(PointerKind.Mouse); + var clickSequence = new ActionSequence(pointer); + clickSequence.AddAction(pointer.CreatePointerMove(CoordinateOrigin.Viewport, pointerX, pointerY, TimeSpan.Zero)); + clickSequence.AddAction(pointer.CreatePointerDown(MouseButton.Left)); + clickSequence.AddAction(pointer.CreatePointerUp(MouseButton.Left)); + actionExecutor.PerformActions([clickSequence]); + } + else + { + resolvedElement.Click(); + } + } + catch + { + new Actions(driver) + .MoveToElement(resolvedElement) + .Click() + .Perform(); + } + + HarnessLog.Write($"Browser native click outcome for '{automationId}': clicked"); + return true; + } + catch (Exception exception) + { + HarnessLog.Write($"Browser native click failed for '{automationId}': {exception.Message}"); + return false; + } + } + + private bool TrySelectBrowserComboBoxOption(string automationId, string optionText) + { + 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 || + driver is not IJavaScriptExecutor javaScriptExecutor) + { + return false; + } + + var escapedAutomationId = automationId.Replace("'", "\\'", StringComparison.Ordinal); + var escapedOptionText = optionText.Replace("'", "\\'", StringComparison.Ordinal); + var outcome = Convert.ToString( + javaScriptExecutor.ExecuteScript( + string.Concat( + """ + return (() => { + const automationId = ' + """, + escapedAutomationId, + """ + '; + const optionText = ' + """, + escapedOptionText, + """ + '; + const selector = `[xamlautomationid="${automationId}"], [aria-label="${automationId}"]`; + const matches = Array.from(document.querySelectorAll(selector)); + const host = matches.find(element => { + const rect = element.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + }) ?? matches[0]; + if (!host) { + return 'missing'; + } + + host.scrollIntoView({ block: 'center', inline: 'center' }); + + const select = host.matches('select') + ? host + : host.querySelector('select'); + if (select) { + const option = Array.from(select.options).find(candidate => + (candidate.textContent ?? '').trim() === optionText || + (candidate.value ?? '').trim() === optionText); + if (!option) { + return 'option-missing'; + } + + option.selected = true; + select.value = option.value; + select.dispatchEvent(new Event('input', { bubbles: true })); + select.dispatchEvent(new Event('change', { bubbles: true })); + return 'selected'; + } + + const combobox = host.matches('[role="combobox"], button, input, div') + ? host + : host.querySelector('[role="combobox"], button, input, div'); + if (!combobox) { + return 'combobox-missing'; + } + + combobox.click(); + + const option = Array.from(document.querySelectorAll('[role="option"], option, li, button, div, p, span')) + .filter(candidate => candidate !== host && candidate !== combobox) + .find(candidate => { + const rect = candidate.getBoundingClientRect(); + const style = window.getComputedStyle(candidate); + const isHidden = style.display === 'none' || + style.visibility === 'hidden' || + candidate.getAttribute('aria-hidden') === 'true'; + + return rect.width > 0 && + rect.height > 0 && + rect.right > 0 && + rect.bottom > 0 && + rect.left < window.innerWidth && + rect.top < window.innerHeight && + !isHidden && + (candidate.textContent ?? '').trim() === optionText; + }); + if (!option) { + return 'option-missing'; + } + + option.scrollIntoView({ block: 'center', inline: 'center' }); + option.click(); + return 'selected'; + })(); + """)), + System.Globalization.CultureInfo.InvariantCulture); + + HarnessLog.Write( + $"Browser combo-box selection outcome for '{automationId}' and option '{optionText}': {outcome}"); + if (string.Equals(outcome, "selected", StringComparison.Ordinal)) + { + return true; + } + + return TrySelectBrowserComboBoxOptionWithKeyboard(driver, automationId, optionText); + } + catch (Exception exception) + { + HarnessLog.Write( + $"Browser combo-box selection failed for '{automationId}' and option '{optionText}': {exception.Message}"); + return false; + } + } + + private bool TrySelectBrowserComboBoxOptionWithKeyboard( + IWebDriver driver, + string automationId, + string optionText) + { + ArgumentNullException.ThrowIfNull(driver); + ArgumentException.ThrowIfNullOrWhiteSpace(automationId); + ArgumentException.ThrowIfNullOrWhiteSpace(optionText); + + static bool MatchesTarget(string[] texts, string optionText) + { + return texts.Any(text => + text.Contains(optionText, StringComparison.Ordinal) || + text.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Any(line => string.Equals(line, optionText, StringComparison.Ordinal))); + } + + bool SelectionReachedTarget() + { + return TryReadBrowserAutomationTexts(automationId, out var texts) && MatchesTarget(texts, optionText); + } + + void LogCurrentSelection(string context) + { + if (!TryReadBrowserAutomationTexts(automationId, out var texts)) + { + HarnessLog.Write($"Browser combo-box text probe for '{automationId}' during '{context}': "); + return; + } + + HarnessLog.Write( + $"Browser combo-box text probe for '{automationId}' during '{context}': {string.Join(" | ", texts)}"); + } + + static void SendKeysToFocusedElement(IWebDriver driver, string keys) + { + driver.SwitchTo().ActiveElement().SendKeys(keys); + } + + try + { + var host = TryResolveBrowserInputHost(driver, automationId); + if (host is null) + { + return false; + } + + if (!TryFocusBrowserInput(driver, automationId, host)) + { + new Actions(driver) + .MoveToElement(host) + .Click() + .Perform(); + } + + LogCurrentSelection("focus"); + if (SelectionReachedTarget()) + { + return true; + } + + var actionSteps = new (string Name, Action Execute)[] + { + ("home", static (webDriver, _) => SendKeysToFocusedElement(webDriver, Keys.Home)), + ("space", static (webDriver, _) => SendKeysToFocusedElement(webDriver, Keys.Space)), + ("arrow-down", static (webDriver, _) => SendKeysToFocusedElement(webDriver, Keys.ArrowDown)), + ("alt-arrow-down", static (webDriver, element) => + { + new Actions(webDriver) + .MoveToElement(element) + .Click() + .KeyDown(Keys.Alt) + .SendKeys(Keys.ArrowDown) + .KeyUp(Keys.Alt) + .Perform(); + }), + }; + + foreach (var (name, execute) in actionSteps) + { + execute(driver, host); + Task.Delay(QueryRetryFrequency).GetAwaiter().GetResult(); + LogCurrentSelection(name); + if (SelectionReachedTarget()) + { + SendKeysToFocusedElement(driver, Keys.Enter); + HarnessLog.Write( + $"Browser combo-box keyboard fallback selected '{optionText}' for '{automationId}'."); + return true; + } + } + + for (var index = 0; index < 8; index++) + { + SendKeysToFocusedElement(driver, Keys.ArrowDown); + Task.Delay(QueryRetryFrequency).GetAwaiter().GetResult(); + LogCurrentSelection($"arrow-loop-{index + 1}"); + if (!SelectionReachedTarget()) + { + continue; + } + + SendKeysToFocusedElement(driver, Keys.Enter); + HarnessLog.Write( + $"Browser combo-box arrow fallback selected '{optionText}' for '{automationId}' after {index + 1} moves."); + return true; + } + } + catch (Exception exception) + { + HarnessLog.Write( + $"Browser combo-box keyboard fallback failed for '{automationId}' and option '{optionText}': {exception.Message}"); + } + + return false; + } + + private bool TryScrollBrowserAutomationElementIntoView(string automationId) + { + if (Constants.CurrentPlatform != UITestPlatform.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 matches = Array.from(document.querySelectorAll(selector)); + const visibleMatches = matches.filter(element => { + const rect = element.getBoundingClientRect(); + return rect.width > 0 && + rect.height > 0 && + rect.right > 0 && + rect.bottom > 0 && + rect.left < window.innerWidth && + rect.top < window.innerHeight; + }); + const host = visibleMatches.find(element => element.getAttribute('xamlautomationid') === automationId) + ?? visibleMatches[0] + ?? matches.find(element => element.getAttribute('xamlautomationid') === automationId) + ?? matches[0]; + if (!host) { + return 'missing'; + } + + host.scrollIntoView({ block: 'center', inline: 'center' }); + return 'scrolled'; + })(); + """), + Array.Empty(), + ]); + + HarnessLog.Write($"Browser scroll outcome for '{automationId}': {outcome}"); + return string.Equals( + Convert.ToString(outcome, System.Globalization.CultureInfo.InvariantCulture), + "scrolled", + StringComparison.Ordinal); + } + catch (Exception exception) + { + HarnessLog.Write($"Browser scroll failed for '{automationId}': {exception.Message}"); + return false; + } + } + + 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; + } + + var inputHost = TryResolveBrowserInputHost(driver, automationId); + try + { + if (inputHost is not null && TryFocusBrowserInput(driver, automationId, inputHost)) + { + ClearActiveBrowserInput(inputHost); + inputHost.SendKeys(text); + DispatchBrowserInputEvents(driver, inputHost); + CommitBrowserInput(driver, inputHost); + + if (TryReadBrowserInputValue(automationId, out var hostRoutedValue) && + string.Equals(hostRoutedValue, text, StringComparison.Ordinal)) + { + HarnessLog.Write($"Browser host typing outcome for '{automationId}': '{hostRoutedValue}'."); + return true; + } + } + } + catch (Exception exception) + { + HarnessLog.Write($"Browser host typing path failed for '{automationId}': {exception.Message}"); + } + + var inputElement = TryResolveBrowserInputElement(driver, automationId); + if (inputElement is null) + { + return false; + } + + PrepareBrowserInputForTyping(driver, inputElement); + if (!TryFocusBrowserInput(driver, automationId, inputElement)) + { + return false; + } + + ClearActiveBrowserInput(inputElement); + inputElement.SendKeys(text); + DispatchBrowserInputEvents(driver, inputElement); + CommitBrowserInput(driver, inputElement); + + var value = inputElement.GetAttribute("value") ?? inputElement.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 static void PrepareBrowserInputForTyping(IWebDriver driver, IWebElement inputElement) + { + ArgumentNullException.ThrowIfNull(driver); + ArgumentNullException.ThrowIfNull(inputElement); + + if (driver is not IJavaScriptExecutor javaScriptExecutor) + { + return; + } + + javaScriptExecutor.ExecuteScript( + """ + const element = arguments[0]; + if (!element) { + return; + } + + const style = window.getComputedStyle(element); + const isHidden = style.display === 'none' || style.visibility === 'hidden' || Number.parseFloat(style.opacity || '1') === 0; + if (!isHidden) { + return; + } + + if (!element.dataset.codexOriginalStyle) { + element.dataset.codexOriginalStyle = element.getAttribute('style') ?? ''; + } + + element.style.display = 'block'; + element.style.visibility = 'visible'; + element.style.opacity = '0.01'; + element.style.position = 'fixed'; + element.style.left = '8px'; + element.style.top = '8px'; + element.style.width = `${Math.max(element.getBoundingClientRect().width, 1)}px`; + element.style.height = `${Math.max(element.getBoundingClientRect().height, 1)}px`; + element.style.zIndex = '2147483647'; + element.style.pointerEvents = 'auto'; + """, + inputElement); + } + + private bool TryPressEnterBrowserInput(string automationId, BrowserEnterModifier modifier) + { + 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 (TryDispatchBrowserComposerEnterShortcut(driver, automationId, modifier)) + { + HarnessLog.Write( + $"Browser input {DescribeEnterGesture(modifier)} outcome for '{automationId}': dispatched via composer interop."); + return true; + } + + if (!TryFocusBrowserInput(driver, automationId, elementOverride: null)) + { + return false; + } + + var inputElement = TryResolveBrowserInputElement(driver, automationId); + if (inputElement is null) + { + return false; + } + + MoveBrowserInputCaretToEnd(driver, inputElement); + + switch (modifier) + { + case BrowserEnterModifier.None: + inputElement.SendKeys(Keys.Enter); + break; + case BrowserEnterModifier.Shift: + case BrowserEnterModifier.Control: + case BrowserEnterModifier.Alt: + case BrowserEnterModifier.Command: + var modifierKey = ResolveBrowserModifierKey(modifier); + new Actions(driver) + .Click(inputElement) + .KeyDown(modifierKey) + .SendKeys(Keys.Enter) + .KeyUp(modifierKey) + .Perform(); + break; + default: + return false; + } + + HarnessLog.Write( + $"Browser input {DescribeEnterGesture(modifier)} outcome for '{automationId}': pressed."); + return true; + } + catch (Exception exception) + { + HarnessLog.Write( + $"Browser input {DescribeEnterGesture(modifier)} failed for '{automationId}': {exception.Message}"); + return false; + } + } + + private static bool TryDispatchBrowserComposerEnterShortcut( + IWebDriver driver, + string automationId, + BrowserEnterModifier modifier) + { + if (driver is not IJavaScriptExecutor javaScriptExecutor) + { + return false; + } + + var timeoutAt = DateTimeOffset.UtcNow.AddSeconds(2); + while (DateTimeOffset.UtcNow < timeoutAt) + { + var dispatched = javaScriptExecutor.ExecuteScript( + """ + const automationId = arguments[0]; + const modifier = arguments[1]; + const interop = globalThis.dotPilotComposerInterop; + if (!interop || typeof interop.dispatchEnter !== 'function') { + return "__missing__"; + } + + return interop.dispatchEnter(automationId, modifier); + """, + automationId, + modifier.ToString()); + + if (dispatched is true) + { + var debugState = javaScriptExecutor.ExecuteScript( + """ + return JSON.stringify(globalThis.dotPilotComposerInterop?.getDebugState?.() ?? null); + """); + HarnessLog.Write( + $"Browser composer interop debug state after {DescribeEnterGesture(modifier)} for '{automationId}': {debugState}"); + return true; + } + + Task.Delay(TimeSpan.FromMilliseconds(50)).GetAwaiter().GetResult(); + } + + return false; + } + + private static string ComposeEnterSequence(BrowserEnterModifier modifier) + { + return modifier switch + { + BrowserEnterModifier.None => Keys.Enter, + BrowserEnterModifier.Shift => string.Concat(Keys.Shift, Keys.Enter, Keys.Null), + BrowserEnterModifier.Control => string.Concat(Keys.Control, Keys.Enter, Keys.Null), + BrowserEnterModifier.Alt => string.Concat(Keys.Alt, Keys.Enter, Keys.Null), + BrowserEnterModifier.Command => string.Concat(Keys.Command, Keys.Enter, Keys.Null), + _ => Keys.Enter, + }; + } + + private static string DescribeEnterGesture(BrowserEnterModifier modifier) + { + return modifier switch + { + BrowserEnterModifier.None => "enter", + BrowserEnterModifier.Shift => "shift+enter", + BrowserEnterModifier.Control => "ctrl+enter", + BrowserEnterModifier.Alt => "alt+enter", + BrowserEnterModifier.Command => "command+enter", + _ => "enter", + }; + } + + private static string ResolveBrowserModifierKey(BrowserEnterModifier modifier) + { + return modifier switch + { + BrowserEnterModifier.Shift => Keys.Shift, + BrowserEnterModifier.Control => Keys.Control, + BrowserEnterModifier.Alt => Keys.Alt, + BrowserEnterModifier.Command => Keys.Command, + _ => string.Empty, + }; + } + + private static void MoveBrowserInputCaretToEnd(IWebDriver driver, IWebElement inputElement) + { + ArgumentNullException.ThrowIfNull(driver); + ArgumentNullException.ThrowIfNull(inputElement); + + if (driver is not IJavaScriptExecutor javaScriptExecutor) + { + return; + } + + javaScriptExecutor.ExecuteScript( + """ + const element = arguments[0]; + if (!element) { + return; + } + + if (typeof element.focus === 'function') { + element.focus({ preventScroll: true }); + } + + if (typeof element.setSelectionRange === 'function') { + const value = element.value ?? ''; + element.setSelectionRange(value.length, value.length); + return; + } + + if (!element.isContentEditable) { + return; + } + + const selection = window.getSelection(); + if (!selection) { + return; + } + + const range = document.createRange(); + range.selectNodeContents(element); + range.collapse(false); + selection.removeAllRanges(); + selection.addRange(range); + """, + inputElement); + } + + 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 void DispatchBrowserInputEvents(IWebDriver driver, IWebElement activeElement) + { + ArgumentNullException.ThrowIfNull(driver); + ArgumentNullException.ThrowIfNull(activeElement); + + if (driver is not IJavaScriptExecutor javaScriptExecutor) + { + return; + } + + javaScriptExecutor.ExecuteScript( + """ + const element = arguments[0]; + if (!element) { + return; + } + + const options = { bubbles: true, cancelable: true, composed: true }; + element.dispatchEvent(new Event('input', options)); + element.dispatchEvent(new Event('change', options)); + """, + activeElement); + } + + private static void CommitBrowserInput(IWebDriver driver, IWebElement activeElement) + { + ArgumentNullException.ThrowIfNull(driver); + ArgumentNullException.ThrowIfNull(activeElement); + + if (driver is IJavaScriptExecutor javaScriptExecutor) + { + javaScriptExecutor.ExecuteScript( + """ + const element = arguments[0]; + if (!element) { + return; + } + + const host = element.closest?.('[xamlautomationid]') ?? element.parentElement ?? element; + const options = { bubbles: true, cancelable: true, composed: true }; + + element.dispatchEvent(new Event('change', options)); + element.dispatchEvent(new FocusEvent('blur', options)); + + if (host && host !== element) { + host.dispatchEvent(new Event('change', options)); + host.dispatchEvent(new FocusEvent('blur', options)); + } + + if (document.activeElement === element || document.activeElement === host) { + document.body.focus(); + } + """, + activeElement); + } + + try + { + activeElement.SendKeys(Keys.Tab); + } + catch (Exception) + { + } + } + + private static bool TryFocusBrowserInput( + IWebDriver driver, + string automationId, + IWebElement? elementOverride) + { + ArgumentNullException.ThrowIfNull(driver); + ArgumentException.ThrowIfNullOrWhiteSpace(automationId); + + if (driver is not IJavaScriptExecutor javaScriptExecutor) + { + return false; + } + + var inputElement = elementOverride ?? TryResolveBrowserInputElement(driver, automationId); + if (inputElement is null) + { + HarnessLog.Write($"Browser input focus outcome for '{automationId}': missing"); + return false; + } + + var outcome = javaScriptExecutor.ExecuteScript( + """ + const element = arguments[0]; + if (!element) { + return 'missing'; + } + + element.scrollIntoView({ block: 'center', inline: 'center' }); + element.focus(); + + if ('select' in element) { + element.select(); + } + + return 'focused'; + """, + inputElement); + + HarnessLog.Write($"Browser input focus outcome for '{automationId}': {outcome}"); + return string.Equals( + Convert.ToString(outcome, System.Globalization.CultureInfo.InvariantCulture), + "focused", + StringComparison.Ordinal); + } + + private static IWebElement? TryResolveBrowserInputHost(IWebDriver driver, string automationId) + { + ArgumentNullException.ThrowIfNull(driver); + ArgumentException.ThrowIfNullOrWhiteSpace(automationId); + + if (driver is not IJavaScriptExecutor javaScriptExecutor) + { + return null; + } + + var escapedAutomationId = automationId.Replace("'", "\\'", StringComparison.Ordinal); + return javaScriptExecutor.ExecuteScript( + string.Concat( + """ + return (() => { + const automationId = ' + """, + escapedAutomationId, + """ + '; + const selector = `[xamlautomationid="${automationId}"], [aria-label="${automationId}"]`; + const matches = Array.from(document.querySelectorAll(selector)); + const visibleMatches = matches.filter(element => { + const rect = element.getBoundingClientRect(); + return rect.width > 0 && + rect.height > 0 && + rect.right > 0 && + rect.bottom > 0 && + rect.left < window.innerWidth && + rect.top < window.innerHeight; + }); + + return visibleMatches.find(element => element.getAttribute('xamlautomationid') === automationId) + ?? visibleMatches[0] + ?? matches.find(element => element.getAttribute('xamlautomationid') === automationId) + ?? matches[0] + ?? null; + })(); + """)) as IWebElement; + } + + private static IWebElement? TryResolveBrowserInputElement(IWebDriver driver, string automationId) + { + ArgumentNullException.ThrowIfNull(driver); + ArgumentException.ThrowIfNullOrWhiteSpace(automationId); + + if (driver is not IJavaScriptExecutor javaScriptExecutor) + { + return null; + } + + var escapedAutomationId = automationId.Replace("'", "\\'", StringComparison.Ordinal); + return javaScriptExecutor.ExecuteScript( + string.Concat( + """ + return (() => { + const automationId = ' + """, + escapedAutomationId, + """ + '; + const selector = `[xamlautomationid="${automationId}"], [aria-label="${automationId}"]`; + const matches = Array.from(document.querySelectorAll(selector)); + const visibleMatches = matches.filter(element => { + const rect = element.getBoundingClientRect(); + return rect.width > 0 && + rect.height > 0 && + rect.right > 0 && + rect.bottom > 0 && + rect.left < window.innerWidth && + rect.top < window.innerHeight; + }); + const host = visibleMatches.find(element => element.getAttribute('xamlautomationid') === automationId) + ?? visibleMatches[0] + ?? matches.find(element => element.getAttribute('xamlautomationid') === automationId) + ?? matches[0]; + if (!host) { + return null; + } + + const inputSelector = 'textarea, input:not([type="hidden"]), [contenteditable="true"], [contenteditable="plaintext-only"], [role="textbox"]'; + const candidates = [ + ...(host.matches(inputSelector) ? [host] : []), + ...Array.from(host.querySelectorAll(inputSelector)) + ]; + const visibleCandidates = candidates.filter(element => { + const rect = element.getBoundingClientRect(); + const style = window.getComputedStyle(element); + const isHidden = style.display === 'none' || style.visibility === 'hidden'; + const isDisabled = 'disabled' in element && element.disabled; + const isReadOnly = 'readOnly' in element && element.readOnly; + const isAriaHidden = element.getAttribute('aria-hidden') === 'true'; + + return rect.width > 0 && + rect.height > 0 && + !isHidden && + !isDisabled && + !isReadOnly && + !isAriaHidden; + }); + const rankedCandidates = (visibleCandidates.length > 0 ? visibleCandidates : candidates) + .map(element => { + const rect = element.getBoundingClientRect(); + const isTextArea = element.tagName === 'TEXTAREA'; + const isTextInput = element.tagName === 'INPUT'; + const isRoleTextbox = element.getAttribute('role') === 'textbox'; + const score = + (isTextArea ? 1000000 : 0) + + (isTextInput ? 100000 : 0) + + (isRoleTextbox ? 10000 : 0) + + (rect.width * rect.height); + + return { element, score }; + }) + .sort((left, right) => right.score - left.score); + + return rankedCandidates[0]?.element ?? null; + })(); + """)) as IWebElement; + } + + private bool TrySetBrowserInputValue(string automationId, string text) + { + if (Constants.CurrentPlatform != UITestPlatform.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 matches = Array.from(document.querySelectorAll(selector)); + const visibleMatches = matches.filter(element => { + const rect = element.getBoundingClientRect(); + return rect.width > 0 && + rect.height > 0 && + rect.right > 0 && + rect.bottom > 0 && + rect.left < window.innerWidth && + rect.top < window.innerHeight; + }); + const host = visibleMatches.find(element => element.getAttribute('xamlautomationid') === automationId) + ?? visibleMatches[0] + ?? matches.find(element => element.getAttribute('xamlautomationid') === automationId) + ?? matches[0]; + if (!host) { + return 'missing'; + } + + const inputSelector = 'textarea, input:not([type="hidden"]), [contenteditable="true"], [contenteditable="plaintext-only"], [role="textbox"]'; + const backingSelector = 'textarea, input:not([type="hidden"])'; + const candidates = [ + ...(host.matches(inputSelector) ? [host] : []), + ...Array.from(host.querySelectorAll(inputSelector)) + ]; + const backingElement = host.matches(backingSelector) + ? host + : host.querySelector(backingSelector); + const visibleCandidates = candidates.filter(element => { + const rect = element.getBoundingClientRect(); + const style = window.getComputedStyle(element); + const isHidden = style.display === 'none' || style.visibility === 'hidden'; + const isDisabled = 'disabled' in element && element.disabled; + const isReadOnly = 'readOnly' in element && element.readOnly; + const isAriaHidden = element.getAttribute('aria-hidden') === 'true'; + + return rect.width > 0 && + rect.height > 0 && + !isHidden && + !isDisabled && + !isReadOnly && + !isAriaHidden; + }); + const visibleBackingElement = backingElement && visibleCandidates.includes(backingElement) + ? backingElement + : null; + const rankedSource = visibleBackingElement + ? [visibleBackingElement] + : visibleCandidates.length > 0 + ? visibleCandidates + : backingElement + ? [backingElement, ...candidates] + : candidates; + const element = rankedSource + .map(candidate => { + const rect = candidate.getBoundingClientRect(); + const isTextArea = candidate.tagName === 'TEXTAREA'; + const isTextInput = candidate.tagName === 'INPUT'; + const isRoleTextbox = candidate.getAttribute('role') === 'textbox'; + const score = + (isTextArea ? 1000000 : 0) + + (isTextInput ? 100000 : 0) + + (isRoleTextbox ? 10000 : 0) + + (rect.width * rect.height); + + return { candidate, score }; + }) + .sort((left, right) => right.score - left.score)[0]?.candidate; + if (!element) { + return 'not-an-input'; + } + + host.scrollIntoView({ block: 'center', inline: 'center' }); + if (typeof host.focus === 'function') { + host.focus({ preventScroll: true }); + } + if (typeof element.focus === 'function') { + element.focus({ preventScroll: true }); + } + + if ('value' in element) { + element.value = value; + } else { + element.textContent = value; + } + + const options = { bubbles: true, cancelable: true, composed: true }; + const inputEvent = typeof InputEvent === 'function' + ? new InputEvent('input', { ...options, data: value, inputType: 'insertText' }) + : new Event('input', options); + element.dispatchEvent(new Event('beforeinput', options)); + element.dispatchEvent(inputEvent); + element.dispatchEvent(new Event('change', options)); + const hostInputEvent = typeof InputEvent === 'function' + ? new InputEvent('input', { ...options, data: value, inputType: 'insertText' }) + : new Event('input', options); + host.dispatchEvent(new Event('beforeinput', options)); + host.dispatchEvent(hostInputEvent); + host.dispatchEvent(new Event('change', options)); + element.blur(); + if (typeof host.blur === 'function') { + host.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 != UITestPlatform.Browser || _app is null) + { + return; + } + + try + { + var driver = _app + .GetType() + .GetField("_driver", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic) + ?.GetValue(_app); + + if (driver is null) + { + HarnessLog.Write($"Browser automation diagnostics skipped for '{automationId}': Selenium driver field was not found."); + return; + } + + var executeScriptMethod = driver.GetType().GetMethod( + "ExecuteScript", + [typeof(string), typeof(object[])]); + + if (executeScriptMethod is null) + { + HarnessLog.Write($"Browser automation diagnostics skipped for '{automationId}': ExecuteScript was not found."); + return; + } + + var script = string.Concat( + """ + return (() => { const automationId = """, "'", @@ -395,6 +2355,22 @@ private 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"], [contenteditable="plaintext-only"], [role="textbox"]')).map((child, childIndex) => ({ + childIndex, + tag: child.tagName, + className: child.className, + value: 'value' in child ? child.value : child.textContent ?? '', + ariaLabel: child.getAttribute('aria-label') ?? '', + xamlAutomationId: child.getAttribute('xamlautomationid') ?? '', + xamlType: child.getAttribute('xamltype') ?? '', + width: child.getBoundingClientRect().width, + height: child.getBoundingClientRect().height, + display: window.getComputedStyle(child).display, + visibility: window.getComputedStyle(child).visibility, + disabled: 'disabled' in child ? child.disabled : false, + readOnly: 'readOnly' in child ? child.readOnly : false + })), html: element.outerHTML.slice(0, 300) })); const byAria = Array.from(document.querySelectorAll(`[aria-label="${automationId}"]`)) @@ -406,6 +2382,22 @@ private 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"], [contenteditable="plaintext-only"], [role="textbox"]')).map((child, childIndex) => ({ + childIndex, + tag: child.tagName, + className: child.className, + value: 'value' in child ? child.value : child.textContent ?? '', + ariaLabel: child.getAttribute('aria-label') ?? '', + xamlAutomationId: child.getAttribute('xamlautomationid') ?? '', + xamlType: child.getAttribute('xamltype') ?? '', + width: child.getBoundingClientRect().width, + height: child.getBoundingClientRect().height, + display: window.getComputedStyle(child).display, + visibility: window.getComputedStyle(child).visibility, + disabled: 'disabled' in child ? child.disabled : false, + readOnly: 'readOnly' in child ? child.readOnly : false + })), html: element.outerHTML.slice(0, 300) })); return JSON.stringify({ byAutomation, byAria }); @@ -421,6 +2413,341 @@ private void WriteBrowserAutomationDiagnostics(string automationId) } } + protected bool BrowserHasAutomationElement(string automationId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(automationId); + + if (Constants.CurrentPlatform != UITestPlatform.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 script = string.Concat( + """ + return (() => { + const automationId = ' + """, + automationId.Replace("'", "\\'", StringComparison.Ordinal), + """ + '; + const selector = `[xamlautomationid="${automationId}"], [aria-label="${automationId}"]`; + const matches = Array.from(document.querySelectorAll(selector)); + return matches.some(element => { + const rect = element.getBoundingClientRect(); + const style = window.getComputedStyle(element); + const isHidden = style.display === 'none' || + style.visibility === 'hidden' || + element.getAttribute('aria-hidden') === 'true'; + + return rect.width > 0 && + rect.height > 0 && + rect.right > 0 && + rect.bottom > 0 && + rect.left < window.innerWidth && + rect.top < window.innerHeight && + !isHidden; + }); + })(); + """); + + return executeScriptMethod.Invoke(driver, [script, Array.Empty()]) is true; + } + catch (Exception exception) + { + HarnessLog.Write($"Browser automation existence check failed for '{automationId}': {exception.Message}"); + return false; + } + } + + protected bool TryReadBrowserAutomationTexts(string automationId, out string[] texts) + { + ArgumentException.ThrowIfNullOrWhiteSpace(automationId); + + texts = []; + if (Constants.CurrentPlatform != UITestPlatform.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 script = string.Concat( + """ + return (() => { + const automationId = ' + """, + automationId.Replace("'", "\\'", StringComparison.Ordinal), + """ + '; + const selector = `[xamlautomationid="${automationId}"], [aria-label="${automationId}"]`; + const matches = Array.from(document.querySelectorAll(selector)); + const visibleMatches = matches.filter(element => { + const rect = element.getBoundingClientRect(); + const style = window.getComputedStyle(element); + const isHidden = style.display === 'none' || + style.visibility === 'hidden' || + element.getAttribute('aria-hidden') === 'true'; + + return rect.width > 0 && + rect.height > 0 && + rect.right > 0 && + rect.bottom > 0 && + rect.left < window.innerWidth && + rect.top < window.innerHeight && + !isHidden; + }); + + return visibleMatches + .map(element => { + const inlineText = (element.innerText ?? '').trim(); + if (inlineText) { + return inlineText; + } + + const nestedInput = element.matches('textarea, input:not([type="hidden"]), [contenteditable="true"], [contenteditable="plaintext-only"], [role="textbox"]') + ? element + : element.querySelector('textarea, input:not([type="hidden"]), [contenteditable="true"], [contenteditable="plaintext-only"], [role="textbox"]'); + if (!nestedInput) { + return ''; + } + + return 'value' in nestedInput + ? (nestedInput.value ?? '').trim() + : (nestedInput.textContent ?? '').trim(); + }) + .filter(text => text); + })(); + """); + + if (executeScriptMethod.Invoke(driver, [script, Array.Empty()]) is not System.Collections.ObjectModel.ReadOnlyCollection browserTexts) + { + return false; + } + + texts = browserTexts + .Select(text => Convert.ToString(text, System.Globalization.CultureInfo.InvariantCulture) ?? string.Empty) + .Where(text => !string.IsNullOrWhiteSpace(text)) + .ToArray(); + + return texts.Length > 0; + } + catch (Exception exception) + { + HarnessLog.Write($"Browser automation text read failed for '{automationId}': {exception.Message}"); + texts = []; + return false; + } + } + + protected bool TryReadBrowserInputValue(string automationId, out string value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(automationId); + + value = string.Empty; + if (Constants.CurrentPlatform != UITestPlatform.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 script = string.Concat( + """ + return (() => { + const automationId = ' + """, + automationId.Replace("'", "\\'", StringComparison.Ordinal), + """ + '; + const selector = `[xamlautomationid="${automationId}"], [aria-label="${automationId}"]`; + const matches = Array.from(document.querySelectorAll(selector)); + const visibleMatches = matches.filter(element => { + const rect = element.getBoundingClientRect(); + return rect.width > 0 && + rect.height > 0 && + rect.right > 0 && + rect.bottom > 0 && + rect.left < window.innerWidth && + rect.top < window.innerHeight; + }); + const host = visibleMatches.find(element => element.getAttribute('xamlautomationid') === automationId) + ?? visibleMatches[0] + ?? matches.find(element => element.getAttribute('xamlautomationid') === automationId) + ?? matches[0]; + if (!host) { + return null; + } + + const inputSelector = 'textarea, input:not([type="hidden"]), [contenteditable="true"], [contenteditable="plaintext-only"], [role="textbox"]'; + const backingSelector = 'textarea, input:not([type="hidden"])'; + const candidates = [ + ...(host.matches(inputSelector) ? [host] : []), + ...Array.from(host.querySelectorAll(inputSelector)) + ]; + const backingElement = host.matches(backingSelector) + ? host + : host.querySelector(backingSelector); + const visibleCandidates = candidates.filter(element => { + const rect = element.getBoundingClientRect(); + const style = window.getComputedStyle(element); + const isHidden = style.display === 'none' || style.visibility === 'hidden'; + const isDisabled = 'disabled' in element && element.disabled; + const isReadOnly = 'readOnly' in element && element.readOnly; + const isAriaHidden = element.getAttribute('aria-hidden') === 'true'; + + return rect.width > 0 && + rect.height > 0 && + !isHidden && + !isDisabled && + !isReadOnly && + !isAriaHidden; + }); + const element = backingElement ?? (visibleCandidates.length > 0 ? visibleCandidates : candidates) + .map(candidate => { + const rect = candidate.getBoundingClientRect(); + const isTextArea = candidate.tagName === 'TEXTAREA'; + const isTextInput = candidate.tagName === 'INPUT'; + const isRoleTextbox = candidate.getAttribute('role') === 'textbox'; + const score = + (isTextArea ? 1000000 : 0) + + (isTextInput ? 100000 : 0) + + (isRoleTextbox ? 10000 : 0) + + (rect.width * rect.height); + + return { candidate, score }; + }) + .sort((left, right) => right.score - left.score)[0]?.candidate; + if (!element) { + return null; + } + + return 'value' in element ? (element.value ?? '') : (element.textContent ?? ''); + })(); + """); + + var result = executeScriptMethod.Invoke(driver, [script, Array.Empty()]); + value = Convert.ToString(result, System.Globalization.CultureInfo.InvariantCulture) ?? string.Empty; + return true; + } + catch (Exception exception) + { + HarnessLog.Write($"Browser input read failed for '{automationId}': {exception.Message}"); + value = string.Empty; + return false; + } + } + + private void LogAutomationQueryState(string automationId, string context) + { + ArgumentException.ThrowIfNullOrWhiteSpace(automationId); + ArgumentException.ThrowIfNullOrWhiteSpace(context); + + try + { + var matches = App.Query(automationId); + HarnessLog.Write($"Automation query state for '{automationId}' during '{context}' returned {matches.Length} matches."); + + for (var index = 0; index < matches.Length; index++) + { + var match = matches[index]; + HarnessLog.Write( + $"Automation query state for '{automationId}' during '{context}' match[{index}] text='{match.Text}' label='{match.Label}' enabled='{match.Enabled}' rect='{match.Rect}'."); + } + } + catch (Exception exception) + { + HarnessLog.Write($"Automation query state logging failed for '{automationId}' during '{context}': {exception.Message}"); + } + } + + private bool DoesAutomationElementReflectText(string automationId, string expectedText) + { + ArgumentException.ThrowIfNullOrWhiteSpace(automationId); + ArgumentNullException.ThrowIfNull(expectedText); + + var normalizedExpectedText = NormalizeWhitespace(expectedText); + try + { + return App.Query(automationId) + .Any(match => + string.Equals(NormalizeWhitespace(match.Text ?? string.Empty), normalizedExpectedText, StringComparison.Ordinal) || + string.Equals(NormalizeWhitespace(match.Label ?? string.Empty), normalizedExpectedText, StringComparison.Ordinal)); + } + catch (Exception exception) + { + HarnessLog.Write($"Automation reflection check failed for '{automationId}': {exception.Message}"); + return false; + } + } + + private static string NormalizeWhitespace(string value) + { + ArgumentNullException.ThrowIfNull(value); + + var segments = value + .Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + return string.Join(' ', segments); + } + private static bool ResolveBrowserHeadless() { #if DEBUG diff --git a/DotPilot.slnx b/DotPilot.slnx index da26d5e..bd83cdd 100644 --- a/DotPilot.slnx +++ b/DotPilot.slnx @@ -13,20 +13,17 @@ - + + + - - - - - diff --git a/DotPilot/AGENTS.md b/DotPilot/AGENTS.md index 40c152d..0bfd04f 100644 --- a/DotPilot/AGENTS.md +++ b/DotPilot/AGENTS.md @@ -6,8 +6,9 @@ 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 also owns app-host and application-configuration types that are specific to the desktop shell. +- 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 @@ -16,25 +17,54 @@ Stack: `.NET 10`, `Uno Platform`, `Uno.Extensions.Navigation`, `Uno Toolkit`, de - `App.xaml` - `App.xaml.cs` - `Platforms/Desktop/Program.cs` -- `Presentation/Shell.xaml` -- `Presentation/MainPage.xaml` -- `Presentation/SecondPage.xaml` +- `Presentation/Shell/Views/Shell.xaml` +- `Presentation/Chat/Views/ChatPage.xaml` +- `Presentation/AgentBuilder/Views/AgentBuilderPage.xaml` +- `Presentation/Settings/Views/SettingsPage.xaml` - `Styles/ColorPaletteOverride.xaml` ## Boundaries - 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. +- Keep the Uno UI as a thin representation layer: background orchestration, long-running commands, and durable session updates should come from `DotPilot.Core` 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. +- Keep agent creation prompt-first in the UI: the default `New agent` experience should start from a natural-language description that generates a draft agent, while manual field-by-field configuration stays a secondary fallback path. +- Keep a visible default-agent path in the shell: the app should surface a usable system/default agent by default and must not make the operator build everything manually before they can start a session. +- Starting a chat from an agent card or immediately after creating an agent must open/select the resulting chat session directly; do not leave the operator on `Agents` with a message telling them to switch to `Chat` manually. +- In `New agent`, the selected provider must be the single source of truth for the provider status card and model dropdown; switching from `Codex` to `Claude Code` or `GitHub Copilot` must immediately replace the shown suggested/supported models and provider summary instead of leaving stale values from the previous provider. +- Keep debug fallback out of the operator-facing authoring surface: `New agent` must only present real provider/model choices, and when no real provider is enabled or installed the screen should direct the operator to `Providers` instead of selecting debug defaults. +- Treat agent creation as profile authoring, not role assignment: do not expose role pickers or role-derived copy in the operator flow because an agent is created from its prompt, instructions, provider, model, and system prompt. +- Do not fabricate tool lists, skill lists, role taxonomies, or capability chips in the operator flow when the app does not have a real backing registry or runtime implementation for them; if a capability is not real, leave it out of the UI and saved draft. +- Do not present a live provider like Codex in the desktop shell as selected or runnable when the local runtime still cannot execute through its installed CLI; the visible session path must match the actual provider that will answer. +- 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. +- Keep one consistent desktop app chrome across all primary routes: the left rail, branding, and operator footer should stay structurally stable while the main content region changes. +- Treat primary navigation as a state switch inside one desktop shell, not as a chance to rebuild page-specific chrome; `Chat`, `Agents`, and `Providers` should share the same shell rhythm and only replace the main working surface. +- Keep shell geometry and control sizing stable across route changes; the left rail, nav rows, footer profile block, and main page header rhythm must not jump or reflow between `Chat`, `Agents`, and `Providers`. +- Do not leave placeholder screen names in the presentation layer: page, model, and generated view-model names must describe the actual feature surface such as `ChatPage` or `AgentBuilderModel`, not scaffolding leftovers like `MainPage` or `SecondModel`. +- Avoid duplicated side-panel content and oversized decorative copy; prefer compact navigation, clear current-task headers, and content-first layouts. +- Avoid placeholder-looking XAML chrome such as ASCII pseudo-icons, duplicate provider lists, inflated pill buttons, or decorative labels that repeat the same state in multiple panes. +- Do not let the desktop shell collapse into an unstructured visual mash: keep reusable styles, brushes, templates, and spacing rules in dedicated XAML resources or focused controls instead of burying ad hoc visual decisions inline across pages. +- 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. -- Build presentation with `MVVM`-friendly view models and separate reusable XAML components instead of large monolithic pages. +- 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. +- 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; 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`. +- 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. - 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. @@ -62,5 +92,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. -- 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 b/DotPilot/App.xaml index d335c51..3e9059a 100644 --- a/DotPilot/App.xaml +++ b/DotPilot/App.xaml @@ -20,6 +20,8 @@ --> + + diff --git a/DotPilot/App.xaml.cs b/DotPilot/App.xaml.cs index bf621be..5df0711 100644 --- a/DotPilot/App.xaml.cs +++ b/DotPilot/App.xaml.cs @@ -1,10 +1,7 @@ using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -using DotPilot.Runtime.Features.RuntimeFoundation; -#if !__WASM__ -using DotPilot.Runtime.Host.Features.RuntimeFoundation; -#endif namespace DotPilot; @@ -16,6 +13,9 @@ 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"; private const string WindowStartupLocationPropertyName = "WindowStartupLocation"; @@ -41,6 +41,8 @@ public App() protected Window? MainWindow { get; private set; } protected IHost? Host { get; private set; } + internal IServiceProvider? Services => Host?.Services; + internal event EventHandler? ServicesReady; [SuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code", Justification = "Uno.Extensions APIs are used in a way that is safe for trimming in this template context.")] protected override async void OnLaunched(LaunchActivatedEventArgs args) @@ -55,9 +57,6 @@ protected override async void OnLaunched(LaunchActivatedEventArgs args) #if DEBUG // Switch to Development environment when running in DEBUG .UseEnvironment(Environments.Development) -#endif -#if !__WASM__ - .UseDotPilotEmbeddedRuntime() #endif .UseLogging(configure: (context, logBuilder) => { @@ -67,10 +66,15 @@ 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); +#if !__WASM__ + logBuilder.AddConsole(); +#endif + // Uno Platform namespace filter groups // Uncomment individual methods to see more detailed logging //// Generic Xaml events @@ -96,38 +100,12 @@ protected override async void OnLaunched(LaunchActivatedEventArgs args) ) // Enable localization (see appsettings.json for supported languages) .UseLocalization() - .UseHttp((context, services) => - { -#if DEBUG - // DelegatingHandler will be automatically injected - Microsoft.Extensions.DependencyInjection.ServiceCollectionServiceExtensions - .AddTransient(services); -#endif - - }) .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); - Microsoft.Extensions.DependencyInjection.ServiceCollectionServiceExtensions - .AddTransient(services); - Microsoft.Extensions.DependencyInjection.ServiceCollectionServiceExtensions - .AddTransient(services); - Microsoft.Extensions.DependencyInjection.ServiceCollectionServiceExtensions - .AddTransient(services); - Microsoft.Extensions.DependencyInjection.ServiceCollectionServiceExtensions - .AddTransient(services); + .AddSingleton(services, TimeProvider.System); + services.AddAgentSessions(); + services.AddPresentationModels(); }) .UseNavigation(RegisterRoutes) ); @@ -143,6 +121,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, + StartupHydrationCompletedMarker); #if !__WASM__ CenterDesktopWindow(MainWindow); #endif @@ -318,8 +305,8 @@ private static void RegisterRoutes(IViewRegistry views, IRouteRegistry routes) { views.Register( new ViewMap(ViewModel: typeof(ShellViewModel)), - new ViewMap(), - new ViewMap(), + new ViewMap(), + new ViewMap(), new ViewMap() ); @@ -327,8 +314,8 @@ private static void RegisterRoutes(IViewRegistry views, IRouteRegistry routes) new RouteMap("", View: views.FindByViewModel(), Nested: [ - new ("Main", View: views.FindByViewModel(), IsDefault:true), - new ("Second", View: views.FindByViewModel()), + new ("Chat", View: views.FindByViewModel(), IsDefault:true), + new ("Agents", View: views.FindByViewModel()), new ("Settings", View: views.FindByViewModel()), ] ) diff --git a/DotPilot/DotPilot.csproj b/DotPilot/DotPilot.csproj index 042ac3b..f1ef956 100644 --- a/DotPilot/DotPilot.csproj +++ b/DotPilot/DotPilot.csproj @@ -29,6 +29,7 @@ Toolkit; Logging; Mvvm; + MVUX; Configuration; Http; Serialization; @@ -46,6 +47,16 @@ $(UnoFeatures);SkiaRenderer; + + https://github.com/managedcode/dotPilot/issues + https://github.com/managedcode/dotPilot/issues + https://github.com/managedcode/dotPilot + https://dotpilot.managed-code.com + MIT + $(MSBuildProjectDirectory)/Platforms/Linux/snapcraft.template.yaml + $(MSBuildProjectDirectory)/Platforms/Linux/dotpilot.desktop + + true true @@ -54,6 +65,15 @@ true + + + + + + True $(DefineConstants);USE_UITESTS @@ -61,17 +81,35 @@ - - - - - - + + + <_DotPilotSnapAppName>$([System.String]::Copy('$(AssemblyName)').ToLowerInvariant().Replace('.', '-')) + <_DotPilotSnapManifestPath>$(IntermediateOutputPath)dotpilot.snapcraft.yaml + <_DotPilotSnapDesktopFilePath>$(IntermediateOutputPath)dotpilot.desktop + $(_DotPilotSnapManifestPath) + $(_DotPilotSnapDesktopFilePath) + + + + + + + + diff --git a/DotPilot/GlobalUsings.cs b/DotPilot/GlobalUsings.cs index 287e966..05137fb 100644 --- a/DotPilot/GlobalUsings.cs +++ b/DotPilot/GlobalUsings.cs @@ -1,4 +1,7 @@ -global using DotPilot.Core.Features.ApplicationShell; -global using DotPilot.Core.Features.RuntimeFoundation; -global using DotPilot.Core.Features.Workbench; +global using DotPilot.Core.ChatSessions; +global using DotPilot.Core.ChatSessions.Commands; +global using DotPilot.Core.ChatSessions.Contracts; +global using DotPilot.Core.ChatSessions.Models; +global using DotPilot.Core.Workspace.Interfaces; global using DotPilot.Presentation; +[assembly: Uno.Extensions.Reactive.Config.BindableGenerationTool(3)] diff --git a/DotPilot/Host/Power/DesktopSleepPreventionService.cs b/DotPilot/Host/Power/DesktopSleepPreventionService.cs new file mode 100644 index 0000000..610aa94 --- /dev/null +++ b/DotPilot/Host/Power/DesktopSleepPreventionService.cs @@ -0,0 +1,302 @@ +using System.ComponentModel; +using System.Diagnostics; +using System.Runtime.InteropServices; +using Microsoft.Extensions.Logging; + +namespace DotPilot; + +public sealed class DesktopSleepPreventionService : IDisposable +{ + private const string LinuxInhibitReason = "dotPilot live session"; + private const string LinuxInhibitCommand = "sh"; + private const string LinuxInhibitScript = "while :; do sleep 3600; done"; + private const uint EsContinuous = 0x80000000; + private const uint EsSystemRequired = 0x00000001; + + private readonly ISessionActivityMonitor sessionActivityMonitor; + private readonly ILogger logger; + private readonly Lock gate = new(); + private Process? inhibitorProcess; + private bool isSleepPreventionActive; + private bool isSleepPreventionPending; + private long stateVersion; + + public DesktopSleepPreventionService( + ISessionActivityMonitor sessionActivityMonitor, + ILogger logger) + { + this.sessionActivityMonitor = sessionActivityMonitor; + this.logger = logger; + this.sessionActivityMonitor.StateChanged += OnSessionActivityStateChanged; + ApplySessionActivityState(); + } + + public event EventHandler? StateChanged; + + public bool IsSleepPreventionActive + { + get + { + lock (gate) + { + return isSleepPreventionActive; + } + } + } + + public void Dispose() + { + sessionActivityMonitor.StateChanged -= OnSessionActivityStateChanged; + ReleaseSleepPrevention(); + } + + private void OnSessionActivityStateChanged(object? sender, EventArgs e) + { + ApplySessionActivityState(); + } + + private void ApplySessionActivityState() + { + if (OperatingSystem.IsBrowser()) + { + return; + } + + if (sessionActivityMonitor.Current.HasActiveSessions) + { + AcquireSleepPrevention(); + return; + } + + ReleaseSleepPrevention(); + } + + private void AcquireSleepPrevention() + { + var acquisition = TryBeginAcquisition(); + if (!acquisition.ShouldAcquire) + { + return; + } + + try + { + if (OperatingSystem.IsWindows()) + { + AcquireWindowsSleepPrevention(); + CompleteWindowsAcquisition(acquisition.Version, "SetThreadExecutionState"); + return; + } + + if (OperatingSystem.IsMacOS()) + { + CompleteProcessAcquisition(acquisition.Version, StartMacOsInhibitorProcess()); + return; + } + + if (OperatingSystem.IsLinux()) + { + CompleteProcessAcquisition(acquisition.Version, StartLinuxInhibitorProcess()); + return; + } + + CancelPendingAcquisition(acquisition.Version); + } + catch (Exception exception) + { + ShellSleepPreventionLog.AcquireFailed(logger, exception); + CancelPendingAcquisition(acquisition.Version); + ReleaseSleepPrevention(); + } + } + + private void ReleaseSleepPrevention() + { + Process? processToStop; + bool shouldReleaseWindows; + bool wasActive; + + lock (gate) + { + stateVersion++; + if (!isSleepPreventionActive && !isSleepPreventionPending && inhibitorProcess is null) + { + return; + } + + shouldReleaseWindows = OperatingSystem.IsWindows() && isSleepPreventionActive; + processToStop = inhibitorProcess; + inhibitorProcess = null; + wasActive = isSleepPreventionActive; + isSleepPreventionActive = false; + isSleepPreventionPending = false; + } + + if (shouldReleaseWindows) + { + ReleaseWindowsSleepPrevention(); + } + + StopProcess(processToStop); + if (!wasActive) + { + return; + } + + ShellSleepPreventionLog.Released(logger); + StateChanged?.Invoke(this, EventArgs.Empty); + } + + private (bool ShouldAcquire, long Version) TryBeginAcquisition() + { + lock (gate) + { + if (isSleepPreventionActive || isSleepPreventionPending) + { + return (false, stateVersion); + } + + stateVersion++; + isSleepPreventionPending = true; + return (true, stateVersion); + } + } + + private void CompleteProcessAcquisition(long version, Process process) + { + var acquired = false; + lock (gate) + { + if (isSleepPreventionPending && !isSleepPreventionActive && version == stateVersion) + { + inhibitorProcess = process; + isSleepPreventionActive = true; + isSleepPreventionPending = false; + acquired = true; + } + } + + if (!acquired) + { + StopProcess(process); + return; + } + + ShellSleepPreventionLog.Acquired(logger, process.ProcessName); + StateChanged?.Invoke(this, EventArgs.Empty); + } + + private void CompleteWindowsAcquisition(long version, string mechanism) + { + var acquired = false; + lock (gate) + { + if (isSleepPreventionPending && !isSleepPreventionActive && version == stateVersion) + { + isSleepPreventionActive = true; + isSleepPreventionPending = false; + acquired = true; + } + } + + if (!acquired) + { + ReleaseWindowsSleepPrevention(); + return; + } + + ShellSleepPreventionLog.Acquired(logger, mechanism); + StateChanged?.Invoke(this, EventArgs.Empty); + } + + private void CancelPendingAcquisition(long version) + { + lock (gate) + { + if (isSleepPreventionPending && !isSleepPreventionActive && version == stateVersion) + { + isSleepPreventionPending = false; + } + } + } + + private static void AcquireWindowsSleepPrevention() + { + var result = SetThreadExecutionState(EsContinuous | EsSystemRequired); + if (result == 0) + { + throw new Win32Exception(Marshal.GetLastWin32Error(), "Could not acquire the Windows execution-state wake lock."); + } + } + + private static void ReleaseWindowsSleepPrevention() + { + _ = SetThreadExecutionState(EsContinuous); + } + + private static Process StartMacOsInhibitorProcess() + { + return StartProcess("caffeinate", static arguments => + { + arguments.Add("-i"); + }); + } + + private static Process StartLinuxInhibitorProcess() + { + return StartProcess("systemd-inhibit", static arguments => + { + arguments.Add($"--why={LinuxInhibitReason}"); + arguments.Add("--what=sleep"); + arguments.Add("--mode=block"); + arguments.Add(LinuxInhibitCommand); + arguments.Add("-c"); + arguments.Add(LinuxInhibitScript); + }); + } + + private static Process StartProcess(string fileName, Action> configureArguments) + { + var startInfo = new ProcessStartInfo + { + FileName = fileName, + RedirectStandardError = true, + RedirectStandardOutput = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + configureArguments(startInfo.ArgumentList); + + return Process.Start(startInfo) ?? + throw new InvalidOperationException($"Could not start '{fileName}' for desktop sleep prevention."); + } + + private static void StopProcess(Process? process) + { + if (process is null) + { + return; + } + + try + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + process.WaitForExit(5000); + } + } + catch (InvalidOperationException) + { + } + finally + { + process.Dispose(); + } + } + +#pragma warning disable SYSLIB1054 + [DllImport("kernel32.dll", SetLastError = true)] + private static extern uint SetThreadExecutionState(uint executionState); +#pragma warning restore SYSLIB1054 +} diff --git a/DotPilot/Models/AppConfig.cs b/DotPilot/Models/AppConfig.cs index 9f40361..35f4e8a 100644 --- a/DotPilot/Models/AppConfig.cs +++ b/DotPilot/Models/AppConfig.cs @@ -1,6 +1,6 @@ -namespace DotPilot.Models; +namespace DotPilot; -public record AppConfig +public sealed record AppConfig { public string? Environment { get; init; } } diff --git a/DotPilot/Platforms/Linux/dotpilot.desktop b/DotPilot/Platforms/Linux/dotpilot.desktop new file mode 100644 index 0000000..f6a7811 --- /dev/null +++ b/DotPilot/Platforms/Linux/dotpilot.desktop @@ -0,0 +1,7 @@ +[Desktop Entry] +Type=Application +Version=1.0 +Name=DotPilot +Comment=DotPilot powered by Uno Platform. +Exec=dotpilot +Terminal=false diff --git a/DotPilot/Platforms/Linux/snapcraft.template.yaml b/DotPilot/Platforms/Linux/snapcraft.template.yaml new file mode 100644 index 0000000..968d657 --- /dev/null +++ b/DotPilot/Platforms/Linux/snapcraft.template.yaml @@ -0,0 +1,40 @@ +name: __APP_NAME__ +version: '__VERSION__' +summary: __TITLE__ +title: __TITLE__ +description: __DESCRIPTION__ +contact: __CONTACT_URL__ +issues: __ISSUES_URL__ +source-code: __SOURCE_CODE_URL__ +website: __WEBSITE_URL__ +license: __LICENSE__ + +base: core24 +confinement: classic +grade: stable + +lint: + ignore: + - classic + - library + +apps: + __APP_NAME__: + command: __ASSEMBLY_NAME__ + desktop: snap/gui/__APP_NAME__.desktop + +parts: + __APP_NAME__: + plugin: dump + source: ./ + stage-packages: + - ca-certificates + - libc6 + - libfontconfig1 + - libgcc-s1 + - libicu74 + - liblttng-ust1 + - libssl3 + - libstdc++6 + - libunwind8 + - zlib1g diff --git a/DotPilot/Platforms/WebAssembly/WasmScripts/AppManifest.js b/DotPilot/Platforms/WebAssembly/WasmScripts/AppManifest.js index b6ae9d0..8a94261 100644 --- a/DotPilot/Platforms/WebAssembly/WasmScripts/AppManifest.js +++ b/DotPilot/Platforms/WebAssembly/WasmScripts/AppManifest.js @@ -1,3 +1,3 @@ var UnoAppManifest = { displayName: "DotPilot" -} +}; diff --git a/DotPilot/Platforms/WebAssembly/wwwroot/scripts/ChatComposerBrowserInterop.js b/DotPilot/Platforms/WebAssembly/wwwroot/scripts/ChatComposerBrowserInterop.js new file mode 100644 index 0000000..7a218f0 --- /dev/null +++ b/DotPilot/Platforms/WebAssembly/wwwroot/scripts/ChatComposerBrowserInterop.js @@ -0,0 +1,282 @@ +const nativeTextboxSelector = 'textarea, input:not([type="hidden"]), [contenteditable="true"], [contenteditable="plaintext-only"]'; +const textboxSelector = `${nativeTextboxSelector}, [role="textbox"]`; +const state = { + inputAutomationId: '', + sendButtonAutomationId: '', + behavior: 'EnterSends', + attachedInput: null, + lastModifier: '', + lastAction: '', + lastBeforeValue: '', + lastAfterValue: '', +}; + +const escapeAttribute = value => + `${value ?? ''}` + .replaceAll('\\', '\\\\') + .replaceAll('"', '\\"'); + +const selectorFor = automationId => + `[xamlautomationid="${escapeAttribute(automationId)}"], [aria-label="${escapeAttribute(automationId)}"]`; + +const resolveHost = automationId => + automationId + ? document.querySelector(selectorFor(automationId)) + : null; + +const resolveInput = automationId => { + const host = resolveHost(automationId); + if (!host) { + return null; + } + + const nativeInput = host.matches(nativeTextboxSelector) + ? host + : host.querySelector(nativeTextboxSelector); + if (nativeInput) { + return nativeInput; + } + + return host.matches('[role="textbox"]') + ? host + : host.querySelector('[role="textbox"]'); +}; + +const resolveSendButton = automationId => { + const host = resolveHost(automationId); + if (!host) { + return null; + } + + const clickableSelector = 'button, [role="button"], input[type="button"], input[type="submit"], input[type="checkbox"], input[type="radio"], a[href]'; + return host.matches(clickableSelector) + ? host + : host.closest(clickableSelector) ?? host.querySelector(clickableSelector) ?? host; +}; + +const notifyInputChanged = input => { + input.dispatchEvent(new Event('input', { bubbles: true, cancelable: true, composed: true })); + input.dispatchEvent(new Event('change', { bubbles: true, cancelable: true, composed: true })); +}; + +const moveCaretToEnd = input => { + if (!input) { + return; + } + + if (typeof input.focus === 'function') { + input.focus({ preventScroll: true }); + } + + if (typeof input.setSelectionRange === 'function') { + const value = `${input.value ?? ''}`; + input.setSelectionRange(value.length, value.length); + return; + } + + if (!input.isContentEditable) { + return; + } + + const selection = globalThis.getSelection?.(); + if (!selection) { + return; + } + + const range = document.createRange(); + range.selectNodeContents(input); + range.collapse(false); + selection.removeAllRanges(); + selection.addRange(range); +}; + +const insertNewLine = input => { + if (!input) { + return; + } + + if ('value' in input) { + const value = `${input.value ?? ''}`; + const start = typeof input.selectionStart === 'number' + ? input.selectionStart + : value.length; + const end = typeof input.selectionEnd === 'number' + ? input.selectionEnd + : start; + const nextValue = `${value.slice(0, start)}\n${value.slice(end)}`; + input.value = nextValue; + + if (typeof input.setSelectionRange === 'function') { + const caret = start + 1; + input.setSelectionRange(caret, caret); + } + } else if (typeof input.setRangeText === 'function') { + const start = input.selectionStart ?? (input.value?.length ?? 0); + const end = input.selectionEnd ?? start; + input.setRangeText('\n', start, end, 'end'); + } else if ('textContent' in input) { + const value = `${input.textContent ?? ''}`; + input.textContent = `${value}\n`; + } else if (input.isContentEditable) { + document.execCommand('insertLineBreak'); + } + + notifyInputChanged(input); +}; + +let dotPilotExportsPromise = null; + +const getDotPilotExports = () => + dotPilotExportsPromise ??= globalThis + .getDotnetRuntime(0) + .getAssemblyExports('DotPilot.dll'); + +const submitMessage = inputAutomationId => { + void getDotPilotExports() + .then(exports => exports.DotPilot.Presentation.Controls.ChatComposerBrowserExports.SubmitMessage(inputAutomationId)); +}; + +const requestTextSync = (inputAutomationId, input) => { + const value = 'value' in input + ? `${input.value ?? ''}` + : `${input.textContent ?? ''}`; + const selectionStart = typeof input.selectionStart === 'number' + ? input.selectionStart + : value.length; + + void getDotPilotExports() + .then(exports => exports.DotPilot.Presentation.Controls.ChatComposerBrowserExports.ApplyText( + inputAutomationId, + value, + selectionStart)); +}; + +const normalizeModifier = modifier => `${modifier ?? ''}`.toLowerCase(); + +const buildEnterEventInit = modifier => { + const normalizedModifier = normalizeModifier(modifier); + return { + key: 'Enter', + code: 'Enter', + which: 13, + keyCode: 13, + charCode: 13, + bubbles: true, + cancelable: true, + composed: true, + shiftKey: normalizedModifier === 'shift', + ctrlKey: normalizedModifier === 'control' || normalizedModifier === 'ctrl', + altKey: normalizedModifier === 'alt', + metaKey: normalizedModifier === 'command' || normalizedModifier === 'meta' || normalizedModifier === 'windows', + }; +}; + +const shouldSend = hasModifier => + state.behavior === 'EnterInsertsNewLine' + ? hasModifier + : !hasModifier; + +const onInputKeyDown = event => { + if (event.key !== 'Enter') { + return; + } + + const hasModifier = event.shiftKey || event.ctrlKey || event.altKey || event.metaKey; + const send = shouldSend(hasModifier); + const input = resolveInput(state.inputAutomationId) ?? event.currentTarget; + const beforeValue = 'value' in input + ? `${input.value ?? ''}` + : `${input.textContent ?? ''}`; + state.lastModifier = hasModifier + ? [ + event.shiftKey ? 'shift' : '', + event.ctrlKey ? 'ctrl' : '', + event.altKey ? 'alt' : '', + event.metaKey ? 'meta' : '', + ].filter(Boolean).join('+') + : 'none'; + state.lastAction = send ? 'send' : 'newline'; + state.lastBeforeValue = beforeValue; + + event.preventDefault(); + event.stopPropagation(); + + if (send) { + submitMessage(state.inputAutomationId); + state.lastAfterValue = beforeValue; + return; + } + + insertNewLine(input); + state.lastAfterValue = 'value' in input + ? `${input.value ?? ''}` + : `${input.textContent ?? ''}`; + + if (state.inputAutomationId) { + requestTextSync(state.inputAutomationId, input); + } +}; + +const synchronizeInputBinding = () => { + const nextInput = resolveInput(state.inputAutomationId); + if (state.attachedInput === nextInput) { + return; + } + + if (state.attachedInput) { + state.attachedInput.removeEventListener('keydown', onInputKeyDown, true); + } + + state.attachedInput = nextInput; + if (state.attachedInput) { + state.attachedInput.addEventListener('keydown', onInputKeyDown, true); + } +}; + +const observer = new MutationObserver(() => synchronizeInputBinding()); +observer.observe(document.documentElement, { childList: true, subtree: true }); + +export function synchronize(inputAutomationId, sendButtonAutomationId, behavior) { + state.inputAutomationId = inputAutomationId; + state.sendButtonAutomationId = sendButtonAutomationId; + state.behavior = behavior; + synchronizeInputBinding(); +} + +export function dispose(inputAutomationId) { + if (state.inputAutomationId !== inputAutomationId) { + return; + } + + state.inputAutomationId = ''; + state.sendButtonAutomationId = ''; + state.behavior = 'EnterSends'; + synchronizeInputBinding(); +} + +globalThis.dotPilotComposerInterop = { + dispatchEnter: (inputAutomationId, modifier) => { + const input = resolveInput(inputAutomationId); + if (!input) { + return false; + } + + moveCaretToEnd(input); + const eventInit = buildEnterEventInit(modifier); + input.dispatchEvent(new KeyboardEvent('keydown', eventInit)); + input.dispatchEvent(new KeyboardEvent('keyup', eventInit)); + return true; + }, + getDebugState: () => ({ + inputAutomationId: state.inputAutomationId, + sendButtonAutomationId: state.sendButtonAutomationId, + behavior: state.behavior, + hasAttachedInput: !!state.attachedInput, + attachedInputTag: state.attachedInput?.tagName ?? '', + hasExportsPromise: !!dotPilotExportsPromise, + lastModifier: state.lastModifier, + lastAction: state.lastAction, + lastBeforeValue: state.lastBeforeValue, + lastAfterValue: state.lastAfterValue, + }), +}; diff --git a/DotPilot/Presentation/AgentBuilder/Controls/AgentBasicInfoSection.xaml b/DotPilot/Presentation/AgentBuilder/Controls/AgentBasicInfoSection.xaml new file mode 100644 index 0000000..7d07f2b --- /dev/null +++ b/DotPilot/Presentation/AgentBuilder/Controls/AgentBasicInfoSection.xaml @@ -0,0 +1,153 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/DotPilot/Presentation/AgentBuilder/Controls/AgentBasicInfoSection.xaml.cs b/DotPilot/Presentation/AgentBuilder/Controls/AgentBasicInfoSection.xaml.cs new file mode 100644 index 0000000..35e1cc9 --- /dev/null +++ b/DotPilot/Presentation/AgentBuilder/Controls/AgentBasicInfoSection.xaml.cs @@ -0,0 +1,61 @@ +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 ICommand? ProviderSelectionChangedCommand + { + get => (ICommand?)GetValue(ProviderSelectionChangedCommandProperty); + set => SetValue(ProviderSelectionChangedCommandProperty, value); + } + + public ICommand? SelectModelCommand + { + get => (ICommand?)GetValue(SelectModelCommandProperty); + set => SetValue(SelectModelCommandProperty, value); + } + + public bool IsBrowserHead => OperatingSystem.IsBrowser(); + + private void OnProviderSelectionChanged(object sender, SelectionChangedEventArgs e) + { + 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) + { + var provider = (sender as FrameworkElement)?.DataContext as AgentProviderOption; + BrowserConsoleDiagnostics.Info( + $"[DotPilot.AgentBuilder] Provider quick-select clicked. Provider={provider?.Kind.ToString() ?? ""}."); + BoundCommandBridge.Execute(ProviderSelectionChangedCommand, provider); + } + + private void OnModelQuickSelectButtonClick(object sender, RoutedEventArgs e) + { + var model = (sender as FrameworkElement)?.DataContext as AgentModelOption; + BrowserConsoleDiagnostics.Info( + $"[DotPilot.AgentBuilder] Model quick-select clicked. Model={model?.DisplayName ?? ""}."); + BoundCommandBridge.Execute(SelectModelCommand, model); + } +} diff --git a/DotPilot/Presentation/AgentBuilder/Controls/AgentBuilderSurfaceTemplateSelector.cs b/DotPilot/Presentation/AgentBuilder/Controls/AgentBuilderSurfaceTemplateSelector.cs new file mode 100644 index 0000000..222dbf2 --- /dev/null +++ b/DotPilot/Presentation/AgentBuilder/Controls/AgentBuilderSurfaceTemplateSelector.cs @@ -0,0 +1,37 @@ +namespace DotPilot.Presentation.Controls; + +public sealed class AgentBuilderSurfaceTemplateSelector : DataTemplateSelector +{ + private const string MissingTemplateMessage = "Agent builder surface templates must be configured."; + + public DataTemplate? CatalogTemplate { get; set; } + + public DataTemplate? PromptTemplate { get; set; } + + public DataTemplate? EditorTemplate { get; set; } + + protected override DataTemplate SelectTemplateCore(object item) + { + return item switch + { + AgentBuilderSurfaceKind.Catalog => + CatalogTemplate ?? throw new InvalidOperationException(MissingTemplateMessage), + AgentBuilderSurfaceKind.PromptComposer => + PromptTemplate ?? throw new InvalidOperationException(MissingTemplateMessage), + AgentBuilderSurfaceKind.Editor => + EditorTemplate ?? throw new InvalidOperationException(MissingTemplateMessage), + AgentBuilderSurface { Kind: AgentBuilderSurfaceKind.Catalog } => + CatalogTemplate ?? throw new InvalidOperationException(MissingTemplateMessage), + AgentBuilderSurface { Kind: AgentBuilderSurfaceKind.PromptComposer } => + PromptTemplate ?? throw new InvalidOperationException(MissingTemplateMessage), + AgentBuilderSurface { Kind: AgentBuilderSurfaceKind.Editor } => + EditorTemplate ?? throw new InvalidOperationException(MissingTemplateMessage), + _ => CatalogTemplate ?? PromptTemplate ?? EditorTemplate ?? throw new InvalidOperationException(MissingTemplateMessage), + }; + } + + protected override DataTemplate SelectTemplateCore(object item, DependencyObject container) + { + return SelectTemplateCore(item); + } +} diff --git a/DotPilot/Presentation/AgentBuilder/Controls/AgentCatalogSection.xaml b/DotPilot/Presentation/AgentBuilder/Controls/AgentCatalogSection.xaml new file mode 100644 index 0000000..2bbf4aa --- /dev/null +++ b/DotPilot/Presentation/AgentBuilder/Controls/AgentCatalogSection.xaml @@ -0,0 +1,144 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/DotPilot/Presentation/AgentBuilder/Controls/AgentCatalogSection.xaml.cs b/DotPilot/Presentation/AgentBuilder/Controls/AgentCatalogSection.xaml.cs new file mode 100644 index 0000000..17daadf --- /dev/null +++ b/DotPilot/Presentation/AgentBuilder/Controls/AgentCatalogSection.xaml.cs @@ -0,0 +1,29 @@ +namespace DotPilot.Presentation.Controls; + +public sealed partial class AgentCatalogSection : UserControl +{ + public static readonly DependencyProperty OpenCreateAgentCommandProperty = + DependencyProperty.Register( + nameof(OpenCreateAgentCommand), + typeof(ICommand), + typeof(AgentCatalogSection), + new PropertyMetadata(null)); + + public AgentCatalogSection() + { + InitializeComponent(); + } + + public ICommand? OpenCreateAgentCommand + { + get => (ICommand?)GetValue(OpenCreateAgentCommandProperty); + set => SetValue(OpenCreateAgentCommandProperty, value); + } + + private void OnOpenCreateAgentButtonClick(object sender, RoutedEventArgs e) + { + BrowserConsoleDiagnostics.Info( + $"[DotPilot.AgentBuilder] Open create agent click received. CommandNull={OpenCreateAgentCommand is null}."); + BoundCommandBridge.Execute(OpenCreateAgentCommand); + } +} diff --git a/DotPilot/Presentation/AgentBuilder/Controls/AgentPromptSection.xaml b/DotPilot/Presentation/AgentBuilder/Controls/AgentPromptSection.xaml new file mode 100644 index 0000000..afba7cd --- /dev/null +++ b/DotPilot/Presentation/AgentBuilder/Controls/AgentPromptSection.xaml @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/DotPilot/Presentation/Controls/AgentPromptSection.xaml.cs b/DotPilot/Presentation/AgentBuilder/Controls/AgentPromptSection.xaml.cs similarity index 53% rename from DotPilot/Presentation/Controls/AgentPromptSection.xaml.cs rename to DotPilot/Presentation/AgentBuilder/Controls/AgentPromptSection.xaml.cs index 434e50d..ef3f182 100644 --- a/DotPilot/Presentation/Controls/AgentPromptSection.xaml.cs +++ b/DotPilot/Presentation/AgentBuilder/Controls/AgentPromptSection.xaml.cs @@ -6,4 +6,9 @@ public AgentPromptSection() { InitializeComponent(); } + + private void OnSaveAgentButtonClick(object sender, RoutedEventArgs e) + { + BoundCommandBridge.Execute(SaveAgentActionButton.Tag as ICommand); + } } diff --git a/DotPilot/Presentation/AgentBuilder/Controls/AgentPromptStartSection.xaml b/DotPilot/Presentation/AgentBuilder/Controls/AgentPromptStartSection.xaml new file mode 100644 index 0000000..ee72e6f --- /dev/null +++ b/DotPilot/Presentation/AgentBuilder/Controls/AgentPromptStartSection.xaml @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/DotPilot/Presentation/AgentBuilder/Controls/AgentPromptStartSection.xaml.cs b/DotPilot/Presentation/AgentBuilder/Controls/AgentPromptStartSection.xaml.cs new file mode 100644 index 0000000..e68485e --- /dev/null +++ b/DotPilot/Presentation/AgentBuilder/Controls/AgentPromptStartSection.xaml.cs @@ -0,0 +1,21 @@ +namespace DotPilot.Presentation.Controls; + +public sealed partial class AgentPromptStartSection : UserControl +{ + public AgentPromptStartSection() + { + InitializeComponent(); + } + + private void OnGenerateAgentButtonClick(object sender, RoutedEventArgs e) + { + 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 new file mode 100644 index 0000000..90d32af --- /dev/null +++ b/DotPilot/Presentation/AgentBuilder/Models/AgentBuilderModels.cs @@ -0,0 +1,90 @@ +using DotPilot.Core.ControlPlaneDomain; +using Microsoft.UI.Xaml.Data; + +namespace DotPilot.Presentation; + +public enum AgentBuilderSurfaceKind +{ + Catalog, + PromptComposer, + Editor, +} + +[Bindable] +public sealed partial record AgentProviderOption( + AgentProviderKind Kind, + string DisplayName, + string CommandName, + string StatusSummary, + string SuggestedModelName, + IReadOnlyList SupportedModelNames, + string? InstalledVersion, + 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 AgentCatalogEditRequest( + AgentProfileId AgentId, + string AgentName); + +[Bindable] +public sealed partial record AgentCatalogItem( + AgentProfileId Id, + string Initial, + string Name, + string Description, + string ProviderDisplayName, + string ModelName, + bool IsDefault, + string EditAutomationId, + AgentCatalogEditRequest EditRequest, + ICommand? EditCommand, + string StartChatAutomationId, + AgentCatalogStartChatRequest StartChatRequest, + ICommand? StartChatCommand); + +[Bindable] +public sealed partial record AgentBuilderSurface( + AgentBuilderSurfaceKind Kind, + string Title, + string Subtitle, + bool ShowBackButton, + string PrimaryActionLabel) +{ + public bool ShowCatalog => Kind == AgentBuilderSurfaceKind.Catalog; + + public bool ShowPromptComposer => Kind == AgentBuilderSurfaceKind.PromptComposer; + + 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 new file mode 100644 index 0000000..24de102 --- /dev/null +++ b/DotPilot/Presentation/AgentBuilder/ViewModels/AgentBuilderModel.cs @@ -0,0 +1,998 @@ +using System.Collections.Immutable; +using DotPilot.Core.AgentBuilder; +using DotPilot.Core.ControlPlaneDomain; +using Microsoft.Extensions.Logging; +using Microsoft.UI.Xaml.Data; + +namespace DotPilot.Presentation; + +[Bindable] +public partial record AgentBuilderModel( + IAgentWorkspaceState workspaceState, + AgentPromptDraftGenerator draftGenerator, + WorkspaceProjectionNotifier workspaceProjectionNotifier, + ShellNavigationNotifier shellNavigationNotifier, + ILogger logger) +{ + private const string EmptyProviderDisplayName = "Select a provider"; + private const string EmptyProviderStatusSummary = + "Enable Codex, Claude Code, or GitHub Copilot in Providers before saving the profile."; + private const string EmptyProviderCommandName = ""; + private const string EmptyModelHelperText = + "Select an enabled provider to load its supported models."; + private const string SuggestedModelHelperFormat = + "Choose one of the supported models for this provider. Suggested: {0}."; + private const string DraftGenerationProgressMessage = "Generating agent draft..."; + private const string AgentCreationProgressMessage = "Saving local agent profile..."; + private const string PromptGenerationValidationMessage = "Describe the agent you want before generating a draft."; + private const string AgentValidationMessage = "Enter an agent name before saving the profile."; + private const string VersionPrefix = "Version "; + private const string SavedAgentMessageFormat = "Saved {0} using {1}."; + private const string GeneratedDraftMessageFormat = "Generated draft for {0}. Review and adjust before saving."; + private const string ManualDraftMessage = "Manual draft ready. Adjust the profile before saving."; + private const string CatalogTitle = "All agents"; + private const string CatalogSubtitle = "Create, inspect, and reuse local agent profiles."; + private const string PromptTitle = "New agent"; + private const string PromptSubtitle = "Describe the agent once and review the generated draft."; + private const string EditorSubtitle = "Review the generated draft and save the profile."; + private const string EditTitle = "Edit agent"; + private const string EditSubtitle = "Review the current profile and save changes."; + private const string CreateActionLabel = "Create agent"; + private const string SaveActionLabel = "Save agent"; + private const string SaveChangesActionLabel = "Save changes"; + private const string SessionTitlePrefix = "Session with "; + private const string StartedChatMessageFormat = "Started a session with {0}."; + private const string EditingAgentMessageFormat = "Editing {0}. Adjust the profile before saving."; + private const string UpdatedAgentMessageFormat = "Saved changes to {0} using {1}."; + private static readonly System.Text.CompositeFormat SavedAgentCompositeFormat = + System.Text.CompositeFormat.Parse(SavedAgentMessageFormat); + private static readonly System.Text.CompositeFormat GeneratedDraftCompositeFormat = + System.Text.CompositeFormat.Parse(GeneratedDraftMessageFormat); + private static readonly System.Text.CompositeFormat StartedChatCompositeFormat = + System.Text.CompositeFormat.Parse(StartedChatMessageFormat); + private static readonly System.Text.CompositeFormat EditingAgentCompositeFormat = + System.Text.CompositeFormat.Parse(EditingAgentMessageFormat); + private static readonly System.Text.CompositeFormat UpdatedAgentCompositeFormat = + System.Text.CompositeFormat.Parse(UpdatedAgentMessageFormat); + private static readonly System.Text.CompositeFormat SuggestedModelHelperCompositeFormat = + System.Text.CompositeFormat.Parse(SuggestedModelHelperFormat); + private static readonly AgentProviderOption EmptySelectedProvider = + new(AgentProviderKind.Debug, string.Empty, string.Empty, string.Empty, string.Empty, [], null, false); + private static readonly AgentBuilderSurface CatalogSurface = + new(AgentBuilderSurfaceKind.Catalog, CatalogTitle, CatalogSubtitle, false, CreateActionLabel); + private static readonly AgentBuilderSurface PromptSurface = + new(AgentBuilderSurfaceKind.PromptComposer, PromptTitle, PromptSubtitle, true, string.Empty); + private static readonly AgentBuilderSurface EditorSurface = + new(AgentBuilderSurfaceKind.Editor, PromptTitle, EditorSubtitle, true, SaveActionLabel); + private static readonly AgentBuilderSurface EditSurface = + new(AgentBuilderSurfaceKind.Editor, EditTitle, EditSubtitle, true, SaveChangesActionLabel); + private static readonly AgentBuilderView EmptyBuilderView = + new( + EmptyProviderDisplayName, + EmptyProviderStatusSummary, + EmptyProviderCommandName, + string.Empty, + false, + string.Empty, + [], + false, + EmptyModelHelperText, + EmptyProviderStatusSummary, + false); + private AsyncCommand? _openCreateAgentCommand; + private AsyncCommand? _returnToCatalogCommand; + private AsyncCommand? _buildManuallyCommand; + private AsyncCommand? _generateAgentDraftCommand; + private AsyncCommand? _saveAgentCommand; + private AsyncCommand? _startChatForAgentCommand; + private AsyncCommand? _openEditAgentCommand; + private AsyncCommand? _providerSelectionChangedCommand; + private AsyncCommand? _selectModelCommand; + private readonly Signal _workspaceRefresh = new(); + + public IState Surface => State.Value(this, static () => CatalogSurface); + + public IListState Agents => ListState.Async(this, LoadAgentsAsync, _workspaceRefresh); + + public IListState Providers => ListState.Async(this, LoadProvidersAsync, _workspaceRefresh); + + public IState AgentRequest => State.Value(this, static () => string.Empty); + + public IState AgentName => State.Value(this, static () => string.Empty); + + public IState AgentDescription => State.Value(this, static () => string.Empty); + + public IState ModelName => State.Value(this, static () => string.Empty); + + public IState SystemPrompt => State.Value(this, static () => string.Empty); + + public IState EditingAgentId => + State.Value(this, static () => default(AgentProfileId)); + + public IState OperationMessage => State.Value(this, static () => string.Empty); + + public IState SelectedProvider => State.Value(this, static () => EmptySelectedProvider); + + public IState 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.Value(this, static () => EmptyBuilderView); + + public ICommand OpenCreateAgentCommand => + _openCreateAgentCommand ??= new AsyncCommand( + () => OpenCreateAgent(CancellationToken.None)); + + public ICommand ReturnToCatalogCommand => + _returnToCatalogCommand ??= new AsyncCommand( + () => ReturnToCatalog(CancellationToken.None)); + + public ICommand BuildManuallyCommand => + _buildManuallyCommand ??= new AsyncCommand( + () => BuildManually(CancellationToken.None)); + + public ICommand GenerateAgentDraftCommand => + _generateAgentDraftCommand ??= new AsyncCommand( + parameter => SubmitAgentDraftCore(parameter as string, CancellationToken.None)); + + public ICommand SaveAgentCommand => + _saveAgentCommand ??= new AsyncCommand( + () => SubmitAgentSave(CancellationToken.None)); + + public ICommand StartChatForAgentCommand => + _startChatForAgentCommand ??= new AsyncCommand( + parameter => StartChatForParameter(parameter, CancellationToken.None)); + + public ICommand OpenEditAgentCommand => + _openEditAgentCommand ??= new AsyncCommand( + parameter => OpenEditAgentForParameter(parameter, CancellationToken.None)); + + public ICommand ProviderSelectionChangedCommand => + _providerSelectionChangedCommand ??= new AsyncCommand( + parameter => HandleProviderSelectionChanged(parameter, CancellationToken.None)); + + public ICommand SelectModelCommand => + _selectModelCommand ??= new AsyncCommand( + parameter => SelectModel(parameter, CancellationToken.None)); + + public async ValueTask OpenCreateAgent(CancellationToken cancellationToken) + { + await AgentRequest.SetAsync(string.Empty, cancellationToken); + await AgentName.SetAsync(string.Empty, cancellationToken); + await AgentDescription.SetAsync(string.Empty, cancellationToken); + await ModelName.SetAsync(string.Empty, cancellationToken); + await SystemPrompt.SetAsync(string.Empty, cancellationToken); + await EditingAgentId.SetAsync(default, 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); + } + + public async ValueTask ReturnToCatalog(CancellationToken cancellationToken) + { + await Surface.UpdateAsync(_ => CatalogSurface, cancellationToken); + } + + public async ValueTask BuildManually(CancellationToken cancellationToken) + { + try + { + await OperationMessage.SetAsync(ManualDraftMessage, cancellationToken); + await ApplyDraftAsync(await draftGenerator.CreateManualDraftAsync(cancellationToken), cancellationToken); + await Surface.UpdateAsync(_ => EditorSurface, cancellationToken); + AgentBuilderModelLog.ManualDraftCreated(logger); + } + catch (Exception exception) + { + AgentBuilderModelLog.Failure(logger, exception); + await OperationMessage.SetAsync(exception.Message, cancellationToken); + } + } + + public ValueTask GenerateAgentDraft(CancellationToken cancellationToken) + { + return SubmitAgentDraftCore(promptOverride: null, cancellationToken); + } + + public ValueTask SubmitAgentDraft(CancellationToken cancellationToken) + { + return SubmitAgentDraftCore(promptOverride: null, cancellationToken); + } + + public async ValueTask HandleSelectedProviderChanged( + AgentProviderOption? provider, + CancellationToken cancellationToken) + { + await SetSelectedProviderStateAsync(provider, synchronizeModel: true, refreshBuilder: false, cancellationToken); + await RefreshBuilderAsync(provider, cancellationToken); + } + + 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); + await RefreshBuilderAsync(cancellationToken); + } + + private async ValueTask SubmitAgentDraftCore(string? promptOverride, CancellationToken cancellationToken) + { + try + { + var prompt = string.IsNullOrWhiteSpace(promptOverride) + ? ((await AgentRequest) ?? string.Empty).Trim() + : promptOverride.Trim(); + if (string.IsNullOrWhiteSpace(prompt)) + { + await OperationMessage.SetAsync(PromptGenerationValidationMessage, cancellationToken); + return; + } + + await AgentRequest.SetAsync(prompt, cancellationToken); + await OperationMessage.SetAsync(DraftGenerationProgressMessage, cancellationToken); + AgentBuilderModelLog.DraftGenerationRequested(logger, prompt.Length); + var draft = await draftGenerator.GenerateAsync(prompt, cancellationToken); + await ApplyDraftAsync(draft, cancellationToken); + await OperationMessage.SetAsync( + string.Format( + System.Globalization.CultureInfo.InvariantCulture, + GeneratedDraftCompositeFormat, + draft.Name), + cancellationToken); + await Surface.UpdateAsync(_ => EditorSurface, cancellationToken); + } + catch (Exception exception) + { + AgentBuilderModelLog.Failure(logger, exception); + await OperationMessage.SetAsync(exception.Message, cancellationToken); + } + } + + public async ValueTask SaveAgent(CancellationToken cancellationToken) + { + try + { + var selectedProvider = await ResolveSelectedProviderAsync(cancellationToken); + if (IsEmptySelectedProvider(selectedProvider)) + { + await OperationMessage.SetAsync(EmptyProviderStatusSummary, cancellationToken); + return; + } + + if (!selectedProvider.CanCreateAgents) + { + await OperationMessage.SetAsync(selectedProvider.StatusSummary, cancellationToken); + return; + } + + var agentName = ((await AgentName) ?? string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(agentName)) + { + await OperationMessage.SetAsync(AgentValidationMessage, cancellationToken); + return; + } + + var modelName = await ResolveEffectiveModelNameAsync(cancellationToken); + var description = ((await AgentDescription) ?? string.Empty).Trim(); + var systemPrompt = ((await SystemPrompt) ?? string.Empty).Trim(); + var editingAgentId = await EditingAgentId; + if (editingAgentId != default) + { + await OperationMessage.SetAsync(AgentCreationProgressMessage, cancellationToken); + AgentBuilderModelLog.AgentUpdateRequested(logger, editingAgentId.Value, agentName, selectedProvider.Kind, modelName); + + var updatedResult = await workspaceState.UpdateAgentAsync( + new UpdateAgentProfileCommand( + editingAgentId, + agentName, + selectedProvider.Kind, + modelName, + systemPrompt, + description), + cancellationToken); + if (!updatedResult.TryGetValue(out var updated)) + { + await OperationMessage.SetAsync(updatedResult.ToOperatorMessage("Could not save the agent."), cancellationToken); + return; + } + + var updatedMessage = string.Format( + System.Globalization.CultureInfo.InvariantCulture, + UpdatedAgentCompositeFormat, + updated.Name, + updated.ProviderDisplayName); + _workspaceRefresh.Raise(); + workspaceProjectionNotifier.Publish(); + await EditingAgentId.SetAsync(default, cancellationToken); + await Surface.UpdateAsync(_ => CatalogSurface, cancellationToken); + await OperationMessage.SetAsync(updatedMessage, cancellationToken); + AgentBuilderModelLog.AgentUpdated(logger, updated.Id.Value, updated.Name, updated.ProviderKind, updated.ModelName); + return; + } + + await OperationMessage.SetAsync(AgentCreationProgressMessage, cancellationToken); + AgentBuilderModelLog.AgentCreationRequested(logger, agentName, selectedProvider.Kind, modelName); + + var createdResult = await workspaceState.CreateAgentAsync( + new CreateAgentProfileCommand( + agentName, + selectedProvider.Kind, + modelName, + systemPrompt, + description), + cancellationToken); + if (!createdResult.TryGetValue(out var created)) + { + await OperationMessage.SetAsync(createdResult.ToOperatorMessage("Could not save the agent."), cancellationToken); + return; + } + + var savedMessage = string.Format( + System.Globalization.CultureInfo.InvariantCulture, + SavedAgentCompositeFormat, + created.Name, + created.ProviderDisplayName); + _workspaceRefresh.Raise(); + 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) + { + AgentBuilderModelLog.Failure(logger, exception); + await OperationMessage.SetAsync(exception.Message, cancellationToken); + } + } + + public ValueTask SubmitAgentSave(CancellationToken cancellationToken) + { + return SaveAgent(cancellationToken); + } + + public async ValueTask StartChatForAgent(AgentCatalogItem? agent, CancellationToken cancellationToken) + { + if (agent is null) + { + return; + } + + try + { + var startedChatMessage = string.Format( + System.Globalization.CultureInfo.InvariantCulture, + StartedChatCompositeFormat, + agent.Name); + AgentBuilderModelLog.ChatSessionRequested(logger, agent.Id.Value, agent.Name); + await StartSessionAndOpenChatAsync( + agent.Id, + agent.Name, + startedChatMessage, + cancellationToken); + await OperationMessage.SetAsync(startedChatMessage, cancellationToken); + } + catch (Exception exception) + { + AgentBuilderModelLog.Failure(logger, exception); + await OperationMessage.SetAsync(exception.Message, cancellationToken); + } + } + + private ValueTask StartChatForParameter(object? parameter, CancellationToken cancellationToken) + { + return parameter switch + { + AgentCatalogItem item => StartChatForAgent(item, cancellationToken), + AgentCatalogStartChatRequest request => StartChatForRequest(request, cancellationToken), + _ => ValueTask.CompletedTask, + }; + } + + private ValueTask OpenEditAgentForParameter(object? parameter, CancellationToken cancellationToken) + { + return parameter switch + { + AgentCatalogItem item => OpenEditAgent(item.Id, cancellationToken), + AgentCatalogEditRequest request => OpenEditAgent(request.AgentId, cancellationToken), + AgentProfileId agentId => OpenEditAgent(agentId, cancellationToken), + _ => ValueTask.CompletedTask, + }; + } + + public async ValueTask OpenEditAgent(AgentProfileId agentId, CancellationToken cancellationToken) + { + try + { + var workspaceResult = await workspaceState.GetWorkspaceAsync(cancellationToken); + if (!workspaceResult.TryGetValue(out var workspace)) + { + await OperationMessage.SetAsync(workspaceResult.ToOperatorMessage("Could not load the agent profile."), cancellationToken); + return; + } + + var agent = workspace.Agents.FirstOrDefault(candidate => candidate.Id == agentId); + if (agent is null) + { + await OperationMessage.SetAsync($"Agent '{agentId}' was not found.", cancellationToken); + return; + } + + await AgentRequest.SetAsync(string.Empty, cancellationToken); + await AgentName.SetAsync(agent.Name, cancellationToken); + await AgentDescription.SetAsync(agent.Description, cancellationToken); + await SystemPrompt.SetAsync(agent.SystemPrompt, cancellationToken); + await EditingAgentId.SetAsync(agent.Id, cancellationToken); + + var provider = FindProviderByKind(await Providers, agent.ProviderKind); + if (IsEmptySelectedProvider(provider)) + { + provider = await ResolveSelectedProviderAsync(cancellationToken); + } + + await HandleSelectedProviderChanged(provider, cancellationToken); + await ModelName.SetAsync(agent.ModelName, cancellationToken); + await RefreshBuilderAsync(cancellationToken); + await OperationMessage.SetAsync( + string.Format( + System.Globalization.CultureInfo.InvariantCulture, + EditingAgentCompositeFormat, + agent.Name), + cancellationToken); + await Surface.UpdateAsync(_ => EditSurface, cancellationToken); + } + catch (Exception exception) + { + AgentBuilderModelLog.Failure(logger, exception); + await OperationMessage.SetAsync(exception.Message, cancellationToken); + } + } + + 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 + { + var workspaceResult = await workspaceState.GetWorkspaceAsync(cancellationToken); + if (!workspaceResult.TryGetValue(out var workspace)) + { + await OperationMessage.SetAsync(workspaceResult.ToOperatorMessage("Could not load agents."), cancellationToken); + return ImmutableArray.Empty; + } + + return workspace.Agents + .Select(MapAgent) + .ToImmutableArray(); + } + catch (TaskCanceledException) + { + return ImmutableArray.Empty; + } + catch (Exception exception) + { + AgentBuilderModelLog.Failure(logger, exception); + await OperationMessage.SetAsync(exception.Message, cancellationToken); + return ImmutableArray.Empty; + } + } + + private async ValueTask> LoadProvidersAsync(CancellationToken cancellationToken) + { + try + { + AgentBuilderModelLog.LoadingProviders(logger); + var workspaceResult = await workspaceState.GetWorkspaceAsync(cancellationToken); + if (!workspaceResult.TryGetValue(out var workspace)) + { + await OperationMessage.SetAsync(workspaceResult.ToOperatorMessage("Could not load providers."), cancellationToken); + return ImmutableArray.Empty; + } + + AgentBuilderModelLog.ProvidersLoaded(logger, workspace.Providers.Count); + + var providers = workspace.Providers + .Select(MapProviderOption) + .Where(static provider => provider.Kind != AgentProviderKind.Debug) + .ToImmutableArray(); + + await EnsureSelectedProviderAsync(providers, cancellationToken); + return providers; + } + catch (TaskCanceledException) + { + return ImmutableArray.Empty; + } + catch (Exception exception) + { + AgentBuilderModelLog.Failure(logger, exception); + await OperationMessage.SetAsync(exception.Message, cancellationToken); + return ImmutableArray.Empty; + } + } + + private async ValueTask LoadCanGenerateDraftAsync(CancellationToken cancellationToken) + { + var prompt = (await AgentRequest) ?? string.Empty; + return !string.IsNullOrWhiteSpace(prompt); + } + + private async ValueTask LoadCanSaveAgentAsync(CancellationToken cancellationToken) + { + var selectedProvider = await ResolveSelectedProviderAsync(cancellationToken); + var agentName = (await AgentName) ?? string.Empty; + return !IsEmptySelectedProvider(selectedProvider) && + selectedProvider.CanCreateAgents && + !string.IsNullOrWhiteSpace(agentName); + } + + private async ValueTask ApplyDraftAsync(AgentPromptDraft draft, CancellationToken cancellationToken) + { + await AgentName.SetAsync(draft.Name, cancellationToken); + await AgentDescription.SetAsync(draft.Description, cancellationToken); + await SystemPrompt.SetAsync(draft.SystemPrompt, cancellationToken); + + var providers = await Providers; + var provider = FindProviderByKind(providers, draft.ProviderKind); + if (IsEmptySelectedProvider(provider)) + { + provider = FindFirstCreatableProvider(providers); + } + + if (IsEmptySelectedProvider(provider) && providers.Count > 0) + { + provider = providers[0]; + } + + await HandleSelectedProviderChanged(provider, cancellationToken); + await ModelName.SetAsync(ResolveDraftModelName(draft, provider), cancellationToken); + await RefreshBuilderAsync(cancellationToken); + } + + private async ValueTask EnsureSelectedProviderAsync( + IImmutableList providers, + CancellationToken cancellationToken) + { + var selectedProvider = (await SelectedProvider) ?? EmptySelectedProvider; + var selectedProviderKind = await SelectedProviderKind; + var resolvedProvider = IsEmptySelectedProvider(selectedProvider) + ? EmptySelectedProvider + : FindProviderByKind(providers, selectedProvider.Kind); + + if (IsEmptySelectedProvider(resolvedProvider)) + { + resolvedProvider = FindProviderByKind(providers, selectedProviderKind); + } + + if (IsEmptySelectedProvider(resolvedProvider)) + { + resolvedProvider = FindFirstCreatableProvider(providers); + } + + if (IsEmptySelectedProvider(resolvedProvider) && providers.Count > 0) + { + resolvedProvider = providers[0]; + } + + 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)) + { + 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)) + { + 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 resolvedProvider = FindProviderByKind(providers, selectedProviderKind); + if (!IsEmptySelectedProvider(resolvedProvider)) + { + await SetSelectedProviderStateAsync( + resolvedProvider, + synchronizeModel: true, + refreshBuilder: false, + cancellationToken); + return resolvedProvider; + } + + var creatableProvider = FindFirstCreatableProvider(providers); + if (!IsEmptySelectedProvider(creatableProvider)) + { + await SetSelectedProviderStateAsync( + creatableProvider, + synchronizeModel: true, + refreshBuilder: false, + cancellationToken); + return creatableProvider; + } + + if (providers.Count > 0) + { + await SetSelectedProviderStateAsync( + providers[0], + synchronizeModel: true, + refreshBuilder: false, + cancellationToken); + return providers[0]; + } + + 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(); + var selectedProvider = await ResolveSelectedProviderAsync(cancellationToken); + if (!string.IsNullOrWhiteSpace(modelName) && SupportsModel(selectedProvider, modelName)) + { + return modelName; + } + + return ResolveSuggestedModelName(selectedProvider); + } + + private AgentCatalogItem MapAgent(AgentProfileSummary agent) + { + var automationIdSuffix = CreateAutomationIdSuffix(agent.Name); + return new AgentCatalogItem( + agent.Id, + agent.Name[..1], + agent.Name, + agent.Description, + agent.ProviderDisplayName, + agent.ModelName, + AgentSessionDefaults.IsSystemAgent(agent.Name), + "AgentCatalogEditButton_" + automationIdSuffix, + new AgentCatalogEditRequest(agent.Id, agent.Name), + OpenEditAgentCommand, + "AgentCatalogStartChatButton_" + automationIdSuffix, + new AgentCatalogStartChatRequest(agent.Id, agent.Name), + StartChatForAgentCommand); + } + + private static AgentProviderOption MapProviderOption(ProviderStatusDescriptor provider) + { + return new AgentProviderOption( + provider.Kind, + provider.DisplayName, + provider.CommandName, + provider.StatusSummary, + provider.SuggestedModelName, + provider.SupportedModelNames, + provider.InstalledVersion, + provider.CanCreateAgents); + } + + private static AgentProviderOption FindProviderByKind(IImmutableList providers, AgentProviderKind kind) + { + for (var index = 0; index < providers.Count; index++) + { + if (providers[index].Kind == kind) + { + return providers[index]; + } + } + + return EmptySelectedProvider; + } + + private static AgentProviderOption FindFirstCreatableProvider(IImmutableList providers) + { + for (var index = 0; index < providers.Count; index++) + { + if (providers[index].CanCreateAgents) + { + return providers[index]; + } + } + + return EmptySelectedProvider; + } + + private static string ResolveStatusMessage(AgentProviderOption provider) + { + return IsEmptySelectedProvider(provider) + ? EmptyProviderStatusSummary + : provider.StatusSummary; + } + + private static string ResolveSuggestedModelName(AgentProviderOption provider) + { + return IsEmptySelectedProvider(provider) + ? string.Empty + : provider.SuggestedModelName; + } + + private static List ResolveSupportedModelNames(AgentProviderOption provider) + { + if (IsEmptySelectedProvider(provider)) + { + return []; + } + + HashSet seen = new(StringComparer.OrdinalIgnoreCase); + List models = []; + foreach (var model in new[] { provider.SuggestedModelName }.Concat(provider.SupportedModelNames)) + { + if (string.IsNullOrWhiteSpace(model) || !seen.Add(model)) + { + continue; + } + + models.Add(model); + } + + return models; + } + + private static string ResolveDraftModelName(AgentPromptDraft draft, AgentProviderOption provider) + { + var suggestedModelName = ResolveSuggestedModelName(provider); + if (IsEmptySelectedProvider(provider)) + { + return string.Empty; + } + + if (provider.Kind != draft.ProviderKind) + { + return suggestedModelName; + } + + return string.IsNullOrWhiteSpace(draft.ModelName) || !SupportsModel(provider, draft.ModelName) + ? suggestedModelName + : draft.ModelName; + } + + private static bool SupportsModel(AgentProviderOption provider, string modelName) + { + return ResolveSupportedModelNames(provider) + .Any(candidate => string.Equals(candidate, modelName, StringComparison.OrdinalIgnoreCase)); + } + + 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 new file mode 100644 index 0000000..1ad4eae --- /dev/null +++ b/DotPilot/Presentation/AgentBuilder/Views/AgentBuilderPage.xaml @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/DotPilot/Presentation/AgentBuilder/Views/AgentBuilderPage.xaml.cs b/DotPilot/Presentation/AgentBuilder/Views/AgentBuilderPage.xaml.cs new file mode 100644 index 0000000..f2f3b82 --- /dev/null +++ b/DotPilot/Presentation/AgentBuilder/Views/AgentBuilderPage.xaml.cs @@ -0,0 +1,9 @@ +namespace DotPilot.Presentation; + +public sealed partial class AgentBuilderPage : Page +{ + public AgentBuilderPage() + { + InitializeComponent(); + } +} diff --git a/DotPilot/Presentation/AgentBuilderModels.cs b/DotPilot/Presentation/AgentBuilderModels.cs deleted file mode 100644 index 70fafa5..0000000 --- a/DotPilot/Presentation/AgentBuilderModels.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace DotPilot.Presentation; - -public sealed record AgentMenuItem(string Name, string ModelSummary, string Initial, Brush? AvatarBrush); - -public sealed record AgentTypeOption(string Label, bool IsSelected); - -public sealed record AvatarOption(string Initial, Brush? AvatarBrush); - -public sealed record SkillToggleItem(string Name, string Description, string IconGlyph, bool IsEnabled); diff --git a/DotPilot/Presentation/Chat/Configuration/ChatComposerKeyboardPolicy.cs b/DotPilot/Presentation/Chat/Configuration/ChatComposerKeyboardPolicy.cs new file mode 100644 index 0000000..367c8b5 --- /dev/null +++ b/DotPilot/Presentation/Chat/Configuration/ChatComposerKeyboardPolicy.cs @@ -0,0 +1,47 @@ + +namespace DotPilot.Presentation; + +public static class ChatComposerKeyboardPolicy +{ + public static ChatComposerKeyboardAction Resolve( + ComposerSendBehavior behavior, + bool isEnterKey, + bool hasModifier) + { + if (!isEnterKey) + { + return ChatComposerKeyboardAction.None; + } + + return behavior switch + { + ComposerSendBehavior.EnterSends => hasModifier + ? ChatComposerKeyboardAction.InsertNewLine + : ChatComposerKeyboardAction.SendMessage, + ComposerSendBehavior.EnterInsertsNewLine => hasModifier + ? ChatComposerKeyboardAction.SendMessage + : ChatComposerKeyboardAction.InsertNewLine, + _ => ChatComposerKeyboardAction.SendMessage, + }; + } + + public static bool ShouldHandleInComposer( + ComposerSendBehavior behavior, + ChatComposerKeyboardAction action, + bool hasModifier) + { + return action switch + { + ChatComposerKeyboardAction.SendMessage => true, + ChatComposerKeyboardAction.InsertNewLine => behavior is ComposerSendBehavior.EnterSends || hasModifier, + _ => false, + }; + } +} + +public enum ChatComposerKeyboardAction +{ + None, + SendMessage, + InsertNewLine, +} diff --git a/DotPilot/Presentation/Chat/Configuration/ChatComposerModifierState.cs b/DotPilot/Presentation/Chat/Configuration/ChatComposerModifierState.cs new file mode 100644 index 0000000..5c466d6 --- /dev/null +++ b/DotPilot/Presentation/Chat/Configuration/ChatComposerModifierState.cs @@ -0,0 +1,95 @@ +using Windows.System; + +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 IsPressed(VirtualKey key) + { + var normalizedKey = NormalizeModifierKey(key); + return normalizedKey is not null && pressedModifierKeys.ContainsKey(normalizedKey.Value); + } + + 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); + if (normalizedKey is null) + { + return; + } + + pressedModifierKeys.TryGetValue(normalizedKey.Value, out var count); + pressedModifierKeys[normalizedKey.Value] = count + 1; + } + + public void RegisterKeyUp(VirtualKey key) + { + var normalizedKey = NormalizeModifierKey(key); + if (normalizedKey is null || + !pressedModifierKeys.TryGetValue(normalizedKey.Value, out var count)) + { + return; + } + + if (count <= 1) + { + pressedModifierKeys.Remove(normalizedKey.Value); + return; + } + + pressedModifierKeys[normalizedKey.Value] = count - 1; + } + + public void Reset() + { + pressedModifierKeys.Clear(); + } + + private static VirtualKey? NormalizeModifierKey(VirtualKey key) + { + return key switch + { + VirtualKey.Shift or VirtualKey.LeftShift or VirtualKey.RightShift => VirtualKey.Shift, + VirtualKey.Control or VirtualKey.LeftControl or VirtualKey.RightControl => VirtualKey.Control, + VirtualKey.Menu or VirtualKey.LeftMenu or VirtualKey.RightMenu => VirtualKey.Menu, + VirtualKey.LeftWindows or VirtualKey.RightWindows => VirtualKey.LeftWindows, + _ => null, + }; + } +} diff --git a/DotPilot/Presentation/Chat/Configuration/ChatComposerSendBehaviorText.cs b/DotPilot/Presentation/Chat/Configuration/ChatComposerSendBehaviorText.cs new file mode 100644 index 0000000..046e7d6 --- /dev/null +++ b/DotPilot/Presentation/Chat/Configuration/ChatComposerSendBehaviorText.cs @@ -0,0 +1,42 @@ + +namespace DotPilot.Presentation; + +internal static class ChatComposerSendBehaviorText +{ + private const string EnterSendsTitle = "Press Enter to send"; + private const string EnterSendsSummary = "Use a modifier with Enter when you want a new line."; + private const string EnterSendsHint = "Enter sends. Enter with a modifier adds a new line."; + private const string EnterInsertsNewLineTitle = "Press Enter for a new line"; + private const string EnterInsertsNewLineSummary = "Use a modifier with Enter when you want to send."; + private const string EnterInsertsNewLineHint = "Enter adds a new line. Enter with a modifier sends."; + + public static string GetTitle(ComposerSendBehavior behavior) + { + return behavior switch + { + ComposerSendBehavior.EnterSends => EnterSendsTitle, + ComposerSendBehavior.EnterInsertsNewLine => EnterInsertsNewLineTitle, + _ => EnterSendsTitle, + }; + } + + public static string GetSummary(ComposerSendBehavior behavior) + { + return behavior switch + { + ComposerSendBehavior.EnterSends => EnterSendsSummary, + ComposerSendBehavior.EnterInsertsNewLine => EnterInsertsNewLineSummary, + _ => EnterSendsSummary, + }; + } + + public static string GetHint(ComposerSendBehavior behavior) + { + return behavior switch + { + ComposerSendBehavior.EnterSends => EnterSendsHint, + ComposerSendBehavior.EnterInsertsNewLine => EnterInsertsNewLineHint, + _ => EnterSendsHint, + }; + } +} diff --git a/DotPilot/Presentation/Chat/Controls/ChatComposer.xaml b/DotPilot/Presentation/Chat/Controls/ChatComposer.xaml new file mode 100644 index 0000000..0f25cc7 --- /dev/null +++ b/DotPilot/Presentation/Chat/Controls/ChatComposer.xaml @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/DotPilot/Presentation/Chat/Controls/ChatComposer.xaml.cs b/DotPilot/Presentation/Chat/Controls/ChatComposer.xaml.cs new file mode 100644 index 0000000..2c1fba8 --- /dev/null +++ b/DotPilot/Presentation/Chat/Controls/ChatComposer.xaml.cs @@ -0,0 +1,210 @@ +#if !__WASM__ +using Windows.System; +#endif + +namespace DotPilot.Presentation.Controls; + +public sealed partial class ChatComposer : UserControl +{ + private const string ComposerInputAutomationId = "ChatComposerInput"; + private const string SendButtonAutomationId = "ChatComposerSendButton"; + private const string NewLineValue = "\n"; + private readonly ChatComposerModifierState _modifierState = new(); + + public ChatComposer() + { + InitializeComponent(); + RegisterPropertyChangedCallback(TagProperty, OnBehaviorTagChanged); + UpdateAcceptsReturn(); + } + + private void OnComposerInputKeyDown(object sender, Microsoft.UI.Xaml.Input.KeyRoutedEventArgs args) + { +#if __WASM__ + return; +#else + if (sender is not TextBox textBox) + { + return; + } + + _modifierState.RegisterKeyDown(args.Key); + if (args.Key is not VirtualKey.Enter) + { + return; + } + + var hasModifier = _modifierState.HasPressedModifier; + + var action = ChatComposerKeyboardPolicy.Resolve( + behavior: CurrentSendBehavior, + isEnterKey: true, + hasModifier: hasModifier); + if (!ChatComposerKeyboardPolicy.ShouldHandleInComposer(CurrentSendBehavior, action, hasModifier)) + { + return; + } + +#if USE_UITESTS + BrowserConsoleDiagnostics.Error( + $"[DotPilot.ChatComposer] KeyDown invoked. HasModifier={hasModifier} Action={action} Behavior={CurrentSendBehavior}."); +#endif + + args.Handled = true; + if (action is ChatComposerKeyboardAction.SendMessage) + { + ExecuteSubmitAction(textBox); + return; + } + + InsertNewLine(textBox); +#endif + } + + private void OnComposerInputKeyUp(object sender, Microsoft.UI.Xaml.Input.KeyRoutedEventArgs args) + { +#if !__WASM__ + _modifierState.RegisterKeyUp(args.Key); +#endif + } + + private void OnComposerInputLostFocus(object sender, RoutedEventArgs e) + { + _modifierState.Reset(); + } + + private void OnBehaviorTagChanged(DependencyObject sender, DependencyProperty dependencyProperty) + { + if (!ReferenceEquals(sender, this) || dependencyProperty != TagProperty) + { + return; + } + + UpdateAcceptsReturn(); + _ = SynchronizeBrowserKeyboardInteropAsync(); + } + + private async void OnLoadedAsync(object sender, RoutedEventArgs e) + { + ChatComposerBrowserInterop.RegisterComposer(ComposerInputAutomationId, this); + await SynchronizeBrowserKeyboardInteropAsync(); + } + + private async void OnUnloadedAsync(object sender, RoutedEventArgs e) + { + _modifierState.Reset(); + ChatComposerBrowserInterop.UnregisterComposer(ComposerInputAutomationId, this); + await ChatComposerBrowserInterop.DisposeAsync(ComposerInputAutomationId); + } + + private void OnSendButtonClick(object sender, RoutedEventArgs e) + { +#if USE_UITESTS + BrowserConsoleDiagnostics.Error("[DotPilot.ChatComposer] Send button click received."); +#endif + ExecuteSubmitAction(ComposerInput); + } + + private void ExecuteSubmitAction(TextBox textBox) + { + ArgumentNullException.ThrowIfNull(textBox); + + SynchronizeComposerText(textBox); + var command = SendButton.Command; + if (command is null) + { +#if USE_UITESTS + BrowserConsoleDiagnostics.Error("[DotPilot.ChatComposer] Submit ignored because SendButton.Command is null."); +#endif + return; + } + + var parameter = textBox.Text; + var canExecute = command.CanExecute(parameter); + if (!canExecute) + { +#if USE_UITESTS + BrowserConsoleDiagnostics.Error("[DotPilot.ChatComposer] Submit ignored because command.CanExecute returned false."); +#endif + return; + } + +#if USE_UITESTS + BrowserConsoleDiagnostics.Error("[DotPilot.ChatComposer] Submit command executing."); +#endif + if (canExecute) + { + command.Execute(parameter); +#if USE_UITESTS + BrowserConsoleDiagnostics.Error("[DotPilot.ChatComposer] Submit command executed."); +#endif + } + } + + internal void SubmitFromBrowser() + { + ExecuteSubmitAction(ComposerInput); + } + + internal void ApplyTextFromBrowser(string value, int selectionStart) + { + ArgumentNullException.ThrowIfNull(value); + + ComposerInput.Text = value; + ComposerInput.SelectionStart = Math.Clamp(selectionStart, 0, value.Length); + ComposerInput.SelectionLength = 0; + SynchronizeComposerText(ComposerInput); + } + + private static void SynchronizeComposerText(TextBox textBox) + { + ArgumentNullException.ThrowIfNull(textBox); + + var bindingExpression = textBox.GetBindingExpression(TextBox.TextProperty); + bindingExpression?.UpdateSource(); + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Style", + "IDE0051:Remove unused private members", + Justification = "Used on non-browser targets for desktop keyboard handling.")] + 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; + SynchronizeComposerText(textBox); + } + + private void UpdateAcceptsReturn() + { + if (ComposerInput is null) + { + return; + } + + ComposerInput.AcceptsReturn = true; + } + + private Task SynchronizeBrowserKeyboardInteropAsync() + { + return ChatComposerBrowserInterop.SynchronizeAsync( + ComposerInputAutomationId, + SendButtonAutomationId, + CurrentSendBehavior); + } + + private ComposerSendBehavior CurrentSendBehavior => + Tag is ComposerSendBehavior behavior + ? behavior + : ComposerSendBehavior.EnterSends; +} diff --git a/DotPilot/Presentation/Chat/Controls/ChatComposerBrowserInterop.cs b/DotPilot/Presentation/Chat/Controls/ChatComposerBrowserInterop.cs new file mode 100644 index 0000000..06a0a05 --- /dev/null +++ b/DotPilot/Presentation/Chat/Controls/ChatComposerBrowserInterop.cs @@ -0,0 +1,132 @@ +namespace DotPilot.Presentation.Controls; + +internal static partial class ChatComposerBrowserInterop +{ + private static readonly Dictionary> RegisteredComposers = []; + + internal static void RegisterComposer(string inputAutomationId, ChatComposer composer) + { + ArgumentException.ThrowIfNullOrWhiteSpace(inputAutomationId); + ArgumentNullException.ThrowIfNull(composer); + + RegisteredComposers[inputAutomationId] = new WeakReference(composer); + } + + internal static void UnregisterComposer(string inputAutomationId, ChatComposer composer) + { + ArgumentException.ThrowIfNullOrWhiteSpace(inputAutomationId); + ArgumentNullException.ThrowIfNull(composer); + + if (!RegisteredComposers.TryGetValue(inputAutomationId, out var reference) || + !reference.TryGetTarget(out var target) || + !ReferenceEquals(target, composer)) + { + return; + } + + RegisteredComposers.Remove(inputAutomationId); + } + + internal static void SubmitRegisteredComposer(string inputAutomationId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(inputAutomationId); + + if (!RegisteredComposers.TryGetValue(inputAutomationId, out var reference)) + { + return; + } + + if (!reference.TryGetTarget(out var composer)) + { + RegisteredComposers.Remove(inputAutomationId); + return; + } + + composer.SubmitFromBrowser(); + } + + internal static void ApplyTextForRegisteredComposer(string inputAutomationId, string value, int selectionStart) + { + ArgumentException.ThrowIfNullOrWhiteSpace(inputAutomationId); + ArgumentNullException.ThrowIfNull(value); + + if (!RegisteredComposers.TryGetValue(inputAutomationId, out var reference)) + { + return; + } + + if (!reference.TryGetTarget(out var composer)) + { + RegisteredComposers.Remove(inputAutomationId); + return; + } + + composer.ApplyTextFromBrowser(value, selectionStart); + } + + internal static async Task SynchronizeAsync( + string inputAutomationId, + string sendButtonAutomationId, + ComposerSendBehavior behavior) + { +#if __WASM__ +#pragma warning disable CA1416 + await EnsureInitializedAsync(); + JSImportMethods.Synchronize(inputAutomationId, sendButtonAutomationId, behavior.ToString()); +#pragma warning restore CA1416 +#endif + } + + internal static async Task DisposeAsync(string inputAutomationId) + { +#if __WASM__ +#pragma warning disable CA1416 + await EnsureInitializedAsync(); + JSImportMethods.Dispose(inputAutomationId); +#pragma warning restore CA1416 +#endif + } + +#if __WASM__ + private const string ModuleName = "DotPilotChatComposer"; + private const string ModulePath = "/scripts/ChatComposerBrowserInterop.js"; + private static Task? _moduleImportTask; + + [System.Runtime.Versioning.SupportedOSPlatform("browser")] + private static Task EnsureInitializedAsync() + { + _moduleImportTask ??= System.Runtime.InteropServices.JavaScript.JSHost + .ImportAsync(ModuleName, ModulePath) + .AsTask(); + return _moduleImportTask; + } + + [System.Runtime.Versioning.SupportedOSPlatform("browser")] + private static partial class JSImportMethods + { + [System.Runtime.InteropServices.JavaScript.JSImport("synchronize", ModuleName)] + internal static partial void Synchronize(string inputAutomationId, string sendButtonAutomationId, string behavior); + + [System.Runtime.InteropServices.JavaScript.JSImport("dispose", ModuleName)] + internal static partial void Dispose(string inputAutomationId); + } +#endif +} + +#if __WASM__ +[System.Runtime.Versioning.SupportedOSPlatform("browser")] +public static partial class ChatComposerBrowserExports +{ + [System.Runtime.InteropServices.JavaScript.JSExport] + public static void SubmitMessage(string inputAutomationId) + { + ChatComposerBrowserInterop.SubmitRegisteredComposer(inputAutomationId); + } + + [System.Runtime.InteropServices.JavaScript.JSExport] + public static void ApplyText(string inputAutomationId, string value, int selectionStart) + { + ChatComposerBrowserInterop.ApplyTextForRegisteredComposer(inputAutomationId, value, selectionStart); + } +} +#endif diff --git a/DotPilot/Presentation/Chat/Controls/ChatConversationView.xaml b/DotPilot/Presentation/Chat/Controls/ChatConversationView.xaml new file mode 100644 index 0000000..6618cb5 --- /dev/null +++ b/DotPilot/Presentation/Chat/Controls/ChatConversationView.xaml @@ -0,0 +1,204 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/DotPilot/Presentation/Chat/Controls/ChatConversationView.xaml.cs b/DotPilot/Presentation/Chat/Controls/ChatConversationView.xaml.cs new file mode 100644 index 0000000..65171c3 --- /dev/null +++ b/DotPilot/Presentation/Chat/Controls/ChatConversationView.xaml.cs @@ -0,0 +1,126 @@ +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; } + + public DataTemplate? OutgoingTemplate { get; set; } + + 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); + } + + protected override DataTemplate SelectTemplateCore(object item, DependencyObject container) + { + return SelectTemplateCore(item); + } +} diff --git a/DotPilot/Presentation/Chat/Controls/ChatFleetBoard.xaml b/DotPilot/Presentation/Chat/Controls/ChatFleetBoard.xaml new file mode 100644 index 0000000..8616fae --- /dev/null +++ b/DotPilot/Presentation/Chat/Controls/ChatFleetBoard.xaml @@ -0,0 +1,132 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/DotPilot/Presentation/Controls/ChatSidebar.xaml.cs b/DotPilot/Presentation/Chat/Controls/ChatFleetBoard.xaml.cs similarity index 51% rename from DotPilot/Presentation/Controls/ChatSidebar.xaml.cs rename to DotPilot/Presentation/Chat/Controls/ChatFleetBoard.xaml.cs index eb20aa1..107aed1 100644 --- a/DotPilot/Presentation/Controls/ChatSidebar.xaml.cs +++ b/DotPilot/Presentation/Chat/Controls/ChatFleetBoard.xaml.cs @@ -1,8 +1,8 @@ namespace DotPilot.Presentation.Controls; -public sealed partial class ChatSidebar : UserControl +public sealed partial class ChatFleetBoard : UserControl { - public ChatSidebar() + public ChatFleetBoard() { InitializeComponent(); } diff --git a/DotPilot/Presentation/Chat/Controls/ChatInfoPanel.xaml b/DotPilot/Presentation/Chat/Controls/ChatInfoPanel.xaml new file mode 100644 index 0000000..34a78f1 --- /dev/null +++ b/DotPilot/Presentation/Chat/Controls/ChatInfoPanel.xaml @@ -0,0 +1,169 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/DotPilot/Presentation/Controls/ChatInfoPanel.xaml.cs b/DotPilot/Presentation/Chat/Controls/ChatInfoPanel.xaml.cs similarity index 100% rename from DotPilot/Presentation/Controls/ChatInfoPanel.xaml.cs rename to DotPilot/Presentation/Chat/Controls/ChatInfoPanel.xaml.cs diff --git a/DotPilot/Presentation/Chat/Controls/ChatSessionRail.xaml b/DotPilot/Presentation/Chat/Controls/ChatSessionRail.xaml new file mode 100644 index 0000000..fe5685e --- /dev/null +++ b/DotPilot/Presentation/Chat/Controls/ChatSessionRail.xaml @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/DotPilot/Presentation/Controls/ChatComposer.xaml.cs b/DotPilot/Presentation/Chat/Controls/ChatSessionRail.xaml.cs similarity index 50% rename from DotPilot/Presentation/Controls/ChatComposer.xaml.cs rename to DotPilot/Presentation/Chat/Controls/ChatSessionRail.xaml.cs index 787981f..8c22f27 100644 --- a/DotPilot/Presentation/Controls/ChatComposer.xaml.cs +++ b/DotPilot/Presentation/Chat/Controls/ChatSessionRail.xaml.cs @@ -1,8 +1,8 @@ namespace DotPilot.Presentation.Controls; -public sealed partial class ChatComposer : UserControl +public sealed partial class ChatSessionRail : UserControl { - public ChatComposer() + public ChatSessionRail() { InitializeComponent(); } diff --git a/DotPilot/Presentation/Chat/ViewModels/ChatModel.FleetBoard.cs b/DotPilot/Presentation/Chat/ViewModels/ChatModel.FleetBoard.cs new file mode 100644 index 0000000..5686117 --- /dev/null +++ b/DotPilot/Presentation/Chat/ViewModels/ChatModel.FleetBoard.cs @@ -0,0 +1,196 @@ +using System.Collections.Immutable; +using System.Globalization; +using DotPilot.Core.ControlPlaneDomain; + +namespace DotPilot.Presentation; + +public partial record ChatModel +{ + private const string LiveSessionsMetricLabel = "Live sessions"; + private const string ReadyProvidersMetricLabel = "Providers ready"; + private const string AttentionProvidersMetricLabel = "Needs attention"; + private const string EmptyLiveSessionsMessage = "No live sessions right now."; + private const string LiveSessionsMetricSummary = "Sessions that are actively generating output."; + private const string ReadyProvidersMetricSummary = "Enabled providers that are ready for local work."; + private const string AttentionProvidersMetricSummary = "Enabled providers that need setup or recovery."; + private AsyncCommand? _openFleetSessionCommand; + + public IState FleetBoard => State.Async(this, LoadFleetBoardAsync, _sessionRefresh); + + public ICommand OpenFleetSessionCommand => + _openFleetSessionCommand ??= new AsyncCommand( + parameter => OpenFleetSessionCore(parameter, CancellationToken.None)); + + public ValueTask OpenFleetSession(FleetBoardSessionRequest request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + return OpenFleetSessionCore(request, cancellationToken); + } + + private void OnSessionActivityChanged(object? sender, EventArgs e) + { + uiDispatcher.Execute(_sessionRefresh.Raise); + } + + private async ValueTask LoadFleetBoardAsync(CancellationToken cancellationToken) + { + var activitySnapshot = sessionActivityMonitor.Current; + var providers = await GetFleetProvidersAsync(cancellationToken); + + var liveSessions = activitySnapshot.ActiveSessions + .Select(MapFleetSession) + .ToImmutableArray(); + var providerItems = providers + .Select(MapFleetProvider) + .ToImmutableArray(); + + return new FleetBoardView( + CreateFleetMetrics(activitySnapshot.ActiveSessionCount, providers), + liveSessions, + providerItems, + liveSessions.Length > 0, + liveSessions.Length == 0, + EmptyLiveSessionsMessage); + } + + private async ValueTask> GetFleetProvidersAsync(CancellationToken cancellationToken) + { + if (hasFleetProviderSnapshot && !fleetProviderSnapshotStale) + { + return fleetProviderSnapshot; + } + + await fleetProviderRefreshGate.WaitAsync(cancellationToken); + try + { + if (hasFleetProviderSnapshot && !fleetProviderSnapshotStale) + { + return fleetProviderSnapshot; + } + + var workspaceResult = await workspaceState.GetWorkspaceAsync(cancellationToken); + if (!workspaceResult.TryGetValue(out var workspace)) + { + return hasFleetProviderSnapshot + ? fleetProviderSnapshot + : []; + } + + fleetProviderSnapshot = [.. workspace.Providers]; + hasFleetProviderSnapshot = true; + fleetProviderSnapshotStale = false; + return fleetProviderSnapshot; + } + finally + { + fleetProviderRefreshGate.Release(); + } + } + + private async ValueTask OpenFleetSessionCore(object? parameter, CancellationToken cancellationToken) + { + var request = parameter switch + { + FleetBoardSessionRequest value => value, + SessionId sessionId => new FleetBoardSessionRequest(sessionId, string.Empty), + _ => null, + }; + if (request is null) + { + return; + } + + var workspaceResult = await workspaceState.GetWorkspaceAsync(cancellationToken); + if (!workspaceResult.TryGetValue(out var workspace)) + { + return; + } + + var sessions = workspace.Sessions + .Select(MapSidebarItem) + .ToImmutableArray(); + var selectedSession = FindSessionById(sessions, request.SessionId); + if (IsEmptySelectedChat(selectedSession)) + { + return; + } + + await SelectedChat.UpdateAsync(_ => selectedSession, cancellationToken); + await FeedbackMessage.SetAsync(string.Empty, cancellationToken); + _sessionRefresh.Raise(); + } + + private static ImmutableArray CreateFleetMetrics( + int activeSessionCount, + IReadOnlyList providers) + { + var enabledProviders = providers.Where(provider => provider.IsEnabled).ToImmutableArray(); + var readyProviders = enabledProviders.Count(provider => provider.Status == AgentProviderStatus.Ready); + var attentionProviders = enabledProviders.Count(provider => provider.Status != AgentProviderStatus.Ready); + + return + [ + new FleetBoardMetricItem( + LiveSessionsMetricLabel, + activeSessionCount.ToString(CultureInfo.InvariantCulture), + LiveSessionsMetricSummary, + FleetBoardAutomationIds.ForMetric(LiveSessionsMetricLabel)), + new FleetBoardMetricItem( + ReadyProvidersMetricLabel, + readyProviders.ToString(CultureInfo.InvariantCulture), + ReadyProvidersMetricSummary, + FleetBoardAutomationIds.ForMetric(ReadyProvidersMetricLabel)), + new FleetBoardMetricItem( + AttentionProvidersMetricLabel, + attentionProviders.ToString(CultureInfo.InvariantCulture), + AttentionProvidersMetricSummary, + FleetBoardAutomationIds.ForMetric(AttentionProvidersMetricLabel)), + ]; + } + + private FleetBoardSessionItem MapFleetSession(SessionActivityDescriptor descriptor) + { + return new FleetBoardSessionItem( + descriptor.SessionTitle, + $"{descriptor.AgentName} · {descriptor.ProviderDisplayName}", + FleetBoardAutomationIds.ForSession(descriptor.SessionTitle), + new FleetBoardSessionRequest(descriptor.SessionId, descriptor.SessionTitle), + OpenFleetSessionCommand); + } + + private static FleetBoardProviderItem MapFleetProvider(ProviderStatusDescriptor provider) + { + return new FleetBoardProviderItem( + provider.DisplayName, + GetProviderStatusLabel(provider.Status), + provider.StatusSummary, + ResolveProviderStatusBrush(provider.Status), + FleetBoardAutomationIds.ForProvider(provider.DisplayName)); + } + + private static string GetProviderStatusLabel(AgentProviderStatus status) + { + return status switch + { + AgentProviderStatus.Ready => "Ready", + AgentProviderStatus.Disabled => "Disabled", + AgentProviderStatus.RequiresSetup => "Setup needed", + AgentProviderStatus.Unsupported => "Unsupported", + AgentProviderStatus.Error => "Error", + _ => "Unknown", + }; + } + + private static Brush? ResolveProviderStatusBrush(AgentProviderStatus status) + { + return status switch + { + AgentProviderStatus.Ready => DesignBrushPalette.AccentBrush, + AgentProviderStatus.Disabled => DesignBrushPalette.BadgeSurfaceBrush, + AgentProviderStatus.RequiresSetup or AgentProviderStatus.Unsupported => DesignBrushPalette.AvatarVariantEmilyBrush, + AgentProviderStatus.Error => DesignBrushPalette.AvatarVariantFrankBrush, + _ => DesignBrushPalette.BadgeSurfaceBrush, + }; + } +} diff --git a/DotPilot/Presentation/Chat/ViewModels/ChatModel.cs b/DotPilot/Presentation/Chat/ViewModels/ChatModel.cs new file mode 100644 index 0000000..5b8107a --- /dev/null +++ b/DotPilot/Presentation/Chat/ViewModels/ChatModel.cs @@ -0,0 +1,475 @@ +using System.Collections.Immutable; +using System.Globalization; +using DotPilot.Core.ControlPlaneDomain; +using Microsoft.Extensions.Logging; +using Microsoft.UI.Xaml.Data; + +namespace DotPilot.Presentation; + +[Bindable] +public partial record ChatModel +{ + private const string EmptyTitleValue = "No active session"; + private const string EmptyStatusValue = "A default system agent is ready. Start a session or create another agent."; + private const string ReadyToStartStatusValue = "Start a session or send the first message."; + private const string DefaultComposerPlaceholder = "Message your local agent session"; + private const string SendInProgressMessage = "Sending message..."; + private const string StartSessionValidationMessage = "Create an agent before starting a session."; + private const string LocalMemberName = "Local operator"; + private const string LocalMemberSummary = "This desktop instance"; + private static readonly SessionSidebarItem EmptySelectedChat = new(default, string.Empty, string.Empty); + private static readonly ParticipantItem LocalMember = new( + LocalMemberName, + LocalMemberSummary, + "L", + DesignBrushPalette.UserAvatarBrush); + private readonly UiDispatcher uiDispatcher; + private readonly IAgentWorkspaceState workspaceState; + private readonly ISessionActivityMonitor sessionActivityMonitor; + private readonly IOperatorPreferencesStore operatorPreferencesStore; + private readonly ILogger logger; + private readonly SemaphoreSlim fleetProviderRefreshGate = new(1, 1); + private AsyncCommand? _startNewSessionCommand; + private AsyncCommand? _submitMessageCommand; + private readonly Signal _workspaceRefresh = new(); + private readonly Signal _sessionRefresh = new(); + private ImmutableArray fleetProviderSnapshot = []; + private bool hasFleetProviderSnapshot; + private bool fleetProviderSnapshotStale = true; + + public ChatModel( + UiDispatcher uiDispatcher, + IAgentWorkspaceState workspaceState, + ISessionActivityMonitor sessionActivityMonitor, + IOperatorPreferencesStore operatorPreferencesStore, + WorkspaceProjectionNotifier workspaceProjectionNotifier, + ILogger logger) + { + this.uiDispatcher = uiDispatcher; + this.workspaceState = workspaceState; + this.sessionActivityMonitor = sessionActivityMonitor; + this.operatorPreferencesStore = operatorPreferencesStore; + this.logger = logger; + workspaceProjectionNotifier.Changed += OnWorkspaceProjectionChanged; + this.sessionActivityMonitor.StateChanged += OnSessionActivityChanged; + } + + public string ComposerPlaceholder => DefaultComposerPlaceholder; + + public IState ComposerText => State.Value(this, static () => string.Empty); + + public IState FeedbackMessage => State.Value(this, static () => string.Empty); + + public IState ComposerSendBehavior => State.Async(this, LoadComposerSendBehaviorAsync, _workspaceRefresh); + + public IState ComposerSendHint => State.Async(this, LoadComposerSendHintAsync, _workspaceRefresh); + + public IState SelectedChat => State.Value(this, static () => EmptySelectedChat); + + public IListState RecentChats => ListState.Async(this, LoadRecentChatsAsync, _workspaceRefresh); + + public IState ActiveSession => State.Async(this, LoadActiveSessionViewAsync, _sessionRefresh); + + public IState HasAgents => State.Async(this, LoadHasAgentsAsync, _workspaceRefresh); + + public IState CanSend => State.Async(this, LoadCanSendAsync); + + public ICommand StartNewSessionCommand => + _startNewSessionCommand ??= new AsyncCommand( + () => StartNewSession(CancellationToken.None)); + + public ICommand SubmitMessageCommand => + _submitMessageCommand ??= new AsyncCommand( + parameter => SendMessageCore(parameter as string, CancellationToken.None)); + + public async ValueTask Refresh(CancellationToken cancellationToken) + { + try + { + ChatModelLog.RefreshRequested(logger); + var refresh = await workspaceState.RefreshWorkspaceAsync(cancellationToken); + if (refresh.IsFailed) + { + await FeedbackMessage.SetAsync(refresh.ToOperatorMessage("Could not refresh workspace."), cancellationToken); + return; + } + + if (refresh.TryGetValue(out var workspace)) + { + fleetProviderSnapshot = [.. workspace.Providers]; + hasFleetProviderSnapshot = true; + fleetProviderSnapshotStale = false; + } + + _workspaceRefresh.Raise(); + _sessionRefresh.Raise(); + await EnsureSelectedChatAsync(cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + } + catch (Exception exception) + { + ChatModelLog.Failure(logger, exception); + await FeedbackMessage.SetAsync(exception.Message, cancellationToken); + } + } + + public async ValueTask StartNewSession(CancellationToken cancellationToken) + { + try + { + var workspaceResult = await workspaceState.GetWorkspaceAsync(cancellationToken); + if (!workspaceResult.TryGetValue(out var workspace)) + { + await FeedbackMessage.SetAsync(workspaceResult.ToOperatorMessage("Could not load workspace."), cancellationToken); + return; + } + + if (workspace.Agents.Count == 0) + { + await FeedbackMessage.SetAsync(StartSessionValidationMessage, cancellationToken); + return; + } + + ChatModelLog.StartingSession(logger); + var agent = workspace.Agents[0]; + var sessionResult = await workspaceState.CreateSessionAsync( + new CreateSessionCommand($"Session with {agent.Name}", agent.Id), + cancellationToken); + if (!sessionResult.TryGetValue(out var session)) + { + await FeedbackMessage.SetAsync(sessionResult.ToOperatorMessage("Could not start a session."), cancellationToken); + return; + } + + await SelectedChat.UpdateAsync(_ => MapSidebarItem(session.Session), cancellationToken); + await FeedbackMessage.SetAsync(string.Empty, cancellationToken); + _workspaceRefresh.Raise(); + _sessionRefresh.Raise(); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + } + catch (Exception exception) + { + ChatModelLog.Failure(logger, exception); + await FeedbackMessage.SetAsync(exception.Message, cancellationToken); + } + } + + public ValueTask SendMessage(CancellationToken cancellationToken) + { + return SendMessageCore(messageOverride: null, cancellationToken); + } + + public ValueTask SubmitMessage(CancellationToken cancellationToken) + { + return SendMessageCore(messageOverride: null, cancellationToken); + } + + private void OnWorkspaceProjectionChanged(object? sender, EventArgs e) + { + fleetProviderSnapshotStale = true; + uiDispatcher.Execute(() => + { + _workspaceRefresh.Raise(); + _sessionRefresh.Raise(); + }); + } + + private async ValueTask SendMessageCore(string? messageOverride, CancellationToken cancellationToken) + { + var message = string.IsNullOrWhiteSpace(messageOverride) + ? ((await ComposerText) ?? string.Empty).Trim() + : messageOverride.Trim(); + if (string.IsNullOrWhiteSpace(message)) + { + ChatModelLog.SendIgnoredEmpty(logger); + return; + } + + if (!await HasAgents) + { + ChatModelLog.SendIgnoredNoAgents(logger); + await FeedbackMessage.SetAsync(StartSessionValidationMessage, cancellationToken); + return; + } + + await ComposerText.SetAsync(string.Empty, cancellationToken); + await FeedbackMessage.SetAsync(SendInProgressMessage, cancellationToken); + + try + { + var selectedChat = (await SelectedChat) ?? EmptySelectedChat; + if (IsEmptySelectedChat(selectedChat)) + { + await StartNewSession(cancellationToken); + selectedChat = (await SelectedChat) ?? EmptySelectedChat; + if (IsEmptySelectedChat(selectedChat)) + { + return; + } + } + + if (logger.IsEnabled(LogLevel.Information)) + { + var sessionId = selectedChat.Id.Value.ToString("N", CultureInfo.InvariantCulture); + ChatModelLog.SendRequested(logger, sessionId, message.Length); + BrowserConsoleDiagnostics.Info($"[DotPilot.Chat] Send requested. SessionId={sessionId} CharacterCount={message.Length}"); + } + + var sendFailed = false; + await foreach (var _ in workspaceState.SendMessageAsync( + new SendSessionMessageCommand(selectedChat.Id, message), + cancellationToken)) + { + if (_.IsFailed) + { + await FeedbackMessage.SetAsync(_.ToOperatorMessage("Message send failed."), cancellationToken); + sendFailed = true; + break; + } + + _workspaceRefresh.Raise(); + _sessionRefresh.Raise(); + } + + if (logger.IsEnabled(LogLevel.Information)) + { + var sessionId = selectedChat.Id.Value.ToString("N", CultureInfo.InvariantCulture); + ChatModelLog.SendCompleted(logger, sessionId); + BrowserConsoleDiagnostics.Info($"[DotPilot.Chat] Send completed. SessionId={sessionId}"); + } + + if (!sendFailed) + { + await FeedbackMessage.SetAsync(string.Empty, cancellationToken); + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + } + catch (Exception exception) + { + ChatModelLog.Failure(logger, exception); + await FeedbackMessage.SetAsync(exception.Message, cancellationToken); + } + } + + private async ValueTask> LoadRecentChatsAsync(CancellationToken cancellationToken) + { + try + { + ChatModelLog.LoadingWorkspace(logger); + var workspaceResult = await workspaceState.GetWorkspaceAsync(cancellationToken); + if (!workspaceResult.TryGetValue(out var workspace)) + { + await FeedbackMessage.SetAsync(workspaceResult.ToOperatorMessage("Could not load sessions."), cancellationToken); + return ImmutableArray.Empty; + } + + ChatModelLog.WorkspaceLoaded(logger, workspace.Sessions.Count, workspace.Agents.Count); + var sessions = workspace.Sessions + .Select(MapSidebarItem) + .ToImmutableArray(); + await EnsureSelectedChatAsync(workspace, sessions, cancellationToken); + return sessions; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) + { + ChatModelLog.Failure(logger, exception); + await FeedbackMessage.SetAsync(exception.Message, cancellationToken); + return ImmutableArray.Empty; + } + } + + private async ValueTask LoadActiveSessionViewAsync(CancellationToken cancellationToken) + { + var snapshot = await LoadActiveSessionAsync(cancellationToken); + if (snapshot is null) + { + return new ChatSessionView( + "S", + EmptyTitleValue, + await HasAgents ? ReadyToStartStatusValue : EmptyStatusValue, + [], + [LocalMember], + []); + } + + return new ChatSessionView( + GetInitial(snapshot.Session.Title), + snapshot.Session.Title, + $"{snapshot.Session.PrimaryAgentName} · {snapshot.Session.ProviderDisplayName}", + snapshot.Entries + .Select(MapTimelineItem) + .ToImmutableArray(), + [LocalMember], + snapshot.Participants + .Select(MapParticipant) + .ToImmutableArray()); + } + + private async ValueTask LoadHasAgentsAsync(CancellationToken cancellationToken) + { + var workspaceResult = await workspaceState.GetWorkspaceAsync(cancellationToken); + if (!workspaceResult.TryGetValue(out var workspace)) + { + return false; + } + + return workspace.Agents.Count > 0; + } + + private async ValueTask LoadComposerSendBehaviorAsync(CancellationToken cancellationToken) + { + var preferences = await operatorPreferencesStore.GetAsync(cancellationToken); + return preferences.ComposerSendBehavior; + } + + private async ValueTask LoadComposerSendHintAsync(CancellationToken cancellationToken) + { + return ChatComposerSendBehaviorText.GetHint(await LoadComposerSendBehaviorAsync(cancellationToken)); + } + + private async ValueTask LoadCanSendAsync(CancellationToken cancellationToken) + { + var composerText = (await ComposerText) ?? string.Empty; + return !string.IsNullOrWhiteSpace(composerText) && await HasAgents; + } + + private async ValueTask LoadActiveSessionAsync(CancellationToken cancellationToken) + { + await EnsureSelectedChatAsync(cancellationToken); + var selectedChat = (await SelectedChat) ?? EmptySelectedChat; + return IsEmptySelectedChat(selectedChat) + ? null + : (await workspaceState.GetSessionAsync(selectedChat.Id, cancellationToken)).TryGetValue(out var session) + ? session + : null; + } + + private async ValueTask EnsureSelectedChatAsync(CancellationToken cancellationToken) + { + var workspaceResult = await workspaceState.GetWorkspaceAsync(cancellationToken); + if (!workspaceResult.TryGetValue(out var workspace)) + { + return; + } + + var sessions = workspace.Sessions + .Select(MapSidebarItem) + .ToImmutableArray(); + await EnsureSelectedChatAsync(workspace, sessions, cancellationToken); + } + + private async ValueTask EnsureSelectedChatAsync( + AgentWorkspaceSnapshot workspace, + IImmutableList sessions, + CancellationToken cancellationToken) + { + var selectedChat = (await SelectedChat) ?? EmptySelectedChat; + var resolvedSelection = FindSessionById(sessions, selectedChat.Id); + if (IsEmptySelectedChat(resolvedSelection) && workspace.SelectedSessionId is { } selectedSessionId) + { + resolvedSelection = FindSessionById(sessions, selectedSessionId); + } + + if (IsEmptySelectedChat(resolvedSelection) && sessions.Count > 0) + { + resolvedSelection = sessions[0]; + } + + if (!Equals(selectedChat, resolvedSelection)) + { + await SelectedChat.UpdateAsync(_ => resolvedSelection, cancellationToken); + } + } + + private static SessionSidebarItem FindSessionById(IImmutableList sessions, SessionId sessionId) + { + for (var index = 0; index < sessions.Count; index++) + { + if (sessions[index].Id == sessionId) + { + return sessions[index]; + } + } + + return EmptySelectedChat; + } + + private static bool IsEmptySelectedChat(SessionSidebarItem? item) + { + return item is null || item.Id == default; + } + + private static SessionSidebarItem MapSidebarItem(SessionListItem session) + { + return new SessionSidebarItem(session.Id, session.Title, session.Preview); + } + + private static ParticipantItem MapParticipant(AgentProfileSummary agent) + { + return new ParticipantItem( + agent.Name, + $"{agent.ProviderDisplayName} · {agent.ModelName}", + GetInitial(agent.Name), + ResolveAgentBrush(agent.ProviderKind)); + } + + private static ChatTimelineItem MapTimelineItem(SessionStreamEntry entry) + { + var isCurrentUser = entry.Kind == SessionStreamEntryKind.UserMessage; + var author = entry.Author; + var initial = GetInitial(author); + var avatarBrush = ResolveTimelineBrush(entry); + + return new ChatTimelineItem( + entry.Id, + entry.Kind, + author, + entry.Timestamp.ToLocalTime().ToString("HH:mm", CultureInfo.InvariantCulture), + entry.Text, + initial, + avatarBrush, + isCurrentUser, + entry.AccentLabel); + } + + private static string GetInitial(string value) + { + return string.IsNullOrWhiteSpace(value) + ? "?" + : value.Trim()[0].ToString(CultureInfo.InvariantCulture).ToUpperInvariant(); + } + + private static Brush? ResolveTimelineBrush(SessionStreamEntry entry) + { + return entry.Kind switch + { + SessionStreamEntryKind.UserMessage => DesignBrushPalette.UserAvatarBrush, + SessionStreamEntryKind.ToolStarted or SessionStreamEntryKind.ToolCompleted => DesignBrushPalette.AnalyticsAvatarBrush, + SessionStreamEntryKind.Status => DesignBrushPalette.AvatarVariantEmilyBrush, + SessionStreamEntryKind.Error => DesignBrushPalette.AvatarVariantFrankBrush, + _ => DesignBrushPalette.CodeAvatarBrush, + }; + } + + 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/Chat/Views/ChatPage.xaml b/DotPilot/Presentation/Chat/Views/ChatPage.xaml new file mode 100644 index 0000000..756fab8 --- /dev/null +++ b/DotPilot/Presentation/Chat/Views/ChatPage.xaml @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/DotPilot/Presentation/Chat/Views/ChatPage.xaml.cs b/DotPilot/Presentation/Chat/Views/ChatPage.xaml.cs new file mode 100644 index 0000000..9a63725 --- /dev/null +++ b/DotPilot/Presentation/Chat/Views/ChatPage.xaml.cs @@ -0,0 +1,19 @@ +namespace DotPilot.Presentation; + +public sealed partial class ChatPage : Page +{ + public ChatPage() + { + try + { + BrowserConsoleDiagnostics.Info("[DotPilot.Startup] ChatPage constructor started."); + InitializeComponent(); + BrowserConsoleDiagnostics.Info("[DotPilot.Startup] ChatPage constructor completed."); + } + catch (Exception exception) + { + BrowserConsoleDiagnostics.Error($"[DotPilot.Startup] ChatPage constructor failed: {exception}"); + throw; + } + } +} diff --git a/DotPilot/Presentation/ChatDesignModels.cs b/DotPilot/Presentation/ChatDesignModels.cs deleted file mode 100644 index e12faba..0000000 --- a/DotPilot/Presentation/ChatDesignModels.cs +++ /dev/null @@ -1,19 +0,0 @@ -namespace DotPilot.Presentation; - -public sealed record SidebarChatItem(string Title, string Preview, bool IsSelected); - -public sealed record ChatMessageItem( - string Author, - string Timestamp, - string Content, - string Initial, - Brush? AvatarBrush, - bool IsCurrentUser); - -public sealed record ParticipantItem( - string Name, - string SecondaryText, - string Initial, - Brush? AvatarBrush, - string? BadgeText = null, - Brush? BadgeBrush = null); diff --git a/DotPilot/Presentation/Configuration/PresentationServiceCollectionExtensions.cs b/DotPilot/Presentation/Configuration/PresentationServiceCollectionExtensions.cs new file mode 100644 index 0000000..39a4c98 --- /dev/null +++ b/DotPilot/Presentation/Configuration/PresentationServiceCollectionExtensions.cs @@ -0,0 +1,26 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace DotPilot.Presentation; + +internal static class PresentationServiceCollectionExtensions +{ + public static IServiceCollection AddPresentationModels(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + return services; + } +} diff --git a/DotPilot/Presentation/Controls/AgentBasicInfoSection.xaml b/DotPilot/Presentation/Controls/AgentBasicInfoSection.xaml deleted file mode 100644 index 50080a6..0000000 --- a/DotPilot/Presentation/Controls/AgentBasicInfoSection.xaml +++ /dev/null @@ -1,157 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/DotPilot/Presentation/Controls/AgentBasicInfoSection.xaml.cs b/DotPilot/Presentation/Controls/AgentBasicInfoSection.xaml.cs deleted file mode 100644 index 3393379..0000000 --- a/DotPilot/Presentation/Controls/AgentBasicInfoSection.xaml.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace DotPilot.Presentation.Controls; - -public sealed partial class AgentBasicInfoSection : UserControl -{ - public AgentBasicInfoSection() - { - InitializeComponent(); - } -} diff --git a/DotPilot/Presentation/Controls/AgentPromptSection.xaml b/DotPilot/Presentation/Controls/AgentPromptSection.xaml deleted file mode 100644 index c845100..0000000 --- a/DotPilot/Presentation/Controls/AgentPromptSection.xaml +++ /dev/null @@ -1,103 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/DotPilot/Presentation/Controls/AgentSidebar.xaml b/DotPilot/Presentation/Controls/AgentSidebar.xaml deleted file mode 100644 index ed01d8a..0000000 --- a/DotPilot/Presentation/Controls/AgentSidebar.xaml +++ /dev/null @@ -1,197 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/DotPilot/Presentation/Controls/AgentSidebar.xaml.cs b/DotPilot/Presentation/Controls/AgentSidebar.xaml.cs deleted file mode 100644 index 65ec733..0000000 --- a/DotPilot/Presentation/Controls/AgentSidebar.xaml.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace DotPilot.Presentation.Controls; - -public sealed partial class AgentSidebar : UserControl -{ - public AgentSidebar() - { - InitializeComponent(); - } -} diff --git a/DotPilot/Presentation/Controls/AgentSkillsSection.xaml b/DotPilot/Presentation/Controls/AgentSkillsSection.xaml deleted file mode 100644 index f67bf0b..0000000 --- a/DotPilot/Presentation/Controls/AgentSkillsSection.xaml +++ /dev/null @@ -1,79 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/DotPilot/Presentation/Controls/AgentSkillsSection.xaml.cs b/DotPilot/Presentation/Controls/AgentSkillsSection.xaml.cs deleted file mode 100644 index 46e7b0c..0000000 --- a/DotPilot/Presentation/Controls/AgentSkillsSection.xaml.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace DotPilot.Presentation.Controls; - -public sealed partial class AgentSkillsSection : UserControl -{ - public AgentSkillsSection() - { - InitializeComponent(); - } -} diff --git a/DotPilot/Presentation/Controls/ChatComposer.xaml b/DotPilot/Presentation/Controls/ChatComposer.xaml deleted file mode 100644 index fabfaa5..0000000 --- a/DotPilot/Presentation/Controls/ChatComposer.xaml +++ /dev/null @@ -1,80 +0,0 @@ - - - - - - - - - - - - - - - - - - - - diff --git a/DotPilot/Presentation/Controls/ChatConversationView.xaml b/DotPilot/Presentation/Controls/ChatConversationView.xaml deleted file mode 100644 index ef3bced..0000000 --- a/DotPilot/Presentation/Controls/ChatConversationView.xaml +++ /dev/null @@ -1,175 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/DotPilot/Presentation/Controls/ChatConversationView.xaml.cs b/DotPilot/Presentation/Controls/ChatConversationView.xaml.cs deleted file mode 100644 index 536fdcd..0000000 --- a/DotPilot/Presentation/Controls/ChatConversationView.xaml.cs +++ /dev/null @@ -1,30 +0,0 @@ -namespace DotPilot.Presentation.Controls; - -public sealed partial class ChatConversationView : UserControl -{ - public ChatConversationView() - { - InitializeComponent(); - } -} - -public sealed class ChatMessageTemplateSelector : DataTemplateSelector -{ - private const string MissingTemplateMessage = "Chat message templates must be configured."; - - public DataTemplate? IncomingTemplate { get; set; } - - public DataTemplate? OutgoingTemplate { get; set; } - - protected override DataTemplate SelectTemplateCore(object item) - { - return item is ChatMessageItem { IsCurrentUser: true } - ? OutgoingTemplate ?? IncomingTemplate ?? throw new InvalidOperationException(MissingTemplateMessage) - : IncomingTemplate ?? OutgoingTemplate ?? throw new InvalidOperationException(MissingTemplateMessage); - } - - protected override DataTemplate SelectTemplateCore(object item, DependencyObject container) - { - return SelectTemplateCore(item); - } -} diff --git a/DotPilot/Presentation/Controls/ChatInfoPanel.xaml b/DotPilot/Presentation/Controls/ChatInfoPanel.xaml deleted file mode 100644 index 091e3d7..0000000 --- a/DotPilot/Presentation/Controls/ChatInfoPanel.xaml +++ /dev/null @@ -1,287 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/DotPilot/Presentation/Controls/ChatSidebar.xaml b/DotPilot/Presentation/Controls/ChatSidebar.xaml deleted file mode 100644 index bc2b450..0000000 --- a/DotPilot/Presentation/Controls/ChatSidebar.xaml +++ /dev/null @@ -1,188 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/DotPilot/Presentation/Controls/RuntimeFoundationPanel.xaml b/DotPilot/Presentation/Controls/RuntimeFoundationPanel.xaml deleted file mode 100644 index f4f2cb2..0000000 --- a/DotPilot/Presentation/Controls/RuntimeFoundationPanel.xaml +++ /dev/null @@ -1,162 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 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 deleted file mode 100644 index 18121b8..0000000 --- a/DotPilot/Presentation/Controls/SettingsShell.xaml +++ /dev/null @@ -1,156 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/DotPilot/Presentation/Controls/SettingsShell.xaml.cs b/DotPilot/Presentation/Controls/SettingsShell.xaml.cs deleted file mode 100644 index 0f6ea00..0000000 --- a/DotPilot/Presentation/Controls/SettingsShell.xaml.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace DotPilot.Presentation.Controls; - -public sealed partial class SettingsShell : UserControl -{ - public SettingsShell() - { - InitializeComponent(); - } -} diff --git a/DotPilot/Presentation/Controls/SettingsSidebar.xaml b/DotPilot/Presentation/Controls/SettingsSidebar.xaml deleted file mode 100644 index af38a27..0000000 --- a/DotPilot/Presentation/Controls/SettingsSidebar.xaml +++ /dev/null @@ -1,171 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/DotPilot/Presentation/Controls/SettingsSidebar.xaml.cs b/DotPilot/Presentation/Controls/SettingsSidebar.xaml.cs deleted file mode 100644 index 39a9d8d..0000000 --- a/DotPilot/Presentation/Controls/SettingsSidebar.xaml.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace DotPilot.Presentation.Controls; - -public sealed partial class SettingsSidebar : UserControl -{ - public SettingsSidebar() - { - InitializeComponent(); - } -} 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/DesignBrushPalette.cs b/DotPilot/Presentation/Design/DesignBrushPalette.cs similarity index 100% rename from DotPilot/Presentation/DesignBrushPalette.cs rename to DotPilot/Presentation/Design/DesignBrushPalette.cs diff --git a/DotPilot/Presentation/Diagnostics/PresentationLog.cs b/DotPilot/Presentation/Diagnostics/PresentationLog.cs new file mode 100644 index 0000000..a1e655c --- /dev/null +++ b/DotPilot/Presentation/Diagnostics/PresentationLog.cs @@ -0,0 +1,172 @@ +using Microsoft.Extensions.Logging; + +namespace DotPilot.Presentation; + +internal static partial class ChatModelLog +{ + [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 = 2005, + Level = LogLevel.Information, + Message = "Sending chat message from the shell. SessionId={SessionId} CharacterCount={CharacterCount}.")] + public static partial void SendRequested(ILogger logger, string sessionId, int characterCount); + + [LoggerMessage( + EventId = 2006, + Level = LogLevel.Information, + Message = "Chat shell send completed. SessionId={SessionId}.")] + public static partial void SendCompleted(ILogger logger, string sessionId); + + [LoggerMessage( + EventId = 2008, + Level = LogLevel.Information, + Message = "Ignoring shell send because the submitted message is empty after normalization.")] + public static partial void SendIgnoredEmpty(ILogger logger); + + [LoggerMessage( + EventId = 2009, + Level = LogLevel.Warning, + Message = "Ignoring shell send because no agent is available to start or continue a session.")] + public static partial void SendIgnoredNoAgents(ILogger logger); + + [LoggerMessage(EventId = 2007, Level = LogLevel.Error, Message = "Chat shell operation failed.")] + public static partial void Failure(ILogger logger, Exception exception); +} + +internal static partial class AgentBuilderModelLog +{ + [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.Information, + Message = "Creating local agent profile. Name={AgentName} Provider={ProviderKind} Model={ModelName}.")] + public static partial void AgentCreationRequested( + ILogger logger, + string agentName, + AgentProviderKind providerKind, + string modelName); + + [LoggerMessage( + EventId = 2103, + Level = LogLevel.Information, + Message = "Created local agent profile. AgentId={AgentId} Name={AgentName} Provider={ProviderKind} Model={ModelName}.")] + public static partial void AgentCreated( + ILogger logger, + Guid agentId, + string agentName, + AgentProviderKind providerKind, + string modelName); + + [LoggerMessage( + EventId = 2104, + Level = LogLevel.Information, + Message = "Generating prompt-based agent draft. PromptCharacters={PromptCharacterCount}.")] + public static partial void DraftGenerationRequested(ILogger logger, int promptCharacterCount); + + [LoggerMessage(EventId = 2105, Level = LogLevel.Information, Message = "Created manual agent draft.")] + public static partial void ManualDraftCreated(ILogger logger); + + [LoggerMessage( + EventId = 2106, + Level = LogLevel.Information, + Message = "Starting a chat session from the agent catalog. AgentId={AgentId} Name={AgentName}.")] + public static partial void ChatSessionRequested(ILogger logger, Guid agentId, string agentName); + + [LoggerMessage( + EventId = 2108, + Level = LogLevel.Information, + Message = "Updating local agent profile. AgentId={AgentId} Name={AgentName} Provider={ProviderKind} Model={ModelName}.")] + public static partial void AgentUpdateRequested( + ILogger logger, + Guid agentId, + string agentName, + AgentProviderKind providerKind, + string modelName); + + [LoggerMessage( + EventId = 2109, + Level = LogLevel.Information, + Message = "Updated local agent profile. AgentId={AgentId} Name={AgentName} Provider={ProviderKind} Model={ModelName}.")] + public static partial void AgentUpdated( + ILogger logger, + Guid agentId, + string agentName, + AgentProviderKind providerKind, + string modelName); + + [LoggerMessage(EventId = 2107, Level = LogLevel.Error, Message = "Agent builder operation failed.")] + public static partial void Failure(ILogger logger, Exception exception); +} + +internal static partial class SettingsModelLog +{ + [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.Information, + Message = "Selected provider from settings. Provider={ProviderKind} DisplayName={DisplayName}.")] + public static partial void ProviderSelected(ILogger logger, AgentProviderKind providerKind, string displayName); + + [LoggerMessage(EventId = 2204, 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); +} + +internal static partial class ShellSleepPreventionLog +{ + [LoggerMessage( + EventId = 2301, + Level = LogLevel.Information, + Message = "Acquired desktop sleep prevention using {Mechanism}.")] + public static partial void Acquired(ILogger logger, string mechanism); + + [LoggerMessage( + EventId = 2302, + Level = LogLevel.Information, + Message = "Released desktop sleep prevention.")] + public static partial void Released(ILogger logger); + + [LoggerMessage( + EventId = 2303, + Level = LogLevel.Error, + Message = "Could not acquire desktop sleep prevention.")] + public static partial void AcquireFailed(ILogger logger, Exception exception); +} diff --git a/DotPilot/Presentation/Infrastructure/AsyncCommand.cs b/DotPilot/Presentation/Infrastructure/AsyncCommand.cs new file mode 100644 index 0000000..5828915 --- /dev/null +++ b/DotPilot/Presentation/Infrastructure/AsyncCommand.cs @@ -0,0 +1,86 @@ +using Microsoft.UI.Dispatching; + +namespace DotPilot.Presentation; + +public sealed class AsyncCommand( + Func executeAsync, + Func? canExecute = null) : ICommand +{ + private bool _isExecuting; + private readonly Func _executeAsync = + executeAsync ?? throw new ArgumentNullException(nameof(executeAsync)); + private readonly Func? _canExecute = canExecute; + private readonly DispatcherQueue? _dispatcherQueue = TryGetDispatcherQueue(); + + public AsyncCommand(Func executeAsync, Func? canExecute = null) + : this( + _ => new ValueTask(executeAsync()), + canExecute is null ? null : _ => canExecute()) + { + } + + public AsyncCommand(Func executeAsync, Func? canExecute = null) + : this( + _ => executeAsync(), + canExecute is null ? null : _ => canExecute()) + { + } + + public AsyncCommand(Func executeAsync, Func? canExecute = null) + : this( + parameter => new ValueTask(executeAsync(parameter)), + 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() + { + if (_dispatcherQueue is null || _dispatcherQueue.HasThreadAccess) + { + CanExecuteChanged?.Invoke(this, EventArgs.Empty); + return; + } + + _dispatcherQueue.TryEnqueue(() => CanExecuteChanged?.Invoke(this, EventArgs.Empty)); + } + + private static DispatcherQueue? TryGetDispatcherQueue() + { + try + { + return DispatcherQueue.GetForCurrentThread(); + } + catch (NotSupportedException) + { + return null; + } + } +} diff --git a/DotPilot/Presentation/Infrastructure/Controls/BoundCommandBridge.cs b/DotPilot/Presentation/Infrastructure/Controls/BoundCommandBridge.cs new file mode 100644 index 0000000..7dda67d --- /dev/null +++ b/DotPilot/Presentation/Infrastructure/Controls/BoundCommandBridge.cs @@ -0,0 +1,14 @@ +namespace DotPilot.Presentation.Controls; + +internal static class BoundCommandBridge +{ + public static void Execute(ICommand? command, object? parameter = null) + { + if (command?.CanExecute(parameter) != true) + { + return; + } + + command.Execute(parameter); + } +} diff --git a/DotPilot/Presentation/ObservableObject.cs b/DotPilot/Presentation/Infrastructure/ObservableObject.cs similarity index 100% rename from DotPilot/Presentation/ObservableObject.cs rename to DotPilot/Presentation/Infrastructure/ObservableObject.cs diff --git a/DotPilot/Presentation/PresentationAutomationIds.cs b/DotPilot/Presentation/Infrastructure/PresentationAutomationIds.cs similarity index 100% rename from DotPilot/Presentation/PresentationAutomationIds.cs rename to DotPilot/Presentation/Infrastructure/PresentationAutomationIds.cs diff --git a/DotPilot/Presentation/Infrastructure/UiDispatcher.cs b/DotPilot/Presentation/Infrastructure/UiDispatcher.cs new file mode 100644 index 0000000..93d3581 --- /dev/null +++ b/DotPilot/Presentation/Infrastructure/UiDispatcher.cs @@ -0,0 +1,33 @@ +using Microsoft.UI.Dispatching; + +namespace DotPilot.Presentation; + +public sealed class UiDispatcher +{ + private readonly DispatcherQueue? dispatcherQueue = TryGetDispatcherQueue(); + + public void Execute(Action action) + { + ArgumentNullException.ThrowIfNull(action); + + if (dispatcherQueue is null || dispatcherQueue.HasThreadAccess) + { + action(); + return; + } + + _ = dispatcherQueue.TryEnqueue(() => action()); + } + + private static DispatcherQueue? TryGetDispatcherQueue() + { + try + { + return DispatcherQueue.GetForCurrentThread(); + } + catch (NotSupportedException) + { + return null; + } + } +} diff --git a/DotPilot/Presentation/MainPage.xaml b/DotPilot/Presentation/MainPage.xaml deleted file mode 100644 index 7c2e004..0000000 --- a/DotPilot/Presentation/MainPage.xaml +++ /dev/null @@ -1,52 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/DotPilot/Presentation/MainPage.xaml.cs b/DotPilot/Presentation/MainPage.xaml.cs deleted file mode 100644 index f24979e..0000000 --- a/DotPilot/Presentation/MainPage.xaml.cs +++ /dev/null @@ -1,19 +0,0 @@ -namespace DotPilot.Presentation; - -public sealed partial class MainPage : Page -{ - public MainPage() - { - try - { - BrowserConsoleDiagnostics.Info("[DotPilot.Startup] MainPage constructor started."); - InitializeComponent(); - BrowserConsoleDiagnostics.Info("[DotPilot.Startup] MainPage constructor completed."); - } - catch (Exception exception) - { - BrowserConsoleDiagnostics.Error($"[DotPilot.Startup] MainPage constructor failed: {exception}"); - throw; - } - } -} diff --git a/DotPilot/Presentation/MainViewModel.cs b/DotPilot/Presentation/MainViewModel.cs deleted file mode 100644 index dc60147..0000000 --- a/DotPilot/Presentation/MainViewModel.cs +++ /dev/null @@ -1,281 +0,0 @@ -using System.Collections.Frozen; - -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) - { - 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; - } - } - - public WorkbenchSnapshot Snapshot { get; } - - public RuntimeFoundationSnapshot RuntimeFoundation { get; } - - public string EpicLabel { get; } - - public string PageTitle => Snapshot.SessionTitle; - - public string WorkspaceName => Snapshot.WorkspaceName; - - public string WorkspaceRoot => Snapshot.WorkspaceRoot; - - public string SearchPlaceholder => Snapshot.SearchPlaceholder; - - public string SessionStage => Snapshot.SessionStage; - - public string SessionSummary => Snapshot.SessionSummary; - - public IReadOnlyList SessionEntries => Snapshot.SessionEntries; - - public IReadOnlyList Artifacts => Snapshot.Artifacts; - - public IReadOnlyList Logs => Snapshot.Logs; - - public IReadOnlyList FilteredRepositoryNodes - { - get => _filteredRepositoryNodes; - private set - { - if (ReferenceEquals(_filteredRepositoryNodes, value)) - { - return; - } - - _filteredRepositoryNodes = value; - RaisePropertyChanged(); - RaisePropertyChanged(nameof(RepositoryResultSummary)); - } - } - - public string RepositoryResultSummary => $"{FilteredRepositoryNodes.Count} items"; - - public string RepositorySearchText - { - get => _repositorySearchText; - set - { - if (!SetProperty(ref _repositorySearchText, value)) - { - return; - } - - UpdateFilteredRepositoryNodes(); - } - } - - public WorkbenchRepositoryNodeItem? SelectedRepositoryNode - { - get => _selectedRepositoryNode; - set => SetSelectedRepositoryNode(value); - } - - public string SelectedDocumentTitle => _selectedDocument?.Title ?? DefaultDocumentTitle; - - public string SelectedDocumentPath => _selectedDocument?.RelativePath ?? DefaultDocumentPath; - - public string SelectedDocumentStatus => _selectedDocument?.StatusSummary ?? DefaultDocumentStatus; - - public string SelectedDocumentLanguage => _selectedDocument?.LanguageLabel ?? DefaultLanguageLabel; - - public string SelectedDocumentRenderer => _selectedDocument?.RendererLabel ?? DefaultRendererLabel; - - public bool SelectedDocumentIsReadOnly => _selectedDocument?.IsReadOnly ?? true; - - public IReadOnlyList SelectedDocumentDiffLines => _selectedDocument?.DiffLines ?? []; - - public string EditablePreviewContent - { - get => _editablePreviewContent; - set => SetProperty(ref _editablePreviewContent, value); - } - - public bool IsDiffReviewMode - { - get => _isDiffReviewMode; - set - { - if (!SetProperty(ref _isDiffReviewMode, value)) - { - return; - } - - RaisePropertyChanged(nameof(IsPreviewMode)); - } - } - - public bool IsPreviewMode => !IsDiffReviewMode; - - public bool IsLogConsoleVisible - { - get => _isLogConsoleVisible; - set - { - if (!SetProperty(ref _isLogConsoleVisible, value)) - { - return; - } - - RaisePropertyChanged(nameof(IsArtifactsVisible)); - RaisePropertyChanged(nameof(InspectorTitle)); - RaisePropertyChanged(nameof(InspectorSummary)); - } - } - - public bool IsArtifactsVisible => !IsLogConsoleVisible; - - public string InspectorTitle => IsLogConsoleVisible ? DefaultInspectorLogsTitle : DefaultInspectorArtifactsTitle; - - public string InspectorSummary => IsLogConsoleVisible ? DefaultInspectorLogsSummary : DefaultInspectorArtifactsSummary; - - private static WorkbenchRepositoryNodeItem MapRepositoryNode(WorkbenchRepositoryNode node) - { - 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); - } - - private void UpdateFilteredRepositoryNodes() - { - 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)) - { - SetSelectedRepositoryNode(FindFirstOpenableNode(FilteredRepositoryNodes)); - } - } - - private static WorkbenchRepositoryNodeItem? FindFirstOpenableNode(IReadOnlyList nodes) - { - for (var index = 0; index < nodes.Count; index++) - { - if (nodes[index].CanOpen) - { - return nodes[index]; - } - } - - return nodes.Count > 0 ? nodes[0] : null; - } - - private static WorkbenchRepositoryNodeItem? FindNodeByRelativePath( - IReadOnlyList nodes, - string relativePath) - { - for (var index = 0; index < nodes.Count; index++) - { - if (nodes[index].RelativePath.Equals(relativePath, StringComparison.OrdinalIgnoreCase)) - { - return nodes[index]; - } - } - - return null; - } - - private void SetSelectedRepositoryNode(WorkbenchRepositoryNodeItem? value) - { - if (!SetProperty(ref _selectedRepositoryNode, value, nameof(SelectedRepositoryNode))) - { - return; - } - - if (value?.CanOpen != true || - !_documentsByPath.TryGetValue(value.RelativePath, out var selectedDocument)) - { - return; - } - - _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)); - } -} diff --git a/DotPilot/Presentation/SecondPage.xaml b/DotPilot/Presentation/SecondPage.xaml deleted file mode 100644 index bf8e211..0000000 --- a/DotPilot/Presentation/SecondPage.xaml +++ /dev/null @@ -1,116 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/DotPilot/Presentation/SecondPage.xaml.cs b/DotPilot/Presentation/SecondPage.xaml.cs deleted file mode 100644 index 30e269a..0000000 --- a/DotPilot/Presentation/SecondPage.xaml.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace DotPilot.Presentation; - -public sealed partial class SecondPage : Page -{ - public SecondPage() - { - InitializeComponent(); - } -} diff --git a/DotPilot/Presentation/SecondViewModel.cs b/DotPilot/Presentation/SecondViewModel.cs deleted file mode 100644 index 412b8fb..0000000 --- a/DotPilot/Presentation/SecondViewModel.cs +++ /dev/null @@ -1,70 +0,0 @@ -namespace DotPilot.Presentation; - -public sealed class SecondViewModel -{ - public SecondViewModel(IRuntimeFoundationCatalog runtimeFoundationCatalog) - { - ArgumentNullException.ThrowIfNull(runtimeFoundationCatalog); - RuntimeFoundation = runtimeFoundationCatalog.GetSnapshot(); - } - - 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), - ]; -} diff --git a/DotPilot/Presentation/Settings/Controls/SettingsShell.xaml b/DotPilot/Presentation/Settings/Controls/SettingsShell.xaml new file mode 100644 index 0000000..5ade550 --- /dev/null +++ b/DotPilot/Presentation/Settings/Controls/SettingsShell.xaml @@ -0,0 +1,417 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/DotPilot/Presentation/Settings/Controls/SettingsShell.xaml.cs b/DotPilot/Presentation/Settings/Controls/SettingsShell.xaml.cs new file mode 100644 index 0000000..8980028 --- /dev/null +++ b/DotPilot/Presentation/Settings/Controls/SettingsShell.xaml.cs @@ -0,0 +1,68 @@ +namespace DotPilot.Presentation.Controls; + +public sealed partial class SettingsShell : UserControl +{ + public static readonly DependencyProperty SelectProviderCommandProperty = + DependencyProperty.Register( + nameof(SelectProviderCommand), + typeof(ICommand), + typeof(SettingsShell), + new PropertyMetadata(null)); + + public static readonly DependencyProperty ToggleSelectedProviderCommandProperty = + DependencyProperty.Register( + nameof(ToggleSelectedProviderCommand), + typeof(ICommand), + typeof(SettingsShell), + new PropertyMetadata(null)); + + public static readonly DependencyProperty ExecuteProviderActionCommandProperty = + DependencyProperty.Register( + nameof(ExecuteProviderActionCommand), + typeof(ICommand), + typeof(SettingsShell), + new PropertyMetadata(null)); + + public SettingsShell() + { + InitializeComponent(); + } + + public ICommand? SelectProviderCommand + { + get => (ICommand?)GetValue(SelectProviderCommandProperty); + set => SetValue(SelectProviderCommandProperty, value); + } + + public ICommand? ToggleSelectedProviderCommand + { + get => (ICommand?)GetValue(ToggleSelectedProviderCommandProperty); + set => SetValue(ToggleSelectedProviderCommandProperty, value); + } + + public ICommand? ExecuteProviderActionCommand + { + get => (ICommand?)GetValue(ExecuteProviderActionCommandProperty); + set => SetValue(ExecuteProviderActionCommandProperty, value); + } + + private void OnProviderButtonClick(object sender, RoutedEventArgs e) + { + var provider = (sender as FrameworkElement)?.Tag as ProviderStatusItem; + BrowserConsoleDiagnostics.Info( + $"[DotPilot.Settings] Provider entry clicked. Provider={provider?.Kind.ToString() ?? ""}."); + BoundCommandBridge.Execute(SelectProviderCommand, provider); + } + + private void OnToggleProviderButtonClick(object sender, RoutedEventArgs e) + { + BrowserConsoleDiagnostics.Info("[DotPilot.Settings] Toggle provider click received."); + BoundCommandBridge.Execute(ToggleSelectedProviderCommand); + } + + private void OnProviderActionButtonClick(object sender, RoutedEventArgs e) + { + BrowserConsoleDiagnostics.Info("[DotPilot.Settings] Provider action click received."); + BoundCommandBridge.Execute(ExecuteProviderActionCommand, (sender as FrameworkElement)?.Tag); + } +} diff --git a/DotPilot/Presentation/Settings/Models/OperatorPreferencesJsonSerializerContext.cs b/DotPilot/Presentation/Settings/Models/OperatorPreferencesJsonSerializerContext.cs new file mode 100644 index 0000000..f052beb --- /dev/null +++ b/DotPilot/Presentation/Settings/Models/OperatorPreferencesJsonSerializerContext.cs @@ -0,0 +1,10 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace DotPilot.Presentation; + +[JsonSourceGenerationOptions(JsonSerializerDefaults.Web)] +[JsonSerializable(typeof(OperatorPreferencesDto))] +internal sealed partial class OperatorPreferencesJsonSerializerContext : JsonSerializerContext +{ +} diff --git a/DotPilot/Presentation/Settings/Models/OperatorPreferencesModels.cs b/DotPilot/Presentation/Settings/Models/OperatorPreferencesModels.cs new file mode 100644 index 0000000..5fc2845 --- /dev/null +++ b/DotPilot/Presentation/Settings/Models/OperatorPreferencesModels.cs @@ -0,0 +1,10 @@ +namespace DotPilot.Presentation; + +public enum ComposerSendBehavior +{ + EnterSends = 0, + EnterInsertsNewLine = 1, +} + +public sealed record OperatorPreferencesSnapshot( + ComposerSendBehavior ComposerSendBehavior); diff --git a/DotPilot/Presentation/Settings/Models/SettingsSection.cs b/DotPilot/Presentation/Settings/Models/SettingsSection.cs new file mode 100644 index 0000000..f25f9e7 --- /dev/null +++ b/DotPilot/Presentation/Settings/Models/SettingsSection.cs @@ -0,0 +1,7 @@ +namespace DotPilot.Presentation; + +public enum SettingsSection +{ + Providers = 0, + Messages = 1, +} diff --git a/DotPilot/Presentation/Settings/Services/IOperatorPreferencesStore.cs b/DotPilot/Presentation/Settings/Services/IOperatorPreferencesStore.cs new file mode 100644 index 0000000..285499c --- /dev/null +++ b/DotPilot/Presentation/Settings/Services/IOperatorPreferencesStore.cs @@ -0,0 +1,10 @@ +namespace DotPilot.Presentation; + +public interface IOperatorPreferencesStore +{ + ValueTask GetAsync(CancellationToken cancellationToken); + + ValueTask SetAsync( + ComposerSendBehavior behavior, + CancellationToken cancellationToken); +} diff --git a/DotPilot/Presentation/Settings/Services/LocalOperatorPreferencesStore.cs b/DotPilot/Presentation/Settings/Services/LocalOperatorPreferencesStore.cs new file mode 100644 index 0000000..d130fd2 --- /dev/null +++ b/DotPilot/Presentation/Settings/Services/LocalOperatorPreferencesStore.cs @@ -0,0 +1,118 @@ +using System.Text.Json; + +namespace DotPilot.Presentation; + +public sealed class LocalOperatorPreferencesStore : IOperatorPreferencesStore, IDisposable +{ + private const string PreferencesDirectoryName = "dotPilot"; + private const string PreferencesFileName = "operator-preferences.json"; + private readonly SemaphoreSlim _gate = new(1, 1); + + public async ValueTask GetAsync(CancellationToken cancellationToken) + { + await _gate.WaitAsync(cancellationToken); + try + { + return await LoadFromDiskAsync(cancellationToken); + } + finally + { + _gate.Release(); + } + } + + public async ValueTask SetAsync( + ComposerSendBehavior behavior, + CancellationToken cancellationToken) + { + var snapshot = new OperatorPreferencesSnapshot(behavior); + + await _gate.WaitAsync(cancellationToken); + try + { + await SaveToDiskAsync(snapshot, cancellationToken); + return snapshot; + } + finally + { + _gate.Release(); + } + } + + private static async ValueTask LoadFromDiskAsync(CancellationToken cancellationToken) + { + var filePath = ResolvePreferencesFilePath(); + if (!File.Exists(filePath)) + { + return CreateDefaultSnapshot(); + } + + try + { + var json = await File.ReadAllTextAsync(filePath, cancellationToken); + var dto = JsonSerializer.Deserialize( + json, + OperatorPreferencesJsonSerializerContext.Default.OperatorPreferencesDto); + return dto?.ToSnapshot() ?? CreateDefaultSnapshot(); + } + catch + { + return CreateDefaultSnapshot(); + } + } + + private static async ValueTask SaveToDiskAsync( + OperatorPreferencesSnapshot snapshot, + CancellationToken cancellationToken) + { + var filePath = ResolvePreferencesFilePath(); + var directory = Path.GetDirectoryName(filePath); + if (!string.IsNullOrWhiteSpace(directory)) + { + Directory.CreateDirectory(directory); + } + + var dto = OperatorPreferencesDto.FromSnapshot(snapshot); + var json = JsonSerializer.Serialize( + dto, + OperatorPreferencesJsonSerializerContext.Default.OperatorPreferencesDto); + await File.WriteAllTextAsync(filePath, json, cancellationToken); + } + + private static string ResolvePreferencesFilePath() + { + var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + var baseDirectory = string.IsNullOrWhiteSpace(localAppData) + ? Path.Combine(AppContext.BaseDirectory, ".local") + : localAppData; + return Path.Combine(baseDirectory, PreferencesDirectoryName, PreferencesFileName); + } + + private static OperatorPreferencesSnapshot CreateDefaultSnapshot() + { + return new OperatorPreferencesSnapshot(ComposerSendBehavior.EnterSends); + } + + public void Dispose() + { + _gate.Dispose(); + } +} + +internal sealed record OperatorPreferencesDto(string ComposerSendBehavior) +{ + public OperatorPreferencesSnapshot ToSnapshot() + { + return Enum.TryParse( + ComposerSendBehavior, + ignoreCase: true, + out var behavior) + ? new OperatorPreferencesSnapshot(behavior) + : new OperatorPreferencesSnapshot(global::DotPilot.Presentation.ComposerSendBehavior.EnterSends); + } + + public static OperatorPreferencesDto FromSnapshot(OperatorPreferencesSnapshot snapshot) + { + return new OperatorPreferencesDto(snapshot.ComposerSendBehavior.ToString()); + } +} diff --git a/DotPilot/Presentation/Settings/ViewModels/SettingsModel.cs b/DotPilot/Presentation/Settings/ViewModels/SettingsModel.cs new file mode 100644 index 0000000..6e22c08 --- /dev/null +++ b/DotPilot/Presentation/Settings/ViewModels/SettingsModel.cs @@ -0,0 +1,569 @@ +using System.Collections.Immutable; +using Microsoft.Extensions.Logging; +using Microsoft.UI.Xaml.Data; +using Windows.ApplicationModel.DataTransfer; + +namespace DotPilot.Presentation; + +[Bindable] +public partial record SettingsModel +{ + private const string ProvidersSectionKey = "Providers"; + private const string MessagesSectionKey = "Messages"; + private const string ProviderEntryAutomationIdPrefix = "ProviderEntry_"; + private const string RefreshCompletedMessage = "Provider readiness refreshed."; + private const string ComposerBehaviorSavedMessage = "Message send behavior updated."; + private const string SelectProviderTitleValue = "Select a provider"; + private const string SelectProviderSummaryValue = "Choose a provider to inspect readiness and install guidance."; + private const string SettingsTitleValue = "Settings"; + private const string SettingsSubtitleValue = "Tune providers and message behavior for the local desktop operator."; + private const string EnableProviderLabel = "Enable provider"; + private const string DisableProviderLabel = "Disable provider"; + private static readonly ProviderStatusItem EmptySelectedProvider = new( + AgentProviderKind.Debug, + string.Empty, + string.Empty, + string.Empty, + null, + false, + false, + [], + [], + string.Empty, + false); + + private readonly IAgentWorkspaceState workspaceState; + private readonly IOperatorPreferencesStore operatorPreferencesStore; + private readonly WorkspaceProjectionNotifier workspaceProjectionNotifier; + private readonly ILogger logger; + private AsyncCommand? _refreshCommand; + private AsyncCommand? _toggleSelectedProviderCommand; + private AsyncCommand? _selectProviderCommand; + private AsyncCommand? _executeProviderActionCommand; + private AsyncCommand? _selectSectionCommand; + private AsyncCommand? _selectComposerSendBehaviorCommand; + private readonly Signal _workspaceRefresh = new(); + + public SettingsModel( + IAgentWorkspaceState workspaceState, + IOperatorPreferencesStore operatorPreferencesStore, + WorkspaceProjectionNotifier workspaceProjectionNotifier, + ILogger logger) + { + this.workspaceState = workspaceState; + this.operatorPreferencesStore = operatorPreferencesStore; + this.workspaceProjectionNotifier = workspaceProjectionNotifier; + this.logger = logger; + workspaceProjectionNotifier.Changed += OnWorkspaceProjectionChanged; + } + + public string PageTitle => SettingsTitleValue; + + public string PageSubtitle => SettingsSubtitleValue; + + public string EnterSendsOptionTitle => ChatComposerSendBehaviorText.GetTitle(ComposerSendBehavior.EnterSends); + + public string EnterSendsOptionSummary => ChatComposerSendBehaviorText.GetSummary(ComposerSendBehavior.EnterSends); + + public string EnterInsertsNewLineOptionTitle => ChatComposerSendBehaviorText.GetTitle(ComposerSendBehavior.EnterInsertsNewLine); + + public string EnterInsertsNewLineOptionSummary => ChatComposerSendBehaviorText.GetSummary(ComposerSendBehavior.EnterInsertsNewLine); + + public IListState Providers => ListState.Async(this, LoadProvidersAsync, _workspaceRefresh); + + public IState SelectedSection => State.Value(this, static () => SettingsSection.Providers); + + public IState IsProvidersSectionSelected => State.Value(this, static () => true); + + public IState IsMessagesSectionSelected => State.Value(this, static () => false); + + public IState ShowProvidersSection => State.Value(this, static () => true); + + public IState ShowMessagesSection => State.Value(this, static () => false); + + public IState SelectedProvider => State.Value(this, static () => EmptySelectedProvider); + + public IState SelectedProviderTitle => State.Value(this, static () => SelectProviderTitleValue); + + public IState SelectedProviderSummary => State.Value(this, static () => SelectProviderSummaryValue); + + public IState SelectedProviderCommandName => State.Value(this, static () => string.Empty); + + public IState> SelectedProviderDetails => State.Value(this, static () => ImmutableArray.Empty); + + public IState HasSelectedProviderDetails => State.Value(this, static () => false); + + public IState> SelectedProviderActions => State.Value(this, static () => ImmutableArray.Empty); + + public IState HasSelectedProviderActions => State.Value(this, static () => false); + + public IState ToggleActionLabel => State.Value(this, static () => EnableProviderLabel); + + public IState StatusMessage => State.Value(this, static () => string.Empty); + + public IState CanToggleSelectedProvider => State.Value(this, static () => false); + + public IState SelectedComposerSendBehavior => State.Value(this, static () => ComposerSendBehavior.EnterSends); + + public IState ComposerSendBehaviorHint => State.Value( + this, + static () => ChatComposerSendBehaviorText.GetHint(ComposerSendBehavior.EnterSends)); + + public IState IsEnterSendsSelected => State.Value(this, static () => true); + + public IState IsEnterInsertsNewLineSelected => State.Value(this, static () => false); + + public ICommand RefreshCommand => + _refreshCommand ??= new AsyncCommand( + () => Refresh(CancellationToken.None)); + + public ICommand ToggleSelectedProviderCommand => + _toggleSelectedProviderCommand ??= new AsyncCommand( + () => ToggleSelectedProvider(CancellationToken.None)); + + public ICommand SelectProviderCommand => + _selectProviderCommand ??= new AsyncCommand( + parameter => SelectProvider(parameter as ProviderStatusItem, CancellationToken.None)); + + public ICommand ExecuteProviderActionCommand => + _executeProviderActionCommand ??= new AsyncCommand( + parameter => ExecuteProviderAction(parameter as ProviderActionItem, CancellationToken.None)); + + public ICommand SelectSectionCommand => + _selectSectionCommand ??= new AsyncCommand( + parameter => SelectSection(parameter as string, CancellationToken.None)); + + public ICommand SelectComposerSendBehaviorCommand => + _selectComposerSendBehaviorCommand ??= new AsyncCommand( + parameter => SelectComposerSendBehavior(parameter as string, CancellationToken.None)); + + public async ValueTask Refresh(CancellationToken cancellationToken) + { + try + { + SettingsModelLog.RefreshRequested(logger); + var workspaceResult = await workspaceState.RefreshWorkspaceAsync(cancellationToken); + if (!workspaceResult.TryGetValue(out var workspace)) + { + await StatusMessage.SetAsync(workspaceResult.ToOperatorMessage("Could not refresh provider readiness."), cancellationToken); + return; + } + + var providers = MapProviderStatusItems(workspace.Providers, selectedProvider: null); + await EnsureSelectedProviderAsync(workspace, providers, cancellationToken); + _workspaceRefresh.Raise(); + await StatusMessage.SetAsync(RefreshCompletedMessage, cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + } + catch (Exception exception) + { + SettingsModelLog.Failure(logger, exception); + await StatusMessage.SetAsync(exception.Message, cancellationToken); + } + } + + public async ValueTask ToggleSelectedProvider(CancellationToken cancellationToken) + { + var selectedProvider = (await SelectedProvider) ?? EmptySelectedProvider; + if (IsEmptySelectedProvider(selectedProvider)) + { + return; + } + + try + { + var currentSelectedProvider = await SelectedProvider ?? EmptySelectedProvider; + var updatedResult = await workspaceState.UpdateProviderAsync( + new UpdateProviderPreferenceCommand(currentSelectedProvider.Kind, !currentSelectedProvider.IsEnabled), + cancellationToken); + if (!updatedResult.TryGetValue(out var updated)) + { + await StatusMessage.SetAsync(updatedResult.ToOperatorMessage("Could not update provider state."), cancellationToken); + return; + } + + var workspaceResult = await workspaceState.GetWorkspaceAsync(cancellationToken); + if (!workspaceResult.TryGetValue(out var workspace)) + { + await StatusMessage.SetAsync(workspaceResult.ToOperatorMessage("Could not reload workspace."), cancellationToken); + return; + } + + var providers = MapProviderStatusItems(workspace.Providers, selectedProvider: null); + await EnsureSelectedProviderAsync(workspace, providers, cancellationToken); + _workspaceRefresh.Raise(); + await StatusMessage.SetAsync($"{updated.DisplayName} updated.", cancellationToken); + workspaceProjectionNotifier.Publish(); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + } + catch (Exception exception) + { + SettingsModelLog.Failure(logger, exception); + await StatusMessage.SetAsync(exception.Message, cancellationToken); + } + } + + public async ValueTask SelectProvider(ProviderStatusItem? provider, CancellationToken cancellationToken) + { + if (provider is null || IsEmptySelectedProvider(provider)) + { + return; + } + + await SetSelectedProviderAsync(provider, cancellationToken); + _workspaceRefresh.Raise(); + } + + public async ValueTask SelectSection(string? sectionKey, CancellationToken cancellationToken) + { + if (!TryParseSection(sectionKey, out var section)) + { + return; + } + + await SetSelectedSectionAsync(section, cancellationToken); + } + + public async ValueTask SelectComposerSendBehavior(string? behaviorKey, CancellationToken cancellationToken) + { + if (!TryParseComposerSendBehavior(behaviorKey, out var behavior)) + { + return; + } + + try + { + var preferences = await operatorPreferencesStore.SetAsync(behavior, cancellationToken); + await SynchronizeComposerSendBehaviorAsync(preferences, cancellationToken); + await StatusMessage.SetAsync(ComposerBehaviorSavedMessage, cancellationToken); + _workspaceRefresh.Raise(); + workspaceProjectionNotifier.Publish(); + } + catch (Exception exception) + { + SettingsModelLog.Failure(logger, exception); + await StatusMessage.SetAsync(exception.Message, cancellationToken); + } + } + + public async ValueTask ExecuteProviderAction(ProviderActionItem? action, CancellationToken cancellationToken) + { + if (action is null) + { + return; + } + + if (string.IsNullOrWhiteSpace(action.Command)) + { + await StatusMessage.SetAsync(action.Summary, cancellationToken); + return; + } + + try + { + var dataPackage = new DataPackage(); + dataPackage.SetText(action.Command); + Clipboard.SetContent(dataPackage); + Clipboard.Flush(); + await StatusMessage.SetAsync($"Copied command: {action.Command}", cancellationToken); + } + catch (Exception) + { + await StatusMessage.SetAsync($"Run this command in your terminal: {action.Command}", cancellationToken); + } + } + + private async ValueTask> LoadProvidersAsync(CancellationToken cancellationToken) + { + try + { + SettingsModelLog.LoadingProviders(logger); + var workspaceResult = await workspaceState.GetWorkspaceAsync(cancellationToken); + if (!workspaceResult.TryGetValue(out var workspace)) + { + await StatusMessage.SetAsync(workspaceResult.ToOperatorMessage("Could not load providers."), cancellationToken); + return ImmutableArray.Empty; + } + + SettingsModelLog.ProvidersLoaded(logger, workspace.Providers.Count); + var providers = MapProviderStatusItems(workspace.Providers, selectedProvider: (await SelectedProvider) ?? EmptySelectedProvider); + var selectedProvider = await EnsureSelectedProviderAsync(workspace, providers, cancellationToken); + return MapProviderStatusItems(workspace.Providers, selectedProvider); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + return ImmutableArray.Empty; + } + catch (Exception exception) + { + SettingsModelLog.Failure(logger, exception); + await StatusMessage.SetAsync(exception.Message, cancellationToken); + return ImmutableArray.Empty; + } + } + + private async ValueTask EnsureSelectedProviderAsync( + AgentWorkspaceSnapshot workspace, + IImmutableList providers, + CancellationToken cancellationToken) + { + var selectedProvider = (await SelectedProvider) ?? EmptySelectedProvider; + var resolvedProvider = IsEmptySelectedProvider(selectedProvider) + ? EmptySelectedProvider + : FindProviderByKind(providers, selectedProvider.Kind); + + if (IsEmptySelectedProvider(resolvedProvider)) + { + var enabledProviderKind = FindEnabledProviderKind(workspace.Providers); + if (enabledProviderKind is { } providerKind) + { + resolvedProvider = FindProviderByKind(providers, providerKind); + } + } + + if (IsEmptySelectedProvider(resolvedProvider) && providers.Count > 0) + { + resolvedProvider = providers[0]; + } + + await SetSelectedProviderAsync(resolvedProvider, cancellationToken); + return resolvedProvider; + } + + private async ValueTask SetSelectedProviderAsync( + ProviderStatusItem selectedProvider, + CancellationToken cancellationToken) + { + var currentProvider = (await SelectedProvider) ?? EmptySelectedProvider; + if (!AreSameProvider(currentProvider, selectedProvider)) + { + await SelectedProvider.UpdateAsync(_ => selectedProvider, cancellationToken); + } + + if (!IsEmptySelectedProvider(selectedProvider)) + { + SettingsModelLog.ProviderSelected(logger, selectedProvider.Kind, selectedProvider.DisplayName); + } + + await SynchronizeSelectedProviderProjectionAsync(selectedProvider, cancellationToken); + } + + private async ValueTask SynchronizeSelectedProviderProjectionAsync( + ProviderStatusItem? selectedProvider, + CancellationToken cancellationToken) + { + selectedProvider ??= EmptySelectedProvider; + var actions = IsEmptySelectedProvider(selectedProvider) + ? ImmutableArray.Empty + : selectedProvider.Actions.ToImmutableArray(); + var details = IsEmptySelectedProvider(selectedProvider) + ? ImmutableArray.Empty + : selectedProvider.Details.ToImmutableArray(); + + await SelectedProviderTitle.SetAsync( + IsEmptySelectedProvider(selectedProvider) ? SelectProviderTitleValue : selectedProvider.DisplayName, + cancellationToken); + await SelectedProviderSummary.SetAsync( + IsEmptySelectedProvider(selectedProvider) ? SelectProviderSummaryValue : selectedProvider.StatusSummary, + cancellationToken); + await SelectedProviderCommandName.SetAsync( + IsEmptySelectedProvider(selectedProvider) ? string.Empty : selectedProvider.CommandName, + cancellationToken); + await SelectedProviderDetails.UpdateAsync(_ => details, cancellationToken); + await HasSelectedProviderDetails.UpdateAsync(_ => details.Length > 0, cancellationToken); + await SelectedProviderActions.UpdateAsync(_ => actions, cancellationToken); + await HasSelectedProviderActions.UpdateAsync(_ => actions.Length > 0, cancellationToken); + await ToggleActionLabel.SetAsync( + !IsEmptySelectedProvider(selectedProvider) && selectedProvider.IsEnabled + ? DisableProviderLabel + : EnableProviderLabel, + cancellationToken); + await CanToggleSelectedProvider.UpdateAsync(_ => !IsEmptySelectedProvider(selectedProvider), cancellationToken); + } + + private void OnWorkspaceProjectionChanged(object? sender, EventArgs e) + { + _workspaceRefresh.Raise(); + } + + private async ValueTask SetSelectedSectionAsync(SettingsSection section, CancellationToken cancellationToken) + { + await SelectedSection.SetAsync(section, cancellationToken); + await IsProvidersSectionSelected.SetAsync(section is SettingsSection.Providers, cancellationToken); + await IsMessagesSectionSelected.SetAsync(section is SettingsSection.Messages, cancellationToken); + await ShowProvidersSection.SetAsync(section is SettingsSection.Providers, cancellationToken); + await ShowMessagesSection.SetAsync(section is SettingsSection.Messages, cancellationToken); + if (section is SettingsSection.Messages) + { + await SynchronizeComposerSendBehaviorAsync(await operatorPreferencesStore.GetAsync(cancellationToken), cancellationToken); + } + } + + private async ValueTask SynchronizeComposerSendBehaviorAsync( + OperatorPreferencesSnapshot preferences, + CancellationToken cancellationToken) + { + await SelectedComposerSendBehavior.SetAsync(preferences.ComposerSendBehavior, cancellationToken); + await ComposerSendBehaviorHint.SetAsync( + ChatComposerSendBehaviorText.GetHint(preferences.ComposerSendBehavior), + cancellationToken); + await IsEnterSendsSelected.SetAsync( + preferences.ComposerSendBehavior is ComposerSendBehavior.EnterSends, + cancellationToken); + await IsEnterInsertsNewLineSelected.SetAsync( + preferences.ComposerSendBehavior is ComposerSendBehavior.EnterInsertsNewLine, + cancellationToken); + } + + private static bool TryParseSection(string? sectionKey, out SettingsSection section) + { + if (string.Equals(sectionKey, ProvidersSectionKey, StringComparison.Ordinal)) + { + section = SettingsSection.Providers; + return true; + } + + if (string.Equals(sectionKey, MessagesSectionKey, StringComparison.Ordinal)) + { + section = SettingsSection.Messages; + return true; + } + + section = SettingsSection.Providers; + return false; + } + + private static bool TryParseComposerSendBehavior(string? behaviorKey, out ComposerSendBehavior behavior) + { + if (Enum.TryParse(behaviorKey, ignoreCase: true, out behavior)) + { + return true; + } + + behavior = ComposerSendBehavior.EnterSends; + return false; + } + + private static AgentProviderKind? FindEnabledProviderKind(IReadOnlyList providers) + { + for (var index = 0; index < providers.Count; index++) + { + if (providers[index].IsEnabled) + { + return providers[index].Kind; + } + } + + return null; + } + + private static ProviderStatusItem FindProviderByKind(IImmutableList providers, AgentProviderKind kind) + { + for (var index = 0; index < providers.Count; index++) + { + if (providers[index].Kind == kind) + { + return providers[index]; + } + } + + return EmptySelectedProvider; + } + + private static bool IsEmptySelectedProvider(ProviderStatusItem provider) + { + return string.IsNullOrWhiteSpace(provider.DisplayName); + } + + private static bool AreSameProvider(ProviderStatusItem left, ProviderStatusItem right) + { + return left.Kind == right.Kind && + string.Equals(left.DisplayName, right.DisplayName, StringComparison.Ordinal) && + string.Equals(left.CommandName, right.CommandName, StringComparison.Ordinal) && + string.Equals(left.StatusSummary, right.StatusSummary, StringComparison.Ordinal) && + left.IsEnabled == right.IsEnabled && + left.CanCreateAgents == right.CanCreateAgents && + HaveSameDetails(left.Details, right.Details) && + HaveSameActions(left.Actions, right.Actions); + } + + private static ImmutableArray MapProviderStatusItems( + IReadOnlyList providers, + ProviderStatusItem? selectedProvider) + { + return providers + .Select(provider => MapProviderStatusItem(provider, selectedProvider)) + .ToImmutableArray(); + } + + private static ProviderStatusItem MapProviderStatusItem( + ProviderStatusDescriptor provider, + ProviderStatusItem? selectedProvider) + { + return new ProviderStatusItem( + provider.Kind, + provider.DisplayName, + provider.CommandName, + provider.StatusSummary, + provider.InstalledVersion, + provider.IsEnabled, + provider.CanCreateAgents, + provider.Details + .Select(detail => new ProviderDetailItem(detail.Label, detail.Value)) + .ToArray(), + provider.Actions + .Select(action => new ProviderActionItem(action.Label, action.Summary, action.Command)) + .ToArray(), + ProviderEntryAutomationIdPrefix + provider.Kind, + selectedProvider is not null && + !IsEmptySelectedProvider(selectedProvider) && + selectedProvider.Kind == provider.Kind); + } + + private static bool HaveSameDetails( + IReadOnlyList left, + IReadOnlyList right) + { + if (left.Count != right.Count) + { + return false; + } + + for (var index = 0; index < left.Count; index++) + { + if (!string.Equals(left[index].Label, right[index].Label, StringComparison.Ordinal) || + !string.Equals(left[index].Value, right[index].Value, StringComparison.Ordinal)) + { + return false; + } + } + + return true; + } + + private static bool HaveSameActions( + IReadOnlyList left, + IReadOnlyList right) + { + if (left.Count != right.Count) + { + return false; + } + + for (var index = 0; index < left.Count; index++) + { + if (!string.Equals(left[index].Label, right[index].Label, StringComparison.Ordinal) || + !string.Equals(left[index].Summary, right[index].Summary, StringComparison.Ordinal) || + !string.Equals(left[index].Command, right[index].Command, StringComparison.Ordinal)) + { + return false; + } + } + + return true; + } +} diff --git a/DotPilot/Presentation/Settings/Views/SettingsPage.xaml b/DotPilot/Presentation/Settings/Views/SettingsPage.xaml new file mode 100644 index 0000000..53aeda4 --- /dev/null +++ b/DotPilot/Presentation/Settings/Views/SettingsPage.xaml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + diff --git a/DotPilot/Presentation/SettingsPage.xaml.cs b/DotPilot/Presentation/Settings/Views/SettingsPage.xaml.cs similarity index 100% rename from DotPilot/Presentation/SettingsPage.xaml.cs rename to DotPilot/Presentation/Settings/Views/SettingsPage.xaml.cs diff --git a/DotPilot/Presentation/SettingsPage.xaml b/DotPilot/Presentation/SettingsPage.xaml deleted file mode 100644 index 9dd6b31..0000000 --- a/DotPilot/Presentation/SettingsPage.xaml +++ /dev/null @@ -1,41 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - diff --git a/DotPilot/Presentation/SettingsViewModel.cs b/DotPilot/Presentation/SettingsViewModel.cs deleted file mode 100644 index 2afd493..0000000 --- a/DotPilot/Presentation/SettingsViewModel.cs +++ /dev/null @@ -1,122 +0,0 @@ -using DotPilot.Core.Features.ToolchainCenter; - -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) - { - 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); - } - - public WorkbenchSnapshot Snapshot { get; } - - public RuntimeFoundationSnapshot RuntimeFoundation { get; } - - public ToolchainCenterSnapshot ToolchainCenter { get; } - - public string SettingsIssueLabel { get; } - - public string PageTitle => PageTitleValue; - - public string PageSubtitle => PageSubtitleValue; - - public IReadOnlyList Categories { get; } - - public IReadOnlyList ToolchainProviders { get; } - - public IReadOnlyList ToolchainWorkstreams { get; } - - public WorkbenchSettingsCategoryItem? SelectedCategory - { - get => _selectedCategory; - set - { - if (!SetProperty(ref _selectedCategory, value)) - { - return; - } - - RaisePropertyChanged(nameof(SelectedCategoryTitle)); - RaisePropertyChanged(nameof(SelectedCategorySummary)); - RaisePropertyChanged(nameof(VisibleEntries)); - RaisePropertyChanged(nameof(IsToolchainCenterVisible)); - RaisePropertyChanged(nameof(AreGenericSettingsVisible)); - } - } - - public ToolchainProviderItem? SelectedToolchainProvider - { - get => _selectedToolchainProvider; - set - { - if (!SetProperty(ref _selectedToolchainProvider, value)) - { - return; - } - - RaisePropertyChanged(nameof(SelectedToolchainProviderSnapshot)); - } - } - - public string SelectedCategoryTitle => SelectedCategory?.Title ?? DefaultCategoryTitle; - - public string SelectedCategorySummary => SelectedCategory?.Summary ?? DefaultCategorySummary; - - public bool IsToolchainCenterVisible => SelectedCategory?.Key == WorkbenchSettingsCategoryKeys.Toolchains; - - public bool AreGenericSettingsVisible => !IsToolchainCenterVisible; - - public IReadOnlyList VisibleEntries => SelectedCategory?.Entries ?? []; - - public ToolchainProviderSnapshot? SelectedToolchainProviderSnapshot => SelectedToolchainProvider?.Snapshot; - - public string ProviderSummary => string.Format( - System.Globalization.CultureInfo.InvariantCulture, - ToolchainProviderSummaryCompositeFormat, - ToolchainCenter.ReadyProviderCount, - ToolchainCenter.AttentionRequiredProviderCount); -} diff --git a/DotPilot/Presentation/Shared/Models/ChatDesignModels.cs b/DotPilot/Presentation/Shared/Models/ChatDesignModels.cs new file mode 100644 index 0000000..9cbb27d --- /dev/null +++ b/DotPilot/Presentation/Shared/Models/ChatDesignModels.cs @@ -0,0 +1,69 @@ +using DotPilot.Core.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; + + public SessionId Id { get; } = id; + + public string Title { get; } = title; + + public string Preview + { + get => _preview; + set => SetProperty(ref _preview, value); + } +} + +[Bindable] +public sealed partial record ChatTimelineItem( + string Id, + SessionStreamEntryKind Kind, + string Author, + string Timestamp, + string Content, + string Initial, + Brush? AvatarBrush, + bool IsCurrentUser, + string? AccentLabel = null); + +[Bindable] +public sealed partial record ParticipantItem( + string Name, + string SecondaryText, + string Initial, + Brush? AvatarBrush, + string? BadgeText = null, + Brush? BadgeBrush = null) +{ + public bool HasBadge => !string.IsNullOrWhiteSpace(BadgeText); +} + +[Bindable] +public sealed partial record ProviderStatusItem( + AgentProviderKind Kind, + string DisplayName, + string CommandName, + string StatusSummary, + string? InstalledVersion, + bool IsEnabled, + bool CanCreateAgents, + IReadOnlyList Details, + IReadOnlyList Actions, + string AutomationId, + bool IsSelected); + +[Bindable] +public sealed partial record ProviderDetailItem( + string Label, + string Value); + +[Bindable] +public sealed partial record ProviderActionItem( + string Label, + string Summary, + string Command); diff --git a/DotPilot/Presentation/Shared/Models/FleetBoardProjectionModels.cs b/DotPilot/Presentation/Shared/Models/FleetBoardProjectionModels.cs new file mode 100644 index 0000000..49d1077 --- /dev/null +++ b/DotPilot/Presentation/Shared/Models/FleetBoardProjectionModels.cs @@ -0,0 +1,65 @@ +using DotPilot.Core.ControlPlaneDomain; +using Microsoft.UI.Xaml.Data; + +namespace DotPilot.Presentation; + +[Bindable] +public sealed partial record FleetBoardView( + IReadOnlyList Metrics, + IReadOnlyList ActiveSessions, + IReadOnlyList Providers, + bool HasActiveSessions, + bool ShowActiveSessionsEmptyState, + string ActiveSessionsEmptyMessage); + +[Bindable] +public sealed partial record FleetBoardMetricItem( + string Label, + string Value, + string Summary, + string AutomationId); + +[Bindable] +public sealed partial record FleetBoardSessionRequest( + SessionId SessionId, + string SessionTitle); + +[Bindable] +public sealed partial record FleetBoardSessionItem( + string Title, + string Summary, + string AutomationId, + FleetBoardSessionRequest OpenRequest, + ICommand? OpenCommand); + +[Bindable] +public sealed partial record FleetBoardProviderItem( + string DisplayName, + string StatusLabel, + string Summary, + Brush? StatusBrush, + string AutomationId); + +internal static class FleetBoardAutomationIds +{ + public static string ForMetric(string label) + { + return "ChatFleetMetric_" + CreateAutomationIdSuffix(label); + } + + public static string ForSession(string sessionTitle) + { + return "ChatFleetSession_" + CreateAutomationIdSuffix(sessionTitle); + } + + public static string ForProvider(string providerName) + { + return "ChatFleetProvider_" + CreateAutomationIdSuffix(providerName); + } + + 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/Shared/Models/PresentationProjectionModels.cs b/DotPilot/Presentation/Shared/Models/PresentationProjectionModels.cs new file mode 100644 index 0000000..fb5230a --- /dev/null +++ b/DotPilot/Presentation/Shared/Models/PresentationProjectionModels.cs @@ -0,0 +1,31 @@ +using Microsoft.UI.Xaml.Data; + +namespace DotPilot.Presentation; + +[Bindable] +public sealed partial record ChatSessionView( + string Initial, + string Title, + string StatusSummary, + IReadOnlyList Messages, + IReadOnlyList Members, + IReadOnlyList Agents); + +[Bindable] +public sealed partial record AgentBuilderView( + string ProviderDisplayName, + string ProviderStatusSummary, + string ProviderCommandName, + string ProviderVersionLabel, + bool HasProviderVersion, + string SuggestedModelName, + IReadOnlyList SupportedModelNames, + bool HasSupportedModels, + string ModelHelperText, + string StatusMessage, + 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/Shared/Notifications/WorkspaceProjectionNotifier.cs b/DotPilot/Presentation/Shared/Notifications/WorkspaceProjectionNotifier.cs new file mode 100644 index 0000000..3ff5b00 --- /dev/null +++ b/DotPilot/Presentation/Shared/Notifications/WorkspaceProjectionNotifier.cs @@ -0,0 +1,11 @@ +namespace DotPilot.Presentation; + +public sealed class WorkspaceProjectionNotifier +{ + public event EventHandler? Changed; + + public void Publish() + { + Changed?.Invoke(this, EventArgs.Empty); + } +} diff --git a/DotPilot/Presentation/Shared/Results/OperationResultExtensions.cs b/DotPilot/Presentation/Shared/Results/OperationResultExtensions.cs new file mode 100644 index 0000000..8d229a8 --- /dev/null +++ b/DotPilot/Presentation/Shared/Results/OperationResultExtensions.cs @@ -0,0 +1,24 @@ +using ManagedCode.Communication; +using ManagedCode.Communication.Results.Extensions; + +namespace DotPilot.Presentation; + +internal static class OperationResultExtensions +{ + public static bool TryGetValue(this Result result, out T value) + { + if (result.IsSuccess) + { + value = result.Value; + return true; + } + + value = default!; + return false; + } + + public static string ToOperatorMessage(this IResultProblem result, string fallbackMessage) + { + return result.ToDisplayMessage(fallbackMessage); + } +} diff --git a/DotPilot/Presentation/Shell.xaml b/DotPilot/Presentation/Shell.xaml deleted file mode 100644 index dc23db4..0000000 --- a/DotPilot/Presentation/Shell.xaml +++ /dev/null @@ -1,36 +0,0 @@ - - - - - - - - - - - - - - - - - - diff --git a/DotPilot/Presentation/Shell.xaml.cs b/DotPilot/Presentation/Shell.xaml.cs deleted file mode 100644 index 609be02..0000000 --- a/DotPilot/Presentation/Shell.xaml.cs +++ /dev/null @@ -1,20 +0,0 @@ -namespace DotPilot.Presentation; - -public sealed partial class Shell : UserControl, IContentControlProvider -{ - public Shell() - { - try - { - BrowserConsoleDiagnostics.Info("[DotPilot.Startup] Shell constructor started."); - InitializeComponent(); - BrowserConsoleDiagnostics.Info("[DotPilot.Startup] Shell constructor completed."); - } - catch (Exception exception) - { - BrowserConsoleDiagnostics.Error($"[DotPilot.Startup] Shell constructor failed: {exception}"); - throw; - } - } - public ContentControl ContentControl => Splash; -} diff --git a/DotPilot/Presentation/Shell/ViewModels/ShellViewModel.cs b/DotPilot/Presentation/Shell/ViewModels/ShellViewModel.cs new file mode 100644 index 0000000..5dc17b0 --- /dev/null +++ b/DotPilot/Presentation/Shell/ViewModels/ShellViewModel.cs @@ -0,0 +1,119 @@ +using Microsoft.UI.Xaml.Data; + +namespace DotPilot.Presentation; + +[Bindable] +public sealed class ShellViewModel : ObservableObject, IDisposable +{ + private const string StartupTitleValue = "Preparing local runtime"; + private const string StartupSummaryValue = + "Loading workspace state and detecting installed CLI providers."; + private const string LiveSessionIndicatorTitleValue = "Live session active"; + private const string LiveSessionSummaryFormat = "Running {0} in {1}."; + private const string SleepPreventionSummaryFormat = "Keeping this machine awake while {0} runs in {1}."; + + private readonly IStartupWorkspaceHydration startupWorkspaceHydration; + private readonly ISessionActivityMonitor sessionActivityMonitor; + private readonly DesktopSleepPreventionService desktopSleepPreventionService; + private readonly UiDispatcher uiDispatcher; + private Microsoft.UI.Xaml.Visibility startupOverlayVisibility = Microsoft.UI.Xaml.Visibility.Visible; + private Microsoft.UI.Xaml.Visibility liveSessionIndicatorVisibility = Microsoft.UI.Xaml.Visibility.Collapsed; + private string liveSessionIndicatorTitle = string.Empty; + private string liveSessionIndicatorSummary = string.Empty; + + public ShellViewModel( + IStartupWorkspaceHydration startupWorkspaceHydration, + ISessionActivityMonitor sessionActivityMonitor, + DesktopSleepPreventionService desktopSleepPreventionService, + UiDispatcher uiDispatcher) + { + this.startupWorkspaceHydration = startupWorkspaceHydration; + this.sessionActivityMonitor = sessionActivityMonitor; + this.desktopSleepPreventionService = desktopSleepPreventionService; + this.uiDispatcher = uiDispatcher; + this.startupWorkspaceHydration.StateChanged += OnStartupWorkspaceHydrationStateChanged; + this.sessionActivityMonitor.StateChanged += OnSessionActivityStateChanged; + this.desktopSleepPreventionService.StateChanged += OnDesktopSleepPreventionStateChanged; + ApplyHydrationState(); + ApplyLiveSessionState(); + } + + public string StartupTitle => StartupTitleValue; + + public string StartupSummary => StartupSummaryValue; + + public Microsoft.UI.Xaml.Visibility StartupOverlayVisibility + { + get => startupOverlayVisibility; + private set => SetProperty(ref startupOverlayVisibility, value); + } + + public Microsoft.UI.Xaml.Visibility LiveSessionIndicatorVisibility + { + get => liveSessionIndicatorVisibility; + private set => SetProperty(ref liveSessionIndicatorVisibility, value); + } + + public string LiveSessionIndicatorTitle + { + get => liveSessionIndicatorTitle; + private set => SetProperty(ref liveSessionIndicatorTitle, value); + } + + public string LiveSessionIndicatorSummary + { + get => liveSessionIndicatorSummary; + private set => SetProperty(ref liveSessionIndicatorSummary, value); + } + + public void Dispose() + { + startupWorkspaceHydration.StateChanged -= OnStartupWorkspaceHydrationStateChanged; + sessionActivityMonitor.StateChanged -= OnSessionActivityStateChanged; + desktopSleepPreventionService.StateChanged -= OnDesktopSleepPreventionStateChanged; + } + + private void OnStartupWorkspaceHydrationStateChanged(object? sender, EventArgs e) + { + uiDispatcher.Execute(ApplyHydrationState); + } + + private void OnSessionActivityStateChanged(object? sender, EventArgs e) + { + uiDispatcher.Execute(ApplyLiveSessionState); + } + + private void OnDesktopSleepPreventionStateChanged(object? sender, EventArgs e) + { + uiDispatcher.Execute(ApplyLiveSessionState); + } + + private void ApplyHydrationState() + { + StartupOverlayVisibility = startupWorkspaceHydration.IsReady + ? Microsoft.UI.Xaml.Visibility.Collapsed + : Microsoft.UI.Xaml.Visibility.Visible; + } + + private void ApplyLiveSessionState() + { + var snapshot = sessionActivityMonitor.Current; + if (!snapshot.HasActiveSessions) + { + LiveSessionIndicatorVisibility = Microsoft.UI.Xaml.Visibility.Collapsed; + LiveSessionIndicatorTitle = string.Empty; + LiveSessionIndicatorSummary = string.Empty; + return; + } + + LiveSessionIndicatorVisibility = Microsoft.UI.Xaml.Visibility.Visible; + LiveSessionIndicatorTitle = LiveSessionIndicatorTitleValue; + LiveSessionIndicatorSummary = string.Format( + System.Globalization.CultureInfo.InvariantCulture, + desktopSleepPreventionService.IsSleepPreventionActive + ? SleepPreventionSummaryFormat + : LiveSessionSummaryFormat, + snapshot.AgentName, + snapshot.SessionTitle); + } +} diff --git a/DotPilot/Presentation/Shell/Views/Shell.xaml b/DotPilot/Presentation/Shell/Views/Shell.xaml new file mode 100644 index 0000000..b7f20b1 --- /dev/null +++ b/DotPilot/Presentation/Shell/Views/Shell.xaml @@ -0,0 +1,237 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/DotPilot/Presentation/Shell/Views/Shell.xaml.cs b/DotPilot/Presentation/Shell/Views/Shell.xaml.cs new file mode 100644 index 0000000..49b9fa1 --- /dev/null +++ b/DotPilot/Presentation/Shell/Views/Shell.xaml.cs @@ -0,0 +1,215 @@ +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 UnknownContentTypeName = ""; + private ShellNavigationNotifier? _shellNavigationNotifier; + private string _currentRoute = ResolveRouteName(ShellRoute.Chat); + + public Shell() + { + try + { + BrowserConsoleDiagnostics.Info("[DotPilot.Startup] Shell constructor started."); + InitializeComponent(); + Loaded += OnLoaded; + Unloaded += OnUnloaded; + RegisterContentHostObserver(); + UpdateNavigationSelection(ResolveRouteName(ShellRoute.Chat)); + UpdateNavigationSelectionFromContent(); + BrowserConsoleDiagnostics.Info("[DotPilot.Startup] Shell constructor completed."); + } + catch (Exception exception) + { + BrowserConsoleDiagnostics.Error($"[DotPilot.Startup] Shell constructor failed: {exception}"); + throw; + } + } + + public ContentControl ContentControl => ContentHost; + + private void OnChatNavButtonClick(object sender, RoutedEventArgs e) + { + _ = NavigateToRouteAsync(ShellRoute.Chat); + } + + private void OnAgentsNavButtonClick(object sender, RoutedEventArgs e) + { + _ = NavigateToRouteAsync(ShellRoute.Agents); + } + + private void OnProvidersNavButtonClick(object sender, RoutedEventArgs e) + { + _ = NavigateToRouteAsync(ShellRoute.Settings); + } + + private void OnLoaded(object sender, RoutedEventArgs e) + { + if (Application.Current is App app) + { + app.ServicesReady -= OnAppServicesReady; + app.ServicesReady += OnAppServicesReady; + } + + TryAssignShellDataContext(); + 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) + { + TryAssignShellDataContext(); + TryRegisterNavigationNotifier(); + } + + private void TryAssignShellDataContext() + { + if (DataContext is ShellViewModel) + { + return; + } + + if (Application.Current is not App { Services: { } services }) + { + BrowserConsoleDiagnostics.Info("[DotPilot.Shell] Shell data context is waiting for app services."); + return; + } + + DataContext = services.GetRequiredService(); + BrowserConsoleDiagnostics.Info("[DotPilot.Shell] Shell data context assigned."); + } + + 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 '{routeName}'."); + return; + } + + var response = await navigator.NavigateRouteAsync(ContentHost, routeName); + var success = response?.Success ?? false; + BrowserConsoleDiagnostics.Info($"[DotPilot.Navigation] Route '{routeName}' success={success}."); + } + + private void RegisterContentHostObserver() + { + ContentHost.RegisterPropertyChangedCallback( + ContentControl.ContentProperty, + (_, _) => UpdateNavigationSelectionFromContent()); + } + + private void UpdateNavigationSelectionFromContent() + { + var route = ContentHost.Content switch + { + ChatPage => ResolveRouteName(ShellRoute.Chat), + AgentBuilderPage => ResolveRouteName(ShellRoute.Agents), + SettingsPage => ResolveRouteName(ShellRoute.Settings), + _ => string.Empty, + }; + + if (string.IsNullOrWhiteSpace(route)) + { + var contentTypeName = ContentHost.Content?.GetType().FullName ?? UnknownContentTypeName; + BrowserConsoleDiagnostics.Info($"[DotPilot.Navigation] Ignoring unrecognized content host type '{contentTypeName}'."); + return; + } + + UpdateNavigationSelection(route); + } + + private void UpdateNavigationSelection(string route) + { + _currentRoute = route; + var selectedStyle = ResolveStyle(SidebarButtonSelectedStyleKey); + var normalStyle = ResolveStyle(SidebarButtonStyleKey); + + ChatNavButton.Style = string.Equals(_currentRoute, ResolveRouteName(ShellRoute.Chat), StringComparison.Ordinal) + ? selectedStyle + : normalStyle; + AgentsNavButton.Style = string.Equals(_currentRoute, ResolveRouteName(ShellRoute.Agents), StringComparison.Ordinal) + ? selectedStyle + : normalStyle; + 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); + + if (Application.Current.Resources.TryGetValue(key, out var style) && + style is Style resolvedStyle) + { + return resolvedStyle; + } + + throw new InvalidOperationException($"Unable to resolve style '{key}'."); + } +} diff --git a/DotPilot/Presentation/ShellViewModel.cs b/DotPilot/Presentation/ShellViewModel.cs deleted file mode 100644 index e11ae34..0000000 --- a/DotPilot/Presentation/ShellViewModel.cs +++ /dev/null @@ -1,5 +0,0 @@ -namespace DotPilot.Presentation; - -public class ShellViewModel -{ -} 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/DotPilot/Styles/AppDesign.xaml b/DotPilot/Styles/AppDesign.xaml index 0603f6b..23a9b22 100644 --- a/DotPilot/Styles/AppDesign.xaml +++ b/DotPilot/Styles/AppDesign.xaml @@ -5,21 +5,21 @@ Poppins Kodchasan - #FFE9EEEA - #66FFFFFF - #A6FFFFFF + #FFF0F2EC + #CCFFFFFF + #1F000000 #FF1C1C1C - #61999999 - #61555555 - #14000000 - #99FFFFFF - #CCFFFFFF - #FFE2E6E3 + #80696969 + #A14D4D4D + #16000000 + #F2FFFFFF + #FFFFFFFF + #FFE7EDD9 #FFDDE0DE #FF2C2C2C - #FFECFF8C + #FFE4F86A #FF4CAF50 - #0F000000 + #09000000 @@ -75,9 +75,9 @@ - - - + + + @@ -85,33 +85,93 @@ + + + + + + + + + + + + + + @@ -119,7 +179,7 @@ - + @@ -127,22 +187,59 @@ - + - + + - + + + + + + + + + + + + + + + + + + + + diff --git a/DotPilot/Styles/SettingsWorkspace.xaml b/DotPilot/Styles/SettingsWorkspace.xaml new file mode 100644 index 0000000..203149f --- /dev/null +++ b/DotPilot/Styles/SettingsWorkspace.xaml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + diff --git a/README.md b/README.md index 1329d05..bde732f 100644 --- a/README.md +++ b/README.md @@ -1,166 +1,153 @@ +
+ # dotPilot -`dotPilot` is a desktop-first, local-first control plane for AI agents built with `.NET 10` and `Uno Platform`. +### Local Agent Orchestrator -## Product Summary +**Run AI agents locally. Build workflows. Own your data.** -`dotPilot` is designed as a single operator workbench for running, supervising, and reviewing agent workflows from one desktop UI. Coding workflows are first-class, but the product is not limited to coding agents. The same control plane is intended to support research, analysis, orchestration, reviewer, and operator-style flows. +*Intent becomes the interface* 🎯 -From the workbench, the operator should be able to: - -- manage agent profiles and fleets -- connect external agent runtimes such as `Codex`, `Claude Code`, and `GitHub Copilot` -- run local models through `LLamaSharp` and `ONNX Runtime` -- browse repositories, inspect files, review diffs, and work with Git -- orchestrate sessions, approvals, telemetry, replay, and evaluation from one UI - -## Main Features - -### Available In The Current Repository - -- a desktop-first three-pane workbench shell -- repository tree search and open-file navigation -- read-only file inspection and diff-review surface -- artifact dock and runtime log console -- unified settings shell for providers, policies, and storage -- a Toolchain Center for `Codex`, `Claude Code`, and `GitHub Copilot` readiness -- provider diagnostics, environment and secret visibility, operator actions, and background polling summaries -- dedicated agent-builder screen -- deterministic runtime foundation panel for provider readiness and control-plane state -- `NUnit` unit tests plus `Uno.UITest` browser UI coverage - -### Main Product Capabilities On The Roadmap - -- multi-agent session composition and orchestration -- embedded local-first runtime hosting with `Orleans` -- SDK-first provider integrations for `Codex`, `Claude Code`, and `GitHub Copilot` -- local model runtime support through `LLamaSharp` and `ONNX Runtime` -- approvals, replay, audit trails, and artifact inspection -- OpenTelemetry-first observability and official `.NET` AI evaluation flows - -## Current Status - -The repository is in the **active foundation and workbench implementation** stage. - -What already exists: - -- the first runtime foundation slices in `DotPilot.Core` and `DotPilot.Runtime` -- the first operator workbench slice for repository browsing, document inspection, artifacts, logs, and settings -- the first Toolchain Center slice for pre-session provider readiness and operator diagnostics -- a presentation-only `Uno Platform` app shell with separate non-UI class-library boundaries -- unit, coverage, and UI automation validation paths -- architecture docs, ADRs, feature specs, and GitHub backlog tracking - -What is planned next: - -- embedded `Orleans` hosting inside the desktop app -- `Microsoft Agent Framework` orchestration and session workflows -- richer provider adapters and toolchain management -- MCP and repository-intelligence tooling -- local runtime execution flows -- telemetry, replay, and evaluation surfaces backed by real runtime events - -## Product Direction - -The approved architectural defaults are: - -- `dotPilot` stays desktop-first and reuses the current shell direction instead of replacing it -- the first runtime cut is **local-first** with an embedded `Orleans` silo -- `Session = grain`, with related workspace, fleet, artifact, and policy state -- `Microsoft Agent Framework` is the preferred orchestration layer -- provider integrations are SDK-first: - - `ManagedCode.CodexSharpSDK` - - `ManagedCode.ClaudeCodeSharpSDK` - - `GitHub.Copilot.SDK` -- tool federation is centered on `ManagedCode.MCPGateway` -- repository intelligence is centered on `ManagedCode.RagSharp` -- agent quality and safety evaluation use `Microsoft.Extensions.AI.Evaluation*` -- observability is OpenTelemetry-first, with local-first visibility and optional cloud export later -- `MLXSharp` is explicitly **not** part of the first roadmap wave - -## Documentation Map - -Start here if you want the current source of truth: - -- [Architecture Overview](docs/Architecture.md) -- [ADR-0001: Agent Control Plane Architecture](docs/ADR/ADR-0001-agent-control-plane-architecture.md) -- [ADR-0003: Vertical Slices And UI-Only Uno App](docs/ADR/ADR-0003-vertical-slices-and-ui-only-uno-app.md) -- [Feature Spec: Agent Control Plane Experience](docs/Features/agent-control-plane-experience.md) -- [Feature Spec: Workbench Foundation](docs/Features/workbench-foundation.md) -- [Feature Spec: Toolchain Center](docs/Features/toolchain-center.md) -- [Task Plan: Vertical Slice Runtime Foundation](vertical-slice-runtime-foundation.plan.md) -- [Task Plan: Workbench Foundation](issue-13-workbench-foundation.plan.md) -- [Task Plan: Toolchain Center](issue-14-toolchain-center.plan.md) -- [Root Governance](AGENTS.md) - -GitHub tracking: - -- [Issue Backlog](https://github.com/managedcode/dotPilot/issues) - -## Repository Layout - -```text -. -├── DotPilot/ # Uno desktop presentation host -├── DotPilot.Core/ # Vertical-slice contracts and typed identifiers -├── DotPilot.Runtime/ # Provider-independent runtime implementations -├── DotPilot.ReleaseTool/ # Release automation utilities -├── DotPilot.Tests/ # NUnit contract and composition tests -├── DotPilot.UITests/ # Uno.UITest browser coverage -├── docs/ -│ ├── ADR/ # architectural decisions -│ ├── Features/ # executable feature specs -│ └── Architecture.md # repo architecture map -├── AGENTS.md # root governance for humans and agents -├── vertical-slice-runtime-foundation.plan.md -├── issue-13-workbench-foundation.plan.md -├── issue-14-toolchain-center.plan.md -└── DotPilot.slnx # solution entry point -``` +[![Download](https://img.shields.io/github/v/release/managedcode/dotPilot?label=Download&style=for-the-badge)](https://github.com/managedcode/dotPilot/releases/latest) +[![License](https://img.shields.io/github/license/managedcode/dotPilot?style=for-the-badge)](LICENSE) +[![.NET](https://img.shields.io/badge/.NET-10-purple?style=for-the-badge)](https://dotnet.microsoft.com/) +[![YouTube](https://img.shields.io/badge/YouTube-Subscribe-red?style=for-the-badge&logo=youtube)](https://www.youtube.com/@ManagedCode) -## Getting Started +[Website](https://dotpilot.managed-code.com) · [Downloads](#downloads) · [Getting Started](#getting-started) · [YouTube](https://www.youtube.com/@ManagedCode) · [Issues](https://github.com/managedcode/dotPilot/issues) -### Prerequisites +--- -- `.NET SDK 10.0.103` -- `Uno.Sdk 6.5.31` -- a supported desktop environment for `net10.0-desktop` +
-### Core Commands +## What is dotPilot? -```bash -dotnet build DotPilot.slnx -warnaserror -m:1 -p:BuildInParallel=false -dotnet test DotPilot.slnx -dotnet format DotPilot.slnx --verify-no-changes -dotnet publish DotPilot/DotPilot.csproj -c Release -f net10.0-desktop -``` +dotPilot is an **open source desktop app** for running AI agents locally on your machine. + +- 🤖 **Run multiple agents** — Launch and manage several AI agents at once +- 🔄 **Build workflows** — Create multi-agent pipelines with Microsoft Agent Framework +- 🔌 **Any provider** — Codex, Claude Code, GitHub Copilot, Gemini, or local models +- 🏠 **100% local** — Your data stays on your device, no cloud required +- 💬 **Natural language** — Create agents by describing what you need + +Built with **C#**, **.NET 10**, and **Uno Platform**. + +--- + +## Downloads + +| Platform | Architecture | Link | +|:--------:|:------------:|:----:| +| 🍎 **macOS** | Apple Silicon | [Download .dmg](https://github.com/managedcode/dotPilot/releases/latest) | +| 🪟 **Windows** | x64 | [Download .exe](https://github.com/managedcode/dotPilot/releases/latest) | +| 🐧 **Linux** | x64 | [Download .snap](https://github.com/managedcode/dotPilot/releases/latest) | + +--- + +## Supported Providers -`build` and `analyze` use the same serialized `-warnaserror` command because the multi-target Uno app must not build in parallel in a shared workspace or CI cache. +| Provider | Type | Status | +|:---------|:-----|:------:| +| **Codex CLI** | OpenAI coding agent | ✅ | +| **Claude Code** | Anthropic assistant | ✅ | +| **GitHub Copilot** | Microsoft AI | ✅ | +| **Gemini** | Google AI | ✅ | +| **OpenAI API** | Direct API | ✅ | +| **Azure OpenAI** | Enterprise API | ✅ | +| **LLamaSharp** | Local models | ✅ | +| **ONNX Runtime** | Local inference | ✅ | -### Run the App +--- + +## Getting Started + +### Download & Run + +1. Download the latest release for your platform from [Releases](https://github.com/managedcode/dotPilot/releases/latest) +2. Install and launch dotPilot +3. Configure your preferred AI provider in Settings +4. Create your first agent and start chatting + +### Build from Source ```bash +git clone https://github.com/managedcode/dotPilot.git +cd dotPilot +dotnet build DotPilot.slnx dotnet run --project DotPilot/DotPilot.csproj -f net10.0-desktop ``` -### Run the Browser UI Suite +**Requirements:** `.NET SDK 10.0.103`, `Uno.Sdk 6.5.31` -```bash -dotnet test DotPilot.UITests/DotPilot.UITests.csproj -``` +--- + +## Features + +### 🤖 Run Multiple Agents Simultaneously + +Launch as many AI agents as you need, all running in parallel on your local machine. Each agent operates with its own isolated context, memory, and tool set. The **Fleet Board** gives you a real-time dashboard to monitor all active sessions — see which agents are working, what they're doing, and their current status. Switch between agents instantly, pause or resume sessions, and manage your entire agent fleet from one unified interface. + +### 🔄 Build Agentic Workflows with Microsoft Agent Framework + +dotPilot integrates with [Microsoft Agent Framework](https://github.com/microsoft/agent-framework) to enable sophisticated multi-agent orchestration. Design workflows where agents collaborate — one agent researches, another analyzes, a third generates code. Use **sequential pipelines** for step-by-step tasks, **parallel execution** for independent workloads, or **handoff patterns** where agents pass context to each other. All workflows support streaming output, checkpoints for long-running tasks, and human-in-the-loop approvals when you need to stay in control. + +### 🔌 Connect Any Provider — Cloud or Local + +Freedom to choose your AI backend. Connect to **Codex CLI** for OpenAI's coding agent, **Claude Code** for Anthropic's assistant, **GitHub Copilot** for Microsoft's AI pair programmer, or **Gemini** for Google's models. Need direct API access? Use **OpenAI API** or **Azure OpenAI** for enterprise scenarios. Want full privacy? Run models entirely on your hardware with **LLamaSharp** or **ONNX Runtime** — zero data leaves your machine. Mix and match providers across different agents based on what each task needs. + +### 💬 Intent-Driven Agent Creation + +Stop configuring, start describing. Tell dotPilot what kind of agent you need in plain language: *"I need an agent that reviews pull requests and suggests improvements"* or *"Create a research agent that summarizes technical papers"*. The system generates the complete agent profile — system prompt, tool configuration, provider selection, and behavioral settings. Refine with follow-up instructions or dive into manual config when you need fine-grained control. **Intent becomes the interface.** + +### 📊 Full Observability with OpenTelemetry + +Every agent action is traceable. dotPilot integrates **OpenTelemetry** to capture detailed telemetry for each session — every prompt sent, every response received, every tool invocation, every workflow step. Visualize agent reasoning flows, identify performance bottlenecks, debug unexpected behaviors. Export traces to your preferred observability backend or analyze locally. When something goes wrong, you'll know exactly where and why. + +### 🔒 100% Local, 100% Private + +Your data never leaves your device unless you explicitly choose a cloud provider. All session history, agent configurations, and conversation transcripts are stored locally in **SQLite**. No telemetry sent to external servers. No account required to use local models. Run completely air-gapped if needed. You own your data, your workflows, and your AI infrastructure. + +--- + +## Tech Stack + +| | | +|:--|:--| +| **Language** | C# | +| **Runtime** | .NET 10 | +| **UI** | Uno Platform | +| **Orchestration** | Microsoft Agent Framework | +| **Database** | SQLite + EF Core | +| **Observability** | OpenTelemetry | + +--- + +## Building in Public + +We're developing dotPilot in the open. + +- 📺 **YouTube** — [@ManagedCode](https://www.youtube.com/@ManagedCode) +- 💬 **Issues** — [GitHub Issues](https://github.com/managedcode/dotPilot/issues) +- 🌐 **Website** — [dotpilot.managed-code.com](https://dotpilot.managed-code.com) + +--- + +## Documentation + +- [Architecture](docs/Architecture.md) +- [Contributing](AGENTS.md) + +--- + +## License + +Open source. See [LICENSE](LICENSE) for details. -## Quality Gates +--- -This repository treats the following as mandatory: +
-- real `NUnit` unit tests -- real `Uno.UITest` browser UI coverage -- repo-root `.editorconfig` as the formatting and analyzer source of truth -- central package management through `Directory.Packages.props` -- descriptive GitHub Actions validation and desktop artifact publishing +**[dotpilot.managed-code.com](https://dotpilot.managed-code.com)** -## Notes +Made by [ManagedCode](https://dotpilot.managed-code.com) -- The current repository still contains prototype data in the shell; the new backlog tracks the transition to runtime-backed features. -- If you are working on non-trivial changes, start with [AGENTS.md](AGENTS.md) and [docs/Architecture.md](docs/Architecture.md). -- The current machine-local baseline may still hit a `Uno.Resizetizer` file-lock during `dotnet build`; that risk is documented in [ci-build-lock-fix.plan.md](ci-build-lock-fix.plan.md). +
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/docs/ADR/ADR-0001-agent-control-plane-architecture.md b/docs/ADR/ADR-0001-agent-control-plane-architecture.md index 2de713e..7993093 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,27 +25,24 @@ 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. -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. -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**: +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 local-first inside the desktop app, using `SQLite` projections plus local `AgentSession` and chat-history persistence instead of a separate embedded host subsystem. +3. **Microsoft Agent Framework** is the preferred orchestration layer for agent sessions, workflows, HITL, MCP-aware tool use, and OpenTelemetry-friendly observability. +4. Provider integrations are **SDK-first**: - `ManagedCode.CodexSharpSDK` - `ManagedCode.ClaudeCodeSharpSDK` - `GitHub.Copilot.SDK` -6. Tool federation is centered on `ManagedCode.MCPGateway`, and repository intelligence is centered on `ManagedCode.RagSharp`. -7. Quality, safety, and agent evaluation should use the official `Microsoft.Extensions.AI.Evaluation*` libraries. -8. Observability should be **OpenTelemetry-first**, aligned with Agent Framework patterns, with local visualization first and optional Azure Monitor / Foundry export later. -9. Local model support is planned through `LLamaSharp` and `ONNX Runtime`. `MLXSharp` is explicitly excluded from the first roadmap wave. +5. Tool federation is centered on `ManagedCode.MCPGateway`, and repository intelligence is centered on `ManagedCode.RagSharp`. +6. Quality, safety, and agent evaluation should use the official `Microsoft.Extensions.AI.Evaluation*` libraries. +7. Observability should be **OpenTelemetry-first**, aligned with Agent Framework patterns, with local visualization first and optional Azure Monitor / Foundry export later. +8. Local model support is planned through `LLamaSharp` and `ONNX Runtime`. `MLXSharp` is explicitly excluded from the first roadmap wave. ## Decision Diagram ```mermaid flowchart LR - Workbench["dotPilot desktop workbench"] - Silo["Embedded Orleans silo"] - Session["Session grains"] - Fleet["Fleet / policy / artifact grains"] + Client["dotPilot desktop chat client"] + Runtime["Local session runtime"] MAF["Microsoft Agent Framework"] Providers["Provider adapters"] Tools["MCPGateway + built-in tools + RagSharp"] @@ -53,10 +50,8 @@ flowchart LR Eval["Microsoft.Extensions.AI.Evaluation*"] OTel["OpenTelemetry-first observability"] - Workbench --> Silo - Silo --> Session - Silo --> Fleet - Session --> MAF + Client --> Runtime + Runtime --> MAF MAF --> Providers MAF --> Tools MAF --> Local @@ -72,11 +67,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 @@ -88,7 +83,7 @@ This would duplicate maintenance effort, weaken typed contracts, and ignore mana Rejected for the first roadmap wave. -The approved default is local-first with an embedded host. Remote fleet expansion can be planned later on top of the same contracts. +The approved default is local-first inside the desktop app. Remote fleet expansion can be planned later on top of the same contracts. ### 5. Include MLXSharp in the first runtime wave @@ -109,7 +104,7 @@ The dependency is not ready for the first roadmap wave and would distract from t ### Negative - The target architecture is larger than the current codebase and will require a substantial implementation backlog. -- An embedded Orleans host raises startup, lifecycle, and local state-management complexity. +- A local-first session runtime still raises startup, lifecycle, and local state-management complexity. - Provider CLIs and SDKs each bring distinct operational prerequisites that the UI must surface clearly. - Evaluation and observability requirements add product scope before user-visible automation features are complete. 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..1d1aabc 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 @@ -29,11 +29,9 @@ We will use these architectural defaults for implementation work going forward: - desktop startup - app composition 2. Non-UI feature work moves into separate class libraries: - - `DotPilot.Core` for contracts, typed identifiers, and public slice interfaces - - `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. + - `DotPilot.Core` for contracts, typed identifiers, public slice interfaces, provider integration, persistence, and local desktop session runtime infrastructure +3. Feature code must be organized as vertical slices under top-level feature folders such as `ChatSessions/`, `Providers/`, or `AgentBuilder/`, not under an extra `Features/` wrapper and not as shared horizontal `Services`, `Models`, or `Helpers` buckets. +4. The active core slices are `ChatSessions`, `AgentBuilder`, and `Providers`, built around provider readiness, durable agent profiles, durable sessions, transcript streaming, and local persistence. 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. @@ -43,21 +41,14 @@ We will use these architectural defaults for implementation work going forward: flowchart LR Ui["DotPilot Uno UI host"] 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 - Ui --> Host - Host --> Core - Runtime --> TestClient - Runtime --> ProviderChecks + Core --> TestClient + Core --> ProviderChecks Future --> Core - Future --> Runtime - Future --> Host ``` ## Alternatives Considered @@ -86,23 +77,23 @@ 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. +- The local desktop session runtime can evolve inside `DotPilot.Core` without leaking UI concerns into the presentation project. ### Negative -- The solution now has more projects and local governance files to maintain. +- The single non-UI project is denser, so folder discipline matters more. - Some pre-existing non-UI files in the app project may need follow-up cleanup as more slices move out. - The deterministic client adds maintenance work even though it is not a live provider adapter. ## 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. +- Keep `DotPilot.Core` as the single non-UI project with a local `AGENTS.md` file. +- 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..be578f1 100644 --- a/docs/Architecture.md +++ b/docs/Architecture.md @@ -1,27 +1,25 @@ # 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 or edit an agent profile, start or resume a session, send a message, and watch streaming status/tool output in the transcript while the chat info panel surfaces a compact fleet board for live-session visibility and provider health. +- **Presentation boundary:** [../DotPilot/](../DotPilot/) is the `Uno Platform` shell only. It owns desktop startup, routes, XAML composition, `MVUX` screen models plus generated view-model proxies, and visible operator flows such as session list, transcript, agent creation, and provider settings. +- **Core boundary:** [../DotPilot.Core/](../DotPilot.Core/) is the shared non-UI contract and application layer. It owns contract-shaped folders such as `ControlPlaneDomain` and `Workspace`, plus operational slices such as `AgentBuilder`, `ChatSessions`, `Providers`, and `HttpDiagnostics`, including the local session runtime and persistence paths used by the desktop app. +- **Startup hydration rule:** app startup is allowed to perform one splash-time provider/CLI hydration pass and reuse that provider snapshot for ordinary workspace reads until the operator explicitly refreshes readiness or changes provider preferences. +- **Live-session desktop rule:** while a session is actively generating, `DotPilot.Core` owns the live-session signal and the desktop host may hold a bounded sleep-prevention lock; the shell must show that state so the operator knows why the machine is being kept awake. +- **Extraction rule:** large non-UI features start in `DotPilot.Core`, but once a slice becomes big enough to need its own boundary, it should move into a dedicated DLL that references `DotPilot.Core`, while the desktop app references that feature DLL directly. +- **Solution-shape rule:** solution folders may group projects by stable categories such as libraries and tests, but extracted subsystems must still keep their own files, namespaces, and project-local rules inside their real project directory. +- **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, 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, distributed runtime topology, cloud persistence, multi-user identity, and external durable stores. ## Diagrams @@ -32,291 +30,145 @@ 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"] - Core["DotPilot.Core contracts"] - Runtime["DotPilot.Runtime services"] - Host["DotPilot.Runtime.Host Orleans silo"] + Ui["DotPilot Uno desktop shell"] + Core["DotPilot.Core contracts + shared application code"] Unit["DotPilot.Tests"] UiTests["DotPilot.UITests"] Root --> Governance Root --> Architecture - Root --> Adr1 - Root --> Adr3 - Root --> Feature - Root --> Toolchains Root --> Ui Root --> Core - Root --> Runtime - Root --> Host Root --> Unit Root --> UiTests Ui --> Core - Ui --> Runtime - Ui --> Host - Host --> Core 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 or edit agent profile"] + SessionList["Session list"] + Session["Active session"] + Stream["Streaming transcript + status + tool activity"] + Fleet["Fleet board + live session monitor"] + Git["Optional repo/git actions"] + + Settings --> Providers + Providers --> AgentCreate + AgentCreate --> SessionList + SessionList --> Session + Session --> Stream + Session --> Fleet + 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"] + Splash["Startup splash + shell overlay"] + ViewModels["MVUX screen models + generated view-model proxies"] + Service["IAgentSessionService"] + Hydration["Startup workspace hydration"] + LiveActivity["Session activity monitor"] + WakeLock["Desktop sleep prevention host"] + ProjectionStore["EF Core + SQLite projections"] + SessionStore["Folder AgentSession + chat history"] + ProviderCatalog["Provider catalog + readiness probe"] + ProviderSnapshot["Startup-owned provider snapshot"] + ProviderClient["Provider SDK / IChatClient or debug client"] + Stream["SessionStreamEntry updates"] + + Ui --> ViewModels + Ui --> Splash + Splash --> Hydration + Hydration --> Service + Hydration --> ProviderCatalog + ViewModels --> Service + Service --> ProjectionStore + Service --> SessionStore + Service --> LiveActivity + Service --> ProviderCatalog + Service --> ProviderSnapshot + LiveActivity --> WakeLock + LiveActivity --> ViewModels + WakeLock --> ViewModels + ProviderCatalog --> ProviderSnapshot + 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 projections + participant FS as Local folder AgentSession/history store + participant Provider as Provider SDK / Debug Client + + UI->>Service: CreateAgentAsync(...) or UpdateAgentAsync(...) + Service->>DB: Save or update agent profile + UI->>Service: CreateSessionAsync(...) + Service->>DB: Save session + initial status entry + Service->>FS: Create/persist opaque AgentSession + 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 ``` ## 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 rules` — [../DotPilot.Core/AGENTS.md](../DotPilot.Core/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/) +- `Core contracts and shared application code` — [../DotPilot.Core/](../DotPilot.Core/) +- `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/Chat/Views/ChatPage.xaml](../DotPilot/Presentation/Chat/Views/ChatPage.xaml) +- `Chat info panel + fleet board` — [../DotPilot/Presentation/Chat/Controls/ChatInfoPanel.xaml](../DotPilot/Presentation/Chat/Controls/ChatInfoPanel.xaml), [../DotPilot/Presentation/Chat/Controls/ChatFleetBoard.xaml](../DotPilot/Presentation/Chat/Controls/ChatFleetBoard.xaml) +- `Agent creation route` — [../DotPilot/Presentation/AgentBuilder/Views/AgentBuilderPage.xaml](../DotPilot/Presentation/AgentBuilder/Views/AgentBuilderPage.xaml) +- `Settings shell` — [../DotPilot/Presentation/Settings/Controls/SettingsShell.xaml](../DotPilot/Presentation/Settings/Controls/SettingsShell.xaml) +- `Active contracts` — [../DotPilot.Core/ChatSessions/Contracts/AgentSessionContracts.cs](../DotPilot.Core/ChatSessions/Contracts/AgentSessionContracts.cs) +- `Active commands` — [../DotPilot.Core/ChatSessions/Commands/](../DotPilot.Core/ChatSessions/Commands/) +- `Session service interface` — [../DotPilot.Core/ChatSessions/Interfaces/IAgentSessionService.cs](../DotPilot.Core/ChatSessions/Interfaces/IAgentSessionService.cs) +- `Session application service` — [../DotPilot.Core/ChatSessions/Execution/AgentSessionService.cs](../DotPilot.Core/ChatSessions/Execution/AgentSessionService.cs) +- `Provider readiness catalog` — [../DotPilot.Core/Providers/Configuration/AgentSessionProviderCatalog.cs](../DotPilot.Core/Providers/Configuration/AgentSessionProviderCatalog.cs) +- `UI end-to-end flow` — [../DotPilot.UITests/ChatSessions/Flows/GivenChatSessionsShell.cs](../DotPilot.UITests/ChatSessions/Flows/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, transcript, and durable runtime state. +- Keep repo/git operations as optional tools inside a session, not as the app's primary information architecture. +- Keep presentation models long-lived and projection-only so desktop navigation stays memory-hot instead of rehydrating each screen from scratch. +- 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 diff --git a/docs/Features/agent-control-plane-experience.md b/docs/Features/agent-control-plane-experience.md index ee6601a..c039be4 100644 --- a/docs/Features/agent-control-plane-experience.md +++ b/docs/Features/agent-control-plane-experience.md @@ -2,205 +2,163 @@ ## 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 +- 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 +- distributed runtime topology +- 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 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-->>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 from a natural-language draft and then reviews: + - provider + - model + - tools + - skills + - 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/control-plane-domain-model.md b/docs/Features/control-plane-domain-model.md index e93d385..6b2b67c 100644 --- a/docs/Features/control-plane-domain-model.md +++ b/docs/Features/control-plane-domain-model.md @@ -2,7 +2,7 @@ ## Summary -Issue [#22](https://github.com/managedcode/dotPilot/issues/22) defines the first stable domain contracts that later runtime, communication, Orleans, and orchestration slices will share. The goal is to keep these shapes broad enough for coding and non-coding agents while staying serialization-safe and independent from the Uno UI host. +Issue [#22](https://github.com/managedcode/dotPilot/issues/22) defines the first stable domain contracts that later runtime, communication, and orchestration slices will share. The goal is to keep these shapes broad enough for coding and non-coding agents while staying serialization-safe and independent from the Uno UI host. ## Scope @@ -15,7 +15,6 @@ Issue [#22](https://github.com/managedcode/dotPilot/issues/22) defines the first ### Out Of Scope -- Orleans grain implementations - live provider execution or SDK adapters - transport contracts for issue `#23` diff --git a/docs/Features/embedded-orleans-host.md b/docs/Features/embedded-orleans-host.md deleted file mode 100644 index 76ae500..0000000 --- a/docs/Features/embedded-orleans-host.md +++ /dev/null @@ -1,70 +0,0 @@ -# Embedded Orleans Host - -## 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. - -## 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 -- 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 -- UI redesign around the runtime host - -## Flow - -```mermaid -flowchart LR - App["DotPilot/App.xaml.cs"] - HostExt["UseDotPilotEmbeddedRuntime()"] - Silo["Embedded Orleans silo"] - Store["In-memory grain storage + reminders"] - Grains["Session / Workspace / Fleet / Policy / Artifact grains"] - Contracts["DotPilot.Core runtime-host contracts"] - - App --> HostExt - HostExt --> Silo - Silo --> Store - Silo --> Grains - Grains --> 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.Runtime.Host` owns: - - Orleans host configuration - - host lifecycle catalog state - - 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. - -## 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` -- `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) 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/gh-pages/index.html b/gh-pages/index.html new file mode 100644 index 0000000..29d2f85 --- /dev/null +++ b/gh-pages/index.html @@ -0,0 +1,681 @@ + + + + + + dotPilot - Local Agent Orchestrator for .NET + + + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+
+

Local Agent Orchestrator

+

Run AI Agents Locally. Build Workflows. Own Your Data.

+

Open source desktop platform for running multiple AI agents locally. Connect to Codex, Claude Code, GitHub Copilot, Gemini, or use local models. Build agentic workflows with Microsoft Agent Framework. Written in C# and .NET.

+ + +
+
+ +
+
+
+

Our Vision

+

"Intent becomes the interface"

+

We believe the future of human-AI interaction is about expressing what you need, not clicking through interfaces. dotPilot is our experiment in building that future — a production-ready .NET application that proves modern, beautiful software can be built on the Microsoft stack.

+
+
+
+ +
+
+

What You Can Do

+
+
+
🤖
+

Run Multiple Agents

+

Launch and manage multiple AI agents simultaneously. Each agent runs locally with its own context, tools, and capabilities. Full control over your agent fleet.

+
+
+
🔄
+

Build Workflows

+

Create agentic workflows using Microsoft Agent Framework. Sequential, parallel, handoff patterns. Orchestrate complex multi-agent tasks with streaming and checkpoints.

+
+
+
🔌
+

Any Provider

+

Connect to Codex CLI, Claude Code, GitHub Copilot, Gemini through SDK integrations. Use your favorite AI providers or run completely local with LLamaSharp and ONNX.

+
+
+
🏠
+

100% Local

+

Everything runs on your machine. Your data never leaves your device. No cloud dependency. SQLite persistence. Full privacy and control.

+
+
+
💬
+

Natural Language First

+

Create agents by describing what you need. Generate agent profiles, prompts, and tool configurations from natural language. Intent-driven interface.

+
+
+
📊
+

Observable

+

OpenTelemetry integration for tracing every agent action, tool call, and workflow step. Fleet board for real-time monitoring of all active sessions.

+
+
+
+
+ +
+
+

Supported Providers

+
+ Codex CLI + Claude Code + GitHub Copilot + Gemini + OpenAI API + Azure OpenAI + LLamaSharp (Local) + ONNX Runtime (Local) +
+
+
+ +
+
+

Downloads

+
+
+
🍎
+

macOS

+

Apple Silicon (arm64)

+ Download .dmg +
+
+
🪟
+

Windows

+

x64

+ Download .exe +
+
+
🐧
+

Linux

+

x64 Snap Package

+ Download .snap +
+
+
+
+ +
+
+

Built With

+
+ C# + .NET 10 + Uno Platform + Microsoft Agent Framework + EF Core + SQLite + OpenTelemetry +
+
+
+ +
+
+

Building in Public

+

dotPilot is fully open source. Join us on GitHub, watch our progress on YouTube, share your feedback, and help shape the future of local AI agents.

+ +
+
+
+ + + + 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.