Size: 50.8 KB Modified: 6/07/2026 4:06 AM
using KaiChat.Models;
using KaiChat.Hubs;
using KaiChat.Services;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using System.Runtime.CompilerServices;
using System.Reflection;
using System.Text;
using System.Text.RegularExpressions;

var tests = new (string Name, Action Run)[]
{
    ("cleans duplicate leading separator lines", CleansDuplicateLeadingSeparatorLines),
    ("strips whole-response markdown fences", StripsWholeResponseMarkdownFences),
    ("rejects missing existing headings", RejectsMissingExistingHeadings),
    ("treats separator-only rewrite as no meaningful change", TreatsSeparatorOnlyRewriteAsNoMeaningfulChange),
    ("allows additive rewrite that preserves existing headings", AllowsAdditiveRewriteThatPreservesExistingHeadings),
    ("service rejects heading loss without writing", () => ServiceRejectsHeadingLossWithoutWriting().GetAwaiter().GetResult()),
    ("service skips separator-only no-op without writing", () => ServiceSkipsSeparatorOnlyNoOpWithoutWriting().GetAwaiter().GetResult()),
    ("service writes and commits valid rewrite", () => ServiceWritesAndCommitsValidRewrite().GetAwaiter().GetResult()),
    ("kaitimer service starts pauses and resets", () => KaiTimerServiceStartsPausesAndResets().GetAwaiter().GetResult()),
    ("kaitimer service formats status", () => KaiTimerServiceFormatsStatus().GetAwaiter().GetResult()),
    ("kaitimer wheel spins enabled segment", () => KaiTimerWheelSpinsEnabledSegment().GetAwaiter().GetResult()),
    ("kaitimer counters support manual changes", () => KaiTimerCountersSupportManualChanges().GetAwaiter().GetResult()),
    ("kaitimer milestones claim once at threshold", () => KaiTimerMilestonesClaimOnceAtThreshold().GetAwaiter().GetResult()),
    ("server tool planner starts KaiTimer", ServerToolPlannerStartsKaiTimer),
    ("server tool planner checks KaiTimer status", ServerToolPlannerChecksKaiTimerStatus),
    ("server tool planner pauses KaiTimer", ServerToolPlannerPausesKaiTimer),
    ("server tool planner resets KaiTimer", ServerToolPlannerResetsKaiTimer),
    ("server tool planner ignores tool meta questions", ServerToolPlannerIgnoresToolMetaQuestions),
    ("tool parser extracts KaiTimer start", ToolParserExtractsKaiTimerStart),
    ("tool parser ignores fenced tool examples", ToolParserIgnoresFencedToolExamples),
    ("native tool call converter extracts KaiTimer start", NativeToolCallConverterExtractsKaiTimerStart),
    ("chat service serializes native tool transcript", ChatServiceSerializesNativeToolTranscript),
    ("chat service disables DeepSeek thinking for tool acquisition", ChatServiceDisablesDeepSeekThinkingForToolAcquisition),
    ("server tool planner ignores non-tool user messages", ServerToolPlannerIgnoresNonToolMessages),
    ("memory updater batches pending messages", MemoryUpdaterBatchesPendingMessages),
    ("memory updater trims oversized message transcript", MemoryUpdaterTrimsOversizedMessageTranscript),
    ("memory updater cools down after marker write", MemoryUpdaterCoolsDownAfterMarkerWrite),
    ("memory updater trips fuse after consecutive failures", MemoryUpdaterTripsFuseAfterConsecutiveFailures),
    ("memory updater does not advance marker without successful operation", MemoryUpdaterDoesNotAdvanceMarkerWithoutSuccessfulOperation),
    ("memory updater detects staged marker file changes", MemoryUpdaterDetectsStagedMarkerFileChanges),
    ("memory marker creates once and deletes cleanly", () => MemoryMarkerCreatesOnceAndDeletesCleanly().GetAwaiter().GetResult()),
    ("memory service saves deduplicated sections", () => MemoryServiceSavesDeduplicatedSections().GetAwaiter().GetResult()),
    ("memory manager rejects truncated placeholder content", () => MemoryManagerRejectsTruncatedContent().GetAwaiter().GetResult()),
    ("memory manager prompt asks for stale cross-file reconciliation", () => MemoryManagerPromptAsksForStaleCrossFileReconciliation().GetAwaiter().GetResult())
};

var failed = 0;
foreach (var test in tests)
{
    try
    {
        test.Run();
        Console.WriteLine($"PASS {test.Name}");
    }
    catch (Exception ex)
    {
        failed++;
        Console.Error.WriteLine($"FAIL {test.Name}: {ex.Message}");
    }
}

if (failed > 0)
{
    Console.Error.WriteLine($"{failed} test(s) failed.");
    Environment.Exit(1);
}

Console.WriteLine("All tests passed.");

static void CleansDuplicateLeadingSeparatorLines()
{
    var cleaned = InstructionRewriteGuard.CleanModelOutput("""
        ---
        ---
        ---
        # Kai.md

        ## Mode 1: In the cave (roleplay)
        """);

    AssertStartsWith("# Kai.md", cleaned);
    AssertDoesNotContain("---\n---", Normalize(cleaned));
}

static void StripsWholeResponseMarkdownFences()
{
    var cleaned = InstructionRewriteGuard.CleanModelOutput("""
        ```markdown
        # Kai.md

        ## Mode 2: Real talk
        Keep this.
        ```
        """);

    AssertStartsWith("# Kai.md", cleaned);
    AssertDoesNotContain("```", cleaned);
}

static void RejectsMissingExistingHeadings()
{
    var current = """
        # Kai.md

        ## Mode 2: Real talk
        Existing real-talk rule.
        """;

    var candidate = """
        # Kai.md

        ## Mode 1: In the cave (roleplay)
        New roleplay rule.
        """;

    var validation = InstructionRewriteGuard.Validate(
        current,
        candidate,
        "In Mode 1, add an explicit formatting rule.");

    AssertFalse(validation.Valid, "rewrite should have been rejected");
    AssertContains("## Mode 2: Real talk", validation.Message);
}

static void TreatsSeparatorOnlyRewriteAsNoMeaningfulChange()
{
    var current = """
        ---
        # Writing Standards

        Existing text.
        """;

    var candidate = """
        ---
        ---
        ---
        # Writing Standards

        Existing text.
        """;

    var validation = InstructionRewriteGuard.Validate(
        current,
        InstructionRewriteGuard.CleanModelOutput(candidate),
        "Add a hard rule.");

    AssertTrue(validation.Valid, validation.Message);
    AssertFalse(validation.HasMeaningfulChange, "separator-only rewrite should be a no-op");
}

