From 0ef2eb12ca49fa7ab5ef2d4ae126e226cc275d6e Mon Sep 17 00:00:00 2001 From: telli Date: Tue, 14 Apr 2026 15:22:50 -0700 Subject: [PATCH 1/4] test: complete slice 3 runtime parity coverage --- docs/runtime.md | 22 +++- .../AgentToolPolicyIntegrationTests.cs | 112 +++++++++++++++++- 2 files changed, 131 insertions(+), 3 deletions(-) diff --git a/docs/runtime.md b/docs/runtime.md index d0a5a93..f819ddb 100644 --- a/docs/runtime.md +++ b/docs/runtime.md @@ -11,8 +11,8 @@ Registration: `RuntimeServiceCollectionExtensions.AddSharpClawRuntime`. **`DefaultTurnRunner`** is the **`ITurnRunner`** implementation used for prompt turns. It: -1. Calls **`IPromptContextAssembler.AssembleAsync`** to build **`PromptContext`** (prompt text, metadata such as resolved **`model`**). -2. Maps **`RunPromptRequest`** + session into **`AgentRunContext`** (session/turn ids, working directory, permission mode, output format, **`IToolExecutor`**, metadata). +1. Calls **`IPromptContextAssembler.AssembleAsync`** to build **`PromptContext`** (prompt text, metadata such as resolved **`model`**, and prior-turn conversation history assembled from persisted runtime events). +2. Maps **`RunPromptRequest`** + session into **`AgentRunContext`** (session/turn ids, working directory, permission mode, output format, **`IToolExecutor`**, metadata, and normalized caller interactivity). 3. Invokes **`PrimaryCodingAgent.RunAsync`**. Before the agent runs, **`ConversationRuntime`** also layers in: @@ -24,6 +24,17 @@ Before the agent runs, **`ConversationRuntime`** also layers in: The agent stack is described in [agents.md](agents.md). +## Agent tool loop + +The current runtime supports provider-driven tool calling through the normal agent path. + +- **`AgentFrameworkBridge`** resolves the effective allowed-tool set from agent metadata. +- It advertises only that filtered tool set to the provider. +- **`ProviderBackedAgentKernel`** runs the provider loop, dispatches tool-use events through **`IToolExecutor`**, and feeds tool results back into the next provider iteration. +- Tool execution still goes through the normal permission engine, so allowlists, approval requirements, plugin trust, MCP trust, and primary-mode mutation restrictions all apply consistently. + +This means the model-visible tool surface and the executor-visible tool surface are derived from the same resolved policy input. + ## Lifecycle and state - **`IRuntimeStateMachine`** (`DefaultRuntimeStateMachine`) transitions **`ConversationSession.State`**. @@ -35,8 +46,15 @@ The agent stack is described in [agents.md](agents.md). It also includes a compact diagnostics summary from **`IWorkspaceDiagnosticsService`**, which currently surfaces configured diagnostics sources and build-derived findings for .NET workspaces. +Prompt references are resolved before provider execution. Outside-workspace file references are evaluated through the permission engine, and the approval path now respects the normalized caller interactivity mode: + +- interactive CLI and REPL callers can participate in approval prompts +- non-interactive surfaces such as ACP and the embedded HTTP server cannot + When the effective **`PrimaryMode`** is **`Spec`**, the assembler appends a structured output contract that requires the model to return machine-readable requirements, design, and task content. +Conversation history is rebuilt from persisted session events and truncated by token budget before being attached to the next provider request. Assistant history prefers the persisted final turn output and only falls back to streamed provider deltas when needed. + ## Spec workflow **`ISpecWorkflowService`** handles the post-processing path for **`spec`** mode: diff --git a/tests/SharpClaw.Code.IntegrationTests/Runtime/AgentToolPolicyIntegrationTests.cs b/tests/SharpClaw.Code.IntegrationTests/Runtime/AgentToolPolicyIntegrationTests.cs index b78144b..bc71341 100644 --- a/tests/SharpClaw.Code.IntegrationTests/Runtime/AgentToolPolicyIntegrationTests.cs +++ b/tests/SharpClaw.Code.IntegrationTests/Runtime/AgentToolPolicyIntegrationTests.cs @@ -5,6 +5,8 @@ using SharpClaw.Code.Infrastructure.Models; using SharpClaw.Code.Permissions.Abstractions; using SharpClaw.Code.Permissions.Models; +using SharpClaw.Code.Plugins.Abstractions; +using SharpClaw.Code.Plugins.Models; using SharpClaw.Code.Protocol.Commands; using SharpClaw.Code.Protocol.Enums; using SharpClaw.Code.Protocol.Models; @@ -52,6 +54,42 @@ await runtime.RunPromptAsync( provider.CapturedRequests[0].Tools!.Select(static tool => tool.Name).Should().Equal("read_file"); } + /// + /// Ensures plugin-backed tools are filtered through the same explicit allow list as built-ins. + /// + [Fact] + public async Task RunPrompt_should_filter_plugin_tools_through_same_allow_list_path() + { + var workspacePath = CreateTemporaryWorkspace(); + var provider = new CapturingToolPolicyProvider(ToolPolicyScenario.CaptureOnly); + using var serviceProvider = CreateServiceProvider( + provider, + pluginManager: new StubPluginManager([ + new PluginToolDescriptor("plugin_echo", "Echo via plugin.", "Plugin payload.", ["plugin"]), + ])); + var runtime = serviceProvider.GetRequiredService(); + + await runtime.RunPromptAsync( + new RunPromptRequest( + Prompt: "list available tools", + SessionId: null, + WorkingDirectory: workspacePath, + PermissionMode: PermissionMode.WorkspaceWrite, + OutputFormat: OutputFormat.Text, + Metadata: new Dictionary + { + ["provider"] = TestProviderName, + ["model"] = "tool-policy-model", + [SharpClawWorkflowMetadataKeys.AgentAllowedToolsJson] = """["plugin_echo"]""", + [ScenarioMetadataKey] = ToolPolicyScenario.CaptureOnly, + }), + CancellationToken.None); + + provider.CapturedRequests.Should().NotBeEmpty(); + provider.CapturedRequests[0].Tools.Should().NotBeNull(); + provider.CapturedRequests[0].Tools!.Select(static tool => tool.Name).Should().Equal("plugin_echo"); + } + /// /// Ensures interactive agent tool calls can request approval and reach the shell executor when approved. /// @@ -124,10 +162,49 @@ public async Task RunPrompt_should_deny_non_interactive_agent_tool_execution_bef shellExecutor.CallCount.Should().Be(0); } + /// + /// Ensures a tool requested outside the explicit allow list is denied even if the provider emits it. + /// + [Fact] + public async Task RunPrompt_should_deny_provider_requested_tool_that_is_not_in_allow_list() + { + var workspacePath = CreateTemporaryWorkspace(); + var provider = new CapturingToolPolicyProvider(ToolPolicyScenario.ToolRoundTrip); + var approvalService = new RecordingApprovalService(approve: true); + var shellExecutor = new RecordingShellExecutor(); + using var serviceProvider = CreateServiceProvider(provider, approvalService, shellExecutor); + var runtime = serviceProvider.GetRequiredService(); + + var result = await runtime.RunPromptAsync( + new RunPromptRequest( + Prompt: "run bash", + SessionId: null, + WorkingDirectory: workspacePath, + PermissionMode: PermissionMode.WorkspaceWrite, + OutputFormat: OutputFormat.Text, + Metadata: new Dictionary + { + ["provider"] = TestProviderName, + ["model"] = "tool-policy-model", + [SharpClawWorkflowMetadataKeys.AgentAllowedToolsJson] = """["read_file"]""", + [ScenarioMetadataKey] = ToolPolicyScenario.ToolRoundTrip, + }, + IsInteractive: true), + CancellationToken.None); + + result.FinalOutput.Should().Contain("Tool result received"); + result.ToolResults.Should().ContainSingle(); + result.ToolResults[0].Succeeded.Should().BeFalse(); + result.ToolResults[0].ErrorMessage.Should().Contain("allow list"); + approvalService.Requests.Should().BeEmpty(); + shellExecutor.CallCount.Should().Be(0); + } + private static ServiceProvider CreateServiceProvider( CapturingToolPolicyProvider provider, RecordingApprovalService? approvalService = null, - RecordingShellExecutor? shellExecutor = null) + RecordingShellExecutor? shellExecutor = null, + IPluginManager? pluginManager = null) { var services = new ServiceCollection(); services.AddSharpClawRuntime(); @@ -144,6 +221,12 @@ private static ServiceProvider CreateServiceProvider( services.AddSingleton(shellExecutor); } + if (pluginManager is not null) + { + services.AddSingleton(pluginManager); + services.AddSingleton(pluginManager); + } + return services.BuildServiceProvider(); } @@ -280,4 +363,31 @@ public Task ExecuteAsync( return Task.FromResult(new ProcessRunResult(0, "hi", string.Empty, now, now)); } } + + private sealed class StubPluginManager(IReadOnlyList descriptors) : IPluginManager + { + public Task> ListAsync(string workspaceRoot, CancellationToken cancellationToken) + => Task.FromResult>([]); + + public Task InstallAsync(string workspaceRoot, PluginInstallRequest request, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task EnableAsync(string workspaceRoot, string pluginId, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task DisableAsync(string workspaceRoot, string pluginId, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task UninstallAsync(string workspaceRoot, string pluginId, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task UpdateAsync(string workspaceRoot, PluginInstallRequest request, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task> ListToolDescriptorsAsync(string workspaceRoot, CancellationToken cancellationToken) + => Task.FromResult(descriptors); + + public Task ExecuteToolAsync(string workspaceRoot, string toolName, ToolExecutionRequest request, CancellationToken cancellationToken) + => Task.FromResult(new ToolResult(request.Id, toolName, true, OutputFormat.Text, "plugin", null, 0, null, null)); + } } From a7257a36f47a3ff32ed81bedd85bf2b2ef167c2a Mon Sep 17 00:00:00 2001 From: telli Date: Tue, 14 Apr 2026 22:21:36 -0700 Subject: [PATCH 2/4] fix: address runtime review follow-ups --- .../Internal/ProviderBackedAgentKernel.cs | 9 +- .../Context/ConversationHistoryCache.cs | 100 +++++++++++++++++ .../Context/PromptContextAssembler.cs | 16 ++- .../WorkspaceDiagnosticsService.cs | 26 +++-- .../Orchestration/ConversationRuntime.cs | 2 + .../Server/WorkspaceHttpServer.cs | 21 +++- .../AgentToolPolicyIntegrationTests.cs | 64 ++++++++++- .../Runtime/PromptContextAssemblyTests.cs | 71 ++++++++++++ .../Runtime/AgentCatalogServiceTests.cs | 71 ++++++++++++ .../ShareAndCompactionServicesTests.cs | 15 ++- .../WorkspaceDiagnosticsServiceTests.cs | 104 ++++++++++++++++++ 11 files changed, 472 insertions(+), 27 deletions(-) create mode 100644 src/SharpClaw.Code.Runtime/Context/ConversationHistoryCache.cs create mode 100644 tests/SharpClaw.Code.UnitTests/Runtime/AgentCatalogServiceTests.cs create mode 100644 tests/SharpClaw.Code.UnitTests/Runtime/WorkspaceDiagnosticsServiceTests.cs diff --git a/src/SharpClaw.Code.Agents/Internal/ProviderBackedAgentKernel.cs b/src/SharpClaw.Code.Agents/Internal/ProviderBackedAgentKernel.cs index 5363022..c4f8b31 100644 --- a/src/SharpClaw.Code.Agents/Internal/ProviderBackedAgentKernel.cs +++ b/src/SharpClaw.Code.Agents/Internal/ProviderBackedAgentKernel.cs @@ -254,7 +254,8 @@ internal async Task ExecuteAsync( } // Detect if the loop was exhausted (provider kept requesting tools every iteration). - if (iteration >= options.MaxToolIterations) + var toolLoopExhausted = iteration >= options.MaxToolIterations; + if (toolLoopExhausted) { logger.LogWarning( "Tool-calling loop reached maximum iterations ({MaxIterations}) for session {SessionId}; output may be incomplete.", @@ -280,10 +281,14 @@ internal async Task ExecuteAsync( TotalTokens: request.Context.Prompt.Length + output.Length, EstimatedCostUsd: null); + var summary = toolLoopExhausted + ? $"Provider response from {resolvedProviderName}/{requestedModel} is incomplete because the tool-calling loop reached the maximum of {options.MaxToolIterations} iterations." + : $"Streamed provider response from {resolvedProviderName}/{requestedModel}."; + return new ProviderInvocationResult( Output: output, Usage: usage, - Summary: $"Streamed provider response from {resolvedProviderName}/{requestedModel}.", + Summary: summary, ProviderRequest: lastProviderRequest, ProviderEvents: allProviderEvents, ToolResults: allToolResults.Count > 0 ? allToolResults : null, diff --git a/src/SharpClaw.Code.Runtime/Context/ConversationHistoryCache.cs b/src/SharpClaw.Code.Runtime/Context/ConversationHistoryCache.cs new file mode 100644 index 0000000..5ce0db0 --- /dev/null +++ b/src/SharpClaw.Code.Runtime/Context/ConversationHistoryCache.cs @@ -0,0 +1,100 @@ +using System.Collections.Concurrent; +using SharpClaw.Code.Protocol.Models; + +namespace SharpClaw.Code.Runtime.Context; + +/// +/// Caches assembled conversation history per session so follow-up turns can reuse +/// in-process transcript state without rereading the full event log. +/// +internal static class ConversationHistoryCache +{ + internal const int MaxHistoryTokenBudget = 100_000; + private const int MaxCacheEntries = 100; + private static readonly ConcurrentDictionary Cache = new(StringComparer.Ordinal); + + public static bool TryGet( + string workspaceRoot, + string sessionId, + int completedTurnSequence, + out IReadOnlyList history) + { + if (completedTurnSequence >= 0 + && Cache.TryGetValue(CreateKey(workspaceRoot, sessionId), out var entry) + && entry.CompletedTurnSequence == completedTurnSequence) + { + history = entry.History; + return true; + } + + history = []; + return false; + } + + public static void Store( + string workspaceRoot, + string sessionId, + int completedTurnSequence, + IReadOnlyList history) + { + Cache[CreateKey(workspaceRoot, sessionId)] = new CacheEntry(completedTurnSequence, [.. history]); + EvictOverflow(); + } + + public static void StoreCompletedTurn(string workspaceRoot, string sessionId, ConversationTurn completedTurn) + { + ArgumentNullException.ThrowIfNull(completedTurn); + if (completedTurn.SequenceNumber <= 0 || string.IsNullOrWhiteSpace(completedTurn.Output)) + { + return; + } + + var previousSequence = completedTurn.SequenceNumber - 1; + IReadOnlyList priorHistory = []; + if (previousSequence > 0 + && !TryGet(workspaceRoot, sessionId, previousSequence, out priorHistory)) + { + return; + } + + var updatedHistory = priorHistory + .Concat( + [ + CreateMessage("user", completedTurn.Input), + CreateMessage("assistant", completedTurn.Output), + ]) + .ToArray(); + + Store( + workspaceRoot, + sessionId, + completedTurn.SequenceNumber, + ContextWindowManager.Truncate(updatedHistory, MaxHistoryTokenBudget)); + } + + private static ChatMessage CreateMessage(string role, string text) + => new(role, [new ContentBlock(ContentBlockKind.Text, text, null, null, null, null)]); + + private static string CreateKey(string workspaceRoot, string sessionId) + => $"{workspaceRoot}\u0000{sessionId}"; + + private static void EvictOverflow() + { + if (Cache.Count <= MaxCacheEntries) + { + return; + } + + var overflowKeys = Cache.Keys + .OrderBy(static key => key, StringComparer.Ordinal) + .Take(Cache.Count - MaxCacheEntries) + .ToArray(); + + foreach (var key in overflowKeys) + { + Cache.TryRemove(key, out _); + } + } + + private sealed record CacheEntry(int CompletedTurnSequence, ChatMessage[] History); +} diff --git a/src/SharpClaw.Code.Runtime/Context/PromptContextAssembler.cs b/src/SharpClaw.Code.Runtime/Context/PromptContextAssembler.cs index 37b3922..7bc530d 100644 --- a/src/SharpClaw.Code.Runtime/Context/PromptContextAssembler.cs +++ b/src/SharpClaw.Code.Runtime/Context/PromptContextAssembler.cs @@ -162,15 +162,19 @@ public async Task AssembleAsync( // NOTE: This reads the full event log per turn, which scales linearly with session length. // For long-running sessions, consider persisting a compacted message history in session metadata // to avoid re-reading and re-assembling the full log on every prompt. - const int MaxHistoryTokenBudget = 100_000; IReadOnlyList conversationHistory = []; if (turn.SequenceNumber > 1) { - var sessionEvents = await eventStore - .ReadAllAsync(workspaceRoot, session.Id, cancellationToken) - .ConfigureAwait(false); - var rawHistory = ConversationHistoryAssembler.Assemble(sessionEvents); - conversationHistory = ContextWindowManager.Truncate(rawHistory, MaxHistoryTokenBudget); + var targetSequence = turn.SequenceNumber - 1; + if (!ConversationHistoryCache.TryGet(workspaceRoot, session.Id, targetSequence, out conversationHistory)) + { + var sessionEvents = await eventStore + .ReadAllAsync(workspaceRoot, session.Id, cancellationToken) + .ConfigureAwait(false); + var rawHistory = ConversationHistoryAssembler.Assemble(sessionEvents); + conversationHistory = ContextWindowManager.Truncate(rawHistory, ConversationHistoryCache.MaxHistoryTokenBudget); + ConversationHistoryCache.Store(workspaceRoot, session.Id, targetSequence, conversationHistory); + } } return new PromptExecutionContext( diff --git a/src/SharpClaw.Code.Runtime/Diagnostics/WorkspaceDiagnosticsService.cs b/src/SharpClaw.Code.Runtime/Diagnostics/WorkspaceDiagnosticsService.cs index 316f7e4..b5a8cb6 100644 --- a/src/SharpClaw.Code.Runtime/Diagnostics/WorkspaceDiagnosticsService.cs +++ b/src/SharpClaw.Code.Runtime/Diagnostics/WorkspaceDiagnosticsService.cs @@ -59,7 +59,7 @@ public async Task BuildSnapshotAsync(string worksp var snapshot = new WorkspaceDiagnosticsSnapshot(workspaceRoot, systemClock.UtcNow, configuredServers, diagnostics); Cache[workspaceRoot] = snapshot; - EvictStaleCacheEntries(); + EvictCacheEntries(); return snapshot; } @@ -98,13 +98,8 @@ private static IEnumerable ParseDotnetDiagnostics(strin private static string? NullIfEmpty(string value) => string.IsNullOrWhiteSpace(value) ? null : value; - private void EvictStaleCacheEntries() + private void EvictCacheEntries() { - if (Cache.Count <= MaxCacheEntries) - { - return; - } - var now = systemClock.UtcNow; foreach (var key in Cache.Keys) { @@ -113,6 +108,23 @@ private void EvictStaleCacheEntries() Cache.TryRemove(key, out _); } } + + if (Cache.Count <= MaxCacheEntries) + { + return; + } + + var overflowKeys = Cache + .OrderBy(static pair => pair.Value.GeneratedAtUtc) + .ThenBy(static pair => pair.Key, StringComparer.Ordinal) + .Take(Cache.Count - MaxCacheEntries) + .Select(static pair => pair.Key) + .ToArray(); + + foreach (var key in overflowKeys) + { + Cache.TryRemove(key, out _); + } } [GeneratedRegex(@"^(?.*?)(?:\((?\d+),(?\d+)\))?:\s*(?error|warning)\s*(?[A-Z]{2,}\d+)?\s*:?\s*(?.+)$", RegexOptions.Multiline | RegexOptions.IgnoreCase)] diff --git a/src/SharpClaw.Code.Runtime/Orchestration/ConversationRuntime.cs b/src/SharpClaw.Code.Runtime/Orchestration/ConversationRuntime.cs index 0a077a3..b4e485f 100644 --- a/src/SharpClaw.Code.Runtime/Orchestration/ConversationRuntime.cs +++ b/src/SharpClaw.Code.Runtime/Orchestration/ConversationRuntime.cs @@ -14,6 +14,7 @@ using SharpClaw.Code.Runtime.CustomCommands; using SharpClaw.Code.Runtime.Diagnostics; using SharpClaw.Code.Runtime.Export; +using SharpClaw.Code.Runtime.Context; using SharpClaw.Code.Runtime.Lifecycle; using SharpClaw.Code.Runtime.Mutations; using SharpClaw.Code.Runtime.Turns; @@ -269,6 +270,7 @@ await AppendEventAsync( CompletedAtUtc = completedAtUtc, Usage = turnRunResult.Usage, }; + ConversationHistoryCache.StoreCompletedTurn(workspacePath, session.Id, completedTurn); await AppendRuntimeEventsAsync( workspacePath, diff --git a/src/SharpClaw.Code.Runtime/Server/WorkspaceHttpServer.cs b/src/SharpClaw.Code.Runtime/Server/WorkspaceHttpServer.cs index e8541c2..c91eff4 100644 --- a/src/SharpClaw.Code.Runtime/Server/WorkspaceHttpServer.cs +++ b/src/SharpClaw.Code.Runtime/Server/WorkspaceHttpServer.cs @@ -1,6 +1,7 @@ using System.Net; using System.Text; using System.Text.Json; +using System.Text.Json.Serialization.Metadata; using Microsoft.Extensions.Logging; using SharpClaw.Code.Protocol.Commands; using SharpClaw.Code.Protocol.Enums; @@ -258,11 +259,7 @@ private static async Task WriteCommandResultAsync(HttpListenerResponse resp } } - private static readonly JsonSerializerOptions ServerJsonOptions = new(JsonSerializerDefaults.Web) - { - DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull, - Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() }, - }; + private static readonly JsonSerializerOptions ServerJsonOptions = CreateServerJsonOptions(); private static async Task WriteJsonAsync(HttpListenerResponse response, int statusCode, object payload, CancellationToken cancellationToken) { @@ -283,10 +280,22 @@ private Task DispatchServerHookAsync(string workspaceRoot, HttpListenerRequest r path, statusCode, succeeded, - DateTimeOffset.UtcNow)); + DateTimeOffset.UtcNow), + ServerJsonOptions); return hookDispatcher.DispatchAsync(workspaceRoot, HookTriggerKind.ServerRequestCompleted, payload, CancellationToken.None); } + private static JsonSerializerOptions CreateServerJsonOptions() + { + var options = new JsonSerializerOptions(ProtocolJsonContext.Default.Options) + { + TypeInfoResolver = JsonTypeInfoResolver.Combine( + ProtocolJsonContext.Default, + new DefaultJsonTypeInfoResolver()), + }; + return options; + } + private sealed record ServerCommandEnvelope( bool Succeeded, int ExitCode, diff --git a/tests/SharpClaw.Code.IntegrationTests/Runtime/AgentToolPolicyIntegrationTests.cs b/tests/SharpClaw.Code.IntegrationTests/Runtime/AgentToolPolicyIntegrationTests.cs index bc71341..675b716 100644 --- a/tests/SharpClaw.Code.IntegrationTests/Runtime/AgentToolPolicyIntegrationTests.cs +++ b/tests/SharpClaw.Code.IntegrationTests/Runtime/AgentToolPolicyIntegrationTests.cs @@ -1,6 +1,7 @@ using System.Runtime.CompilerServices; using FluentAssertions; using Microsoft.Extensions.DependencyInjection; +using SharpClaw.Code.Agents.Configuration; using SharpClaw.Code.Infrastructure.Abstractions; using SharpClaw.Code.Infrastructure.Models; using SharpClaw.Code.Permissions.Abstractions; @@ -9,6 +10,7 @@ using SharpClaw.Code.Plugins.Models; using SharpClaw.Code.Protocol.Commands; using SharpClaw.Code.Protocol.Enums; +using SharpClaw.Code.Protocol.Events; using SharpClaw.Code.Protocol.Models; using SharpClaw.Code.Providers.Abstractions; using SharpClaw.Code.Providers.Models; @@ -200,14 +202,55 @@ public async Task RunPrompt_should_deny_provider_requested_tool_that_is_not_in_a shellExecutor.CallCount.Should().Be(0); } + /// + /// Ensures callers can distinguish an incomplete provider result when the tool loop hits its iteration cap. + /// + [Fact] + public async Task RunPrompt_should_surface_tool_loop_exhaustion() + { + var workspacePath = CreateTemporaryWorkspace(); + await File.WriteAllTextAsync(Path.Combine(workspacePath, "README.md"), "SharpClaw"); + + var provider = new CapturingToolPolicyProvider(ToolPolicyScenario.ExhaustToolLoop); + using var serviceProvider = CreateServiceProvider(provider, configureLoop: options => options.MaxToolIterations = 2); + var runtime = serviceProvider.GetRequiredService(); + + var result = await runtime.RunPromptAsync( + new RunPromptRequest( + Prompt: "keep reading the file", + SessionId: null, + WorkingDirectory: workspacePath, + PermissionMode: PermissionMode.WorkspaceWrite, + OutputFormat: OutputFormat.Text, + Metadata: new Dictionary + { + ["provider"] = TestProviderName, + ["model"] = "tool-policy-model", + [SharpClawWorkflowMetadataKeys.AgentAllowedToolsJson] = """["read_file"]""", + [ScenarioMetadataKey] = ToolPolicyScenario.ExhaustToolLoop, + }, + IsInteractive: true), + CancellationToken.None); + + result.FinalOutput.Should().Contain("maximum of 2 iterations"); + var completedEvent = result.Events.OfType().Should().ContainSingle().Subject; + completedEvent.Summary.Should().Contain("maximum of 2 iterations"); + } + private static ServiceProvider CreateServiceProvider( CapturingToolPolicyProvider provider, RecordingApprovalService? approvalService = null, RecordingShellExecutor? shellExecutor = null, - IPluginManager? pluginManager = null) + IPluginManager? pluginManager = null, + Action? configureLoop = null) { var services = new ServiceCollection(); services.AddSharpClawRuntime(); + if (configureLoop is not null) + { + services.Configure(configureLoop); + } + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(_ => new StaticModelProviderResolver(provider)); @@ -244,6 +287,7 @@ private static class ToolPolicyScenario { public const string CaptureOnly = "capture-only"; public const string ToolRoundTrip = "tool-round-trip"; + public const string ExhaustToolLoop = "exhaust-tool-loop"; } private sealed class PassthroughPreflight : IProviderRequestPreflight @@ -316,6 +360,24 @@ private static async IAsyncEnumerable StreamEventsAsync( yield break; } + if (string.Equals(scenario, ToolPolicyScenario.ExhaustToolLoop, StringComparison.Ordinal)) + { + yield return new ProviderEvent( + "provider-event-loop", + request.Id, + "tool_use", + DateTimeOffset.UtcNow, + null, + false, + null, + BlockType: "tool_use", + ToolUseId: $"toolu_read_{request.Messages?.Count ?? 0:D3}", + ToolName: "read_file", + ToolInputJson: """{"path":"README.md"}"""); + yield return new ProviderEvent("provider-event-loop-terminal", request.Id, "completed", DateTimeOffset.UtcNow, null, true, new UsageSnapshot(1, 2, 0, 3, null)); + yield break; + } + yield return new ProviderEvent("provider-event-1", request.Id, "delta", DateTimeOffset.UtcNow, "ok", false, null); await Task.Yield(); yield return new ProviderEvent("provider-event-2", request.Id, "completed", DateTimeOffset.UtcNow, null, true, new UsageSnapshot(1, 2, 0, 3, null)); diff --git a/tests/SharpClaw.Code.IntegrationTests/Runtime/PromptContextAssemblyTests.cs b/tests/SharpClaw.Code.IntegrationTests/Runtime/PromptContextAssemblyTests.cs index f30be8b..f05a0df 100644 --- a/tests/SharpClaw.Code.IntegrationTests/Runtime/PromptContextAssemblyTests.cs +++ b/tests/SharpClaw.Code.IntegrationTests/Runtime/PromptContextAssemblyTests.cs @@ -1,5 +1,6 @@ using FluentAssertions; using Microsoft.Extensions.DependencyInjection; +using SharpClaw.Code.Infrastructure.Services; using SharpClaw.Code.Git.Abstractions; using SharpClaw.Code.Git.Models; using SharpClaw.Code.Memory.Abstractions; @@ -12,6 +13,8 @@ using SharpClaw.Code.Providers.Models; using SharpClaw.Code.Runtime; using SharpClaw.Code.Runtime.Abstractions; +using SharpClaw.Code.Sessions.Abstractions; +using SharpClaw.Code.Sessions.Storage; using SharpClaw.Code.Skills.Abstractions; using SharpClaw.Code.Skills.Models; @@ -65,6 +68,60 @@ public async Task RunPrompt_should_include_memory_skills_and_git_context_in_prov providerStarted.Request.Prompt.Should().Contain("Session summary"); } + /// + /// Ensures follow-up turns reuse the in-process conversation-history cache instead of rereading the event log. + /// + [Fact] + public async Task RunPrompt_should_reuse_in_process_conversation_history_cache() + { + var workspacePath = CreateTemporaryWorkspace(); + var countingEventStore = new CountingEventStore(new NdjsonEventStore(new LocalFileSystem(), new PathService())); + var services = new ServiceCollection(); + services.AddSharpClawRuntime(); + services.AddSingleton(new StubProjectMemoryService()); + services.AddSingleton(new StubSessionSummaryService()); + services.AddSingleton(new StubSkillRegistry()); + services.AddSingleton(new StubGitWorkspaceService()); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(countingEventStore); + using var serviceProvider = services.BuildServiceProvider(); + + var runtime = serviceProvider.GetRequiredService(); + var firstTurn = await runtime.RunPromptAsync( + new RunPromptRequest( + Prompt: "start the session", + SessionId: null, + WorkingDirectory: workspacePath, + PermissionMode: PermissionMode.WorkspaceWrite, + OutputFormat: OutputFormat.Text, + Metadata: new Dictionary + { + ["provider"] = "stub-provider", + ["model"] = "stub-model" + }), + CancellationToken.None); + + countingEventStore.ReadAllCount.Should().Be(0); + + _ = await runtime.RunPromptAsync( + new RunPromptRequest( + Prompt: "continue the session", + SessionId: firstTurn.Session.Id, + WorkingDirectory: workspacePath, + PermissionMode: PermissionMode.WorkspaceWrite, + OutputFormat: OutputFormat.Text, + Metadata: new Dictionary + { + ["provider"] = "stub-provider", + ["model"] = "stub-model" + }), + CancellationToken.None); + + countingEventStore.ReadAllCount.Should().Be(0); + } + private static string CreateTemporaryWorkspace() { var workspacePath = Path.Combine(Path.GetTempPath(), "sharpclaw-prompt-context-tests", Guid.NewGuid().ToString("N")); @@ -153,4 +210,18 @@ private static async IAsyncEnumerable StreamEventsAsync(ProviderR yield return new ProviderEvent("provider-event-2", request.Id, "completed", DateTimeOffset.UtcNow, null, true, new UsageSnapshot(1, 2, 0, 3, null)); } } + + private sealed class CountingEventStore(IEventStore inner) : IEventStore + { + public int ReadAllCount { get; private set; } + + public Task AppendAsync(string workspacePath, string sessionId, RuntimeEvent runtimeEvent, CancellationToken cancellationToken) + => inner.AppendAsync(workspacePath, sessionId, runtimeEvent, cancellationToken); + + public async Task> ReadAllAsync(string workspacePath, string sessionId, CancellationToken cancellationToken) + { + ReadAllCount++; + return await inner.ReadAllAsync(workspacePath, sessionId, cancellationToken); + } + } } diff --git a/tests/SharpClaw.Code.UnitTests/Runtime/AgentCatalogServiceTests.cs b/tests/SharpClaw.Code.UnitTests/Runtime/AgentCatalogServiceTests.cs new file mode 100644 index 0000000..1d7b8da --- /dev/null +++ b/tests/SharpClaw.Code.UnitTests/Runtime/AgentCatalogServiceTests.cs @@ -0,0 +1,71 @@ +using FluentAssertions; +using SharpClaw.Code.Agents.Abstractions; +using SharpClaw.Code.Agents.Models; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Runtime.Abstractions; +using SharpClaw.Code.Runtime.Workflow; + +namespace SharpClaw.Code.UnitTests.Runtime; + +/// +/// Verifies configured agents preserve their immediate parent relationship in the resolved catalog. +/// +public sealed class AgentCatalogServiceTests +{ + [Fact] + public async Task ListAsync_preserves_immediate_parent_for_multi_level_configured_agents() + { + var service = new AgentCatalogService( + [ + new StubAgent("primary-coding-agent", "primary"), + ], + new FixedConfigService( + new SharpClawConfigDocument( + ShareMode.Manual, + null, + null, + [ + new ConfiguredAgentDefinition( + Id: "derived-parent", + Name: "Derived Parent", + Description: "Parent agent.", + BaseAgentId: "primary-coding-agent", + Model: null, + PrimaryMode: null, + AllowedTools: null, + InstructionAppendix: null), + new ConfiguredAgentDefinition( + Id: "derived-child", + Name: "Derived Child", + Description: "Child agent.", + BaseAgentId: "derived-parent", + Model: null, + PrimaryMode: null, + AllowedTools: null, + InstructionAppendix: null), + ], + null, + null, + null))); + + var entries = await service.ListAsync(Path.Combine(Path.GetTempPath(), "sharpclaw-agent-catalog-tests"), CancellationToken.None); + + entries.Should().ContainSingle(entry => entry.Id == "derived-child" && entry.BaseAgentId == "derived-parent"); + } + + private sealed class FixedConfigService(SharpClawConfigDocument document) : ISharpClawConfigService + { + public Task GetConfigAsync(string workspaceRoot, CancellationToken cancellationToken) + => Task.FromResult(new SharpClawConfigSnapshot(workspaceRoot, null, null, document)); + } + + private sealed class StubAgent(string agentId, string agentKind) : ISharpClawAgent + { + public string AgentId { get; } = agentId; + + public string AgentKind { get; } = agentKind; + + public Task RunAsync(AgentRunContext context, CancellationToken cancellationToken) + => throw new NotSupportedException(); + } +} diff --git a/tests/SharpClaw.Code.UnitTests/Runtime/ShareAndCompactionServicesTests.cs b/tests/SharpClaw.Code.UnitTests/Runtime/ShareAndCompactionServicesTests.cs index b49e4ff..058c98b 100644 --- a/tests/SharpClaw.Code.UnitTests/Runtime/ShareAndCompactionServicesTests.cs +++ b/tests/SharpClaw.Code.UnitTests/Runtime/ShareAndCompactionServicesTests.cs @@ -39,7 +39,10 @@ public async Task Share_and_compaction_services_persist_expected_session_metadat UpdatedAtUtc: clock.UtcNow.AddMinutes(-5), ActiveTurnId: null, LastCheckpointId: null, - Metadata: new Dictionary(StringComparer.Ordinal)); + Metadata: new Dictionary(StringComparer.Ordinal) + { + [SharpClawWorkflowMetadataKeys.EditorContextJson] = """{"selectedPath":"secret.txt"}""" + }); await sessionStore.SaveAsync(workspaceRoot, session, CancellationToken.None); await eventStore.AppendAsync( @@ -118,14 +121,16 @@ await eventStore.AppendAsync( var share = await shareService.CreateShareAsync(workspaceRoot, session.Id, CancellationToken.None); var sharedSession = await sessionStore.GetByIdAsync(workspaceRoot, session.Id, CancellationToken.None); var compacted = await compactionService.CompactAsync(workspaceRoot, session.Id, CancellationToken.None); + var snapshot = await shareService.GetSharedSnapshotAsync(workspaceRoot, share.ShareId, CancellationToken.None); var removed = await shareService.RemoveShareAsync(workspaceRoot, session.Id, CancellationToken.None); var unsharedSession = await sessionStore.GetByIdAsync(workspaceRoot, session.Id, CancellationToken.None); share.Url.Should().Be("http://127.0.0.1:7345/s/" + share.ShareId); - // The share snapshot is written under the normalized/canonical workspace path (via pathService.GetFullPath), - // which on macOS resolves /var -> /private/var. The raw workspaceRoot path won't match the snapshot location. - // This assertion verifies that checking the raw path correctly returns false (path normalization is working). - fileSystem.FileExists(SessionStorageLayout.GetShareSnapshotPath(pathService, workspaceRoot, share.ShareId)).Should().BeFalse(); + snapshot.Should().NotBeNull(); + snapshot!.Record.ShareId.Should().Be(share.ShareId); + snapshot.Session.Metadata.Should().ContainKey(SharpClawWorkflowMetadataKeys.ShareId); + snapshot.Session.Metadata.Should().ContainKey(SharpClawWorkflowMetadataKeys.ShareUrl); + snapshot.Session.Metadata.Should().NotContainKey(SharpClawWorkflowMetadataKeys.EditorContextJson); sharedSession!.Metadata.Should().ContainKey(SharpClawWorkflowMetadataKeys.ShareId); compacted.Session.Metadata.Should().ContainKey(SharpClawWorkflowMetadataKeys.CompactedSummary); compacted.Summary.Should().Contain("Recent requests:"); diff --git a/tests/SharpClaw.Code.UnitTests/Runtime/WorkspaceDiagnosticsServiceTests.cs b/tests/SharpClaw.Code.UnitTests/Runtime/WorkspaceDiagnosticsServiceTests.cs new file mode 100644 index 0000000..4832e4a --- /dev/null +++ b/tests/SharpClaw.Code.UnitTests/Runtime/WorkspaceDiagnosticsServiceTests.cs @@ -0,0 +1,104 @@ +using System.Reflection; +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using SharpClaw.Code.Infrastructure.Abstractions; +using SharpClaw.Code.Infrastructure.Models; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Runtime.Abstractions; +using SharpClaw.Code.Runtime.Diagnostics; + +namespace SharpClaw.Code.UnitTests.Runtime; + +/// +/// Verifies workspace diagnostics caching remains bounded as new workspaces are inspected. +/// +public sealed class WorkspaceDiagnosticsServiceTests +{ + [Fact] + public async Task BuildSnapshotAsync_evicts_oldest_entries_when_cache_exceeds_limit() + { + ClearCache(); + var workspaces = Enumerable.Range(0, 60) + .Select(_ => Path.Combine(Path.GetTempPath(), "sharpclaw-diagnostics-tests", Guid.NewGuid().ToString("N"))) + .ToArray(); + + try + { + var clock = new MutableClock(DateTimeOffset.Parse("2026-04-14T20:00:00Z")); + var service = new WorkspaceDiagnosticsService( + new FixedConfigService(), + new NoOpProcessRunner(), + clock, + NullLogger.Instance); + + foreach (var workspace in workspaces) + { + Directory.CreateDirectory(workspace); + await service.BuildSnapshotAsync(workspace, CancellationToken.None); + clock.Advance(TimeSpan.FromMilliseconds(100)); + } + + var cacheEntries = GetCacheEntries(); + cacheEntries.Should().HaveCount(50); + cacheEntries.Keys.Should().NotContain(workspaces[0]); + cacheEntries.Keys.Should().Contain(workspaces[^1]); + } + finally + { + foreach (var workspace in workspaces) + { + if (Directory.Exists(workspace)) + { + Directory.Delete(workspace, recursive: true); + } + } + + ClearCache(); + } + } + + private static Dictionary GetCacheEntries() + { + var field = typeof(WorkspaceDiagnosticsService) + .GetField("Cache", BindingFlags.NonPublic | BindingFlags.Static) + ?? throw new InvalidOperationException("Workspace diagnostics cache field was not found."); + return ((IEnumerable>)field.GetValue(null)!) + .ToDictionary(static entry => entry.Key, static entry => entry.Value); + } + + private static void ClearCache() + { + var field = typeof(WorkspaceDiagnosticsService) + .GetField("Cache", BindingFlags.NonPublic | BindingFlags.Static) + ?? throw new InvalidOperationException("Workspace diagnostics cache field was not found."); + var cache = field.GetValue(null) ?? throw new InvalidOperationException("Workspace diagnostics cache instance was null."); + cache.GetType().GetMethod("Clear")!.Invoke(cache, null); + } + + private sealed class MutableClock(DateTimeOffset utcNow) : ISystemClock + { + public DateTimeOffset UtcNow { get; private set; } = utcNow; + + public void Advance(TimeSpan delta) => UtcNow += delta; + } + + private sealed class FixedConfigService : ISharpClawConfigService + { + public Task GetConfigAsync(string workspaceRoot, CancellationToken cancellationToken) + => Task.FromResult( + new SharpClawConfigSnapshot( + workspaceRoot, + null, + null, + new SharpClawConfigDocument(ShareMode.Manual, null, null, null, null, null, null))); + } + + private sealed class NoOpProcessRunner : IProcessRunner + { + public Task RunAsync(ProcessRunRequest request, CancellationToken cancellationToken) + { + var now = DateTimeOffset.UtcNow; + return Task.FromResult(new ProcessRunResult(0, string.Empty, string.Empty, now, now)); + } + } +} From 867ffd7aa438bb120656e67163c3e2346a247c15 Mon Sep 17 00:00:00 2001 From: telli Date: Tue, 14 Apr 2026 22:34:02 -0700 Subject: [PATCH 3/4] fix: address PR review threads --- .../Context/ConversationHistoryCache.cs | 22 ++++-- .../Context/PromptContextAssembler.cs | 7 +- .../AgentToolPolicyIntegrationTests.cs | 1 - .../Runtime/ConversationHistoryCacheTests.cs | 75 +++++++++++++++++++ 4 files changed, 94 insertions(+), 11 deletions(-) create mode 100644 tests/SharpClaw.Code.UnitTests/Runtime/ConversationHistoryCacheTests.cs diff --git a/src/SharpClaw.Code.Runtime/Context/ConversationHistoryCache.cs b/src/SharpClaw.Code.Runtime/Context/ConversationHistoryCache.cs index 5ce0db0..27476ce 100644 --- a/src/SharpClaw.Code.Runtime/Context/ConversationHistoryCache.cs +++ b/src/SharpClaw.Code.Runtime/Context/ConversationHistoryCache.cs @@ -1,4 +1,5 @@ using System.Collections.Concurrent; +using System.Threading; using SharpClaw.Code.Protocol.Models; namespace SharpClaw.Code.Runtime.Context; @@ -12,6 +13,7 @@ internal static class ConversationHistoryCache internal const int MaxHistoryTokenBudget = 100_000; private const int MaxCacheEntries = 100; private static readonly ConcurrentDictionary Cache = new(StringComparer.Ordinal); + private static long accessCounter; public static bool TryGet( string workspaceRoot, @@ -19,11 +21,14 @@ public static bool TryGet( int completedTurnSequence, out IReadOnlyList history) { + var key = CreateKey(workspaceRoot, sessionId); if (completedTurnSequence >= 0 - && Cache.TryGetValue(CreateKey(workspaceRoot, sessionId), out var entry) + && Cache.TryGetValue(key, out var entry) && entry.CompletedTurnSequence == completedTurnSequence) { - history = entry.History; + var touchedEntry = entry with { AccessOrder = NextAccessOrder() }; + Cache.TryUpdate(key, touchedEntry, entry); + history = touchedEntry.History; return true; } @@ -37,7 +42,8 @@ public static void Store( int completedTurnSequence, IReadOnlyList history) { - Cache[CreateKey(workspaceRoot, sessionId)] = new CacheEntry(completedTurnSequence, [.. history]); + var key = CreateKey(workspaceRoot, sessionId); + Cache[key] = new CacheEntry(completedTurnSequence, [.. history], NextAccessOrder()); EvictOverflow(); } @@ -85,9 +91,11 @@ private static void EvictOverflow() return; } - var overflowKeys = Cache.Keys - .OrderBy(static key => key, StringComparer.Ordinal) + var overflowKeys = Cache + .OrderBy(static pair => pair.Value.AccessOrder) + .ThenBy(static pair => pair.Key, StringComparer.Ordinal) .Take(Cache.Count - MaxCacheEntries) + .Select(static pair => pair.Key) .ToArray(); foreach (var key in overflowKeys) @@ -96,5 +104,7 @@ private static void EvictOverflow() } } - private sealed record CacheEntry(int CompletedTurnSequence, ChatMessage[] History); + private static long NextAccessOrder() => Interlocked.Increment(ref accessCounter); + + private sealed record CacheEntry(int CompletedTurnSequence, ChatMessage[] History, long AccessOrder); } diff --git a/src/SharpClaw.Code.Runtime/Context/PromptContextAssembler.cs b/src/SharpClaw.Code.Runtime/Context/PromptContextAssembler.cs index 7bc530d..3150425 100644 --- a/src/SharpClaw.Code.Runtime/Context/PromptContextAssembler.cs +++ b/src/SharpClaw.Code.Runtime/Context/PromptContextAssembler.cs @@ -158,10 +158,9 @@ public async Task AssembleAsync( sections.Add($"User request:\n{refResolution.ExpandedPrompt}"); - // Assemble prior-turn conversation history for multi-turn context. - // NOTE: This reads the full event log per turn, which scales linearly with session length. - // For long-running sessions, consider persisting a compacted message history in session metadata - // to avoid re-reading and re-assembling the full log on every prompt. + // Prefer cached history for the previous turn when available; on a cache miss, + // fall back to reading the full event log and re-assembling the history for caching. + // The fallback path still scales linearly with session length for long-running sessions. IReadOnlyList conversationHistory = []; if (turn.SequenceNumber > 1) { diff --git a/tests/SharpClaw.Code.IntegrationTests/Runtime/AgentToolPolicyIntegrationTests.cs b/tests/SharpClaw.Code.IntegrationTests/Runtime/AgentToolPolicyIntegrationTests.cs index 675b716..4739c77 100644 --- a/tests/SharpClaw.Code.IntegrationTests/Runtime/AgentToolPolicyIntegrationTests.cs +++ b/tests/SharpClaw.Code.IntegrationTests/Runtime/AgentToolPolicyIntegrationTests.cs @@ -266,7 +266,6 @@ private static ServiceProvider CreateServiceProvider( if (pluginManager is not null) { - services.AddSingleton(pluginManager); services.AddSingleton(pluginManager); } diff --git a/tests/SharpClaw.Code.UnitTests/Runtime/ConversationHistoryCacheTests.cs b/tests/SharpClaw.Code.UnitTests/Runtime/ConversationHistoryCacheTests.cs new file mode 100644 index 0000000..a19a8ee --- /dev/null +++ b/tests/SharpClaw.Code.UnitTests/Runtime/ConversationHistoryCacheTests.cs @@ -0,0 +1,75 @@ +using System.Collections; +using System.Reflection; +using FluentAssertions; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Runtime.Context; + +namespace SharpClaw.Code.UnitTests.Runtime; + +/// +/// Verifies the in-process conversation history cache evicts by recency rather than key ordering. +/// +public sealed class ConversationHistoryCacheTests +{ + private static readonly Type CacheType = typeof(PromptContextAssembler).Assembly + .GetType("SharpClaw.Code.Runtime.Context.ConversationHistoryCache") + ?? throw new InvalidOperationException("ConversationHistoryCache type was not found."); + + [Fact] + public void Store_and_TryGet_keep_recently_touched_entries_when_cache_overflows() + { + ResetCache(); + + var history = CreateHistory("cached"); + for (var index = 0; index < 100; index++) + { + Store($"workspace-{index}", $"session-{index}", 1, history); + } + + TryGet("workspace-0", "session-0", 1).Should().BeTrue(); + + Store("workspace-100", "session-100", 1, history); + + var keys = GetCacheKeys(); + keys.Should().HaveCount(100); + keys.Should().Contain("workspace-0\0session-0"); + keys.Should().NotContain("workspace-1\0session-1"); + } + + private static void Store(string workspaceRoot, string sessionId, int completedTurnSequence, IReadOnlyList history) + => CacheType.GetMethod("Store", BindingFlags.Public | BindingFlags.Static)! + .Invoke(null, [workspaceRoot, sessionId, completedTurnSequence, history]); + + private static bool TryGet(string workspaceRoot, string sessionId, int completedTurnSequence) + { + var arguments = new object?[] { workspaceRoot, sessionId, completedTurnSequence, null }; + var hit = (bool)CacheType.GetMethod("TryGet", BindingFlags.Public | BindingFlags.Static)! + .Invoke(null, arguments)!; + arguments[3].Should().NotBeNull(); + return hit; + } + + private static string[] GetCacheKeys() + { + var cache = GetCacheDictionary(); + return cache.Keys.Cast().Select(static key => (string)key).ToArray(); + } + + private static IDictionary GetCacheDictionary() + => (IDictionary)(CacheType.GetField("Cache", BindingFlags.NonPublic | BindingFlags.Static)! + .GetValue(null) + ?? throw new InvalidOperationException("Conversation history cache instance was null.")); + + private static void ResetCache() + { + GetCacheDictionary().Clear(); + CacheType.GetField("accessCounter", BindingFlags.NonPublic | BindingFlags.Static)! + .SetValue(null, 0L); + } + + private static ChatMessage[] CreateHistory(string text) + => + [ + new ChatMessage("assistant", [new ContentBlock(ContentBlockKind.Text, text, null, null, null, null)]) + ]; +} From 615b1f851358b680f501a218c296dd8fa4f61117 Mon Sep 17 00:00:00 2001 From: telli Date: Tue, 14 Apr 2026 22:37:37 -0700 Subject: [PATCH 4/4] fix: unblock windows CI --- .../Configuration/SharpClawConfigService.cs | 7 ++++++- .../Infrastructure/InfrastructureRegistrationTests.cs | 2 +- .../Runtime/SharpClawConfigServiceTests.cs | 9 ++++++++- .../Runtime/SpecWorkflowServiceTests.cs | 4 ++-- 4 files changed, 17 insertions(+), 5 deletions(-) diff --git a/src/SharpClaw.Code.Runtime/Configuration/SharpClawConfigService.cs b/src/SharpClaw.Code.Runtime/Configuration/SharpClawConfigService.cs index b7de319..ac5d433 100644 --- a/src/SharpClaw.Code.Runtime/Configuration/SharpClawConfigService.cs +++ b/src/SharpClaw.Code.Runtime/Configuration/SharpClawConfigService.cs @@ -119,7 +119,12 @@ private static string GetUserConfigPath() { if (OperatingSystem.IsWindows()) { - var roaming = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); + var roaming = Environment.GetEnvironmentVariable("APPDATA"); + if (string.IsNullOrWhiteSpace(roaming)) + { + roaming = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); + } + return Path.Combine(roaming, "SharpClaw", "config.jsonc"); } diff --git a/tests/SharpClaw.Code.UnitTests/Infrastructure/InfrastructureRegistrationTests.cs b/tests/SharpClaw.Code.UnitTests/Infrastructure/InfrastructureRegistrationTests.cs index 1fdf9cd..ab4f9cf 100644 --- a/tests/SharpClaw.Code.UnitTests/Infrastructure/InfrastructureRegistrationTests.cs +++ b/tests/SharpClaw.Code.UnitTests/Infrastructure/InfrastructureRegistrationTests.cs @@ -42,7 +42,7 @@ public void Path_service_should_normalize_and_combine_paths() var combined = pathService.Combine("/tmp", "sharpclaw", "sessions"); var normalized = pathService.GetFullPath("."); - combined.Should().EndWith("/tmp/sharpclaw/sessions"); + combined.Should().Be(Path.Combine("/tmp", "sharpclaw", "sessions")); normalized.Should().NotBeNullOrWhiteSpace(); } diff --git a/tests/SharpClaw.Code.UnitTests/Runtime/SharpClawConfigServiceTests.cs b/tests/SharpClaw.Code.UnitTests/Runtime/SharpClawConfigServiceTests.cs index ed6aa6b..e4e5cd3 100644 --- a/tests/SharpClaw.Code.UnitTests/Runtime/SharpClawConfigServiceTests.cs +++ b/tests/SharpClaw.Code.UnitTests/Runtime/SharpClawConfigServiceTests.cs @@ -9,6 +9,7 @@ public sealed class SharpClawConfigServiceTests : IDisposable { private readonly string? originalHome = Environment.GetEnvironmentVariable("HOME"); private readonly string? originalUserProfile = Environment.GetEnvironmentVariable("USERPROFILE"); + private readonly string? originalAppData = Environment.GetEnvironmentVariable("APPDATA"); private readonly string tempRoot = Path.Combine(Path.GetTempPath(), $"sharpclaw-config-{Guid.NewGuid():N}"); [Fact] @@ -16,15 +17,20 @@ public async Task GetConfigAsync_merges_user_and_workspace_documents_by_preceden { Directory.CreateDirectory(tempRoot); var home = Path.Combine(tempRoot, "home"); + var appData = Path.Combine(tempRoot, "appdata", "roaming"); var workspace = Path.Combine(tempRoot, "workspace"); Directory.CreateDirectory(Path.Combine(home, ".config", "sharpclaw")); + Directory.CreateDirectory(Path.Combine(appData, "SharpClaw")); Directory.CreateDirectory(workspace); Environment.SetEnvironmentVariable("HOME", home); Environment.SetEnvironmentVariable("USERPROFILE", home); + Environment.SetEnvironmentVariable("APPDATA", appData); await File.WriteAllTextAsync( - Path.Combine(home, ".config", "sharpclaw", "config.jsonc"), + OperatingSystem.IsWindows() + ? Path.Combine(appData, "SharpClaw", "config.jsonc") + : Path.Combine(home, ".config", "sharpclaw", "config.jsonc"), """ { // user defaults @@ -86,6 +92,7 @@ public void Dispose() { Environment.SetEnvironmentVariable("HOME", originalHome); Environment.SetEnvironmentVariable("USERPROFILE", originalUserProfile); + Environment.SetEnvironmentVariable("APPDATA", originalAppData); if (Directory.Exists(tempRoot)) { Directory.Delete(tempRoot, recursive: true); diff --git a/tests/SharpClaw.Code.UnitTests/Runtime/SpecWorkflowServiceTests.cs b/tests/SharpClaw.Code.UnitTests/Runtime/SpecWorkflowServiceTests.cs index 5408479..beecc18 100644 --- a/tests/SharpClaw.Code.UnitTests/Runtime/SpecWorkflowServiceTests.cs +++ b/tests/SharpClaw.Code.UnitTests/Runtime/SpecWorkflowServiceTests.cs @@ -22,8 +22,8 @@ public async Task MaterializeAsync_should_write_spec_documents_and_suffix_collis var first = await service.MaterializeAsync(workspace, "Add offline sync support", payload, CancellationToken.None); var second = await service.MaterializeAsync(workspace, "Add offline sync support", payload, CancellationToken.None); - first.RootPath.Should().EndWith("/2026-04-08-add-offline-sync-support"); - second.RootPath.Should().EndWith("/2026-04-08-add-offline-sync-support-2"); + Path.GetFileName(first.RootPath).Should().Be("2026-04-08-add-offline-sync-support"); + Path.GetFileName(second.RootPath).Should().Be("2026-04-08-add-offline-sync-support-2"); File.ReadAllText(first.RequirementsPath).Should().Contain("## Requirements"); File.ReadAllText(first.RequirementsPath).Should().Contain("the system shall"); File.ReadAllText(first.DesignPath).Should().Contain("## Architecture");