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);
+ }
+}