static void AllowsAdditiveRewriteThatPreservesExistingHeadings()
{
    var current = """
        # Kai.md

        ## Mode 2: Real talk
        Existing real-talk rule.
        """;

    var candidate = """
        # Kai.md

        ## Mode 1: In the cave (roleplay)
        New roleplay rule.

        ## Mode 2: Real talk
        Existing real-talk rule.
        """;

    var validation = InstructionRewriteGuard.Validate(
        current,
        candidate,
        "In Mode 1, add an explicit formatting rule.");

    AssertTrue(validation.Valid, validation.Message);
    AssertTrue(validation.HasMeaningfulChange, "additive rewrite should be meaningful");
}

static async Task ServiceRejectsHeadingLossWithoutWriting()
{
    var files = new FakeFileService("""
        # Kai.md

        ## Mode 2: Real talk
        Existing real-talk rule.
        """);
    var git = new FakeGitService();
    var service = new InstructionRewriteService(
        new FakeChatService("""
            # Kai.md

            ## Mode 1: In the cave (roleplay)
            New roleplay rule.
            """),
        files,
        git);

    var result = await service.RewriteAsync("KaiChat/Kai.md", "In Mode 1, add an explicit formatting rule.");

    AssertTrue(result.IsError, "rewrite should have been rejected");
    AssertFalse(result.Rewritten, "rejected rewrite must not report a write");
    AssertEquals(0, files.WriteCount, "rejected rewrite must not write the file");
    AssertEquals(0, git.CommitCount, "rejected rewrite must not commit");
}

static async Task ServiceSkipsSeparatorOnlyNoOpWithoutWriting()
{
    var files = new FakeFileService("""
        # Writing Standards

        Existing text.
        """);
    var git = new FakeGitService();
    var service = new InstructionRewriteService(
        new FakeChatService("""
            ---
            ---
            # Writing Standards

            Existing text.
            """),
        files,
        git);

    var result = await service.RewriteAsync("Writing Standards.md", "Add a hard rule.");

    AssertFalse(result.IsError, result.Message);
    AssertFalse(result.Rewritten, "separator-only rewrite should be skipped");
    AssertEquals(0, files.WriteCount, "no-op rewrite must not write");
    AssertEquals(0, git.CommitCount, "no-op rewrite must not commit");
}

static async Task ServiceWritesAndCommitsValidRewrite()
{
    var files = new FakeFileService("""
        # Kai.md

        ## Mode 2: Real talk
        Existing real-talk rule.
        """);
    var git = new FakeGitService();
    var service = new InstructionRewriteService(
        new FakeChatService("""
            ---
            ---
            # Kai.md

            ## Mode 1: In the cave (roleplay)
            New roleplay rule.

            ## Mode 2: Real talk
            Existing real-talk rule.
            """),
        files,
        git);

    var result = await service.RewriteAsync("KaiChat/Kai.md", "In Mode 1, add an explicit formatting rule.");

    AssertFalse(result.IsError, result.Message);
    AssertTrue(result.Rewritten, "valid rewrite should write");
    AssertContains("Auto-commit:", result.Message);
    AssertContains("No follow-up /api/git/commit call is needed", result.Message);
    AssertEquals(1, files.WriteCount, "valid rewrite should write exactly once");
    AssertEquals(1, git.CommitCount, "valid rewrite should commit exactly once");
    AssertStartsWith("# Kai.md", files.Content);
    AssertDoesNotContain("---\n---", Normalize(files.Content));
}

static async Task KaiTimerServiceStartsPausesAndResets()
{
    var temp = Path.Combine(Path.GetTempPath(), "kaichat-tests", Guid.NewGuid().ToString("N"));
    Directory.CreateDirectory(temp);
    try
    {
        var service = new KaiTimerService(BuildTestConfiguration(temp));

        var start = await service.StartAsync();
        AssertTrue(start.Success, start.Message);
        AssertTrue(start.State.IsRunning, "timer should be running after start");
        AssertTrue(File.Exists(Path.Combine(temp, ".kaichat-timer.json")), "timer state should be written to disk");

        await Task.Delay(1100);
        var running = await service.GetStateAsync();
        AssertTrue(running.ElapsedSeconds >= 1, "running timer should count up");

        var pause = await service.PauseAsync();
        AssertTrue(pause.Success, pause.Message);
        AssertFalse(pause.State.IsRunning, "timer should be paused after pause");
        var pausedSeconds = pause.State.ElapsedSeconds;
        await Task.Delay(1100);
        var stillPaused = await service.GetStateAsync();
        AssertEquals(pausedSeconds, stillPaused.ElapsedSeconds, "paused timer should not keep counting");

        var reset = await service.ResetAsync();
        AssertTrue(reset.Success, reset.Message);
        AssertFalse(reset.State.IsRunning, "timer should be paused after reset");
        AssertEquals(0L, reset.State.ElapsedSeconds, "reset should zero elapsed seconds");
    }
    finally
    {
        if (Directory.Exists(temp))
            Directory.Delete(temp, recursive: true);
    }
}

static async Task KaiTimerServiceFormatsStatus()
{
    var temp = Path.Combine(Path.GetTempPath(), "kaichat-tests", Guid.NewGuid().ToString("N"));
    Directory.CreateDirectory(temp);
    try
    {
        var service = new KaiTimerService(BuildTestConfiguration(temp));
        var status = service.FormatStateForTool(await service.GetStateAsync());

        AssertContains("KaiTimer:", status);
        AssertContains("Status: paused", status);
        AssertContains("Elapsed seconds: 0", status);
    }
    finally
    {
        if (Directory.Exists(temp))
            Directory.Delete(temp, recursive: true);
    }
}

static async Task KaiTimerWheelSpinsEnabledSegment()
{
    var temp = Path.Combine(Path.GetTempPath(), "kaichat-tests", Guid.NewGuid().ToString("N"));
    Directory.CreateDirectory(temp);
    try
    {
        var service = new KaiTimerService(BuildTestConfiguration(temp));
        var add = await service.CreateWheelSegmentAsync(new KaiWheelSegmentCreateRequest("send Kai a voice note", 1, true));
        AssertTrue(add.Success, add.Message);

        var spin = await service.SpinWheelAsync();
        AssertTrue(spin.Success, spin.Message);
        AssertContains("send Kai a voice note", spin.Message);
        AssertContains("Wheel segments:", service.FormatStateForTool(spin.State));
    }
    finally
    {
        if (Directory.Exists(temp))
            Directory.Delete(temp, recursive: true);
    }
}

