Skip to content

Commit 0ef2eb1

Browse files
committed
test: complete slice 3 runtime parity coverage
1 parent ef727c6 commit 0ef2eb1

2 files changed

Lines changed: 131 additions & 3 deletions

File tree

docs/runtime.md

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ Registration: `RuntimeServiceCollectionExtensions.AddSharpClawRuntime`.
1111

1212
**`DefaultTurnRunner`** is the **`ITurnRunner`** implementation used for prompt turns. It:
1313

14-
1. Calls **`IPromptContextAssembler.AssembleAsync`** to build **`PromptContext`** (prompt text, metadata such as resolved **`model`**).
15-
2. Maps **`RunPromptRequest`** + session into **`AgentRunContext`** (session/turn ids, working directory, permission mode, output format, **`IToolExecutor`**, metadata).
14+
1. Calls **`IPromptContextAssembler.AssembleAsync`** to build **`PromptContext`** (prompt text, metadata such as resolved **`model`**, and prior-turn conversation history assembled from persisted runtime events).
15+
2. Maps **`RunPromptRequest`** + session into **`AgentRunContext`** (session/turn ids, working directory, permission mode, output format, **`IToolExecutor`**, metadata, and normalized caller interactivity).
1616
3. Invokes **`PrimaryCodingAgent.RunAsync`**.
1717

1818
Before the agent runs, **`ConversationRuntime`** also layers in:
@@ -24,6 +24,17 @@ Before the agent runs, **`ConversationRuntime`** also layers in:
2424

2525
The agent stack is described in [agents.md](agents.md).
2626

27+
## Agent tool loop
28+
29+
The current runtime supports provider-driven tool calling through the normal agent path.
30+
31+
- **`AgentFrameworkBridge`** resolves the effective allowed-tool set from agent metadata.
32+
- It advertises only that filtered tool set to the provider.
33+
- **`ProviderBackedAgentKernel`** runs the provider loop, dispatches tool-use events through **`IToolExecutor`**, and feeds tool results back into the next provider iteration.
34+
- 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.
35+
36+
This means the model-visible tool surface and the executor-visible tool surface are derived from the same resolved policy input.
37+
2738
## Lifecycle and state
2839

2940
- **`IRuntimeStateMachine`** (`DefaultRuntimeStateMachine`) transitions **`ConversationSession.State`**.
@@ -35,8 +46,15 @@ The agent stack is described in [agents.md](agents.md).
3546

3647
It also includes a compact diagnostics summary from **`IWorkspaceDiagnosticsService`**, which currently surfaces configured diagnostics sources and build-derived findings for .NET workspaces.
3748

49+
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:
50+
51+
- interactive CLI and REPL callers can participate in approval prompts
52+
- non-interactive surfaces such as ACP and the embedded HTTP server cannot
53+
3854
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.
3955

56+
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.
57+
4058
## Spec workflow
4159

4260
**`ISpecWorkflowService`** handles the post-processing path for **`spec`** mode:

tests/SharpClaw.Code.IntegrationTests/Runtime/AgentToolPolicyIntegrationTests.cs

Lines changed: 111 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
using SharpClaw.Code.Infrastructure.Models;
66
using SharpClaw.Code.Permissions.Abstractions;
77
using SharpClaw.Code.Permissions.Models;
8+
using SharpClaw.Code.Plugins.Abstractions;
9+
using SharpClaw.Code.Plugins.Models;
810
using SharpClaw.Code.Protocol.Commands;
911
using SharpClaw.Code.Protocol.Enums;
1012
using SharpClaw.Code.Protocol.Models;
@@ -52,6 +54,42 @@ await runtime.RunPromptAsync(
5254
provider.CapturedRequests[0].Tools!.Select(static tool => tool.Name).Should().Equal("read_file");
5355
}
5456

57+
/// <summary>
58+
/// Ensures plugin-backed tools are filtered through the same explicit allow list as built-ins.
59+
/// </summary>
60+
[Fact]
61+
public async Task RunPrompt_should_filter_plugin_tools_through_same_allow_list_path()
62+
{
63+
var workspacePath = CreateTemporaryWorkspace();
64+
var provider = new CapturingToolPolicyProvider(ToolPolicyScenario.CaptureOnly);
65+
using var serviceProvider = CreateServiceProvider(
66+
provider,
67+
pluginManager: new StubPluginManager([
68+
new PluginToolDescriptor("plugin_echo", "Echo via plugin.", "Plugin payload.", ["plugin"]),
69+
]));
70+
var runtime = serviceProvider.GetRequiredService<IConversationRuntime>();
71+
72+
await runtime.RunPromptAsync(
73+
new RunPromptRequest(
74+
Prompt: "list available tools",
75+
SessionId: null,
76+
WorkingDirectory: workspacePath,
77+
PermissionMode: PermissionMode.WorkspaceWrite,
78+
OutputFormat: OutputFormat.Text,
79+
Metadata: new Dictionary<string, string>
80+
{
81+
["provider"] = TestProviderName,
82+
["model"] = "tool-policy-model",
83+
[SharpClawWorkflowMetadataKeys.AgentAllowedToolsJson] = """["plugin_echo"]""",
84+
[ScenarioMetadataKey] = ToolPolicyScenario.CaptureOnly,
85+
}),
86+
CancellationToken.None);
87+
88+
provider.CapturedRequests.Should().NotBeEmpty();
89+
provider.CapturedRequests[0].Tools.Should().NotBeNull();
90+
provider.CapturedRequests[0].Tools!.Select(static tool => tool.Name).Should().Equal("plugin_echo");
91+
}
92+
5593
/// <summary>
5694
/// Ensures interactive agent tool calls can request approval and reach the shell executor when approved.
5795
/// </summary>
@@ -124,10 +162,49 @@ public async Task RunPrompt_should_deny_non_interactive_agent_tool_execution_bef
124162
shellExecutor.CallCount.Should().Be(0);
125163
}
126164

