diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index f0afec43..29f00783 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -85,7 +85,8 @@ Use these repository instructions as the starting point. When code or validated - Morning Triage is triggered from `AppHeader` through `ICopilotAgent.RunMorningTriageAsync()`. Triage/Maintenance sessions register `RadarTools.CreateAll()` and `RadarWriteTools.CreateAll()`; write-like tools are permission-gated except `radar_score_commit`, which `RadarPermissionPolicy` pre-approves for scoring persistence. Final review decisions (`radar_save_review`) are user-owned and must not be auto-written by Morning Triage, but registered Ignore rules still auto-mark future matching commits as `Rejected` during ingestion. - `radar_score_commit` auto-marks still-unreviewed commits with score `0.44` or lower as `ReviewStatus.Rejected` with reason `auto-low-score`. Do not overwrite user-owned review states such as `Adopted`, `Later`, `Archived`, or existing `Rejected` rows. - Triage sends need the longer `MorningTriageSession.TriageSendTimeout` through `ICopilotSession.SendAsync(prompt, timeout, ct)`; the SDK default one-minute wait is too short. -- Adoption draft/explanation generation and JSON repair sends need `AdoptionSession.DraftSendTimeout`; docs diffs plus URL fetches can exceed the SDK default one-minute wait and leave regenerated drafts empty. +- Adoption draft/explanation generation needs `AdoptionSession.DraftSendTimeout`; docs diffs plus URL fetches can exceed the SDK default one-minute wait and leave regenerated drafts empty. +- Adoption drafts require SDK typed structured output for every selected model, including fallback models. Validate returned fields before persisting, and surface errors rather than silently resending failed structured turns. ## UI And Product Behavior diff --git a/.github/skills/copilot-sdk-dotnet-audit/SKILL.md b/.github/skills/copilot-sdk-dotnet-audit/SKILL.md index c880269a..3892c39f 100644 --- a/.github/skills/copilot-sdk-dotnet-audit/SKILL.md +++ b/.github/skills/copilot-sdk-dotnet-audit/SKILL.md @@ -90,6 +90,7 @@ EF Core / .NET SDK preview 追随で migration 生成物が変わる場合は、 - `SendAndWaitAsync` は `AssistantMessageEvent?` を返す。最終テキストは `Data.Content`。 - timeout は idle 待ちの上限で、in-flight agent work の中止ではない。 - `MessageOptions` に JSON schema / response format が無い場合、JSON-only prompt だけを強保証として扱わない。 +- Typed structured output が public API にある版では、実際の bundled CLI と対象モデルで synthetic な schema 送信を検証する。SDK の JSON deserialization は完全な schema validation ではないため、必須フィールドを保存前に確認する。構造化出力を必須とする機能では、未対応モデルのエラーを明示し、送信後の失敗を安易に無形式で再送しない。 - beta.9 以降の tool filter は source-qualified (`custom:*`, `builtin:*`, `mcp:*`) を優先する。 ### 5. アプリ側の SDK 利用を棚卸しする diff --git a/Directory.Packages.props b/Directory.Packages.props index 6003f912..eb52f7c1 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -41,7 +41,7 @@ - + diff --git a/docs/USAGE.md b/docs/USAGE.md index c012b85d..f29903b3 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -187,6 +187,7 @@ Commit List で 1 件選び、[`ReviewActions`](../src/RepoSyncRadar.App/Compone - 既に文案があれば即表示。なければ **Regenerate** ボタンで生成。 - [`AdoptionSession`](../src/RepoSyncRadar.App/Copilot/AdoptionSession.cs) が、差分(50KB 超は安全に切り詰め) + 過去 5 件の注目例(few-shot)を Copilot に渡し、Twitter / 顧客向けの 2 媒体を JSON で返させて `Drafts` テーブルに保存。 +- 共有文案は選択中のモデルで SDK の型付き構造化出力を使用します。JSON Schema に対応したモデルを選んでください。構造化出力が失敗した場合は文案を保存せず、エラーを表示します。 - 各媒体には **コピーボタン**(WPF Dispatcher 経由で Clipboard へ)。 - 注目キューで複数コミットをチェックすると、複数の差分を横断した **まとめて解説生成** が使えます。結果は選択セット向けの一時テキストとして Workbench に表示し、必要に応じてコピーできます。 diff --git a/scripts/CopilotCliRelease.props b/scripts/CopilotCliRelease.props index 23f44de9..c99fb860 100644 --- a/scripts/CopilotCliRelease.props +++ b/scripts/CopilotCliRelease.props @@ -1,10 +1,10 @@ - + - 1.0.83 - 0e07221a275fdf7e61619c53566e3a421fd646d74d8e9ca491dbbff221f22945 - 63f35c0ce1a5fdcc6f3e584890d689b1ede8f930933394aaf7b5e139b53d2cc1 + 1.0.89-0 + 516ec467dcb906651b6e80795c1cdc2e20868d3d50b7121fb917c81a4f719c09 + b0c7b5766266f7fc3fc6d1934abdb2ea069d18db0ece58477b73fec2da4462c2 diff --git a/src/RepoSyncRadar.App/Copilot/AdoptionSession.cs b/src/RepoSyncRadar.App/Copilot/AdoptionSession.cs index b0722e86..bf704cfa 100644 --- a/src/RepoSyncRadar.App/Copilot/AdoptionSession.cs +++ b/src/RepoSyncRadar.App/Copilot/AdoptionSession.cs @@ -1,7 +1,5 @@ using System.Globalization; using System.Text; -using System.Text.Json; -using System.Text.Json.Serialization; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Localization; @@ -27,17 +25,9 @@ public sealed partial class AdoptionSession internal const int MaxDiffBytes = 50 * 1024; internal const int MaxBatchCommits = 10; internal const int FewShotLimit = 5; - internal const int MaxRepairSourceChars = 20 * 1024; internal const string TruncatedMarker = "\n…[truncated by RepoSyncRadar — original diff exceeded 50KB]\n"; internal static readonly TimeSpan DraftSendTimeout = TimeSpan.FromMinutes(10); - private static readonly JsonSerializerOptions _draftJsonOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - PropertyNameCaseInsensitive = true, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - }; - private readonly IDbContextFactory _dbFactory; private readonly IDocsGitHubClient _github; private readonly ICopilotSessionFactory _sessionFactory; @@ -107,8 +97,8 @@ public async Task GenerateDraftsAsync(string sha, CancellationToken DraftBundle bundle; await using (session.ConfigureAwait(false)) { - var raw = await session.SendAsync(prompt, DraftSendTimeout, cancellationToken).ConfigureAwait(false); - bundle = await ParseOrRepairBundleAsync(session, raw, cancellationToken).ConfigureAwait(false); + bundle = await session.SendStructuredDraftAsync(prompt, DraftSendTimeout, cancellationToken) + .ConfigureAwait(false); } bundle = EnsureOfficialDocUrls(bundle, officialDocUrls); @@ -328,304 +318,6 @@ private static string EnsureOfficialDocUrl(string draft, string url) return draft.TrimEnd() + Environment.NewLine + url; } - private static async Task ParseOrRepairBundleAsync( - ICopilotSession session, - string raw, - CancellationToken cancellationToken) - { - try - { - return ParseBundle(raw); - } - catch (InvalidOperationException ex) when (IsJsonParseFailure(ex)) - { - var repaired = await session.SendAsync(BuildRepairPrompt(raw), DraftSendTimeout, cancellationToken).ConfigureAwait(false); - try - { - return ParseBundle(repaired); - } - catch (InvalidOperationException repairEx) when (IsJsonParseFailure(repairEx)) - { - if (TryParsePlainTextBundle(repaired, out var repairedBundle)) - { - return repairedBundle; - } - if (TryParsePlainTextBundle(raw, out var rawBundle)) - { - return rawBundle; - } - throw new InvalidOperationException( - "Copilot の応答を文案として読み取れませんでした。もう一度再生成してください。", - repairEx); - } - } - } - - private static bool IsJsonParseFailure(InvalidOperationException ex) - => ex.Message.Contains("non-JSON", StringComparison.Ordinal) - || ex.Message.Contains("null JSON", StringComparison.Ordinal); - - internal static string BuildRepairPrompt(string raw) - { - var source = raw.Length > MaxRepairSourceChars - ? raw[..MaxRepairSourceChars] + "\n...[truncated by RepoSyncRadar for JSON repair]" - : raw; - - var sb = new StringBuilder(); - sb.AppendLine("前回の応答はアプリで処理できる JSON ではありませんでした。"); - sb.AppendLine("前回の内容から、次のスキーマに合う JSON object だけを返してください。"); - sb.AppendLine("説明文、Markdown、コードブロック、前置き、後置きは禁止です。"); - sb.AppendLine("スキーマ: { \"explanation\": string, \"twitter\": string, \"customer\": string }"); - sb.AppendLine("GitHub の product scope としての `Organization` / `Enterprise` は英語のまま残し、どちらも `組織` と訳さないでください。"); - sb.AppendLine(); - sb.AppendLine("前回の応答:"); - sb.AppendLine("```text"); - sb.AppendLine(source); - sb.AppendLine("```"); - return sb.ToString(); - } - - internal static bool TryParsePlainTextBundle(string response, out DraftBundle bundle) - { - var sections = ExtractPlainTextSections(response); - if (sections.Count == 0) - { - var fallback = StripOuterCodeFence(response.Trim()); - if (string.IsNullOrWhiteSpace(fallback)) - { - bundle = new DraftBundle(string.Empty, string.Empty, string.Empty, string.Empty); - return false; - } - - bundle = new DraftBundle( - TwitterJa: string.Empty, - TeamsJa: string.Empty, - CustomerJa: string.Empty, - ExplanationJa: fallback.Trim()); - return true; - } - - bundle = new DraftBundle( - TwitterJa: ValueFor(sections, "twitter"), - TeamsJa: string.Empty, - CustomerJa: ValueFor(sections, "customer"), - ExplanationJa: ValueFor(sections, "explanation")); - return !string.IsNullOrWhiteSpace(bundle.TwitterJa) - || !string.IsNullOrWhiteSpace(bundle.CustomerJa) - || !string.IsNullOrWhiteSpace(bundle.ExplanationJa); - } - - private static Dictionary ExtractPlainTextSections(string response) - { - var sections = new Dictionary(StringComparer.Ordinal); - var currentKey = string.Empty; - var current = new StringBuilder(); - - foreach (var rawLine in StripOuterCodeFence(response.Trim()).Replace("\r\n", "\n", StringComparison.Ordinal).Split('\n')) - { - var line = rawLine.TrimEnd(); - if (TryReadPlainTextSectionHeader(line, out var key, out var inlineValue)) - { - StoreCurrentSection(sections, currentKey, current); - currentKey = key; - current.Clear(); - if (!string.IsNullOrWhiteSpace(inlineValue)) - { - current.AppendLine(inlineValue.Trim()); - } - continue; - } - - if (!string.IsNullOrEmpty(currentKey)) - { - current.AppendLine(line); - } - } - - StoreCurrentSection(sections, currentKey, current); - return sections; - } - - private static bool TryReadPlainTextSectionHeader(string line, out string key, out string inlineValue) - { - var normalized = line.Trim().TrimStart('-', '*', ' ', '#').Trim(); - if (normalized.StartsWith("**", StringComparison.Ordinal) && normalized.Contains("**", StringComparison.Ordinal)) - { - normalized = normalized.Trim('*').Trim(); - } - - var separator = normalized.IndexOfAny([':', ':']); - var label = separator >= 0 ? normalized[..separator].Trim() : normalized.Trim(); - inlineValue = separator >= 0 ? normalized[(separator + 1)..].Trim() : string.Empty; - - key = NormalizeSectionLabel(label); - return key.Length > 0; - } - - private static string NormalizeSectionLabel(string label) - { - var normalized = label.Trim().Trim('`', '*', ' ', ' ').ToLowerInvariant(); - return normalized switch - { - "差分解説" or "解説" or "explanation" or "diff explanation" => "explanation", - "twitter" or "x" or "tweet" or "twitter向け" or "twitter 用" or "twitter用" => "twitter", - "顧客向け" or "顧客" or "customer" or "customer-facing" or "customer facing" => "customer", - _ => string.Empty, - }; - } - - private static void StoreCurrentSection(Dictionary sections, string key, StringBuilder value) - { - if (string.IsNullOrEmpty(key)) - { - return; - } - - var text = value.ToString().Trim(); - if (text.Length > 0) - { - sections[key] = text; - } - } - - private static string ValueFor(Dictionary sections, string key) - => sections.TryGetValue(key, out var value) ? value : string.Empty; - - internal static DraftBundle ParseBundle(string json) - { - if (string.IsNullOrWhiteSpace(json)) - { - throw new InvalidOperationException("Adoption session returned an empty response."); - } - var payload = ExtractJsonPayload(json); - - DraftJson? parsed; - try - { - parsed = JsonSerializer.Deserialize(payload, _draftJsonOptions); - } - catch (JsonException ex) - { - throw new InvalidOperationException("Adoption session returned non-JSON output.", ex); - } - if (parsed is null) - { - throw new InvalidOperationException("Adoption session returned a null JSON document."); - } - - return new DraftBundle( - parsed.Twitter ?? string.Empty, - string.Empty, - parsed.Customer ?? string.Empty, - parsed.Explanation ?? string.Empty); - } - - private static string ExtractJsonPayload(string response) - { - var trimmed = StripOuterCodeFence(response.Trim()); - return TryExtractFirstJsonObject(trimmed, out var payload) - ? payload - : trimmed; - } - - private static string StripOuterCodeFence(string value) - { - if (!value.StartsWith("```", StringComparison.Ordinal)) - { - return value; - } - - var firstNewline = value.IndexOf('\n'); - if (firstNewline < 0) - { - return value.Trim('`').Trim(); - } - - var fenced = value[(firstNewline + 1)..]; - if (fenced.EndsWith("```", StringComparison.Ordinal)) - { - fenced = fenced[..^3]; - } - return fenced.Trim(); - } - - private static bool TryExtractFirstJsonObject(string value, out string payload) - { - for (var start = value.IndexOf('{'); start >= 0; start = value.IndexOf('{', start + 1)) - { - if (TryFindJsonObjectEnd(value, start, out var end)) - { - var candidate = value[start..(end + 1)]; - try - { - using var document = JsonDocument.Parse(candidate); - if (document.RootElement.ValueKind == JsonValueKind.Object) - { - payload = candidate; - return true; - } - } - catch (JsonException) - { - } - } - } - - payload = string.Empty; - return false; - } - - private static bool TryFindJsonObjectEnd(string value, int start, out int end) - { - var depth = 0; - var inString = false; - var escaped = false; - - for (var i = start; i < value.Length; i++) - { - var c = value[i]; - if (inString) - { - if (escaped) - { - escaped = false; - } - else if (c == '\\') - { - escaped = true; - } - else if (c == '"') - { - inString = false; - } - continue; - } - - if (c == '"') - { - inString = true; - continue; - } - if (c == '{') - { - depth++; - continue; - } - if (c == '}') - { - depth--; - if (depth == 0) - { - end = i; - return true; - } - } - } - - end = -1; - return false; - } - private static async Task PersistDraftsAsync( RadarDbContext db, string sha, @@ -650,13 +342,6 @@ private static async Task PersistDraftsAsync( await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); } - private sealed class DraftJson - { - public string? Twitter { get; set; } - public string? Customer { get; set; } - public string? Explanation { get; set; } - } - [LoggerMessage(EventId = 1, Level = LogLevel.Information, Message = "Adoption prompt built for {Sha} ({PromptLength} chars).")] private static partial void LogPromptBuilt(ILogger logger, string sha, int promptLength); diff --git a/src/RepoSyncRadar.App/Copilot/ICopilotSession.cs b/src/RepoSyncRadar.App/Copilot/ICopilotSession.cs index ab5daeaa..905d719b 100644 --- a/src/RepoSyncRadar.App/Copilot/ICopilotSession.cs +++ b/src/RepoSyncRadar.App/Copilot/ICopilotSession.cs @@ -1,3 +1,5 @@ +using RepoSyncRadar.Core.Services; + namespace RepoSyncRadar.App.Copilot; /// @@ -25,6 +27,12 @@ Task SendAsync( TimeSpan? timeout, CancellationToken cancellationToken = default); + /// Sends a draft prompt with a JSON Schema and an explicit timeout for long docs diffs. + Task SendStructuredDraftAsync( + string prompt, + TimeSpan timeout, + CancellationToken cancellationToken = default); + /// Aborts the current turn. Safe to call after the session has already ended. Task AbortAsync(CancellationToken cancellationToken = default); } diff --git a/src/RepoSyncRadar.App/Copilot/SdkCopilotSession.cs b/src/RepoSyncRadar.App/Copilot/SdkCopilotSession.cs index ed5d6a27..adaf4189 100644 --- a/src/RepoSyncRadar.App/Copilot/SdkCopilotSession.cs +++ b/src/RepoSyncRadar.App/Copilot/SdkCopilotSession.cs @@ -1,5 +1,7 @@ +using System.Text.Json.Serialization; using GitHub.Copilot; using Microsoft.Extensions.Logging; +using RepoSyncRadar.Core.Services; namespace RepoSyncRadar.App.Copilot; @@ -52,6 +54,40 @@ public async Task SendAsync( return assistant?.Data?.Content ?? string.Empty; } +#pragma warning disable GHCP001 // Typed structured output is experimental in the installed SDK. + public async Task SendStructuredDraftAsync( + string prompt, + TimeSpan timeout, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(prompt); + var draft = await _session.SendAndWaitAsync( + prompt, + timeout: timeout, + cancellationToken: cancellationToken).ConfigureAwait(false); + await RefreshUsageMetricsAsync(cancellationToken).ConfigureAwait(false); + return CreateDraftBundle(draft.Explanation, draft.Twitter, draft.Customer); + } +#pragma warning restore GHCP001 + + internal static DraftBundle CreateDraftBundle(string? explanation, string? twitter, string? customer) + { + if (explanation is null) + { + throw new InvalidOperationException("Copilot returned a structured draft without explanation."); + } + if (twitter is null) + { + throw new InvalidOperationException("Copilot returned a structured draft without twitter."); + } + if (customer is null) + { + throw new InvalidOperationException("Copilot returned a structured draft without customer."); + } + + return new DraftBundle(twitter, string.Empty, customer, explanation); + } + private async Task RefreshUsageMetricsAsync(CancellationToken cancellationToken) { if (_usageTracker is null) @@ -82,4 +118,10 @@ public async ValueTask DisposeAsync() [LoggerMessage(EventId = 1, Level = LogLevel.Debug, Message = "Could not refresh Copilot SDK usage metrics for session {SessionId}.")] private static partial void LogUsageMetricsRefreshFailed(ILogger logger, Exception ex, string sessionId); + + // Non-nullable fields keep the inferred schema strict; the mapper rejects malformed provider output. + private sealed record StructuredDraft( + [property: JsonPropertyName("explanation")] string Explanation, + [property: JsonPropertyName("twitter")] string Twitter, + [property: JsonPropertyName("customer")] string Customer); } diff --git a/src/RepoSyncRadar.App/Settings/ThirdPartyNotices.cs b/src/RepoSyncRadar.App/Settings/ThirdPartyNotices.cs index 1cc96f35..fcf974de 100644 --- a/src/RepoSyncRadar.App/Settings/ThirdPartyNotices.cs +++ b/src/RepoSyncRadar.App/Settings/ThirdPartyNotices.cs @@ -13,7 +13,7 @@ public static class ThirdPartyNotices { public static IReadOnlyList All { get; } = [ - Mit("GitHub.Copilot.SDK", "1.0.13", "Copyright (c) Microsoft Corporation. All rights reserved.", "https://github.com/github/copilot-sdk"), + Mit("GitHub.Copilot.SDK", "1.0.15-preview.1", "Copyright (c) Microsoft Corporation. All rights reserved.", "https://github.com/github/copilot-sdk"), new( "Markdig", "1.2.0", diff --git a/tests/RepoSyncRadar.App.Tests/Components/ThirdPartyNoticesPanelTests.cs b/tests/RepoSyncRadar.App.Tests/Components/ThirdPartyNoticesPanelTests.cs index bec6c888..838a77bd 100644 --- a/tests/RepoSyncRadar.App.Tests/Components/ThirdPartyNoticesPanelTests.cs +++ b/tests/RepoSyncRadar.App.Tests/Components/ThirdPartyNoticesPanelTests.cs @@ -28,7 +28,7 @@ public void Renders_Third_Party_Notices() Assert.Contains("MudBlazor", packages); var sdkIndex = Array.IndexOf(packages, "GitHub.Copilot.SDK"); Assert.NotEqual(-1, sdkIndex); - Assert.Equal("1.0.13", versions[sdkIndex]); + Assert.Equal("1.0.15-preview.1", versions[sdkIndex]); Assert.Contains("Microsoft.Web.WebView2", packages); Assert.Contains("BSD-2-Clause", cut.Markup, StringComparison.Ordinal); Assert.Contains("MIT", cut.Markup, StringComparison.Ordinal); diff --git a/tests/RepoSyncRadar.App.Tests/Copilot/AdoptionSessionTests.cs b/tests/RepoSyncRadar.App.Tests/Copilot/AdoptionSessionTests.cs index 1b9732fd..0e7a566c 100644 --- a/tests/RepoSyncRadar.App.Tests/Copilot/AdoptionSessionTests.cs +++ b/tests/RepoSyncRadar.App.Tests/Copilot/AdoptionSessionTests.cs @@ -1,7 +1,6 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; -using NSubstitute.ExceptionExtensions; using RepoSyncRadar.App.Copilot; using RepoSyncRadar.App.Tests.Copilot.Tools; using RepoSyncRadar.Core.Models; @@ -17,6 +16,82 @@ namespace RepoSyncRadar.App.Tests.Copilot; /// public sealed class AdoptionSessionTests { + [Fact] + public async Task Generate_Uses_Typed_Drafts() + { + var ct = TestContext.Current.CancellationToken; + await using var harness = await WriteHarness.CreateAsync(ct); + await harness.InsertReviewedCommitAsync("structured", ReviewStatus.Adopted, cancellationToken: ct); + await using (var seedDb = harness.CreateDb()) + { + seedDb.CommitFiles.Add(new CommitFile + { + Sha = "structured", + Path = "content/actions/learn-github-actions.md", + Status = "modified", + Additions = 1, + Deletions = 0, + }); + await seedDb.SaveChangesAsync(ct); + } + + var github = Substitute.For(); + github.GetUnifiedDiffAsync("structured", Arg.Any()) + .Returns(Task.FromResult("diff --git a/content/test.md b/content/test.md\n+hello\n")); + + var session = new StructuredDraftSessionStub + { + Result = new DraftBundle("tw", string.Empty, "cu", "ex"), + }; + var factory = Substitute.For(); + factory.CreateSessionAsync(SessionPurpose.Adoption, Arg.Any()) + .Returns(Task.FromResult(session)); + + var sut = new AdoptionSession(harness.DbFactory, github, factory, NullLogger.Instance); + var bundle = await sut.GenerateDraftsAsync("structured", ct); + + Assert.Equal(AdoptionSession.DraftSendTimeout, session.CapturedTimeout); + Assert.Equal("ex", bundle.ExplanationJa); + Assert.Contains("https://docs.github.com/en/actions/learn-github-actions", bundle.TwitterJa, StringComparison.Ordinal); + Assert.Contains("https://docs.github.com/en/actions/learn-github-actions", bundle.CustomerJa, StringComparison.Ordinal); + await using var db = harness.CreateDb(); + Assert.Equal(3, await db.Drafts.CountAsync(d => d.Sha == "structured", ct)); + Assert.Contains(await db.Drafts.Where(d => d.Sha == "structured").ToListAsync(ct), + d => d.Channel == "twitter" && d.Body == bundle.TwitterJa); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Generate_Does_Not_Resend_Or_Save_When_Structured_Output_Fails(bool missingField) + { + var ct = TestContext.Current.CancellationToken; + await using var harness = await WriteHarness.CreateAsync(ct); + await harness.InsertReviewedCommitAsync("structured-error", ReviewStatus.Adopted, cancellationToken: ct); + + var github = Substitute.For(); + github.GetUnifiedDiffAsync("structured-error", Arg.Any()) + .Returns(Task.FromResult("diff")); + var session = new StructuredDraftSessionStub + { + Failure = missingField + ? Assert.Throws( + () => SdkCopilotSession.CreateDraftBundle(null, "tw", "cu")) + : new InvalidOperationException("Schema rejected by provider."), + }; + var factory = Substitute.For(); + factory.CreateSessionAsync(SessionPurpose.Adoption, Arg.Any()) + .Returns(Task.FromResult(session)); + + var sut = new AdoptionSession(harness.DbFactory, github, factory, NullLogger.Instance); + var error = await Assert.ThrowsAsync(() => + sut.GenerateDraftsAsync("structured-error", ct)); + + Assert.Contains(missingField ? "explanation" : "Schema rejected", error.Message, StringComparison.Ordinal); + await using var db = harness.CreateDb(); + Assert.Equal(0, await db.Drafts.CountAsync(d => d.Sha == "structured-error", ct)); + } + [Fact] public async Task Generate_Returns_Two_Drafts() { @@ -30,8 +105,8 @@ public async Task Generate_Returns_Two_Drafts() var session = Substitute.For(); session.SessionId.Returns("s1"); - session.SendAsync(Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(Task.FromResult("{\"explanation\":\"ex\",\"twitter\":\"tw\",\"customer\":\"cu\"}")); + session.SendStructuredDraftAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(new DraftBundle("tw", string.Empty, "cu", "ex"))); var factory = Substitute.For(); factory.CreateSessionAsync(SessionPurpose.Adoption, Arg.Any()) .Returns(Task.FromResult(session)); @@ -58,11 +133,11 @@ public async Task Generate_Uses_Long_Draft_Timeout() TimeSpan? capturedTimeout = null; var session = Substitute.For(); - session.SendAsync(Arg.Any(), Arg.Any(), Arg.Any()) + session.SendStructuredDraftAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(call => { - capturedTimeout = call.ArgAt(1); - return Task.FromResult("{\"explanation\":\"ex\",\"twitter\":\"tw\",\"customer\":\"cu\"}"); + capturedTimeout = call.ArgAt(1); + return Task.FromResult(new DraftBundle("tw", string.Empty, "cu", "ex")); }); var factory = Substitute.For(); factory.CreateSessionAsync(SessionPurpose.Adoption, Arg.Any()) @@ -87,8 +162,8 @@ public async Task Generate_Persists_Explanation_And_Two_Drafts() .Returns(Task.FromResult("diff")); var session = Substitute.For(); - session.SendAsync(Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(Task.FromResult("{\"explanation\":\"diff explanation\",\"twitter\":\"a\",\"customer\":\"c\"}")); + session.SendStructuredDraftAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(new DraftBundle("a", string.Empty, "c", "diff explanation"))); var factory = Substitute.For(); factory.CreateSessionAsync(SessionPurpose.Adoption, Arg.Any()) .Returns(Task.FromResult(session)); @@ -105,127 +180,6 @@ public async Task Generate_Persists_Explanation_And_Two_Drafts() Assert.DoesNotContain(drafts, d => d.Channel == "teams"); } - [Fact] - public void ParseBundle_Accepts_Json_Wrapped_In_Assistant_Text() - { - var bundle = AdoptionSession.ParseBundle( - "以下の内容で生成しました。\n```json\n" + - "{\"explanation\":\"ex {braced}\",\"twitter\":\"tw\",\"teams\":\"tm\",\"customer\":\"cu\"}" + - "\n```\n必要に応じて調整してください。"); - - Assert.Equal("tw", bundle.TwitterJa); - Assert.Empty(bundle.TeamsJa); - Assert.Equal("cu", bundle.CustomerJa); - Assert.Equal("ex {braced}", bundle.ExplanationJa); - } - - [Fact] - public async Task Generate_Persists_Drafts_When_Copilot_Wraps_Json() - { - var ct = TestContext.Current.CancellationToken; - await using var harness = await WriteHarness.CreateAsync(ct); - await harness.InsertReviewedCommitAsync("wrapped", ReviewStatus.Adopted, cancellationToken: ct); - - var github = Substitute.For(); - github.GetUnifiedDiffAsync(Arg.Any(), Arg.Any()) - .Returns(Task.FromResult("diff")); - - var session = Substitute.For(); - session.SendAsync(Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(Task.FromResult("Here is the JSON:\n{\"explanation\":\"wrapped-ex\",\"twitter\":\"wrapped-tw\",\"customer\":\"wrapped-cu\"}")); - var factory = Substitute.For(); - factory.CreateSessionAsync(SessionPurpose.Adoption, Arg.Any()) - .Returns(Task.FromResult(session)); - - var sut = new AdoptionSession(harness.DbFactory, github, factory, NullLogger.Instance); - await sut.GenerateDraftsAsync("wrapped", ct); - - await using var db = harness.CreateDb(); - var drafts = await db.Drafts.AsNoTracking().Where(d => d.Sha == "wrapped").ToListAsync(ct); - Assert.Contains(drafts, d => d.Channel == "explanation" && d.Body == "wrapped-ex"); - Assert.Contains(drafts, d => d.Channel == "twitter" && d.Body == "wrapped-tw"); - } - - [Fact] - public async Task Generate_Retries_Once_When_Response_Is_Not_Json() - { - var ct = TestContext.Current.CancellationToken; - await using var harness = await WriteHarness.CreateAsync(ct); - await harness.InsertReviewedCommitAsync("repair", ReviewStatus.Adopted, cancellationToken: ct); - - var github = Substitute.For(); - github.GetUnifiedDiffAsync(Arg.Any(), Arg.Any()) - .Returns(Task.FromResult("diff")); - - var calls = new List(); - var session = Substitute.For(); - session.SendAsync(Arg.Do(calls.Add), Arg.Any(), Arg.Any()) - .Returns( - Task.FromResult("差分解説: repair-ex\nTwitter: repair-tw\n顧客向け: repair-cu"), - Task.FromResult("{\"explanation\":\"repair-ex\",\"twitter\":\"repair-tw\",\"customer\":\"repair-cu\"}")); - var factory = Substitute.For(); - factory.CreateSessionAsync(SessionPurpose.Adoption, Arg.Any()) - .Returns(Task.FromResult(session)); - - var sut = new AdoptionSession(harness.DbFactory, github, factory, NullLogger.Instance); - var bundle = await sut.GenerateDraftsAsync("repair", ct); - - Assert.Equal("repair-tw", bundle.TwitterJa); - Assert.Equal("repair-ex", bundle.ExplanationJa); - Assert.Equal(2, calls.Count); - Assert.Contains("前回の応答はアプリで処理できる JSON ではありませんでした", calls[1], StringComparison.Ordinal); - } - - [Fact] - public async Task Generate_Parses_Labeled_Text_When_Repair_Is_Not_Json() - { - var ct = TestContext.Current.CancellationToken; - await using var harness = await WriteHarness.CreateAsync(ct); - await harness.InsertReviewedCommitAsync("labeled", ReviewStatus.Adopted, cancellationToken: ct); - - var github = Substitute.For(); - github.GetUnifiedDiffAsync(Arg.Any(), Arg.Any()) - .Returns(Task.FromResult("diff")); - - var session = Substitute.For(); - session.SendAsync(Arg.Any(), Arg.Any(), Arg.Any()) - .Returns( - Task.FromResult("JSON ではなく通常文で返します。"), - Task.FromResult(""" - ## 差分解説 - labeled-ex - - ## Twitter - labeled-tw - - ## 顧客向け - labeled-cu - """)); - var factory = Substitute.For(); - factory.CreateSessionAsync(SessionPurpose.Adoption, Arg.Any()) - .Returns(Task.FromResult(session)); - - var sut = new AdoptionSession(harness.DbFactory, github, factory, NullLogger.Instance); - var bundle = await sut.GenerateDraftsAsync("labeled", ct); - - Assert.Equal("labeled-ex", bundle.ExplanationJa); - Assert.Equal("labeled-tw", bundle.TwitterJa); - Assert.Empty(bundle.TeamsJa); - Assert.Equal("labeled-cu", bundle.CustomerJa); - } - - [Fact] - public void ParsePlainTextBundle_Uses_Response_As_Explanation_When_No_Labels_Exist() - { - var parsed = AdoptionSession.TryParsePlainTextBundle("文案として読める本文です。", out var bundle); - - Assert.True(parsed); - Assert.Equal("文案として読める本文です。", bundle.ExplanationJa); - Assert.Empty(bundle.TwitterJa); - Assert.Empty(bundle.TeamsJa); - Assert.Empty(bundle.CustomerJa); - } - [Fact] public async Task Generate_Includes_FewShot_Examples() { @@ -249,8 +203,8 @@ await harness.InsertReviewedCommitAsync( string? capturedPrompt = null; var session = Substitute.For(); - session.SendAsync(Arg.Do(p => capturedPrompt = p), Arg.Any(), Arg.Any()) - .Returns(Task.FromResult("{\"explanation\":\"\",\"twitter\":\"\",\"customer\":\"\"}")); + session.SendStructuredDraftAsync(Arg.Do(p => capturedPrompt = p), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(new DraftBundle(string.Empty, string.Empty, string.Empty, string.Empty))); var factory = Substitute.For(); factory.CreateSessionAsync(SessionPurpose.Adoption, Arg.Any()) .Returns(Task.FromResult(session)); @@ -274,15 +228,6 @@ await harness.InsertReviewedCommitAsync( AssertContainsGitHubScopeTerminologyRule(capturedPrompt); } - [Fact] - public void BuildRepairPrompt_Includes_GitHub_Scope_Terminology_Rule() - { - var prompt = AdoptionSession.BuildRepairPrompt( - "{\"explanation\":\"GitHub Organization settings changed\",\"twitter\":\"\",\"customer\":\"\"}"); - - AssertContainsGitHubScopeTerminologyRule(prompt); - } - [Fact] public async Task Generate_Includes_Official_Doc_Urls_In_Prompt() { @@ -316,8 +261,12 @@ public async Task Generate_Includes_Official_Doc_Urls_In_Prompt() string? capturedPrompt = null; var session = Substitute.For(); - session.SendAsync(Arg.Do(p => capturedPrompt = p), Arg.Any(), Arg.Any()) - .Returns(Task.FromResult("{\"explanation\":\"\",\"twitter\":\"https://docs.github.com/en/copilot/about-copilot\",\"customer\":\"https://docs.github.com/en/copilot/about-copilot\"}")); + session.SendStructuredDraftAsync(Arg.Do(p => capturedPrompt = p), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(new DraftBundle( + "https://docs.github.com/en/copilot/about-copilot", + string.Empty, + "https://docs.github.com/en/copilot/about-copilot", + string.Empty))); var factory = Substitute.For(); factory.CreateSessionAsync(SessionPurpose.Adoption, Arg.Any()) .Returns(Task.FromResult(session)); @@ -405,8 +354,8 @@ public async Task Generate_Appends_Official_Doc_Url_When_Drafts_Omit_It() .Returns(Task.FromResult("diff")); var session = Substitute.For(); - session.SendAsync(Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(Task.FromResult("{\"explanation\":\"ex\",\"twitter\":\"tw\",\"customer\":\"cu\"}")); + session.SendStructuredDraftAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(new DraftBundle("tw", string.Empty, "cu", "ex"))); var factory = Substitute.For(); factory.CreateSessionAsync(SessionPurpose.Adoption, Arg.Any()) .Returns(Task.FromResult(session)); @@ -450,8 +399,8 @@ public async Task Generate_Fallback_Official_Doc_Url_Drops_Index_Segment() .Returns(Task.FromResult("diff")); var session = Substitute.For(); - session.SendAsync(Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(Task.FromResult("{\"explanation\":\"ex\",\"twitter\":\"tw\",\"customer\":\"cu\"}")); + session.SendStructuredDraftAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(new DraftBundle("tw", string.Empty, "cu", "ex"))); var factory = Substitute.For(); factory.CreateSessionAsync(SessionPurpose.Adoption, Arg.Any()) .Returns(Task.FromResult(session)); @@ -500,8 +449,8 @@ public async Task Generate_Normalizes_Mapped_Official_Doc_Url_Index_Segment() string? capturedPrompt = null; var session = Substitute.For(); - session.SendAsync(Arg.Do(prompt => capturedPrompt = prompt), Arg.Any(), Arg.Any()) - .Returns(Task.FromResult("{\"explanation\":\"ex\",\"twitter\":\"tw\",\"customer\":\"cu\"}")); + session.SendStructuredDraftAsync(Arg.Do(prompt => capturedPrompt = prompt), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(new DraftBundle("tw", string.Empty, "cu", "ex"))); var factory = Substitute.For(); factory.CreateSessionAsync(SessionPurpose.Adoption, Arg.Any()) .Returns(Task.FromResult(session)); @@ -531,7 +480,7 @@ public async Task Generate_Rejects_Unadopted_Commit() var sut = new AdoptionSession(harness.DbFactory, github, factory, NullLogger.Instance); await Assert.ThrowsAsync(() => sut.GenerateDraftsAsync("not-yet", ct)); - await session.DidNotReceive().SendAsync(Arg.Any(), Arg.Any(), Arg.Any()); + await session.DidNotReceive().SendStructuredDraftAsync(Arg.Any(), Arg.Any(), Arg.Any()); } [Fact] @@ -570,8 +519,8 @@ public async Task GenerateBatchExplanation_Generates_Drafts_For_Each_Selected_Ad var capturedPrompts = new List(); var session = Substitute.For(); - session.SendAsync(Arg.Do(capturedPrompts.Add), Arg.Any(), Arg.Any()) - .Returns(Task.FromResult("{\"explanation\":\"ex\",\"twitter\":\"tw\",\"customer\":\"cu\"}")); + session.SendStructuredDraftAsync(Arg.Do(capturedPrompts.Add), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(new DraftBundle("tw", string.Empty, "cu", "ex"))); var factory = Substitute.For(); factory.CreateSessionAsync(SessionPurpose.Adoption, Arg.Any()) .Returns(Task.FromResult(session)); @@ -616,6 +565,37 @@ public async Task GenerateBatchExplanation_Rejects_Unadopted_Commits() var sut = new AdoptionSession(harness.DbFactory, github, factory, NullLogger.Instance); await Assert.ThrowsAsync(() => sut.GenerateBatchExplanationAsync(["batch-adopted", "batch-later"], ct)); - await session.DidNotReceive().SendAsync(Arg.Any(), Arg.Any(), Arg.Any()); + await session.DidNotReceive().SendStructuredDraftAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + private sealed class StructuredDraftSessionStub : ICopilotSession + { + public string SessionId => "structured-test"; + public DraftBundle? Result { get; init; } + public Exception? Failure { get; init; } + public TimeSpan? CapturedTimeout { get; private set; } + + public Task SendStructuredDraftAsync( + string prompt, + TimeSpan timeout, + CancellationToken cancellationToken) + { + CapturedTimeout = timeout; + if (Failure is { } failure) + { + return Task.FromException(failure); + } + + return Task.FromResult(Result ?? throw new InvalidOperationException("Missing structured test response.")); + } + + public Task SendAsync(string prompt, CancellationToken cancellationToken = default) + => throw new InvalidOperationException("Legacy send was not expected."); + + public Task SendAsync(string prompt, TimeSpan? timeout, CancellationToken cancellationToken = default) + => throw new InvalidOperationException("Legacy send was not expected."); + + public Task AbortAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; + public ValueTask DisposeAsync() => ValueTask.CompletedTask; } } diff --git a/tests/RepoSyncRadar.App.Tests/Copilot/SdkCopilotSessionTests.cs b/tests/RepoSyncRadar.App.Tests/Copilot/SdkCopilotSessionTests.cs new file mode 100644 index 00000000..00861b36 --- /dev/null +++ b/tests/RepoSyncRadar.App.Tests/Copilot/SdkCopilotSessionTests.cs @@ -0,0 +1,34 @@ +using RepoSyncRadar.App.Copilot; +using Xunit; + +namespace RepoSyncRadar.App.Tests.Copilot; + +public sealed class SdkCopilotSessionTests +{ + [Theory] + [InlineData(null, "tw", "cu", "explanation")] + [InlineData("ex", null, "cu", "twitter")] + [InlineData("ex", "tw", null, "customer")] + public void CreateDraftBundle_Rejects_Missing_Fields( + string? explanation, + string? twitter, + string? customer, + string missingField) + { + var error = Assert.Throws( + () => SdkCopilotSession.CreateDraftBundle(explanation, twitter, customer)); + + Assert.Contains(missingField, error.Message, StringComparison.Ordinal); + } + + [Fact] + public void CreateDraftBundle_Maps_All_Fields() + { + var bundle = SdkCopilotSession.CreateDraftBundle("ex", "tw", "cu"); + + Assert.Equal("ex", bundle.ExplanationJa); + Assert.Equal("tw", bundle.TwitterJa); + Assert.Equal("cu", bundle.CustomerJa); + Assert.Empty(bundle.TeamsJa); + } +}