static async Task KaiTimerCountersSupportManualChanges()
{
    var temp = Path.Combine(Path.GetTempPath(), "kaichat-tests", Guid.NewGuid().ToString("N"));
    Directory.CreateDirectory(temp);
    try
    {
        var service = new KaiTimerService(BuildTestConfiguration(temp));
        var create = await service.CreateCounterAsync(new KaiCounterCreateRequest("checks today", 0));
        AssertTrue(create.Success, create.Message);
        var counter = create.State.Counters.Single();

        var increment = await service.IncrementCounterAsync(new KaiCounterChangeRequest(counter.Id, 2));
        AssertTrue(increment.Success, increment.Message);
        AssertEquals(2, increment.State.Counters.Single().Value, "counter should increment by amount");

        var decrement = await service.DecrementCounterAsync(new KaiCounterChangeRequest(counter.Id, 1));
        AssertTrue(decrement.Success, decrement.Message);
        AssertEquals(1, decrement.State.Counters.Single().Value, "counter should decrement by amount");

        var automatic = await service.CreateCounterAsync(new KaiCounterCreateRequest(
            "elapsed days",
            Mode: KaiCounterModes.Automatic,
            AutomaticKind: KaiAutomaticCounterKinds.ElapsedDays));
        AssertTrue(automatic.Success, automatic.Message);
        AssertEquals(0, automatic.State.Counters.Single(c => c.Mode == KaiCounterModes.Automatic).Value, "new elapsed-days counter should start at zero");
    }
    finally
    {
        if (Directory.Exists(temp))
            Directory.Delete(temp, recursive: true);
    }
}

static async Task KaiTimerMilestonesClaimOnceAtThreshold()
{
    var temp = Path.Combine(Path.GetTempPath(), "kaichat-tests", Guid.NewGuid().ToString("N"));
    Directory.CreateDirectory(temp);
    try
    {
        var service = new KaiTimerService(BuildTestConfiguration(temp));
        var create = await service.CreateMilestoneAsync(new KaiMilestoneCreateRequest(
            "first second",
            1,
            "Say something real."));
        AssertTrue(create.Success, create.Message);
        await service.StartAsync();
        await Task.Delay(1100);

        var due = await service.GetMilestonePromptContextAsync();
        AssertTrue(due.Changed, "milestone should be claimed once after crossing threshold");
        AssertEquals(1, due.Prompts.Count, "one milestone should be due");

        var secondCheck = await service.GetMilestonePromptContextAsync();
        AssertFalse(secondCheck.Changed, "milestone should not be claimed twice");
        AssertEquals(0, secondCheck.Prompts.Count, "claimed milestone should no longer be due");
    }
    finally
    {
        if (Directory.Exists(temp))
            Directory.Delete(temp, recursive: true);
    }
}

static void ServerToolPlannerStartsKaiTimer()
{
    const string userMessage = "Start the timer";
    var conv = new Conversation();
    conv.Messages.Add(new ChatMessage { Role = "user", Content = userMessage });

    var calls = PlanServerToolCalls(userMessage, conv);

    AssertEquals(1, calls.Count, "server planner should start KaiTimer");
    AssertEquals("POST", GetStringProperty(calls[0], "Method"), "start method should be POST");
    AssertEquals("/api/kaitimer/start", GetStringProperty(calls[0], "Path"), "start path should be KaiTimer start");
    AssertEquals("{}", GetStringProperty(calls[0], "Body"), "start body should be empty JSON");
}

static void ServerToolPlannerChecksKaiTimerStatus()
{
    const string userMessage = "Can you check the timer status and show elapsed time?";
    var conv = new Conversation();
    conv.Messages.Add(new ChatMessage { Role = "user", Content = userMessage });

    var calls = PlanServerToolCalls(userMessage, conv);

    AssertEquals(1, calls.Count, "server planner should check KaiTimer status");
    AssertEquals("GET", GetStringProperty(calls[0], "Method"), "status method should be GET");
    AssertEquals("/api/kaitimer/status", GetStringProperty(calls[0], "Path"), "status path should be KaiTimer status");
    AssertEquals("{}", GetStringProperty(calls[0], "Body"), "status body should be empty JSON");
}

static void ServerToolPlannerPausesKaiTimer()
{
    const string userMessage = "Pause the timer";
    var conv = new Conversation();
    conv.Messages.Add(new ChatMessage { Role = "user", Content = userMessage });

    var calls = PlanServerToolCalls(userMessage, conv);

    AssertEquals(1, calls.Count, "server planner should pause KaiTimer");
    AssertEquals("POST", GetStringProperty(calls[0], "Method"), "pause method should be POST");
    AssertEquals("/api/kaitimer/pause", GetStringProperty(calls[0], "Path"), "pause path should be KaiTimer pause");
}

static void ServerToolPlannerResetsKaiTimer()
{
    const string userMessage = "Reset the count-up timer";
    var conv = new Conversation();
    conv.Messages.Add(new ChatMessage { Role = "user", Content = userMessage });

    var calls = PlanServerToolCalls(userMessage, conv);

    AssertEquals(1, calls.Count, "server planner should reset KaiTimer");
    AssertEquals("POST", GetStringProperty(calls[0], "Method"), "reset method should be POST");
    AssertEquals("/api/kaitimer/reset", GetStringProperty(calls[0], "Path"), "reset path should be KaiTimer reset");
}

static void ServerToolPlannerIgnoresToolMetaQuestions()
{
    const string userMessage = "Why did you call /api/memory/files and /api/kaitimer/status multiple times?";
    var conv = new Conversation();
    conv.Messages.Add(new ChatMessage { Role = "user", Content = userMessage });

    var calls = PlanServerToolCalls(userMessage, conv);

    AssertEquals(0, calls.Count, "tool-debugging questions should not trigger planned KaiTimer calls");
}

static void ServerToolPlannerIgnoresNonToolMessages()
{
    const string userMessage = "Tell me a story about a cave.";
    var conv = new Conversation();
    conv.Messages.Add(new ChatMessage { Role = "user", Content = userMessage });

    var calls = PlanServerToolCalls(userMessage, conv);

    AssertEquals(0, calls.Count, "ordinary non-tool messages should not trigger planned calls");
}

static void ToolParserExtractsKaiTimerStart()
{
    var extraction = ExtractToolCalls("""
        Starting it now.
        [TOOL_CALL: POST /api/kaitimer/start, {}]
        Done.
        """);

    var calls = GetToolCalls(extraction);
    AssertEquals(1, calls.Count, "KaiTimer start request should be extracted as one tool call");
    AssertEquals("POST", GetStringProperty(calls[0], "Method"), "method should be POST");
    AssertEquals("/api/kaitimer/start", GetStringProperty(calls[0], "Path"), "path should be KaiTimer start");

    var visible = GetStringProperty(extraction, "VisibleContent");
    AssertFalse(visible.Contains("TOOL_CALL", StringComparison.OrdinalIgnoreCase), "executable tool call should be removed from visible text");
}