165+
/// <summary>
166+
/// Ensures a tool requested outside the explicit allow list is denied even if the provider emits it.
167+
/// </summary>
168+
[Fact]
169+
public async Task RunPrompt_should_deny_provider_requested_tool_that_is_not_in_allow_list()
170+
{
171+
var workspacePath = CreateTemporaryWorkspace();
172+
var provider = new CapturingToolPolicyProvider(ToolPolicyScenario.ToolRoundTrip);
173+
var approvalService = new RecordingApprovalService(approve: true);
174+
var shellExecutor = new RecordingShellExecutor();
175+
using var serviceProvider = CreateServiceProvider(provider, approvalService, shellExecutor);
176+
var runtime = serviceProvider.GetRequiredService<IConversationRuntime>();
177+
178+
var result = await runtime.RunPromptAsync(
179+
new RunPromptRequest(
180+
Prompt: "run bash",
181+
SessionId: null,
182+
WorkingDirectory: workspacePath,
183+
PermissionMode: PermissionMode.WorkspaceWrite,
184+
OutputFormat: OutputFormat.Text,
185+
Metadata: new Dictionary<string, string>
186+
{
187+
["provider"] = TestProviderName,
188+
["model"] = "tool-policy-model",
189+
[SharpClawWorkflowMetadataKeys.AgentAllowedToolsJson] = """["read_file"]""",
190+
[ScenarioMetadataKey] = ToolPolicyScenario.ToolRoundTrip,
191+
},
192+
IsInteractive: true),
193+
CancellationToken.None);
194+
195+
result.FinalOutput.Should().Contain("Tool result received");
196+
result.ToolResults.Should().ContainSingle();
197+
result.ToolResults[0].Succeeded.Should().BeFalse();
198+
result.ToolResults[0].ErrorMessage.Should().Contain("allow list");
199+
approvalService.Requests.Should().BeEmpty();
200+
shellExecutor.CallCount.Should().Be(0);
201+
}
202+
127203
private static ServiceProvider CreateServiceProvider(
128204
CapturingToolPolicyProvider provider,
129205
RecordingApprovalService? approvalService = null,
130-
RecordingShellExecutor? shellExecutor = null)
206+
RecordingShellExecutor? shellExecutor = null,
207+
IPluginManager? pluginManager = null)
131208
{
132209
var services = new ServiceCollection();
133210
services.AddSharpClawRuntime();
@@ -144,6 +221,12 @@ private static ServiceProvider CreateServiceProvider(
144221
services.AddSingleton<IShellExecutor>(shellExecutor);
145222
}
146223

224+
if (pluginManager is not null)
225+
{
226+
services.AddSingleton(pluginManager);
227+
services.AddSingleton<IPluginManager>(pluginManager);
228+
}
229+
147230
return services.BuildServiceProvider();
148231
}
149232

@@ -280,4 +363,31 @@ public Task<ProcessRunResult> ExecuteAsync(
280363
return Task.FromResult(new ProcessRunResult(0, "hi", string.Empty, now, now));
281364
}
282365
}
366+
367+
private sealed class StubPluginManager(IReadOnlyList<PluginToolDescriptor> descriptors) : IPluginManager
368+
{
369+
public Task<IReadOnlyList<LoadedPlugin>> ListAsync(string workspaceRoot, CancellationToken cancellationToken)
370+
=> Task.FromResult<IReadOnlyList<LoadedPlugin>>([]);
371+
372+
public Task<LoadedPlugin> InstallAsync(string workspaceRoot, PluginInstallRequest request, CancellationToken cancellationToken)
373+
=> throw new NotSupportedException();
374+
375+
public Task<LoadedPlugin> EnableAsync(string workspaceRoot, string pluginId, CancellationToken cancellationToken)
376+
=> throw new NotSupportedException();
377+
378+
public Task<LoadedPlugin> DisableAsync(string workspaceRoot, string pluginId, CancellationToken cancellationToken)
379+
=> throw new NotSupportedException();
380+
381+
public Task UninstallAsync(string workspaceRoot, string pluginId, CancellationToken cancellationToken)
382+
=> throw new NotSupportedException();
383+
384+
public Task<LoadedPlugin> UpdateAsync(string workspaceRoot, PluginInstallRequest request, CancellationToken cancellationToken)
385+
=> throw new NotSupportedException();
386+
387+
public Task<IReadOnlyList<PluginToolDescriptor>> ListToolDescriptorsAsync(string workspaceRoot, CancellationToken cancellationToken)
388+
=> Task.FromResult(descriptors);
389+
390+
public Task<ToolResult> ExecuteToolAsync(string workspaceRoot, string toolName, ToolExecutionRequest request, CancellationToken cancellationToken)
391+
=> Task.FromResult(new ToolResult(request.Id, toolName, true, OutputFormat.Text, "plugin", null, 0, null, null));
392+
}
283393
}

0 commit comments

Comments
 (0)