static void ToolParserIgnoresFencedToolExamples()
{
    var extraction = ExtractToolCalls("""
        ```text
        [TOOL_CALL: GET /api/kaitimer/status, {}]
        ```
        """);

    var calls = GetToolCalls(extraction);
    AssertEquals(0, calls.Count, "fenced tool example should not execute");
    AssertContains("[TOOL_CALL: GET /api/kaitimer/status, {}]", GetStringProperty(extraction, "VisibleContent"));
}

static void NativeToolCallConverterExtractsKaiTimerStart()
{
    var calls = ConvertNativeToolCalls(
        "kai_tool_call",
        "{\"method\":\"POST\",\"path\":\"/api/kaitimer/start\",\"body\":{}}");

    AssertEquals(1, calls.Count, "native tool call should convert to one KaiChat tool request");
    AssertEquals("POST", GetStringProperty(calls[0], "Method"), "method should be POST");
    AssertEquals("/api/kaitimer/start", GetStringProperty(calls[0], "Path"), "path should be KaiTimer start");
}

static void ChatServiceSerializesNativeToolTranscript()
{
    using var assistantDoc = ToApiMessageJson(new ChatMessage
    {
        Role = "assistant",
        Content = "",
        Thinking = "I need the status, then I can answer.",
        ToolCalls =
        [
            new StoredToolCall
            {
                Id = "call_test123",
                Name = "kai_tool_call",
                Arguments = "{\"method\":\"GET\",\"path\":\"/api/kaitimer/status\",\"body\":{}}"
            }
        ]
    });

    var assistant = assistantDoc.RootElement;
    AssertEquals("assistant", assistant.GetProperty("role").GetString(), "assistant tool-call message should keep assistant role");
    AssertEquals("I need the status, then I can answer.", assistant.GetProperty("reasoning_content").GetString(), "tool-call reasoning should be passed back");
    var toolCall = assistant.GetProperty("tool_calls")[0];
    AssertEquals("call_test123", toolCall.GetProperty("id").GetString(), "assistant tool call id should be serialized");
    AssertEquals("function", toolCall.GetProperty("type").GetString(), "tool call type should be function");
    AssertEquals("kai_tool_call", toolCall.GetProperty("function").GetProperty("name").GetString(), "function name should be serialized");

    using var toolDoc = ToApiMessageJson(new ChatMessage
    {
        Role = "tool",
        Content = "KaiTimer:\n  Status: paused\n  Elapsed: 0m 0s",
        Tool = new ToolMessage
        {
            ToolCallId = "call_test123",
            Method = "GET",
            Path = "/api/kaitimer/status",
            Body = "{}",
            Status = "success"
        }
    });

    var tool = toolDoc.RootElement;
    AssertEquals("tool", tool.GetProperty("role").GetString(), "matched tool result should keep tool role");
    AssertEquals("call_test123", tool.GetProperty("tool_call_id").GetString(), "tool result should point at the assistant tool call");

    using var legacyDoc = ToApiMessageJson(new ChatMessage
    {
        Role = "tool",
        Content = "Old private tool output without an id."
    });

    AssertEquals("system", legacyDoc.RootElement.GetProperty("role").GetString(), "legacy unpaired tool history should fall back to system context");
}

static void ChatServiceDisablesDeepSeekThinkingForToolAcquisition()
{
    using var toolDoc = System.Text.Json.JsonDocument.Parse(BuildChatRequestJson(
        model: "deepseek-v4-flash",
        enableTools: true,
        enableThinking: false));
    AssertEquals("disabled", toolDoc.RootElement.GetProperty("thinking").GetProperty("type").GetString(), "DeepSeek tool-acquisition request should disable thinking");
    AssertTrue(toolDoc.RootElement.TryGetProperty("tools", out _), "tool-acquisition request should still include tools");

    using var normalDoc = System.Text.Json.JsonDocument.Parse(BuildChatRequestJson(
        model: "deepseek-v4-flash",
        enableTools: true,
        enableThinking: true));
    AssertFalse(normalDoc.RootElement.TryGetProperty("thinking", out _), "normal DeepSeek request should not override default thinking mode");
    var toolDescription = normalDoc.RootElement
        .GetProperty("tools")[0]
        .GetProperty("function")
        .GetProperty("description")
        .GetString() ?? "";
    AssertContains("auto-commit", toolDescription);
    AssertContains("do not call /api/git/commit after them", toolDescription);

    using var otherModelDoc = System.Text.Json.JsonDocument.Parse(BuildChatRequestJson(
        model: "gpt-4",
        enableTools: true,
        enableThinking: false));
    AssertFalse(otherModelDoc.RootElement.TryGetProperty("thinking", out _), "non-DeepSeek models should not receive DeepSeek thinking parameter");
}

static void MemoryUpdaterBatchesPendingMessages()
{
    var batch = SelectMemoryUpdateBatchForTest(300, i => $"message {i}");

    AssertEquals(80, batch.Count, "memory updater should cap one automatic run to 80 messages");
    AssertTrue(batch.Truncated, "oversized pending memory queues should be marked truncated");
}

static void MemoryUpdaterTrimsOversizedMessageTranscript()
{
    var builder = new StringBuilder();
    var message = new ChatMessage
    {
        Role = "user",
        Content = new string('x', 20_000),
        Timestamp = new DateTime(2026, 7, 4, 0, 0, 0, DateTimeKind.Utc)
    };

    AppendMessageForMemoryUpdate(builder, message);
    var transcript = builder.ToString();

    AssertContains("[truncated 8000 character(s)]", transcript);
    AssertTrue(transcript.Length < 13_000, "single message transcript should be capped");
}

static void MemoryUpdaterCoolsDownAfterMarkerWrite()
{
    var now = new DateTime(2026, 7, 4, 10, 0, 0, DateTimeKind.Utc);
    var recentMarker = now.AddMinutes(-10);
    var oldMarker = now.AddHours(-2);

    var recentDelay = GetAutomaticMemoryCooldownDelay(recentMarker, null, now);
    var expiredDelay = GetAutomaticMemoryCooldownDelay(oldMarker, null, now);

    AssertTrue(recentDelay > TimeSpan.FromMinutes(49) && recentDelay < TimeSpan.FromMinutes(51), "recent marker writes should cool automatic updates down for about an hour");
    AssertEquals(TimeSpan.Zero, expiredDelay, "old marker writes should not delay automatic updates");
}

static void MemoryUpdaterDoesNotAdvanceMarkerWithoutSuccessfulOperation()
{
    var failed = new MemoryManagementResult(
        Changed: false,
        RawResponse: "not json",
        Operations: [new("invalid_response", "", false, "AI returned invalid memory JSON; no changes were applied.")]);
    var noop = new MemoryManagementResult(
        Changed: false,
        RawResponse: "{\"operations\":[{\"action\":\"noop\"}]}",
        Operations: [new("noop", "", true, "No durable changes.")]);

    AssertFalse(ShouldAdvanceMemoryMarker(failed), "invalid memory responses must not advance the marker");
    AssertTrue(ShouldAdvanceMemoryMarker(noop), "successful noop responses should advance the marker");
}

static void MemoryUpdaterTripsFuseAfterConsecutiveFailures()
{
    var service = new MemoryUpdateService(
        new NullServiceProvider(),
        Microsoft.Extensions.Logging.Abstractions.NullLogger<MemoryUpdateService>.Instance);

    var handleAttemptOutcome = typeof(MemoryUpdateService).GetMethod(
        "HandleRunAttemptOutcome",
        BindingFlags.NonPublic | BindingFlags.Instance)
        ?? throw new InvalidOperationException("Could not find MemoryUpdateService.HandleRunAttemptOutcome.");
    var resetFuse = typeof(MemoryUpdateService).GetMethod(
        "ResetFuseInternal",
        BindingFlags.NonPublic | BindingFlags.Instance)
        ?? throw new InvalidOperationException("Could not find MemoryUpdateService.ResetFuseInternal.");

    handleAttemptOutcome.Invoke(service, new object[] { false, "simulated failed run" });
    AssertEquals(1, GetPrivateField<int>(service, "_consecutiveFailureCount"), "first failed run should increment the failure counter");
    AssertFalse(GetPrivateField<bool>(service, "_memoryUpdateFuseTripped"), "one failure should not trip the fuse");

    handleAttemptOutcome.Invoke(service, new object[] { false, "simulated failed run" });
    AssertEquals(2, GetPrivateField<int>(service, "_consecutiveFailureCount"), "second failed run should trip the threshold");
    AssertTrue(GetPrivateField<bool>(service, "_memoryUpdateFuseTripped"), "second consecutive failure should trip the fuse");
    AssertTrue(GetPrivateField<DateTime?>(service, "_memoryUpdateFuseTrippedUtc").HasValue, "fuse trip should record timestamp");

    handleAttemptOutcome.Invoke(service, new object[] { true, "simulated healthy run" });
    AssertEquals(0, GetPrivateField<int>(service, "_consecutiveFailureCount"), "successful run should clear failure counter");
    AssertFalse(GetPrivateField<bool>(service, "_memoryUpdateFuseTripped"), "successful run should clear fuse");
    resetFuse.Invoke(service, null);
    AssertEquals(0, GetPrivateField<int>(service, "_consecutiveFailureCount"), "manual reset should clear failure counter");
    AssertFalse(GetPrivateField<bool>(service, "_memoryUpdateFuseTripped"), "manual reset should clear fuse flag");
}

static void MemoryUpdaterDetectsStagedMarkerFileChanges()
{
    AssertTrue(
        HasStagedPathChangeForTest("A  KaiChat/.memory_update_marker", "KaiChat/.memory_update_marker"),
        "staged marker creation should be detected");
    AssertTrue(
        HasStagedPathChangeForTest("D  KaiChat/.memory_update_marker", "KaiChat/.memory_update_marker"),
        "staged marker deletion should be detected");
    AssertTrue(
        HasStagedPathChangeForTest("M  KaiChat/Memory/recent.md", "KaiChat/Memory"),
        "staged memory directory changes should still be detected");
    AssertFalse(
        HasStagedPathChangeForTest(" M KaiChat/.memory_update_marker", "KaiChat/.memory_update_marker"),
        "unstaged marker changes should not be treated as commit-ready");
}

static bool HasStagedPathChangeForTest(string statusOutput, string repoRelativePath)
{
    var method = typeof(MemoryUpdateService).GetMethod("HasStagedPathChange", BindingFlags.NonPublic | BindingFlags.Static)
        ?? throw new InvalidOperationException("Could not find MemoryUpdateService.HasStagedPathChange.");
    return (bool)(method.Invoke(null, new object[] { statusOutput, repoRelativePath })
        ?? throw new InvalidOperationException("HasStagedPathChange returned null."));
}

static async Task MemoryMarkerCreatesOnceAndDeletesCleanly()
{
    var temp = Path.Combine(Path.GetTempPath(), "kaichat-tests", Guid.NewGuid().ToString("N"));
    Directory.CreateDirectory(temp);
    try
    {
        var service = new MemoryService(
            BuildTestConfiguration(temp),
            Microsoft.Extensions.Logging.Abstractions.NullLogger<MemoryService>.Instance);
        var markerPath = Path.Combine(
            temp,
            service.UpdateMarkerRelativePath.Replace('/', Path.DirectorySeparatorChar));
        var firstPendingBaseline = new DateTime(2026, 7, 4, 1, 2, 3, DateTimeKind.Utc);

        AssertTrue(await service.EnsureUpdateMarkerAsync(firstPendingBaseline), "first pending user message should create the marker");
        AssertTrue(File.Exists(markerPath), "marker file should exist after creation");

        var created = await service.GetUpdateMarkerInfoAsync();
        AssertTrue(created.Exists, "marker info should report created marker");
        AssertEquals(firstPendingBaseline, created.ParsedUtc!.Value, "marker should store the first pending baseline");

        AssertFalse(await service.EnsureUpdateMarkerAsync(firstPendingBaseline.AddHours(1)), "later user messages should not overwrite the pending baseline");
        var unchanged = await service.GetUpdateMarkerInfoAsync();
        AssertEquals(firstPendingBaseline, unchanged.ParsedUtc!.Value, "marker baseline should remain unchanged while pending work exists");

        await service.SetLastUpdateAsync(firstPendingBaseline.AddHours(2));
        var advanced = await service.GetUpdateMarkerInfoAsync();
        AssertEquals(firstPendingBaseline.AddHours(2), advanced.ParsedUtc!.Value, "memory updater should be able to advance a partial marker");

        await service.DeleteUpdateMarkerAsync();
        AssertFalse((await service.GetUpdateMarkerInfoAsync()).Exists, "marker should be absent after memory catches up");
        AssertEquals(DateTime.MaxValue, await service.GetLastUpdateAsync(), "missing marker must not be inferred from memory file timestamps");
    }
    finally
    {
        if (Directory.Exists(temp))
            Directory.Delete(temp, recursive: true);
    }
}

static async Task MemoryServiceSavesDeduplicatedSections()
{
    var temp = Path.Combine(Path.GetTempPath(), "kaichat-tests", $"memory-dup-{Guid.NewGuid():N}");
    Directory.CreateDirectory(temp);
    try
    {
        var service = new MemoryService(
            BuildTestConfiguration(temp),
            Microsoft.Extensions.Logging.Abstractions.NullLogger<MemoryService>.Instance);

        var duplicatePayload = """
            ## Durable State
            - A.

            ## Durable State
            - A.
            - B.
            """;

        await service.SaveMemoryFileAsync("recent.md", duplicatePayload);

        var once = await service.ReadMemoryFileAsync("recent.md");
        AssertEquals(1, CountSections(once, "Durable State"), "duplicate headings should be collapsed to one section");
        AssertContains("- A.", once);
        AssertContains("- B.", once);

        await service.SaveMemoryFileAsync("recent.md", duplicatePayload);
        var twice = await service.ReadMemoryFileAsync("recent.md");
        AssertEquals(1, CountSections(twice, "Durable State"), "re-saving same content should not duplicate sections");
    }
    finally
    {
        if (Directory.Exists(temp))
            Directory.Delete(temp, recursive: true);
    }
}

static async Task MemoryManagerRejectsTruncatedContent()
{
    var initial = "# core.md\n\n## Active context\n- A durable point.\n- Another durable point.\n";
    var memory = new FakeMemoryService(new Dictionary<string, string> { ["core.md"] = initial });
    var chat = new FakeChatService("""
        {"operations":[{"action":"save","path":"core.md","content":"## Active context\n- A durable point.\n- Another durable point.\n- ... (rest of existing core.md content, unchanged) ...","reason":"placeholder test"}]}
        """);
    var logger = new RecordingLogger<MemoryManagementService>();
    var service = new MemoryManagementService(memory, chat, logger);

    var result = await service.ManageAsync("Do a minimal update");

    AssertFalse(result.Changed, "blocked guardrail operations should not be marked as successful changes");
    AssertEquals(1, result.Operations.Count, "one operation should be returned");
    AssertFalse(result.Operations[0].Success, "placeholder content should fail safely");
    AssertEquals("core.md", result.Operations[0].Path, "path should remain the target file");
    AssertContains("truncated", result.Operations[0].Message);
    var current = await memory.ReadMemoryFileAsync("core.md");
    AssertEquals(initial.Trim(), current.Trim(), "memory file should remain unchanged after rejection");
    AssertTrue(logger.Messages.Any(m => m.Contains("truncation guard", StringComparison.OrdinalIgnoreCase)), "guardrail rejection should be logged");
}

static async Task MemoryManagerPromptAsksForStaleCrossFileReconciliation()
{
    var memory = new FakeMemoryService(new Dictionary<string, string>
    {
        ["preferences.md"] = "# User Preferences\n\n- Old CGM freshness rule."
    });
    var chat = new FakeChatService("""
        {"operations":[{"action":"noop","reason":"test prompt only"}]}
        """);
    var service = new MemoryManagementService(
        memory,
        chat,
        Microsoft.Extensions.Logging.Abstractions.NullLogger<MemoryManagementService>.Instance);

    await service.ManageAsync("Refine the CGM freshness rule.");

    var prompt = chat.LastMessages?.Single().Content
        ?? throw new InvalidOperationException("Memory manager did not send a prompt.");
    AssertContains("reconcile the memory directory", prompt);
    AssertContains("older or conflicting versions", prompt);
    AssertContains("repeated entries must agree with the latest correction/refinement", prompt);
}

static int CountSections(string markdown, string heading)
{
    return Regex.Matches(markdown, $@"(?m)^##\s+{Regex.Escape(heading)}\s*$").Count;
}

static IConfiguration BuildTestConfiguration(string baseFolder)
{
    return new ConfigurationBuilder()
        .AddInMemoryCollection(new Dictionary<string, string?>
        {
            ["KaiChat:BaseFolder"] = baseFolder
        })
        .Build();
}

static object ExtractToolCalls(string text)
{
    var method = typeof(ChatHub).GetMethod("ExtractExecutableToolCalls", BindingFlags.NonPublic | BindingFlags.Static)
        ?? throw new InvalidOperationException("Could not find ChatHub.ExtractExecutableToolCalls.");
    return method.Invoke(null, new object[] { text })
        ?? throw new InvalidOperationException("Tool parser returned null.");
}

static System.Text.Json.JsonDocument ToApiMessageJson(ChatMessage message)
{
    var method = typeof(ChatService).GetMethod("ToApiMessage", BindingFlags.NonPublic | BindingFlags.Static)
        ?? throw new InvalidOperationException("Could not find ChatService.ToApiMessage.");
    var apiMessage = method.Invoke(null, new object[] { message })
        ?? throw new InvalidOperationException("API message conversion returned null.");
    return System.Text.Json.JsonDocument.Parse(System.Text.Json.JsonSerializer.Serialize(apiMessage));
}

static string BuildChatRequestJson(string model, bool enableTools, bool enableThinking)
{
    var chatService = new ChatService(
        new HttpClient(),
        new KaiChatConfig
        {
            ApiEndpoint = "http://localhost/test",
            ApiKey = "test",
            Model = model
        },
        Microsoft.Extensions.Logging.Abstractions.NullLogger<ChatService>.Instance);

    var method = typeof(ChatService).GetMethod("BuildRequest", BindingFlags.NonPublic | BindingFlags.Instance)
        ?? throw new InvalidOperationException("Could not find ChatService.BuildRequest.");
    var result = method.Invoke(chatService, new object[]
    {
        new List<ChatMessage> { new() { Role = "user", Content = "Check KaiTimer status." } },
        false,
        null!,
        enableTools,
        false,
        enableThinking,
        false
    }) ?? throw new InvalidOperationException("BuildRequest returned null.");

    return result.GetType().GetField("Item1")?.GetValue(result)?.ToString()
        ?? throw new InvalidOperationException("BuildRequest did not return JSON.");
}

static List<object> GetToolCalls(object extraction)
{
    var calls = extraction.GetType().GetProperty("Calls")?.GetValue(extraction) as System.Collections.IEnumerable
        ?? throw new InvalidOperationException("Tool extraction did not expose Calls.");
    return calls.Cast<object>().ToList();
}

static List<object> ConvertNativeToolCalls(string functionName, string arguments)
{
    var nativeType = typeof(ChatHub).GetNestedType("NativeToolCall", BindingFlags.NonPublic)
        ?? throw new InvalidOperationException("Could not find ChatHub.NativeToolCall.");
    var nativeCall = Activator.CreateInstance(
        nativeType,
        BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic,
        null,
        new object[] { "call_1", functionName, arguments },
        null)
        ?? throw new InvalidOperationException("Could not construct native tool call.");
    var nativeCalls = Array.CreateInstance(nativeType, 1);
    nativeCalls.SetValue(nativeCall, 0);

    var method = typeof(ChatHub).GetMethod("ConvertNativeToolCalls", BindingFlags.NonPublic | BindingFlags.Static)
        ?? throw new InvalidOperationException("Could not find ChatHub.ConvertNativeToolCalls.");
    var converted = method.Invoke(null, new object[] { nativeCalls }) as System.Collections.IEnumerable
        ?? throw new InvalidOperationException("Native tool converter returned null.");
    return converted.Cast<object>().ToList();
}

static List<object> PlanServerToolCalls(string userMessage, Conversation conv)
{
    var method = typeof(ChatHub).GetMethod("PlanServerToolCallsForCurrentTurn", BindingFlags.NonPublic | BindingFlags.Static)
        ?? throw new InvalidOperationException("Could not find ChatHub.PlanServerToolCallsForCurrentTurn.");
    var planned = method.Invoke(null, new object[] { userMessage, conv }) as System.Collections.IEnumerable
        ?? throw new InvalidOperationException("Server tool planner returned null.");
    return planned.Cast<object>().ToList();
}

static (int Count, bool Truncated) SelectMemoryUpdateBatchForTest(int count, Func<int, string> contentFactory)
{
    var candidateType = typeof(MemoryUpdateService).GetNestedType("MemoryUpdateCandidate", BindingFlags.NonPublic)
        ?? throw new InvalidOperationException("Could not find MemoryUpdateService.MemoryUpdateCandidate.");
    var list = (System.Collections.IList)Activator.CreateInstance(typeof(List<>).MakeGenericType(candidateType))!;

    for (var i = 0; i < count; i++)
        list.Add(CreateMemoryUpdateCandidate(candidateType, i, contentFactory(i)));

    var method = typeof(MemoryUpdateService).GetMethod("SelectMemoryUpdateBatch", BindingFlags.NonPublic | BindingFlags.Static)
        ?? throw new InvalidOperationException("Could not find MemoryUpdateService.SelectMemoryUpdateBatch.");
    var args = new object?[] { list, false };
    var selected = method.Invoke(null, args) as System.Collections.IEnumerable
        ?? throw new InvalidOperationException("SelectMemoryUpdateBatch returned null.");

    return (selected.Cast<object>().Count(), (bool)args[1]!);
}

static object CreateMemoryUpdateCandidate(Type candidateType, int index, string content)
{
    var timestamp = new DateTime(2026, 7, 4, 0, 0, 0, DateTimeKind.Utc).AddSeconds(index);
    var conversation = new Conversation
    {
        Id = "test-conversation",
        Title = "Test Conversation",
        UpdatedAt = timestamp
    };
    var message = new ChatMessage
    {
        Role = "user",
        Content = content,
        Timestamp = timestamp
    };

    return Activator.CreateInstance(
        candidateType,
        BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic,
        binder: null,
        args: new object[] { conversation, message, timestamp },
        culture: null)
        ?? throw new InvalidOperationException("Could not construct memory update candidate.");
}

static bool ShouldAdvanceMemoryMarker(MemoryManagementResult result)
{
    var method = typeof(MemoryUpdateService).GetMethod("ShouldAdvanceMarker", BindingFlags.NonPublic | BindingFlags.Static)
        ?? throw new InvalidOperationException("Could not find MemoryUpdateService.ShouldAdvanceMarker.");
    return (bool)(method.Invoke(null, new object[] { result })
        ?? throw new InvalidOperationException("ShouldAdvanceMarker returned null."));
}

static TimeSpan GetAutomaticMemoryCooldownDelay(DateTime? markerLastWriteUtc, DateTime? lastRunCompletedUtc, DateTime nowUtc)
{
    var method = typeof(MemoryUpdateService).GetMethod("GetAutomaticCooldownDelay", BindingFlags.NonPublic | BindingFlags.Static)
        ?? throw new InvalidOperationException("Could not find MemoryUpdateService.GetAutomaticCooldownDelay.");
    return (TimeSpan)(method.Invoke(null, new object?[] { markerLastWriteUtc, lastRunCompletedUtc, nowUtc })
        ?? throw new InvalidOperationException("GetAutomaticCooldownDelay returned null."));
}

static void AppendMessageForMemoryUpdate(StringBuilder builder, ChatMessage message)
{
    var method = typeof(MemoryUpdateService).GetMethod("AppendMessageForMemoryUpdate", BindingFlags.NonPublic | BindingFlags.Static)
        ?? throw new InvalidOperationException("Could not find MemoryUpdateService.AppendMessageForMemoryUpdate.");
    method.Invoke(null, new object[] { builder, message });
}

static string GetStringProperty(object target, string name)
{
    return target.GetType().GetProperty(name)?.GetValue(target)?.ToString()
        ?? throw new InvalidOperationException($"Property {name} was missing or null.");
}

static void AssertStartsWith(string expected, string actual)
{
    if (!Normalize(actual).StartsWith(expected, StringComparison.Ordinal))
        throw new InvalidOperationException($"Expected output to start with '{expected}', got '{actual}'.");
}

static void AssertContains(string expected, string actual)
{
    if (!actual.Contains(expected, StringComparison.Ordinal))
        throw new InvalidOperationException($"Expected '{actual}' to contain '{expected}'.");
}

static void AssertDoesNotContain(string unexpected, string actual)
{
    if (actual.Contains(unexpected, StringComparison.Ordinal))
        throw new InvalidOperationException($"Expected '{actual}' not to contain '{unexpected}'.");
}

static void AssertTrue(bool value, string message)
{
    if (!value) throw new InvalidOperationException(message);
}

static void AssertFalse(bool value, string message)
{
    if (value) throw new InvalidOperationException(message);
}

static void AssertEquals<T>(T expected, T actual, string message)
{
    if (!EqualityComparer<T>.Default.Equals(expected, actual))
        throw new InvalidOperationException($"{message}. Expected {expected}, got {actual}.");
}

static T GetPrivateField<T>(object target, string fieldName)
{
    var field = target.GetType().GetField(fieldName, BindingFlags.NonPublic | BindingFlags.Instance)
        ?? throw new InvalidOperationException($"Could not find private field '{fieldName}' on {target.GetType().Name}.");

    var value = field.GetValue(target);
    if (value is null)
        return default!;

    if (value is T cast)
        return cast;

    throw new InvalidOperationException($"Private field '{fieldName}' is not of expected type {typeof(T).Name}.");
}

static string Normalize(string value)
{
    return value.Replace("\r\n", "\n").Replace('\r', '\n').Trim();
}

sealed class FakeChatService : IChatService
{
    private readonly string _response;

    public FakeChatService(string response)
    {
        _response = response;
    }

    public List<ChatMessage>? LastMessages { get; private set; }

    public Task<string> ChatAsync(List<ChatMessage> messages, CancellationToken cancellationToken = default, int? maxTokens = null, bool enableTools = false, bool requireToolCall = false, bool enableThinking = true, bool enableAutonomyTools = false)
    {
        LastMessages = messages;
        return Task.FromResult(_response);
    }

    public async IAsyncEnumerable<string> StreamChatAsync(List<ChatMessage> messages, [EnumeratorCancellation] CancellationToken cancellationToken = default, int? maxTokens = null, bool enableTools = false, bool requireToolCall = false, bool enableThinking = true, bool enableAutonomyTools = false)
    {
        await Task.CompletedTask;
        yield break;
    }
}

sealed class FakeMemoryService : IMemoryService
{
    private readonly Dictionary<string, string> _files;
    private DateTime _lastUpdate = DateTime.MinValue;
    private bool _markerExists = true;

    public FakeMemoryService(Dictionary<string, string> files)
    {
        _files = files;
    }

    public string MemoryDirectoryRelativePath => "KaiChat/Memory";
    public string UpdateMarkerRelativePath => "KaiChat/.memory_update_marker";

    public Task<string> GetPersistentMemoryAsync()
    {
        return Task.FromResult(string.Join("\n\n", _files.Select(f => $"## Memory File: {f.Key}\n\n{f.Value}")));
    }

    public Task<IReadOnlyList<MemoryFileInfo>> ListMemoryFilesAsync(bool includeTrash = false)
    {
        IReadOnlyList<MemoryFileInfo> files = _files
            .Select(f => new MemoryFileInfo(f.Key, f.Value.Length, DateTime.UtcNow, false, true))
            .ToList();
        return Task.FromResult(files);
    }

    public Task<string> ReadMemoryFileAsync(string relativePath)
    {
        return Task.FromResult(_files[relativePath]);
    }

    public Task SaveMemoryFileAsync(string relativePath, string content)
    {
        _files[relativePath] = content;
        return Task.CompletedTask;
    }

    public Task SaveMemoryAsync(string section, string content)
    {
        _files["core.md"] = content;
        return Task.CompletedTask;
    }

    public Task SaveFullAsync(string content)
    {
        _files["core.md"] = content;
        return Task.CompletedTask;
    }

    public Task SoftDeleteMemoryFileAsync(string relativePath, string reason)
    {
        _files.Remove(relativePath);
        return Task.CompletedTask;
    }

    public Task DeleteSectionAsync(string section)
    {
        return Task.CompletedTask;
    }

    public Task<DateTime> GetLastUpdateAsync()
    {
        return Task.FromResult(_markerExists ? _lastUpdate : DateTime.MaxValue);
    }

    public Task<bool> EnsureUpdateMarkerAsync(DateTime timestamp)
    {
        if (_markerExists)
            return Task.FromResult(false);

        _markerExists = true;
        _lastUpdate = timestamp.ToUniversalTime();
        return Task.FromResult(true);
    }

    public Task SetLastUpdateAsync(DateTime timestamp)
    {
        _markerExists = true;
        _lastUpdate = timestamp;
        return Task.CompletedTask;
    }

    public Task DeleteUpdateMarkerAsync()
    {
        _markerExists = false;
        return Task.CompletedTask;
    }

    public Task<MemoryUpdateMarkerInfo> GetUpdateMarkerInfoAsync()
    {
        return Task.FromResult(_markerExists
            ? new MemoryUpdateMarkerInfo(".memory_update_marker", true, _lastUpdate.ToString("O"), _lastUpdate, DateTime.UtcNow)
            : new MemoryUpdateMarkerInfo(".memory_update_marker", false, "", null, null));
    }
}

sealed class FakeFileService : IFileService
{
    public FakeFileService(string content)
    {
        Content = content;
    }

    public string Content { get; private set; }
    public int WriteCount { get; private set; }

    public List<FileItem> GetDirectoryContents(string relativePath = "") => new();
    public string ReadFile(string relativePath) => Content;

    public Task WriteFileAsync(string relativePath, string content)
    {
        WriteCount++;
        Content = content;
        return Task.CompletedTask;
    }

    public Task DeleteFileAsync(string relativePath) => Task.CompletedTask;
    public Task RenameFileAsync(string sourcePath, string destPath) => Task.CompletedTask;
    public string? GetRelativePath(string fullPath) => fullPath;
    public string Combine(string relativePath, string name) => string.IsNullOrEmpty(relativePath) ? name : $"{relativePath}/{name}";
}

sealed class NullServiceProvider : IServiceProvider
{
    public object? GetService(Type serviceType) => null;
}

sealed class FakeGitService : IGitService
{
    public int CommitCount { get; private set; }
    public string RepoPath => "";

    public Task<GitResult> StageAllAsync() => Task.FromResult(new GitResult { Success = true });
    public Task<GitResult> StagePathAsync(string repoRelativePath) => Task.FromResult(new GitResult { Success = true });
    public Task<GitResult> CommitAsync(string message, string? body = null) => Task.FromResult(new GitResult { Success = true });
    public Task<GitResult> StatusAsync() => Task.FromResult(new GitResult { Success = true });

    public Task<GitResult> AutoCommitAsync(string summary, string? filePath = null)
    {
        CommitCount++;
        return Task.FromResult(new GitResult { Success = true, Output = "Committed" });
    }
}

sealed class RecordingLogger<T> : ILogger<T>
{
    public List<string> Messages { get; } = [];

    public IDisposable BeginScope<TState>(TState state) where TState : notnull => NoopDisposable.Instance;

    public bool IsEnabled(LogLevel logLevel) => true;

    public void Log<TState>(
        LogLevel logLevel,
        EventId eventId,
        TState state,
        Exception? exception,
        Func<TState, Exception?, string> formatter)
    {
        var message = formatter(state, exception);
        if (!string.IsNullOrWhiteSpace(message))
            Messages.Add($"[{logLevel}] {message}");
    }
}

sealed class NoopDisposable : IDisposable
{
    public static readonly NoopDisposable Instance = new();

    public void Dispose() { }
}
Offline