diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7ca383a..824c9c2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,6 +25,12 @@ jobs: run: dotnet restore SharpClawCode.sln - name: Build run: dotnet build SharpClawCode.sln --no-restore --configuration Release + - name: Build examples + run: | + dotnet build examples/WebApiAgent/WebApiAgent.csproj --no-restore --configuration Release + dotnet build examples/MinimalConsoleAgent/MinimalConsoleAgent.csproj --no-restore --configuration Release + dotnet build examples/WorkerServiceHost/WorkerServiceHost.csproj --no-restore --configuration Release + dotnet build examples/McpToolAgent/McpToolAgent.csproj --no-restore --configuration Release - name: Test run: dotnet test SharpClawCode.sln --no-build --configuration Release --collect:"XPlat Code Coverage" --results-directory ./coverage - name: Upload coverage diff --git a/Directory.Packages.props b/Directory.Packages.props index 09e29fc..3027c60 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -16,14 +16,18 @@ + + + + - \ No newline at end of file + diff --git a/README.md b/README.md index 4a74792..02b732c 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,8 @@ SharpClaw Code is a C# and .NET-native coding agent runtime for teams building A It combines durable sessions, permission-aware tool execution, provider abstraction, structured telemetry, and an automation-friendly command-line surface in a runtime shaped for real .NET systems: explicit, testable, and operationally legible. +The repository now ships both a terminal-first agent runtime and an embeddable host SDK through `SharpClaw.Code`, which makes it viable for standalone CLIs, local editor backends, and tenant-aware embedded services. + ## What It Is SharpClaw Code is an open-source runtime for building and operating coding-agent experiences in the .NET ecosystem. @@ -65,6 +67,21 @@ dotnet run --project src/SharpClaw.Code.Cli -- prompt "Summarize this workspace" dotnet run --project src/SharpClaw.Code.Cli -- doctor dotnet run --project src/SharpClaw.Code.Cli -- status +# Refresh and query the workspace knowledge index +dotnet run --project src/SharpClaw.Code.Cli -- index refresh +dotnet run --project src/SharpClaw.Code.Cli -- index query WidgetService + +# Save and inspect durable memory +dotnet run --project src/SharpClaw.Code.Cli -- memory save --scope project "Keep prompts concise" +dotnet run --project src/SharpClaw.Code.Cli -- memory list --scope project + +# Inspect metering summaries and details +dotnet run --project src/SharpClaw.Code.Cli -- usage summary +dotnet run --project src/SharpClaw.Code.Cli -- usage detail --limit 25 + +# Manage packaged tool bundles +dotnet run --project src/SharpClaw.Code.Cli -- tool-packages list + # Emit machine-readable output dotnet run --project src/SharpClaw.Code.Cli -- --output-format json doctor ``` @@ -73,6 +90,8 @@ Built-in REPL slash commands include `/help`, `/status`, `/doctor`, `/session`, Parity-oriented commands now include: +- `index` / `/index` +- `memory` / `/memory` - `models` / `/models` - `usage` / `/usage` - `cost` / `/cost` @@ -101,12 +120,16 @@ Primary workflow modes: | Durable sessions | Persist conversation state, turn history, checkpoints, and recovery metadata for longer-running agent work | | Permission-aware tools | Route file, shell, and plugin-backed actions through explicit policy and approval decisions | | Provider abstraction | Run against Anthropic and OpenAI-compatible backends through a typed runtime surface | +| Local runtime catalog | Surface Ollama, llama.cpp, and other OpenAI-compatible profiles with health, model discovery, and embedding defaults | | MCP support | Register, supervise, and integrate MCP servers with explicit lifecycle state | | Plugins and skills | Extend the runtime with trusted plugin manifests and discoverable workspace skills | +| Workspace knowledge | Build a durable local index for lexical, symbol, and semantic workspace search | +| Cross-session memory | Persist project and user memory so later sessions can recall repo-specific guidance and user preferences | | Structured telemetry | Emit runtime events and usage signals that support diagnostics, replay, and automation | +| Enterprise host controls | Add tenant-aware storage, authenticated approvals, admin APIs, and usage metering for embedded deployments | | JSON-friendly CLI | Use the same runtime through human-readable terminal flows or machine-readable command output | | Spec workflow mode | Turn prompts into structured requirements, technical design, and task documents for feature proposals | -| Embedded local server | Expose prompt, session, status, doctor, and share endpoints for editor or automation clients | +| Embedded SDK + server | Host the runtime via `SharpClaw.Code` or expose prompt, session, admin, and SSE endpoints for editor or automation clients | | Config + agent catalog | Layer user/workspace JSONC config with typed agent defaults, tool allowlists, and runtime hooks | | Session sharing | Create self-hosted share links and durable sanitized share snapshots under `.sharpclaw/` | | Diagnostics context | Surface configured diagnostics sources into prompt context, status, and machine-readable output | @@ -123,6 +146,7 @@ Primary workflow modes: | Area | Project(s) | |---|---| +| Embeddable SDK | `SharpClaw.Code` | | CLI and command handlers | `SharpClaw.Code.Cli`, `SharpClaw.Code.Commands` | | Core contracts | `SharpClaw.Code.Protocol` | | Runtime orchestration | `SharpClaw.Code.Runtime` | @@ -135,12 +159,27 @@ Primary workflow modes: For dependency boundaries and project responsibilities, see [docs/architecture.md](docs/architecture.md) and [AGENTS.md](AGENTS.md). +## Example Hosts + +The solution includes embeddable host samples under `examples/`: + +- `MinimalConsoleAgent` for direct SDK prompt execution +- `WebApiAgent` for an HTTP-hosted runtime surface +- `WorkerServiceHost` for lifecycle-managed background hosting +- `McpToolAgent` for MCP-aware host composition + ## Testing ```bash # Run all tests dotnet test SharpClawCode.sln +# Build example hosts as part of local validation +dotnet build examples/WebApiAgent/WebApiAgent.csproj +dotnet build examples/MinimalConsoleAgent/MinimalConsoleAgent.csproj +dotnet build examples/WorkerServiceHost/WorkerServiceHost.csproj +dotnet build examples/McpToolAgent/McpToolAgent.csproj + # Run a single test by name dotnet test SharpClawCode.sln --filter "FullyQualifiedName~YourTestName" @@ -167,8 +206,12 @@ dotnet test SharpClawCode.sln --filter "FullyQualifiedName~ParityScenarioTests" | `--primary-mode ` | Workflow bias for prompts: `build`, `plan`, or `spec` | | `--session ` | Reuse a specific SharpClaw session id for prompt execution | | `--agent ` | Select the active agent for prompt execution | +| `--host-id ` | Stable embedded-host identifier for metering, admin, and event envelopes | +| `--tenant-id ` | Tenant identifier used by host-aware storage, approvals, and metering | +| `--storage-root ` | External root for host-managed durable runtime state | +| `--session-store fileSystem\|sqlite` | Select the embedded session/event storage backend | -Subcommands include `prompt`, `repl`, `doctor`, `status`, `session`, `models`, `usage`, `cost`, `stats`, `connect`, `hooks`, `skills`, `agents`, `todo`, `share`, `unshare`, `compact`, `serve`, `commands`, `mcp`, `plugins`, `acp`, `bridge`, and `version`. +Subcommands include `prompt`, `repl`, `doctor`, `status`, `session`, `index`, `memory`, `models`, `usage`, `cost`, `stats`, `connect`, `hooks`, `skills`, `agents`, `todo`, `share`, `unshare`, `compact`, `serve`, `commands`, `mcp`, `plugins`, `tool-packages`, `acp`, `bridge`, and `version`. ## Documentation Map @@ -205,9 +248,9 @@ Key runtime configuration sections: |---|---| | `SharpClaw:Providers:Catalog` | Default provider, model aliases | | `SharpClaw:Providers:Anthropic` | Anthropic API key, base URL, default model | -| `SharpClaw:Providers:OpenAiCompatible` | OpenAI-compatible API key, base URL, default model | +| `SharpClaw:Providers:OpenAiCompatible` | OpenAI-compatible base settings plus local runtime profiles, auth mode, and default embedding model | | `SharpClaw:Web` | Web search provider name, endpoint template, user agent | -| `SharpClaw:Telemetry` | Runtime event ring buffer capacity | +| `SharpClaw:Telemetry` | Runtime event ring buffer capacity plus webhook event export behavior | Key `sharpclaw.jsonc` capabilities: @@ -226,8 +269,13 @@ All options are validated at startup via `IValidateOptions` implementations. - The shared tooling layer is permission-aware across the runtime. - The current runtime includes multi-turn provider-backed tool execution, session-backed prompt replay, and durable conversation history. - Agent-driven tool calls flow through the same approval and allowlist enforcement path used by direct tool execution, including caller-aware interactive approval behavior. +- Workspace indexing, symbol search, and durable memory are available through both CLI commands and built-in tools. +- ACP now carries editor context, approval round-trips, model catalog queries, workspace search/index actions, and memory actions, which is enough for a real VS Code client over a single transport. +- OpenAI-compatible local runtime profiles can surface Ollama, llama.cpp, and similar endpoints with profile-aware auth and model discovery. +- Embedded hosts can opt into trusted-header or OIDC-backed approval identity, tenant-aware usage metering, webhook/SSE event streaming, and admin APIs for provider catalog, index status, search, memory inspection, and tool package management. +- The CLI mirrors those enterprise surfaces with `usage summary`, `usage detail`, and `tool-packages` commands while preserving the existing workspace-local `usage`, `cost`, and `stats` flows. - Operational commands support stable JSON output via `--output-format json`, which makes them suitable for scripts, editors, and automation. -- The embedded server exposes local JSON and SSE endpoints for prompts, sessions, sharing, status, and doctor flows. +- The embedded server exposes local JSON and SSE endpoints for prompts, sessions, admin control, metering, and event streaming. ## Contributing @@ -238,6 +286,10 @@ Before opening a PR: ```bash dotnet build SharpClawCode.sln dotnet test SharpClawCode.sln +dotnet build examples/WebApiAgent/WebApiAgent.csproj +dotnet build examples/MinimalConsoleAgent/MinimalConsoleAgent.csproj +dotnet build examples/WorkerServiceHost/WorkerServiceHost.csproj +dotnet build examples/McpToolAgent/McpToolAgent.csproj ``` ## License diff --git a/SharpClawCode.sln b/SharpClawCode.sln index 48e8b64..41659ce 100644 --- a/SharpClawCode.sln +++ b/SharpClawCode.sln @@ -53,6 +53,18 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SharpClaw.Code.Acp", "src\S EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SharpClaw.Code.Mcp.FixtureServer", "tests\SharpClaw.Code.Mcp.FixtureServer\SharpClaw.Code.Mcp.FixtureServer.csproj", "{7F55705B-1E53-4075-AB6F-3BA1BDD2CF85}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SharpClaw.Code", "src\SharpClaw.Code\SharpClaw.Code.csproj", "{8552440E-B169-4CD9-9B52-4BFFDDADF053}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "examples", "examples", "{B36A84DF-456D-A817-6EDD-3EC3E7F6E11F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "McpToolAgent", "examples\McpToolAgent\McpToolAgent.csproj", "{97EC01C1-A53A-475B-9364-0BD79E9CE272}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WebApiAgent", "examples\WebApiAgent\WebApiAgent.csproj", "{963C636F-2096-45B1-8101-B8345967F197}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MinimalConsoleAgent", "examples\MinimalConsoleAgent\MinimalConsoleAgent.csproj", "{7BA2E64A-B330-4783-9330-AEF46B91929A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WorkerServiceHost", "examples\WorkerServiceHost\WorkerServiceHost.csproj", "{2E8A9F4F-8161-4E49-9F04-533D972C11CB}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -339,6 +351,66 @@ Global {7F55705B-1E53-4075-AB6F-3BA1BDD2CF85}.Release|x64.Build.0 = Release|Any CPU {7F55705B-1E53-4075-AB6F-3BA1BDD2CF85}.Release|x86.ActiveCfg = Release|Any CPU {7F55705B-1E53-4075-AB6F-3BA1BDD2CF85}.Release|x86.Build.0 = Release|Any CPU + {8552440E-B169-4CD9-9B52-4BFFDDADF053}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8552440E-B169-4CD9-9B52-4BFFDDADF053}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8552440E-B169-4CD9-9B52-4BFFDDADF053}.Debug|x64.ActiveCfg = Debug|Any CPU + {8552440E-B169-4CD9-9B52-4BFFDDADF053}.Debug|x64.Build.0 = Debug|Any CPU + {8552440E-B169-4CD9-9B52-4BFFDDADF053}.Debug|x86.ActiveCfg = Debug|Any CPU + {8552440E-B169-4CD9-9B52-4BFFDDADF053}.Debug|x86.Build.0 = Debug|Any CPU + {8552440E-B169-4CD9-9B52-4BFFDDADF053}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8552440E-B169-4CD9-9B52-4BFFDDADF053}.Release|Any CPU.Build.0 = Release|Any CPU + {8552440E-B169-4CD9-9B52-4BFFDDADF053}.Release|x64.ActiveCfg = Release|Any CPU + {8552440E-B169-4CD9-9B52-4BFFDDADF053}.Release|x64.Build.0 = Release|Any CPU + {8552440E-B169-4CD9-9B52-4BFFDDADF053}.Release|x86.ActiveCfg = Release|Any CPU + {8552440E-B169-4CD9-9B52-4BFFDDADF053}.Release|x86.Build.0 = Release|Any CPU + {97EC01C1-A53A-475B-9364-0BD79E9CE272}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {97EC01C1-A53A-475B-9364-0BD79E9CE272}.Debug|Any CPU.Build.0 = Debug|Any CPU + {97EC01C1-A53A-475B-9364-0BD79E9CE272}.Debug|x64.ActiveCfg = Debug|Any CPU + {97EC01C1-A53A-475B-9364-0BD79E9CE272}.Debug|x64.Build.0 = Debug|Any CPU + {97EC01C1-A53A-475B-9364-0BD79E9CE272}.Debug|x86.ActiveCfg = Debug|Any CPU + {97EC01C1-A53A-475B-9364-0BD79E9CE272}.Debug|x86.Build.0 = Debug|Any CPU + {97EC01C1-A53A-475B-9364-0BD79E9CE272}.Release|Any CPU.ActiveCfg = Release|Any CPU + {97EC01C1-A53A-475B-9364-0BD79E9CE272}.Release|Any CPU.Build.0 = Release|Any CPU + {97EC01C1-A53A-475B-9364-0BD79E9CE272}.Release|x64.ActiveCfg = Release|Any CPU + {97EC01C1-A53A-475B-9364-0BD79E9CE272}.Release|x64.Build.0 = Release|Any CPU + {97EC01C1-A53A-475B-9364-0BD79E9CE272}.Release|x86.ActiveCfg = Release|Any CPU + {97EC01C1-A53A-475B-9364-0BD79E9CE272}.Release|x86.Build.0 = Release|Any CPU + {963C636F-2096-45B1-8101-B8345967F197}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {963C636F-2096-45B1-8101-B8345967F197}.Debug|Any CPU.Build.0 = Debug|Any CPU + {963C636F-2096-45B1-8101-B8345967F197}.Debug|x64.ActiveCfg = Debug|Any CPU + {963C636F-2096-45B1-8101-B8345967F197}.Debug|x64.Build.0 = Debug|Any CPU + {963C636F-2096-45B1-8101-B8345967F197}.Debug|x86.ActiveCfg = Debug|Any CPU + {963C636F-2096-45B1-8101-B8345967F197}.Debug|x86.Build.0 = Debug|Any CPU + {963C636F-2096-45B1-8101-B8345967F197}.Release|Any CPU.ActiveCfg = Release|Any CPU + {963C636F-2096-45B1-8101-B8345967F197}.Release|Any CPU.Build.0 = Release|Any CPU + {963C636F-2096-45B1-8101-B8345967F197}.Release|x64.ActiveCfg = Release|Any CPU + {963C636F-2096-45B1-8101-B8345967F197}.Release|x64.Build.0 = Release|Any CPU + {963C636F-2096-45B1-8101-B8345967F197}.Release|x86.ActiveCfg = Release|Any CPU + {963C636F-2096-45B1-8101-B8345967F197}.Release|x86.Build.0 = Release|Any CPU + {7BA2E64A-B330-4783-9330-AEF46B91929A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7BA2E64A-B330-4783-9330-AEF46B91929A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7BA2E64A-B330-4783-9330-AEF46B91929A}.Debug|x64.ActiveCfg = Debug|Any CPU + {7BA2E64A-B330-4783-9330-AEF46B91929A}.Debug|x64.Build.0 = Debug|Any CPU + {7BA2E64A-B330-4783-9330-AEF46B91929A}.Debug|x86.ActiveCfg = Debug|Any CPU + {7BA2E64A-B330-4783-9330-AEF46B91929A}.Debug|x86.Build.0 = Debug|Any CPU + {7BA2E64A-B330-4783-9330-AEF46B91929A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7BA2E64A-B330-4783-9330-AEF46B91929A}.Release|Any CPU.Build.0 = Release|Any CPU + {7BA2E64A-B330-4783-9330-AEF46B91929A}.Release|x64.ActiveCfg = Release|Any CPU + {7BA2E64A-B330-4783-9330-AEF46B91929A}.Release|x64.Build.0 = Release|Any CPU + {7BA2E64A-B330-4783-9330-AEF46B91929A}.Release|x86.ActiveCfg = Release|Any CPU + {7BA2E64A-B330-4783-9330-AEF46B91929A}.Release|x86.Build.0 = Release|Any CPU + {2E8A9F4F-8161-4E49-9F04-533D972C11CB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2E8A9F4F-8161-4E49-9F04-533D972C11CB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2E8A9F4F-8161-4E49-9F04-533D972C11CB}.Debug|x64.ActiveCfg = Debug|Any CPU + {2E8A9F4F-8161-4E49-9F04-533D972C11CB}.Debug|x64.Build.0 = Debug|Any CPU + {2E8A9F4F-8161-4E49-9F04-533D972C11CB}.Debug|x86.ActiveCfg = Debug|Any CPU + {2E8A9F4F-8161-4E49-9F04-533D972C11CB}.Debug|x86.Build.0 = Debug|Any CPU + {2E8A9F4F-8161-4E49-9F04-533D972C11CB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2E8A9F4F-8161-4E49-9F04-533D972C11CB}.Release|Any CPU.Build.0 = Release|Any CPU + {2E8A9F4F-8161-4E49-9F04-533D972C11CB}.Release|x64.ActiveCfg = Release|Any CPU + {2E8A9F4F-8161-4E49-9F04-533D972C11CB}.Release|x64.Build.0 = Release|Any CPU + {2E8A9F4F-8161-4E49-9F04-533D972C11CB}.Release|x86.ActiveCfg = Release|Any CPU + {2E8A9F4F-8161-4E49-9F04-533D972C11CB}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -367,5 +439,10 @@ Global {5F0ED186-7920-4A49-B0A3-75F84B4215B3} = {0AB3BF05-4346-4AA6-1389-037BE0695223} {0060F8FF-0714-4C01-936F-719D7E5F124D} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {7F55705B-1E53-4075-AB6F-3BA1BDD2CF85} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {8552440E-B169-4CD9-9B52-4BFFDDADF053} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {97EC01C1-A53A-475B-9364-0BD79E9CE272} = {B36A84DF-456D-A817-6EDD-3EC3E7F6E11F} + {963C636F-2096-45B1-8101-B8345967F197} = {B36A84DF-456D-A817-6EDD-3EC3E7F6E11F} + {7BA2E64A-B330-4783-9330-AEF46B91929A} = {B36A84DF-456D-A817-6EDD-3EC3E7F6E11F} + {2E8A9F4F-8161-4E49-9F04-533D972C11CB} = {B36A84DF-456D-A817-6EDD-3EC3E7F6E11F} EndGlobalSection EndGlobal diff --git a/docs/acp.md b/docs/acp.md index 1ce5067..3f248c9 100644 --- a/docs/acp.md +++ b/docs/acp.md @@ -17,18 +17,42 @@ Reads **one JSON-RPC request per line** from stdin; writes **one response object | `initialize` | Returns `protocolVersion`, `agentCapabilities`, and `serverInfo`. | | `session/new` | Creates a session; updates workspace attachment when applicable. | | `session/load` | Loads an existing session id. | -| `session/prompt` | Runs a turn via **`IConversationRuntime`**; may stream **`session/notification`** lines with chunks before the final result. | +| `session/prompt` | Runs a turn via **`IConversationRuntime`**; accepts `cwd`, `sessionId`, `prompt`, optional `model`, and optional `editorContext`. | +| `models/list` | Returns the provider catalog, including discovered local runtime profiles and models. | +| `workspace/index/refresh` | Refreshes the durable workspace knowledge index for `cwd`. | +| `workspace/search` | Executes hybrid workspace search against indexed files, symbols, and semantic chunks. | +| `memory/list` | Lists structured project/user memory entries. | +| `memory/save` | Saves a structured memory entry. | +| `memory/delete` | Deletes a structured memory entry. | +| `approval/respond` | Resolves a pending approval request emitted by the ACP host. | ## Capabilities / limits -The host advertises **`loadSession: true`** and **`promptCapabilities.embeddedContext: true`**; **`image`** and **`audio`** are **`false`**. +The host advertises: -**Intentionally unsupported** (errors or omissions vs full vendor ACP): streaming tool execution, interactive permission UI, rich media parts, MCP hot-plug, cancellation reliability guarantees, and non-core extensions. Callers should treat unknown methods as **unsupported** (JSON-RPC error). +- `loadSession: true` +- `approvalRequests: true` +- `models: true` +- `workspaceSearch: true` +- `workspaceIndex: true` +- `memory: true` +- `promptCapabilities.embeddedContext: true` + +`image` and `audio` remain `false`. + +Notifications use `session/notification` and currently include: + +- streamed assistant text chunks (`sessionUpdate = "agentMessageChunk"`) +- approval prompts (`sessionUpdate = "approvalRequest"`) + +**Intentionally unsupported** (errors or omissions vs full vendor ACP): streaming tool execution details, rich media parts, MCP hot-plug, cancellation reliability guarantees, and non-core extensions. Callers should treat unknown methods as **unsupported** (JSON-RPC error). ## Session attachment Behavior aligns with **`IWorkspaceSessionAttachmentStore`** and **`RunPromptRequest.SessionId`**: create/load sets attachment; prompts resolve cwd via params when provided. +When the client advertises approval support during `initialize`, ACP-driven prompts can participate in the same permission and approval flow as interactive CLI callers without opening a second transport. + ## Implementation See **`SharpClaw.Code.Acp`** / **`AcpStdioHost`**. diff --git a/docs/architecture.md b/docs/architecture.md index 180086c..34cafd1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -19,16 +19,18 @@ This document matches the **current** solution: `SharpClawCode.sln` with project | **SharpClaw.Code.Memory / Git / Web / Skills** | Context and auxiliary services composed by runtime/agents as implemented today. | | **SharpClaw.Code.Agents** | Microsoft Agent Framework bridge, `ProviderBackedAgentKernel`, concrete agents. | | **SharpClaw.Code.Runtime** | `ConversationRuntime`, `DefaultTurnRunner`, lifecycle/state machine, operational diagnostics DI. | +| **SharpClaw.Code** | Embeddable runtime SDK: `SharpClawRuntimeHostBuilder`, `SharpClawRuntimeHost`, host-aware runtime entrypoints. | | **SharpClaw.Code.Commands** | System.CommandLine handlers, REPL host, slash commands, output renderers dispatch. | | **SharpClaw.Code.Cli** | Entry point (`Program.cs`), `Host` wiring: `AddSharpClawRuntime` + `AddSharpClawCli`. | -Test projects: **UnitTests**, **IntegrationTests**, **MockProvider**, **ParityHarness**, **Mcp.FixtureServer**. +Test projects: **UnitTests**, **IntegrationTests**, **MockProvider**, **ParityHarness**, **Mcp.FixtureServer**. Example hosts are included in the solution under `examples/`. ## Composition overview 1. **`CliHostBuilder.BuildHost`** builds `Host.CreateApplicationBuilder`, registers **Runtime** then **CLI**. -2. **`RuntimeServiceCollectionExtensions.AddSharpClawRuntime`** (with `IConfiguration` when used from CLI) registers in order: Telemetry, Infrastructure, **Providers** (from config), **Mcp**, **Tools**, **Agents**, Memory, Skills, Git, **Sessions** stores, context assembler, **DefaultTurnRunner**, state machine, **operational diagnostics checks**, **`ConversationRuntime`** (as `IConversationRuntime` + `IRuntimeCommandService`), and a minimal **`IHostedService`** (`RuntimeCoordinatorHostedServiceAdapter`). -3. **`AddSharpClawCli`** registers command handlers, REPL, renderers, `CommandRegistry`. +2. **`SharpClawRuntimeHostBuilder`** builds the same runtime slice without CLI assumptions for embedded ASP.NET Core, worker-service, or SDK hosts. +3. **`RuntimeServiceCollectionExtensions.AddSharpClawRuntime`** (with `IConfiguration` when used from CLI) registers in order: Telemetry, Infrastructure, **Providers** (from config), **Mcp**, **Tools**, **Agents**, Memory, Skills, Git, **Sessions** stores, context assembler, **DefaultTurnRunner**, state machine, **operational diagnostics checks**, **`ConversationRuntime`** (as `IConversationRuntime` + `IRuntimeCommandService`), and a minimal **`IHostedService`** (`RuntimeCoordinatorHostedServiceAdapter`). +4. **`AddSharpClawCli`** registers command handlers, REPL, renderers, `CommandRegistry`. ## Major execution flows @@ -36,7 +38,7 @@ Test projects: **UnitTests**, **IntegrationTests**, **MockProvider**, **ParityHa 1. **`IRuntimeCommandService.ExecutePromptAsync`** → **`ConversationRuntime.RunPromptAsync`**. 2. Resolves or creates **`ConversationSession`** under the workspace; transitions lifecycle; appends **`RuntimeEvent`**s when persistence is enabled. -3. **`DefaultTurnRunner.RunAsync`** builds prompt context (`IPromptContextAssembler`), constructs **`AgentRunContext`** (includes **`IToolExecutor`**), calls **`PrimaryCodingAgent.RunAsync`**. +3. **`DefaultTurnRunner.RunAsync`** builds prompt context (`IPromptContextAssembler`), constructs **`AgentRunContext`** (includes **`IToolExecutor`** and normalized host/tenant context), calls **`PrimaryCodingAgent.RunAsync`**. 4. **`SharpClawAgentBase`** delegates to **`AgentFrameworkBridge.RunAsync`**, which drives **`ProviderBackedAgentKernel`** (streaming `IModelProvider`, auth checks, **`ProviderExecutionException`** on hard failures). 5. Turn completion updates session, checkpoints as implemented in **`ConversationRuntime`**, publishes events via **`IRuntimeEventPublisher`**. diff --git a/docs/permissions.md b/docs/permissions.md index 9ba3475..7872a87 100644 --- a/docs/permissions.md +++ b/docs/permissions.md @@ -35,11 +35,27 @@ If all rules abstain, **`EvaluateByModeAsync`** applies mode defaults (e.g. **Re **`ConsoleApprovalService`** prints the tool name, scope, prompt, optional “may be remembered” line, and waits for **`y`/`yes`**. +### Authenticated approval transports + +Embedded hosts can enable approval identity independently from provider auth. The current runtime supports: + +- **trusted-header** mode, where an upstream host supplies subject, tenant, role, and scope headers +- **OIDC** mode, where the embedded/admin HTTP surface validates a bearer token against discovery + JWKS metadata + +`ConfiguredApprovalIdentityService` resolves the current `ApprovalPrincipal`, and `AuthenticatedApprovalTransport` approves or denies requests before the console/non-interactive transports are considered. + +Two host flags matter: + +- `RequireForAdmin`: admin routes must present a valid approval identity +- `RequireAuthenticatedApprovals`: approval-required operations are denied when no valid approval identity is present, even if the caller is otherwise interactive + +Authenticated approvals are tenant-bound. If the runtime host context carries `TenantId`, an approval principal with a different tenant is denied before any remembered approval or console fallback path is used. + ### Remembered approvals **`ISessionApprovalMemory`** (**`SessionApprovalMemory`**) stores **approved** decisions in a **process-scoped** dictionary keyed by **`sessionId`** and a composite key (**tool name, scope, source, working directory, originating plugin id/trust**). -When a rule returns **`RequireApproval`** with **`CanRememberApproval`**, an approved outcome may be **`Store`**d and reused via **`TryGet`**. +When a rule returns **`RequireApproval`** with **`CanRememberApproval`**, an approved outcome may be **`Store`**d and reused via **`TryGet`**. In embedded-host flows, the remembered approval remains scoped to the current session and tenant context. ## Tool execution context diff --git a/docs/providers.md b/docs/providers.md index 6d7a7f7..bf42ad6 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -13,12 +13,20 @@ Registered implementations (see **`ProvidersServiceCollectionExtensions`**): Both are registered as **`IModelProvider`** singletons; **`ModelProviderResolver`** builds a case-insensitive dictionary by **`ProviderName`**. +The provider layer also exposes **`IProviderCatalogService`**, which powers the CLI `models` command and ACP `models/list`. It centralizes: + +- provider auth status +- alias/default resolution +- discovered local runtime profiles +- model discovery for local runtimes + ## Resolution and preflight **`ProviderRequestPreflight`** (`IProviderRequestPreflight`) normalizes **`ProviderRequest`**: - Applies **`ProviderCatalogOptions.ModelAliases`** (e.g. `"default"` → provider + model id). - Supports qualified model forms (implementation parses `provider/model`). +- Also supports local runtime forms such as `ollama/qwen2.5-coder`, which route through the OpenAI-compatible provider with profile metadata attached. - Fills default **provider name** from **`ProviderCatalogOptions.DefaultProvider`** when missing. Default catalog (**`ProviderCatalogOptions`**) uses **`DefaultProvider = "openai-compatible"`** if not configured. @@ -31,14 +39,31 @@ When using **`AddSharpClawRuntime(IConfiguration)`** (CLI host): |---------|----------------| | `SharpClaw:Providers:Catalog` | **`ProviderCatalogOptions`** | | `SharpClaw:Providers:Anthropic` | **`AnthropicProviderOptions`** (`ProviderName` defaults to `"anthropic"`, `BaseUrl`, API key binding as in options class) | -| `SharpClaw:Providers:OpenAiCompatible` | **`OpenAiCompatibleProviderOptions`** (`ProviderName` defaults to `"openai-compatible"`) | +| `SharpClaw:Providers:OpenAiCompatible` | **`OpenAiCompatibleProviderOptions`** (`ProviderName` defaults to `"openai-compatible"`, supports auth mode, default embedding model, and named `LocalRuntimes`) | There is no checked-in **`appsettings.json`** in the repo; add one next to the CLI project or rely on environment variables / user secrets per standard .NET configuration. +## Local runtimes + +`OpenAiCompatibleProviderOptions.LocalRuntimes` supports named profiles for local or self-hosted runtimes such as Ollama and llama.cpp. + +Each profile carries: + +- runtime kind (`Generic`, `Ollama`, `LlamaCpp`) +- base URL +- default chat model +- optional default embedding model +- auth mode (`ApiKey`, `Optional`, `None`) +- capability hints for tool calling and embeddings + +At runtime the catalog service probes these profiles and surfaces health plus discovered models. Local runtimes do not assume API-key auth by default. + ## Auth **`IAuthFlowService`** / **`AuthFlowService`** answer whether a provider name is authenticated (used by **`ProviderBackedAgentKernel`**). If not authenticated, the kernel may return a **placeholder** completion (see kernel logs) rather than calling the remote API. +For the OpenAI-compatible provider, auth status now respects provider auth mode plus any configured auth-optional local runtimes. + Hard failures use **`ProviderExecutionException`** with **`ProviderFailureKind`**: **`MissingProvider`**, **`AuthenticationUnavailable`**, **`StreamFailed`**. ## Adding a provider diff --git a/docs/runtime.md b/docs/runtime.md index f819ddb..90b7f66 100644 --- a/docs/runtime.md +++ b/docs/runtime.md @@ -42,7 +42,7 @@ This means the model-visible tool surface and the executor-visible tool surface ## Context assembly -**`PromptContextAssembler`** pulls workspace/session-aware data (skills registry, todo state, memory hooks, git context as wired today) into the prompt path before the agent runs. +**`PromptContextAssembler`** pulls workspace/session-aware data (skills registry, todo state, durable project/user memory, workspace index status, and git context) into the prompt path before the agent runs. It also includes a compact diagnostics summary from **`IWorkspaceDiagnosticsService`**, which currently surfaces configured diagnostics sources and build-derived findings for .NET workspaces. @@ -55,6 +55,14 @@ When the effective **`PrimaryMode`** is **`Spec`**, the assembler appends a stru 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. +Cross-session memory is sourced from: + +- manual project instructions such as `SHARPCLAW.md` +- structured project memory stored under `.sharpclaw/knowledge/knowledge.db` +- structured user memory stored under the SharpClaw user root + +The runtime injects only compact recall text and index freshness metadata. Detailed retrieval happens through explicit workspace-search tools and ACP/CLI search calls. + ## Spec workflow **`ISpecWorkflowService`** handles the post-processing path for **`spec`** mode: @@ -73,7 +81,7 @@ Each spec-mode prompt creates a fresh folder. If the same slug already exists, t **`OperationalDiagnosticsCoordinator`** runs injectable **`IOperationalCheck`** implementations: -- Workspace, configuration, session store, shell, git, provider auth, MCP registry/host, plugin registry. +- Workspace, configuration, session store, shell, git, provider auth, local runtime catalog health, MCP registry/host, plugin registry. Used by **`GetStatusAsync`**, **`RunDoctorAsync`**, and **`InspectSessionAsync`** to build **Protocol** reports (`DoctorReport`, `RuntimeStatusReport`, `SessionInspectionReport`). @@ -90,6 +98,7 @@ The parity layer adds several runtime-owned services: - **`ISharpClawConfigService`** — loads user/workspace `config.jsonc` + `sharpclaw.jsonc` and merges them by precedence - **`IAgentCatalogService`** — overlays configured specialist agents on top of built-in agents - **`IConversationCompactionService`** — creates durable session summaries stored in session metadata +- compaction also promotes a project-scoped summary memory entry for later session recall - **`IShareSessionService`** — creates and removes self-hosted share snapshots - **`IHookDispatcher`** — executes configured hook processes for turn/tool/share/server events and exposes hook inspection/testing - **`ITodoService`** — persists session and workspace todo items under session metadata and `.sharpclaw/tasks.json` @@ -112,6 +121,31 @@ These services are intentionally small and runtime-owned rather than separate or Prompt requests can return JSON or replay the completed runtime event stream as SSE. +The embedded server also exposes a tenant-aware admin/control surface under `/v1/admin`: + +- `GET /v1/admin/providers` +- `GET /v1/admin/auth/status` +- `POST /v1/admin/sessions` +- `POST /v1/admin/sessions/{id}/fork` +- `GET /v1/admin/index/status` +- `POST /v1/admin/index/refresh` +- `POST /v1/admin/search` +- `GET /v1/admin/memory` +- `GET /v1/admin/events/recent` +- `GET /v1/admin/events/stream` +- `GET /v1/admin/tool-packages` +- `POST /v1/admin/tool-packages/install` +- `GET /v1/admin/usage/summary` +- `GET /v1/admin/usage/detail` + +Admin requests reuse the normalized `RuntimeCommandContext.HostContext`, so tenant id, host id, storage root, and selected session store backend remain consistent between CLI, SDK, and HTTP-hosted invocations. + +Usage metering is persisted in a dedicated tenant-aware SQLite store under the resolved storage root. Workspace-local `usage`, `cost`, and `stats` continue to read the existing workspace insights model, while the admin and enterprise CLI surfaces query the normalized metering store for tenant/host reporting. + +When approval auth is enabled, HTTP/admin requests resolve approval identity through either trusted headers or OIDC bearer tokens before approval-sensitive operations run. The resolved `ApprovalPrincipal` is then applied to authenticated approval transports and tenant-matching checks. + +Webhook and SSE event delivery both use the same `RuntimeEventEnvelope` shape, which freezes the external event contract across in-process streams, HTTP event streams, and configured outbound webhook sinks. + ## Hosted service **`RuntimeCoordinatorHostedServiceAdapter`** is registered as **`IHostedService`** and currently logs start/stop only (placeholder for future lifecycle coordination). diff --git a/docs/testing.md b/docs/testing.md index 2dd1840..e5174c3 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -15,6 +15,15 @@ Run all tests: dotnet test SharpClawCode.sln ``` +Build the example hosts as part of normal validation: + +```bash +dotnet build examples/WebApiAgent/WebApiAgent.csproj +dotnet build examples/MinimalConsoleAgent/MinimalConsoleAgent.csproj +dotnet build examples/WorkerServiceHost/WorkerServiceHost.csproj +dotnet build examples/McpToolAgent/McpToolAgent.csproj +``` + Filter examples: ```bash @@ -52,4 +61,4 @@ Stable scenario **ids** are listed in **`ParityScenarioIds`** (e.g. `streaming_t ## CI -Use **`dotnet test`** on the solution; parity tests use temp directories under **`Path.GetTempPath()`** and avoid network. +CI restores and builds the full solution, explicitly builds every example host project, and then runs `dotnet test` on the solution. Parity tests use temp directories under **`Path.GetTempPath()`** and avoid network. diff --git a/examples/MinimalConsoleAgent/MinimalConsoleAgent.csproj b/examples/MinimalConsoleAgent/MinimalConsoleAgent.csproj index 09f2de4..85a15e8 100644 --- a/examples/MinimalConsoleAgent/MinimalConsoleAgent.csproj +++ b/examples/MinimalConsoleAgent/MinimalConsoleAgent.csproj @@ -10,7 +10,7 @@ - + diff --git a/examples/MinimalConsoleAgent/Program.cs b/examples/MinimalConsoleAgent/Program.cs index 8d53747..377ea2f 100644 --- a/examples/MinimalConsoleAgent/Program.cs +++ b/examples/MinimalConsoleAgent/Program.cs @@ -1,9 +1,6 @@ -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using SharpClaw.Code.Protocol.Commands; +using SharpClaw.Code; using SharpClaw.Code.Protocol.Enums; -using SharpClaw.Code.Runtime.Abstractions; -using SharpClaw.Code.Runtime.Composition; +using SharpClaw.Code.Protocol.Models; if (args.Length == 0) { @@ -13,32 +10,31 @@ var prompt = string.Join(' ', args); -var builder = Host.CreateApplicationBuilder(args); -builder.Services.AddSharpClawRuntime(builder.Configuration); - -using var host = builder.Build(); -await host.StartAsync(); - -var runtime = host.Services.GetRequiredService(); +await using var runtimeHost = new SharpClawRuntimeHostBuilder(args).Build(); +await runtimeHost.StartAsync(); var workspacePath = Directory.GetCurrentDirectory(); -var session = await runtime.CreateSessionAsync( +var hostContext = new RuntimeHostContext( + HostId: "minimal-console-agent", + IsEmbeddedHost: true); +var session = await runtimeHost.CreateSessionAsync( workspacePath, PermissionMode.ReadOnly, OutputFormat.Text, + hostContext, CancellationToken.None); -var request = new RunPromptRequest( - Prompt: prompt, - SessionId: session.Id, - WorkingDirectory: workspacePath, - PermissionMode: PermissionMode.ReadOnly, - OutputFormat: OutputFormat.Text, - Metadata: null); - -var result = await runtime.RunPromptAsync(request, CancellationToken.None); +var result = await runtimeHost.ExecutePromptAsync( + prompt, + workspacePath, + model: "default", + permissionMode: PermissionMode.ReadOnly, + outputFormat: OutputFormat.Text, + sessionId: session.Id, + hostContext: hostContext, + cancellationToken: CancellationToken.None); Console.WriteLine(result.FinalOutput ?? "(no output)"); -await host.StopAsync(); +await runtimeHost.StopAsync(); return 0; diff --git a/examples/WebApiAgent/Program.cs b/examples/WebApiAgent/Program.cs index f83023a..9039004 100644 --- a/examples/WebApiAgent/Program.cs +++ b/examples/WebApiAgent/Program.cs @@ -1,46 +1,58 @@ -using SharpClaw.Code.Protocol.Commands; +using Microsoft.Extensions.Hosting; +using SharpClaw.Code; using SharpClaw.Code.Protocol.Enums; -using SharpClaw.Code.Runtime.Abstractions; -using SharpClaw.Code.Runtime.Composition; +using SharpClaw.Code.Protocol.Models; var builder = WebApplication.CreateBuilder(args); -builder.Services.AddSharpClawRuntime(builder.Configuration); +builder.Services.AddSingleton(_ => new SharpClawRuntimeHostBuilder(args).Build()); +builder.Services.AddHostedService(); var app = builder.Build(); -app.MapPost("/chat", async (ChatRequest body, IConversationRuntime runtime, CancellationToken ct) => +app.MapPost("/chat", async (ChatRequest body, SharpClawRuntimeHost runtimeHost, CancellationToken ct) => { var workspacePath = Directory.GetCurrentDirectory(); + var hostContext = new RuntimeHostContext( + HostId: "web-api-agent", + TenantId: body.TenantId, + IsEmbeddedHost: true); - string sessionId; - if (!string.IsNullOrWhiteSpace(body.SessionId)) + var sessionId = body.SessionId; + if (string.IsNullOrWhiteSpace(sessionId)) { - sessionId = body.SessionId; - } - else - { - var session = await runtime.CreateSessionAsync( + var session = await runtimeHost.CreateSessionAsync( workspacePath, PermissionMode.ReadOnly, OutputFormat.Text, + hostContext, ct); sessionId = session.Id; } - var request = new RunPromptRequest( - Prompt: body.Prompt, - SessionId: sessionId, - WorkingDirectory: workspacePath, - PermissionMode: PermissionMode.ReadOnly, - OutputFormat: OutputFormat.Text, - Metadata: null); - - var result = await runtime.RunPromptAsync(request, ct); - - return Results.Ok(new ChatResponse(result.FinalOutput ?? string.Empty, sessionId)); + var result = await runtimeHost.ExecutePromptAsync( + body.Prompt, + workspacePath, + model: "default", + permissionMode: PermissionMode.ReadOnly, + outputFormat: OutputFormat.Text, + sessionId: sessionId, + hostContext: hostContext, + cancellationToken: ct); + + return Results.Ok(new ChatResponse(result.FinalOutput ?? string.Empty, sessionId!)); }); app.Run(); -record ChatRequest(string Prompt, string? SessionId); +sealed class EmbeddedRuntimeLifecycleService(SharpClawRuntimeHost runtimeHost) : IHostedService +{ + public Task StartAsync(CancellationToken cancellationToken) + => runtimeHost.StartAsync(cancellationToken); + + public Task StopAsync(CancellationToken cancellationToken) + => runtimeHost.StopAsync(cancellationToken); +} + +record ChatRequest(string Prompt, string? SessionId, string? TenantId = null); + record ChatResponse(string Output, string SessionId); diff --git a/examples/WebApiAgent/WebApiAgent.csproj b/examples/WebApiAgent/WebApiAgent.csproj index 243d3a3..369ea85 100644 --- a/examples/WebApiAgent/WebApiAgent.csproj +++ b/examples/WebApiAgent/WebApiAgent.csproj @@ -5,7 +5,7 @@ - + diff --git a/examples/WorkerServiceHost/EmbeddedRuntimeLifecycleService.cs b/examples/WorkerServiceHost/EmbeddedRuntimeLifecycleService.cs new file mode 100644 index 0000000..07326ca --- /dev/null +++ b/examples/WorkerServiceHost/EmbeddedRuntimeLifecycleService.cs @@ -0,0 +1,13 @@ +using Microsoft.Extensions.Hosting; +using SharpClaw.Code; + +namespace WorkerServiceHost; + +sealed class EmbeddedRuntimeLifecycleService(SharpClawRuntimeHost runtimeHost) : IHostedService +{ + public Task StartAsync(CancellationToken cancellationToken) + => runtimeHost.StartAsync(cancellationToken); + + public Task StopAsync(CancellationToken cancellationToken) + => runtimeHost.StopAsync(cancellationToken); +} diff --git a/examples/WorkerServiceHost/Program.cs b/examples/WorkerServiceHost/Program.cs new file mode 100644 index 0000000..dff998f --- /dev/null +++ b/examples/WorkerServiceHost/Program.cs @@ -0,0 +1,10 @@ +using Microsoft.Extensions.Hosting; +using SharpClaw.Code; +using WorkerServiceHost; + +var builder = Host.CreateApplicationBuilder(args); +builder.Services.AddSingleton(_ => new SharpClawRuntimeHostBuilder(args).Build()); +builder.Services.AddHostedService(); +builder.Services.AddHostedService(); + +await builder.Build().RunAsync(); diff --git a/examples/WorkerServiceHost/PromptWorker.cs b/examples/WorkerServiceHost/PromptWorker.cs new file mode 100644 index 0000000..0f0ae02 --- /dev/null +++ b/examples/WorkerServiceHost/PromptWorker.cs @@ -0,0 +1,64 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using SharpClaw.Code; +using SharpClaw.Code.Protocol.Enums; +using SharpClaw.Code.Protocol.Models; + +namespace WorkerServiceHost; + +sealed class PromptWorker( + IConfiguration configuration, + ILogger logger, + SharpClawRuntimeHost runtimeHost, + IHostApplicationLifetime hostApplicationLifetime) : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + var configuredWorkspacePath = configuration["Worker:WorkspacePath"]; + var workspacePath = string.IsNullOrWhiteSpace(configuredWorkspacePath) + ? Directory.GetCurrentDirectory() + : configuredWorkspacePath; + var prompt = configuration["Worker:Prompt"] ?? "Summarize the current workspace and highlight any obvious risks."; + var configuredModel = configuration["Worker:Model"]; + var model = string.IsNullOrWhiteSpace(configuredModel) ? "default" : configuredModel; + var tenantId = string.IsNullOrWhiteSpace(configuration["Worker:TenantId"]) + ? null + : configuration["Worker:TenantId"]; + var hostContext = new RuntimeHostContext( + HostId: "worker-service-host", + TenantId: tenantId, + IsEmbeddedHost: true); + + try + { + var session = await runtimeHost.CreateSessionAsync( + workspacePath, + PermissionMode.ReadOnly, + OutputFormat.Text, + hostContext, + stoppingToken); + + var result = await runtimeHost.ExecutePromptAsync( + prompt, + workspacePath, + model, + PermissionMode.ReadOnly, + OutputFormat.Text, + sessionId: session.Id, + hostContext: hostContext, + cancellationToken: stoppingToken); + + logger.LogInformation("Worker prompt completed for session {SessionId}.", session.Id); + logger.LogInformation("{Output}", result.FinalOutput ?? "(no output)"); + } + catch (Exception exception) + { + logger.LogError(exception, "Worker prompt execution failed."); + } + finally + { + hostApplicationLifetime.StopApplication(); + } + } +} diff --git a/examples/WorkerServiceHost/WorkerServiceHost.csproj b/examples/WorkerServiceHost/WorkerServiceHost.csproj new file mode 100644 index 0000000..314e287 --- /dev/null +++ b/examples/WorkerServiceHost/WorkerServiceHost.csproj @@ -0,0 +1,18 @@ + + + + net10.0 + enable + enable + Worker-service example for hosting SharpClaw Code through the embeddable runtime SDK. + + + + + + + + + + + diff --git a/examples/WorkerServiceHost/appsettings.json b/examples/WorkerServiceHost/appsettings.json new file mode 100644 index 0000000..047fca9 --- /dev/null +++ b/examples/WorkerServiceHost/appsettings.json @@ -0,0 +1,25 @@ +{ + "SharpClaw": { + "Providers": { + "Catalog": { + "DefaultProvider": "Anthropic" + }, + "Anthropic": { + "ApiKey": "", + "DefaultModel": "claude-sonnet-4-5" + } + } + }, + "Worker": { + "Prompt": "Summarize the current workspace and highlight any obvious risks.", + "WorkspacePath": "", + "Model": "default", + "TenantId": "" + }, + "Logging": { + "LogLevel": { + "Default": "Warning", + "SharpClaw": "Information" + } + } +} diff --git a/extensions/vscode/README.md b/extensions/vscode/README.md new file mode 100644 index 0000000..c807f75 --- /dev/null +++ b/extensions/vscode/README.md @@ -0,0 +1,29 @@ +# SharpClaw Code VS Code Extension + +This extension launches the SharpClaw ACP host as a subprocess and uses it as the single transport for prompts, workspace search, memory management, and approval requests. + +Available commands: + +- `SharpClaw: Prompt` +- `SharpClaw: Refresh Index` +- `SharpClaw: Search Workspace` +- `SharpClaw: Save Memory` +- `SharpClaw: List Memory` +- `SharpClaw: Select Model` + +What it currently does: + +- sends the active editor file and selection as `editorContext` on prompt requests +- streams assistant output into the SharpClaw output channel +- surfaces approval prompts from the ACP host and routes the response back over `approval/respond` +- refreshes and queries the workspace knowledge index +- saves and lists structured project/user memory +- lists models from the provider catalog, including local runtime profiles exposed through the OpenAI-compatible provider + +By default the extension runs: + +```bash +dotnet run --project src/SharpClaw.Code.Cli -- acp +``` + +Override that command through the `sharpClaw.cliCommand` and `sharpClaw.cliArgs` settings if you want to point the extension at an installed CLI or a different workspace layout. diff --git a/extensions/vscode/package.json b/extensions/vscode/package.json new file mode 100644 index 0000000..43068b5 --- /dev/null +++ b/extensions/vscode/package.json @@ -0,0 +1,83 @@ +{ + "name": "sharpclaw-code", + "displayName": "SharpClaw Code", + "description": "VS Code integration for the SharpClaw ACP runtime.", + "version": "0.1.0", + "publisher": "clawdotnet", + "engines": { + "vscode": "^1.100.0" + }, + "categories": [ + "Other" + ], + "activationEvents": [ + "onCommand:sharpClaw.prompt", + "onCommand:sharpClaw.refreshIndex", + "onCommand:sharpClaw.searchWorkspace", + "onCommand:sharpClaw.saveMemory", + "onCommand:sharpClaw.listMemory", + "onCommand:sharpClaw.selectModel" + ], + "main": "./out/extension.js", + "contributes": { + "commands": [ + { + "command": "sharpClaw.prompt", + "title": "SharpClaw: Prompt" + }, + { + "command": "sharpClaw.refreshIndex", + "title": "SharpClaw: Refresh Index" + }, + { + "command": "sharpClaw.searchWorkspace", + "title": "SharpClaw: Search Workspace" + }, + { + "command": "sharpClaw.saveMemory", + "title": "SharpClaw: Save Memory" + }, + { + "command": "sharpClaw.listMemory", + "title": "SharpClaw: List Memory" + }, + { + "command": "sharpClaw.selectModel", + "title": "SharpClaw: Select Model" + } + ], + "configuration": { + "title": "SharpClaw Code", + "properties": { + "sharpClaw.cliCommand": { + "type": "string", + "default": "dotnet", + "description": "Command used to launch the SharpClaw ACP host." + }, + "sharpClaw.cliArgs": { + "type": "array", + "default": [ + "run", + "--project", + "src/SharpClaw.Code.Cli", + "--", + "acp" + ], + "items": { + "type": "string" + }, + "description": "Arguments passed to the SharpClaw CLI command." + } + } + } + }, + "scripts": { + "compile": "tsc -p ./", + "watch": "tsc -watch -p ./" + }, + "devDependencies": { + "@types/node": "^22.15.3", + "@types/vscode": "^1.100.0", + "typescript": "^5.8.3" + } +} diff --git a/extensions/vscode/src/extension.ts b/extensions/vscode/src/extension.ts new file mode 100644 index 0000000..25f994b --- /dev/null +++ b/extensions/vscode/src/extension.ts @@ -0,0 +1,321 @@ +import * as readline from "node:readline"; +import { ChildProcessWithoutNullStreams, spawn } from "node:child_process"; +import * as path from "node:path"; +import * as vscode from "vscode"; + +type JsonRpcResponse = { jsonrpc: string; id?: string | number; result?: unknown; error?: { code: number; message: string } }; +type ProviderCatalogEntry = { + providerName: string; + defaultModel: string; + availableModels?: Array<{ id: string; displayName: string }>; + localRuntimeProfiles?: Array<{ name: string; defaultChatModel: string; availableModels?: Array<{ id: string; displayName: string }> }>; +}; + +class AcpClient implements vscode.Disposable { + private process: ChildProcessWithoutNullStreams | undefined; + private initialized = false; + private nextId = 1; + private readonly pending = new Map(); + + constructor( + private readonly output: vscode.OutputChannel, + private readonly workspaceState: vscode.Memento, + private readonly context: vscode.ExtensionContext) {} + + dispose(): void { + this.process?.kill(); + this.process = undefined; + this.pending.clear(); + } + + async call(method: string, params: Record): Promise { + await this.ensureStarted(); + const id = this.nextId++; + const payload = JSON.stringify({ jsonrpc: "2.0", id, method, params }); + const response = await new Promise((resolve, reject) => { + this.pending.set(id, { resolve, reject }); + this.process!.stdin.write(payload + "\n", "utf8"); + }); + return response as T; + } + + async ensureSession(workspaceFolder: vscode.WorkspaceFolder): Promise { + const key = `session:${workspaceFolder.uri.toString()}`; + const existing = this.workspaceState.get(key); + if (existing) { + try { + await this.call("session/load", { cwd: workspaceFolder.uri.fsPath, sessionId: existing }); + return existing; + } catch { + } + } + + const created = await this.call<{ sessionId: string }>("session/new", { cwd: workspaceFolder.uri.fsPath }); + await this.workspaceState.update(key, created.sessionId); + return created.sessionId; + } + + private async ensureStarted(): Promise { + if (this.process && this.initialized) { + return; + } + + const config = vscode.workspace.getConfiguration("sharpClaw"); + const command = config.get("cliCommand", "dotnet"); + const args = config.get("cliArgs", ["run", "--project", "src/SharpClaw.Code.Cli", "--", "acp"]); + const cwd = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? this.context.extensionPath; + this.process = spawn(command, args, { cwd, stdio: "pipe" }); + this.process.stderr.on("data", chunk => this.output.append(chunk.toString())); + const rl = readline.createInterface({ input: this.process.stdout }); + rl.on("line", line => this.handleLine(line)); + this.process.on("exit", code => { + for (const pending of this.pending.values()) { + pending.reject(new Error(`SharpClaw ACP exited with code ${code ?? 0}.`)); + } + this.pending.clear(); + this.initialized = false; + this.process = undefined; + }); + + await this.call("initialize", { clientCapabilities: { approvalRequests: true } }); + this.initialized = true; + } + + private handleLine(line: string): void { + if (!line.trim()) { + return; + } + + const message = JSON.parse(line) as JsonRpcResponse & { method?: string; params?: any }; + if (message.method === "session/notification") { + this.handleNotification(message.params); + return; + } + + if (typeof message.id !== "number") { + return; + } + + const pending = this.pending.get(message.id); + if (!pending) { + return; + } + + this.pending.delete(message.id); + if (message.error) { + pending.reject(new Error(message.error.message)); + return; + } + + pending.resolve(message.result); + } + + private async handleNotification(params: any): Promise { + const update = params?.update; + if (!update) { + return; + } + + if (update.sessionUpdate === "agentMessageChunk") { + const text = update.chunk?.content?.text; + if (typeof text === "string" && text.length > 0) { + this.output.appendLine(text); + this.output.show(true); + } + return; + } + + if (update.sessionUpdate === "approvalRequest") { + const approval = update.approval; + const choice = await vscode.window.showWarningMessage( + approval.prompt ?? `Approval required for ${approval.toolName}.`, + { modal: true }, + approval.canRememberDecision ? "Allow and Remember" : "Allow", + "Deny"); + await this.call("approval/respond", { + requestId: approval.requestId, + approved: choice === "Allow" || choice === "Allow and Remember", + remember: choice === "Allow and Remember" + }); + } + } +} + +export function activate(context: vscode.ExtensionContext): void { + const output = vscode.window.createOutputChannel("SharpClaw Code"); + const client = new AcpClient(output, context.workspaceState, context); + context.subscriptions.push(client, output); + + context.subscriptions.push(vscode.commands.registerCommand("sharpClaw.prompt", async () => { + const folder = requireWorkspaceFolder(); + const prompt = await vscode.window.showInputBox({ prompt: "Send a prompt to SharpClaw" }); + if (!prompt) { + return; + } + + const sessionId = await client.ensureSession(folder); + const model = context.workspaceState.get(`model:${folder.uri.toString()}`); + const editor = vscode.window.activeTextEditor; + const editorContext = editor ? buildEditorContext(editor, sessionId) : undefined; + output.show(true); + output.appendLine(`> ${prompt}`); + await client.call("session/prompt", { + cwd: folder.uri.fsPath, + sessionId, + model, + prompt, + editorContext + }); + })); + + context.subscriptions.push(vscode.commands.registerCommand("sharpClaw.refreshIndex", async () => { + const folder = requireWorkspaceFolder(); + const result = await client.call<{ indexedFileCount: number }>("workspace/index/refresh", { cwd: folder.uri.fsPath }); + void vscode.window.showInformationMessage(`SharpClaw indexed ${result.indexedFileCount} file(s).`); + })); + + context.subscriptions.push(vscode.commands.registerCommand("sharpClaw.searchWorkspace", async () => { + const folder = requireWorkspaceFolder(); + const query = await vscode.window.showInputBox({ prompt: "Search the indexed workspace" }); + if (!query) { + return; + } + + const result = await client.call<{ hits: Array<{ path: string; excerpt: string; startLine?: number }> }>("workspace/search", { + cwd: folder.uri.fsPath, + query, + limit: 20 + }); + + const picked = await vscode.window.showQuickPick( + result.hits.map(hit => ({ + label: hit.path, + description: hit.startLine ? `Line ${hit.startLine}` : undefined, + detail: hit.excerpt, + hit + })), + { placeHolder: "Workspace search results" }); + if (!picked) { + return; + } + + const target = vscode.Uri.file(path.join(folder.uri.fsPath, picked.hit.path)); + const document = await vscode.workspace.openTextDocument(target); + const editor = await vscode.window.showTextDocument(document); + if (picked.hit.startLine && picked.hit.startLine > 0) { + const line = Math.max(0, picked.hit.startLine - 1); + const position = new vscode.Position(line, 0); + editor.selection = new vscode.Selection(position, position); + editor.revealRange(new vscode.Range(position, position)); + } + })); + + context.subscriptions.push(vscode.commands.registerCommand("sharpClaw.saveMemory", async () => { + const folder = requireWorkspaceFolder(); + const editor = vscode.window.activeTextEditor; + const selectionText = editor && !editor.selection.isEmpty ? editor.document.getText(editor.selection) : ""; + const content = await vscode.window.showInputBox({ + prompt: "Memory content", + value: selectionText + }); + if (!content) { + return; + } + + const scope = await vscode.window.showQuickPick(["project", "user"], { placeHolder: "Memory scope" }); + if (!scope) { + return; + } + + await client.call("memory/save", { + cwd: folder.uri.fsPath, + sessionId: await client.ensureSession(folder), + request: { + scope, + content, + source: editor ? "vscode-selection" : "vscode-manual", + relatedFilePath: editor ? vscode.workspace.asRelativePath(editor.document.uri, false) : undefined + } + }); + void vscode.window.showInformationMessage(`Saved ${scope} memory.`); + })); + + context.subscriptions.push(vscode.commands.registerCommand("sharpClaw.listMemory", async () => { + const folder = requireWorkspaceFolder(); + const result = await client.call>("memory/list", { + cwd: folder.uri.fsPath, + limit: 30 + }); + await vscode.window.showQuickPick( + result.map(entry => ({ + label: entry.id, + description: entry.scope, + detail: entry.content + })), + { placeHolder: "Saved SharpClaw memory" }); + })); + + context.subscriptions.push(vscode.commands.registerCommand("sharpClaw.selectModel", async () => { + const folder = requireWorkspaceFolder(); + const providers = await client.call("models/list", {}); + const picks = providers.flatMap(provider => { + const base = [{ + label: provider.defaultModel, + description: provider.providerName, + value: provider.defaultModel + }]; + const profilePicks = (provider.localRuntimeProfiles ?? []).flatMap(profile => { + const discovered = profile.availableModels ?? []; + if (discovered.length === 0) { + return [{ + label: `${profile.name}/${profile.defaultChatModel}`, + description: provider.providerName, + value: `${profile.name}/${profile.defaultChatModel}` + }]; + } + + return discovered.map(model => ({ + label: `${profile.name}/${model.id}`, + description: provider.providerName, + value: `${profile.name}/${model.id}` + })); + }); + return [...base, ...profilePicks]; + }); + const selected = await vscode.window.showQuickPick(picks, { placeHolder: "Select the model for ACP prompts" }); + if (!selected) { + return; + } + + await context.workspaceState.update(`model:${folder.uri.toString()}`, selected.value); + void vscode.window.showInformationMessage(`SharpClaw model set to ${selected.value}.`); + })); +} + +export function deactivate(): void { +} + +function requireWorkspaceFolder(): vscode.WorkspaceFolder { + const folder = vscode.workspace.workspaceFolders?.[0]; + if (!folder) { + throw new Error("Open a workspace folder before using SharpClaw."); + } + + return folder; +} + +function buildEditorContext(editor: vscode.TextEditor, sessionId: string): Record { + const selection = editor.selection; + return { + workspaceRoot: vscode.workspace.getWorkspaceFolder(editor.document.uri)?.uri.fsPath, + currentFilePath: editor.document.uri.fsPath, + selection: selection.isEmpty + ? undefined + : { + start: editor.document.offsetAt(selection.start), + end: editor.document.offsetAt(selection.end), + text: editor.document.getText(selection) + }, + sessionId + }; +} diff --git a/extensions/vscode/tsconfig.json b/extensions/vscode/tsconfig.json new file mode 100644 index 0000000..cc23947 --- /dev/null +++ b/extensions/vscode/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "es2022", + "lib": [ + "es2022" + ], + "outDir": "out", + "rootDir": "src", + "strict": true, + "sourceMap": true, + "esModuleInterop": true + }, + "include": [ + "src/**/*.ts" + ] +} diff --git a/src/SharpClaw.Code.Acp/AcpApprovalCoordinator.cs b/src/SharpClaw.Code.Acp/AcpApprovalCoordinator.cs new file mode 100644 index 0000000..f9cb51b --- /dev/null +++ b/src/SharpClaw.Code.Acp/AcpApprovalCoordinator.cs @@ -0,0 +1,125 @@ +using System.Collections.Concurrent; +using System.Text.Json.Nodes; +using SharpClaw.Code.Permissions.Models; +using SharpClaw.Code.Protocol.Models; + +namespace SharpClaw.Code.Acp; + +/// +/// Coordinates ACP approval notifications and replies for a single host process. +/// +public sealed class AcpApprovalCoordinator +{ + private readonly ConcurrentDictionary pending = new(StringComparer.Ordinal); + private Func? notificationSink; + + /// + /// Gets a value indicating whether the connected ACP client advertised approval round-trips. + /// + public bool SupportsApprovals { get; private set; } + + /// + /// Updates the active ACP notification sink and approval capability state for the current host run. + /// + /// Whether the client supports approval callbacks. + /// Notification writer used for approval requests. + public void Configure(bool supportsApprovals, Func notificationWriter) + { + SupportsApprovals = supportsApprovals; + notificationSink = notificationWriter; + } + + /// + /// Sends an approval request to the connected ACP client and waits for the response. + /// + /// Approval request details. + /// Permission evaluation context. + /// Cancellation token. + /// The resolved approval decision. + public async Task RequestApprovalAsync( + ApprovalRequest request, + PermissionEvaluationContext context, + CancellationToken cancellationToken) + { + if (!SupportsApprovals || notificationSink is null) + { + return new ApprovalDecision( + request.Scope, + Approved: false, + RequestedBy: request.RequestedBy, + ResolvedBy: "acp", + Reason: "ACP client does not support approval round-trips.", + ResolvedAtUtc: DateTimeOffset.UtcNow, + ExpiresAtUtc: null, + RememberForSession: false); + } + + var requestId = $"approval-{Guid.NewGuid():N}"; + var tcs = new TaskCompletionSource<(bool Approved, bool Remember)>(TaskCreationOptions.RunContinuationsAsynchronously); + pending[requestId] = new PendingApproval(context.SessionId, request, tcs); + + using var registration = cancellationToken.Register(() => + { + if (pending.TryRemove(requestId, out var removed)) + { + removed.Completion.TrySetCanceled(cancellationToken); + } + }); + + await notificationSink(new JsonObject + { + ["jsonrpc"] = "2.0", + ["method"] = "session/notification", + ["params"] = new JsonObject + { + ["sessionId"] = context.SessionId, + ["update"] = new JsonObject + { + ["sessionUpdate"] = "approvalRequest", + ["approval"] = new JsonObject + { + ["requestId"] = requestId, + ["scope"] = request.Scope.ToString(), + ["toolName"] = request.ToolName, + ["prompt"] = request.Prompt, + ["canRememberDecision"] = request.CanRememberDecision, + }, + }, + }, + }).ConfigureAwait(false); + + var response = await tcs.Task.ConfigureAwait(false); + return new ApprovalDecision( + request.Scope, + Approved: response.Approved, + RequestedBy: request.RequestedBy, + ResolvedBy: "acp", + Reason: response.Approved ? "Approved via ACP client." : "Denied via ACP client.", + ResolvedAtUtc: DateTimeOffset.UtcNow, + ExpiresAtUtc: null, + RememberForSession: response.Remember); + } + + /// + /// Resolves a pending ACP approval request. + /// + /// Approval request id. + /// Whether the request was approved. + /// Whether the decision should be remembered for the session. + /// when a pending request was resolved; otherwise . + public bool TryResolve(string requestId, bool approved, bool remember) + { + if (!pending.TryRemove(requestId, out var pendingApproval)) + { + return false; + } + + pendingApproval.Completion.TrySetResult((approved, remember)); + return true; + } + + private sealed record PendingApproval( + string SessionId, + ApprovalRequest Request, + TaskCompletionSource<(bool Approved, bool Remember)> Completion); +} diff --git a/src/SharpClaw.Code.Acp/AcpApprovalTransport.cs b/src/SharpClaw.Code.Acp/AcpApprovalTransport.cs new file mode 100644 index 0000000..b7441a7 --- /dev/null +++ b/src/SharpClaw.Code.Acp/AcpApprovalTransport.cs @@ -0,0 +1,19 @@ +using SharpClaw.Code.Permissions.Abstractions; +using SharpClaw.Code.Permissions.Models; +using SharpClaw.Code.Protocol.Models; + +namespace SharpClaw.Code.Acp; + +internal sealed class AcpApprovalTransport(AcpApprovalCoordinator coordinator) : IApprovalTransport +{ + public bool CanHandle(PermissionEvaluationContext context) + => context.IsInteractive + && coordinator.SupportsApprovals + && string.Equals(context.SourceName, "acp", StringComparison.OrdinalIgnoreCase); + + public Task RequestApprovalAsync( + ApprovalRequest request, + PermissionEvaluationContext context, + CancellationToken cancellationToken) + => coordinator.RequestApprovalAsync(request, context, cancellationToken); +} diff --git a/src/SharpClaw.Code.Acp/AcpServiceCollectionExtensions.cs b/src/SharpClaw.Code.Acp/AcpServiceCollectionExtensions.cs new file mode 100644 index 0000000..83e5ed9 --- /dev/null +++ b/src/SharpClaw.Code.Acp/AcpServiceCollectionExtensions.cs @@ -0,0 +1,21 @@ +using Microsoft.Extensions.DependencyInjection; +using SharpClaw.Code.Permissions.Abstractions; + +namespace SharpClaw.Code.Acp; + +/// +/// Registers ACP-specific host services. +/// +public static class AcpServiceCollectionExtensions +{ + /// + /// Adds ACP host services, including approval round-trip support. + /// + public static IServiceCollection AddSharpClawAcp(this IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + return services; + } +} diff --git a/src/SharpClaw.Code.Acp/AcpStdioHost.cs b/src/SharpClaw.Code.Acp/AcpStdioHost.cs index 652ee6d..224c8b2 100644 --- a/src/SharpClaw.Code.Acp/AcpStdioHost.cs +++ b/src/SharpClaw.Code.Acp/AcpStdioHost.cs @@ -2,8 +2,12 @@ using System.Text.Json.Nodes; using Microsoft.Extensions.Logging; using SharpClaw.Code.Infrastructure.Abstractions; +using SharpClaw.Code.Memory.Abstractions; using SharpClaw.Code.Protocol.Commands; using SharpClaw.Code.Protocol.Enums; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Protocol.Serialization; +using SharpClaw.Code.Providers.Abstractions; using SharpClaw.Code.Runtime.Abstractions; using SharpClaw.Code.Sessions.Abstractions; @@ -12,21 +16,32 @@ namespace SharpClaw.Code.Acp; /// /// Minimal Agent Client Protocol (ACP) JSON-RPC loop over stdio, aligned with common IDE subprocess integrations. /// -/// -/// Intentionally unsupported (JSON-RPC errors or no-ops): streaming tool execution updates, interactive permission prompts, -/// image/audio prompt parts, MCP hot-plug, session/cancel reliability, and vendor extensions. -/// public sealed class AcpStdioHost( IConversationRuntime conversationRuntime, IWorkspaceSessionAttachmentStore attachmentStore, + IEditorContextBuffer editorContextBuffer, + IWorkspaceIndexService workspaceIndexService, + IWorkspaceSearchService workspaceSearchService, + IPersistentMemoryStore persistentMemoryStore, + IProviderCatalogService providerCatalogService, + AcpApprovalCoordinator approvalCoordinator, IPathService pathService, ILogger logger) { + private readonly SemaphoreSlim writeLock = new(1, 1); + private Func? notificationWriter; + /// /// Processes newline-delimited JSON-RPC requests until the input stream ends. /// public async Task RunAsync(TextReader stdin, TextWriter stdout, CancellationToken cancellationToken) { + approvalCoordinator.Configure( + supportsApprovals: approvalCoordinator.SupportsApprovals, + notificationWriter: payload => WriteJsonLineAsync(stdout, payload, cancellationToken)); + notificationWriter = payload => WriteJsonLineAsync(stdout, payload, cancellationToken); + + var inFlight = new List(); while (!cancellationToken.IsCancellationRequested) { var line = await stdin.ReadLineAsync(cancellationToken).ConfigureAwait(false); @@ -48,46 +63,52 @@ public async Task RunAsync(TextReader stdin, TextWriter stdout, CancellationToke catch (JsonException ex) { logger.LogWarning(ex, "ACP received non-JSON line."); - await WriteErrorAsync(stdout, null, -32700, "Parse error.").ConfigureAwait(false); - await stdout.FlushAsync(cancellationToken).ConfigureAwait(false); + await WriteErrorAsync(stdout, null, -32700, "Parse error.", cancellationToken).ConfigureAwait(false); continue; } if (root is not JsonObject requestObject) { - await WriteErrorAsync(stdout, null, -32600, "Invalid request.").ConfigureAwait(false); - await stdout.FlushAsync(cancellationToken).ConfigureAwait(false); + await WriteErrorAsync(stdout, null, -32600, "Invalid request.", cancellationToken).ConfigureAwait(false); continue; } - var id = requestObject["id"]; - var method = requestObject["method"]?.GetValue(); - if (!string.Equals(requestObject["jsonrpc"]?.GetValue(), "2.0", StringComparison.Ordinal) - || string.IsNullOrWhiteSpace(method) - || id is null) - { - await WriteErrorAsync(stdout, id, -32600, "Invalid request.").ConfigureAwait(false); - await stdout.FlushAsync(cancellationToken).ConfigureAwait(false); - continue; - } + inFlight.Add(ProcessRequestAsync(requestObject, stdout, cancellationToken)); + inFlight.RemoveAll(static task => task.IsCompleted); + } - try - { - var response = await DispatchAsync(method, requestObject["params"], stdout, cancellationToken).ConfigureAwait(false); - await WriteResponseAsync(stdout, id, response).ConfigureAwait(false); - } - catch (AcpJsonRpcException ex) - { - logger.LogWarning(ex, "ACP request failed for method {Method} with JSON-RPC error {Code}.", method, ex.Code); - await WriteErrorAsync(stdout, id, ex.Code, ex.Message).ConfigureAwait(false); - } - catch (Exception ex) - { - logger.LogError(ex, "ACP request failed for method {Method}.", method); - await WriteErrorAsync(stdout, id, -32603, ex.Message).ConfigureAwait(false); - } + if (inFlight.Count > 0) + { + await Task.WhenAll(inFlight).ConfigureAwait(false); + } + } - await stdout.FlushAsync(cancellationToken).ConfigureAwait(false); + private async Task ProcessRequestAsync(JsonObject requestObject, TextWriter stdout, CancellationToken cancellationToken) + { + var id = requestObject["id"]; + var method = requestObject["method"]?.GetValue(); + if (!string.Equals(requestObject["jsonrpc"]?.GetValue(), "2.0", StringComparison.Ordinal) + || string.IsNullOrWhiteSpace(method) + || id is null) + { + await WriteErrorAsync(stdout, id, -32600, "Invalid request.", cancellationToken).ConfigureAwait(false); + return; + } + + try + { + var response = await DispatchAsync(method, requestObject["params"], stdout, cancellationToken).ConfigureAwait(false); + await WriteResponseAsync(stdout, id, response, cancellationToken).ConfigureAwait(false); + } + catch (AcpJsonRpcException ex) + { + logger.LogWarning(ex, "ACP request failed for method {Method} with JSON-RPC error {Code}.", method, ex.Code); + await WriteErrorAsync(stdout, id, ex.Code, ex.Message, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + logger.LogError(ex, "ACP request failed for method {Method}.", method); + await WriteErrorAsync(stdout, id, -32603, ex.Message, cancellationToken).ConfigureAwait(false); } } @@ -98,18 +119,37 @@ public async Task RunAsync(TextReader stdin, TextWriter stdout, CancellationToke CancellationToken cancellationToken) => method switch { - "initialize" => await Task.FromResult(BuildInitializeResult()).ConfigureAwait(false), + "initialize" => await Task.FromResult(HandleInitialize(parameters)).ConfigureAwait(false), "session/new" => await HandleSessionNewAsync(parameters, cancellationToken).ConfigureAwait(false), "session/load" => await HandleSessionLoadAsync(parameters, cancellationToken).ConfigureAwait(false), "session/prompt" => await HandleSessionPromptAsync(parameters, stdout, cancellationToken).ConfigureAwait(false), + "models/list" => await HandleModelsListAsync(cancellationToken).ConfigureAwait(false), + "workspace/index/refresh" => await HandleWorkspaceIndexRefreshAsync(parameters, cancellationToken).ConfigureAwait(false), + "workspace/search" => await HandleWorkspaceSearchAsync(parameters, cancellationToken).ConfigureAwait(false), + "memory/list" => await HandleMemoryListAsync(parameters, cancellationToken).ConfigureAwait(false), + "memory/save" => await HandleMemorySaveAsync(parameters, cancellationToken).ConfigureAwait(false), + "memory/delete" => await HandleMemoryDeleteAsync(parameters, cancellationToken).ConfigureAwait(false), + "approval/respond" => await HandleApprovalRespondAsync(parameters).ConfigureAwait(false), _ => throw new AcpJsonRpcException(-32601, $"Method '{method}' was not found.") }; - private static JsonObject BuildInitializeResult() + private JsonObject HandleInitialize(JsonNode? parameters) { + var supportsApprovals = parameters?["clientCapabilities"]?["approvalRequests"]?.GetValue() ?? false; + approvalCoordinator.Configure( + supportsApprovals, + payload => notificationWriter is null + ? Task.CompletedTask + : notificationWriter(payload)); + var capabilities = new JsonObject { ["loadSession"] = true, + ["approvalRequests"] = true, + ["models"] = true, + ["workspaceSearch"] = true, + ["workspaceIndex"] = true, + ["memory"] = true, ["promptCapabilities"] = new JsonObject { ["image"] = false, @@ -131,8 +171,7 @@ private static JsonObject BuildInitializeResult() private async Task HandleSessionNewAsync(JsonNode? parameters, CancellationToken cancellationToken) { - var cwd = parameters?["cwd"]?.GetValue() ?? throw new InvalidOperationException("session/new requires cwd."); - var workspace = pathService.GetFullPath(cwd); + var workspace = RequireWorkspace(parameters); var session = await conversationRuntime .CreateSessionAsync(workspace, PermissionMode.WorkspaceWrite, OutputFormat.Json, cancellationToken) .ConfigureAwait(false); @@ -143,7 +182,9 @@ private async Task HandleSessionNewAsync(JsonNode? parameters, Cance ["models"] = new JsonObject { ["current"] = "default", - ["available"] = new JsonArray(), + ["available"] = JsonSerializer.SerializeToNode( + (await providerCatalogService.ListAsync(cancellationToken).ConfigureAwait(false)).ToList(), + ProtocolJsonContext.Default.ListProviderModelCatalogEntry), }, }; } @@ -151,8 +192,7 @@ private async Task HandleSessionNewAsync(JsonNode? parameters, Cance private async Task HandleSessionLoadAsync(JsonNode? parameters, CancellationToken cancellationToken) { var sessionId = parameters?["sessionId"]?.GetValue() ?? throw new InvalidOperationException("session/load requires sessionId."); - var cwd = parameters?["cwd"]?.GetValue() ?? throw new InvalidOperationException("session/load requires cwd."); - var workspace = pathService.GetFullPath(cwd); + var workspace = RequireWorkspace(parameters); var session = await conversationRuntime .GetSessionAsync(workspace, sessionId, cancellationToken) .ConfigureAwait(false); @@ -177,10 +217,22 @@ private async Task HandleSessionPromptAsync( CancellationToken cancellationToken) { var sessionId = parameters?["sessionId"]?.GetValue() ?? throw new InvalidOperationException("session/prompt requires sessionId."); - var cwd = parameters?["cwd"]?.GetValue() ?? throw new InvalidOperationException("session/prompt requires cwd."); - var workspace = pathService.GetFullPath(cwd); - + var workspace = RequireWorkspace(parameters); var promptText = ExtractPromptText(parameters?["prompt"]); + var metadata = new Dictionary(StringComparer.Ordinal) + { + ["acp"] = "true", + }; + if (parameters?["model"]?.GetValue() is { Length: > 0 } model) + { + metadata["model"] = model; + } + + if (parameters?["editorContext"] is JsonNode editorContextNode) + { + var editorContext = DeserializeEditorContext(editorContextNode, workspace, sessionId); + editorContextBuffer.Publish(editorContext); + } var turn = await conversationRuntime .RunPromptAsync( @@ -190,29 +242,31 @@ private async Task HandleSessionPromptAsync( WorkingDirectory: workspace, PermissionMode: PermissionMode.WorkspaceWrite, OutputFormat: OutputFormat.Json, - Metadata: new Dictionary { ["acp"] = "true" }, - IsInteractive: false), + Metadata: metadata, + IsInteractive: approvalCoordinator.SupportsApprovals), cancellationToken) .ConfigureAwait(false); - var chunk = new JsonObject - { - ["jsonrpc"] = "2.0", - ["method"] = "session/notification", - ["params"] = new JsonObject + await WriteJsonLineAsync( + stdout, + new JsonObject { - ["sessionId"] = sessionId, - ["update"] = new JsonObject + ["jsonrpc"] = "2.0", + ["method"] = "session/notification", + ["params"] = new JsonObject { - ["sessionUpdate"] = "agentMessageChunk", - ["chunk"] = new JsonObject + ["sessionId"] = sessionId, + ["update"] = new JsonObject { - ["content"] = new JsonObject { ["type"] = "text", ["text"] = turn.FinalOutput ?? string.Empty }, + ["sessionUpdate"] = "agentMessageChunk", + ["chunk"] = new JsonObject + { + ["content"] = new JsonObject { ["type"] = "text", ["text"] = turn.FinalOutput ?? string.Empty }, + }, }, }, }, - }; - await stdout.WriteLineAsync(chunk.ToJsonString()).ConfigureAwait(false); + cancellationToken).ConfigureAwait(false); return new JsonObject { @@ -220,6 +274,109 @@ private async Task HandleSessionPromptAsync( }; } + private async Task HandleModelsListAsync(CancellationToken cancellationToken) + => JsonSerializer.SerializeToNode( + (await providerCatalogService.ListAsync(cancellationToken).ConfigureAwait(false)).ToList(), + ProtocolJsonContext.Default.ListProviderModelCatalogEntry); + + private async Task HandleWorkspaceIndexRefreshAsync(JsonNode? parameters, CancellationToken cancellationToken) + { + var workspace = RequireWorkspace(parameters); + var result = await workspaceIndexService.RefreshAsync(workspace, cancellationToken).ConfigureAwait(false); + return JsonSerializer.SerializeToNode(result, ProtocolJsonContext.Default.WorkspaceIndexRefreshResult); + } + + private async Task HandleWorkspaceSearchAsync(JsonNode? parameters, CancellationToken cancellationToken) + { + var workspace = RequireWorkspace(parameters); + var request = new WorkspaceSearchRequest( + Query: parameters?["query"]?.GetValue() ?? string.Empty, + Limit: parameters?["limit"]?.GetValue(), + IncludeSymbols: parameters?["includeSymbols"]?.GetValue() ?? true, + IncludeSemantic: parameters?["includeSemantic"]?.GetValue() ?? true); + var result = await workspaceSearchService.SearchAsync(workspace, request, cancellationToken).ConfigureAwait(false); + return JsonSerializer.SerializeToNode(result, ProtocolJsonContext.Default.WorkspaceSearchResult); + } + + private async Task HandleMemoryListAsync(JsonNode? parameters, CancellationToken cancellationToken) + { + var workspace = parameters?["cwd"]?.GetValue(); + var normalizedWorkspace = string.IsNullOrWhiteSpace(workspace) ? null : pathService.GetFullPath(workspace); + var scopeText = parameters?["scope"]?.GetValue(); + var scope = Enum.TryParse(scopeText, true, out var parsedScope) ? (MemoryScope?)parsedScope : null; + var rows = await persistentMemoryStore + .ListAsync(normalizedWorkspace, scope, parameters?["query"]?.GetValue(), parameters?["limit"]?.GetValue() ?? 20, cancellationToken) + .ConfigureAwait(false); + return JsonSerializer.SerializeToNode(rows.ToList(), ProtocolJsonContext.Default.ListMemoryEntry); + } + + private async Task HandleMemorySaveAsync(JsonNode? parameters, CancellationToken cancellationToken) + { + var workspace = parameters?["cwd"]?.GetValue(); + var normalizedWorkspace = string.IsNullOrWhiteSpace(workspace) ? null : pathService.GetFullPath(workspace); + var request = parameters?["request"]?.Deserialize(ProtocolJsonContext.Default.MemorySaveRequest) + ?? throw new InvalidOperationException("memory/save requires request."); + var now = DateTimeOffset.UtcNow; + var entry = new MemoryEntry( + Id: $"memory-{Guid.NewGuid():N}", + Scope: request.Scope, + Content: request.Content, + Source: request.Source, + SourceSessionId: parameters?["sessionId"]?.GetValue(), + SourceTurnId: null, + Tags: request.Tags ?? [], + Confidence: request.Confidence, + RelatedFilePath: request.RelatedFilePath, + RelatedSymbolName: request.RelatedSymbolName, + CreatedAtUtc: now, + UpdatedAtUtc: now); + var saved = await persistentMemoryStore + .SaveAsync(request.Scope == MemoryScope.Project ? normalizedWorkspace : null, entry, cancellationToken) + .ConfigureAwait(false); + return JsonSerializer.SerializeToNode(saved, ProtocolJsonContext.Default.MemoryEntry); + } + + private async Task HandleMemoryDeleteAsync(JsonNode? parameters, CancellationToken cancellationToken) + { + var workspace = parameters?["cwd"]?.GetValue(); + var normalizedWorkspace = string.IsNullOrWhiteSpace(workspace) ? null : pathService.GetFullPath(workspace); + var scope = Enum.Parse(parameters?["scope"]?.GetValue() ?? MemoryScope.Project.ToString(), true); + var id = parameters?["id"]?.GetValue() ?? throw new InvalidOperationException("memory/delete requires id."); + var deleted = await persistentMemoryStore + .DeleteAsync(scope == MemoryScope.Project ? normalizedWorkspace : null, scope, id, cancellationToken) + .ConfigureAwait(false); + return new JsonObject { ["deleted"] = deleted }; + } + + private Task HandleApprovalRespondAsync(JsonNode? parameters) + { + var requestId = parameters?["requestId"]?.GetValue() ?? throw new InvalidOperationException("approval/respond requires requestId."); + var approved = parameters?["approved"]?.GetValue() ?? false; + var remember = parameters?["remember"]?.GetValue() ?? false; + var resolved = approvalCoordinator.TryResolve(requestId, approved, remember); + return Task.FromResult(new JsonObject { ["resolved"] = resolved }); + } + + private string RequireWorkspace(JsonNode? parameters) + { + var cwd = parameters?["cwd"]?.GetValue() ?? throw new InvalidOperationException("cwd is required."); + return pathService.GetFullPath(cwd); + } + + private static EditorContextPayload DeserializeEditorContext(JsonNode node, string workspaceRoot, string sessionId) + { + if (node.Deserialize(ProtocolJsonContext.Default.EditorContextPayload) is { } payload) + { + return string.IsNullOrWhiteSpace(payload.SessionId) ? payload with { SessionId = sessionId } : payload; + } + + return new EditorContextPayload( + WorkspaceRoot: workspaceRoot, + CurrentFilePath: node["currentFilePath"]?.GetValue(), + Selection: node["selection"]?.Deserialize(ProtocolJsonContext.Default.TextSelectionRange), + SessionId: sessionId); + } + private static string ExtractPromptText(JsonNode? promptNode) { if (promptNode is JsonValue v && v.TryGetValue(out var single)) @@ -245,31 +402,45 @@ private static string ExtractPromptText(JsonNode? promptNode) throw new InvalidOperationException("session/prompt requires a text prompt payload."); } - private static async Task WriteResponseAsync(TextWriter stdout, JsonNode id, JsonNode? result) + private async Task WriteJsonLineAsync(TextWriter stdout, JsonNode node, CancellationToken cancellationToken) { - var response = new JsonObject + await writeLock.WaitAsync(cancellationToken).ConfigureAwait(false); + try { - ["jsonrpc"] = "2.0", - ["id"] = id.DeepClone(), - ["result"] = result ?? new JsonObject(), - }; - await stdout.WriteLineAsync(response.ToJsonString()).ConfigureAwait(false); + await stdout.WriteLineAsync(node.ToJsonString()).ConfigureAwait(false); + await stdout.FlushAsync(cancellationToken).ConfigureAwait(false); + } + finally + { + writeLock.Release(); + } } - private static async Task WriteErrorAsync(TextWriter stdout, JsonNode? id, int code, string message) - { - var response = new JsonObject - { - ["jsonrpc"] = "2.0", - ["id"] = id?.DeepClone(), - ["error"] = new JsonObject + private Task WriteResponseAsync(TextWriter stdout, JsonNode id, JsonNode? result, CancellationToken cancellationToken) + => WriteJsonLineAsync( + stdout, + new JsonObject { - ["code"] = code, - ["message"] = message, + ["jsonrpc"] = "2.0", + ["id"] = id.DeepClone(), + ["result"] = result ?? new JsonObject(), }, - }; - await stdout.WriteLineAsync(response.ToJsonString()).ConfigureAwait(false); - } + cancellationToken); + + private Task WriteErrorAsync(TextWriter stdout, JsonNode? id, int code, string message, CancellationToken cancellationToken) + => WriteJsonLineAsync( + stdout, + new JsonObject + { + ["jsonrpc"] = "2.0", + ["id"] = id?.DeepClone(), + ["error"] = new JsonObject + { + ["code"] = code, + ["message"] = message, + }, + }, + cancellationToken); private sealed class AcpJsonRpcException(int code, string message) : Exception(message) { diff --git a/src/SharpClaw.Code.Acp/SharpClaw.Code.Acp.csproj b/src/SharpClaw.Code.Acp/SharpClaw.Code.Acp.csproj index 3b86ce5..c583f17 100644 --- a/src/SharpClaw.Code.Acp/SharpClaw.Code.Acp.csproj +++ b/src/SharpClaw.Code.Acp/SharpClaw.Code.Acp.csproj @@ -13,6 +13,9 @@ + + + diff --git a/src/SharpClaw.Code.Agents/Internal/ProviderBackedAgentKernel.cs b/src/SharpClaw.Code.Agents/Internal/ProviderBackedAgentKernel.cs index c4f8b31..49c9439 100644 --- a/src/SharpClaw.Code.Agents/Internal/ProviderBackedAgentKernel.cs +++ b/src/SharpClaw.Code.Agents/Internal/ProviderBackedAgentKernel.cs @@ -9,7 +9,6 @@ using SharpClaw.Code.Providers.Models; using SharpClaw.Code.Protocol.Enums; using SharpClaw.Code.Telemetry.Diagnostics; -using SharpClaw.Code.Telemetry.Metrics; using SharpClaw.Code.Tools.Models; namespace SharpClaw.Code.Agents.Internal; @@ -180,7 +179,6 @@ internal async Task ExecuteAsync( providerSw.Stop(); providerScope.SetCompleted(iterationUsage?.InputTokens, iterationUsage?.OutputTokens); - SharpClawMeterSource.ProviderDuration.Record(providerSw.Elapsed.TotalMilliseconds); } catch (Exception ex) { diff --git a/src/SharpClaw.Code.Agents/Services/AgentFrameworkBridge.cs b/src/SharpClaw.Code.Agents/Services/AgentFrameworkBridge.cs index 8bb9073..25d23f6 100644 --- a/src/SharpClaw.Code.Agents/Services/AgentFrameworkBridge.cs +++ b/src/SharpClaw.Code.Agents/Services/AgentFrameworkBridge.cs @@ -46,7 +46,11 @@ public async Task RunAsync(AgentFrameworkRequest request, Cancel AllowDangerousBypass: false, IsInteractive: request.Context.IsInteractive, SourceKind: PermissionRequestSourceKind.Runtime, - SourceName: null, + SourceName: request.Context.Metadata is not null + && request.Context.Metadata.TryGetValue("acp", out var acp) + && string.Equals(acp, "true", StringComparison.OrdinalIgnoreCase) + ? "acp" + : null, TrustedPluginNames: null, TrustedMcpServerNames: null, PrimaryMode: request.Context.PrimaryMode, diff --git a/src/SharpClaw.Code.Cli/Composition/CliServiceCollectionExtensions.cs b/src/SharpClaw.Code.Cli/Composition/CliServiceCollectionExtensions.cs index 631b7d7..d54fac9 100644 --- a/src/SharpClaw.Code.Cli/Composition/CliServiceCollectionExtensions.cs +++ b/src/SharpClaw.Code.Cli/Composition/CliServiceCollectionExtensions.cs @@ -17,6 +17,7 @@ public static class CliServiceCollectionExtensions /// The updated service collection. public static IServiceCollection AddSharpClawCli(this IServiceCollection services) { + services.AddSharpClawAcp(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -27,8 +28,6 @@ public static IServiceCollection AddSharpClawCli(this IServiceCollection service services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -38,7 +37,9 @@ public static IServiceCollection AddSharpClawCli(this IServiceCollection service services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -49,6 +50,7 @@ public static IServiceCollection AddSharpClawCli(this IServiceCollection service services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -62,7 +64,9 @@ public static IServiceCollection AddSharpClawCli(this IServiceCollection service services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/SharpClaw.Code.Commands/CliCommandFactory.cs b/src/SharpClaw.Code.Commands/CliCommandFactory.cs index 6239a93..aaf730c 100644 --- a/src/SharpClaw.Code.Commands/CliCommandFactory.cs +++ b/src/SharpClaw.Code.Commands/CliCommandFactory.cs @@ -80,7 +80,7 @@ private async Task AddDiscoveredCustomCommandsAsync(RootCommand rootCommand, Can try { var result = await runtimeCommandService - .ExecuteCustomCommandAsync(definition.Name, argLine, ToRuntimeContext(ctx), cancellationToken) + .ExecuteCustomCommandAsync(definition.Name, argLine, ctx.ToRuntimeCommandContext(), cancellationToken) .ConfigureAwait(false); await outputRendererDispatcher.RenderTurnExecutionResultAsync(result, ctx.OutputFormat, cancellationToken).ConfigureAwait(false); return 0; @@ -102,14 +102,4 @@ await outputRendererDispatcher.RenderCommandResultAsync( rootCommand.Subcommands.Add(command); } } - - private static RuntimeCommandContext ToRuntimeContext(CommandExecutionContext context) - => new( - context.WorkingDirectory, - context.Model, - context.PermissionMode, - context.OutputFormat, - context.PrimaryMode, - context.SessionId, - context.AgentId); } diff --git a/src/SharpClaw.Code.Commands/Handlers/CompactCommandHandler.cs b/src/SharpClaw.Code.Commands/Handlers/CompactCommandHandler.cs index 83a042a..311feb2 100644 --- a/src/SharpClaw.Code.Commands/Handlers/CompactCommandHandler.cs +++ b/src/SharpClaw.Code.Commands/Handlers/CompactCommandHandler.cs @@ -31,7 +31,7 @@ public Command BuildCommand(GlobalCliOptions globalOptions) { var context = globalOptions.Resolve(parseResult); var id = parseResult.GetValue(idOption); - var result = await runtimeCommandService.CompactSessionAsync(id, ToRuntimeContext(context), cancellationToken).ConfigureAwait(false); + var result = await runtimeCommandService.CompactSessionAsync(id, context.ToRuntimeCommandContext(), cancellationToken).ConfigureAwait(false); await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false); return result.ExitCode; }); @@ -42,18 +42,8 @@ public Command BuildCommand(GlobalCliOptions globalOptions) public async Task ExecuteAsync(SlashCommandParseResult command, CommandExecutionContext context, CancellationToken cancellationToken) { var id = command.Arguments.Length > 0 ? command.Arguments[0] : null; - var result = await runtimeCommandService.CompactSessionAsync(id, ToRuntimeContext(context), cancellationToken).ConfigureAwait(false); + var result = await runtimeCommandService.CompactSessionAsync(id, context.ToRuntimeCommandContext(), cancellationToken).ConfigureAwait(false); await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false); return result.ExitCode; } - - private static RuntimeCommandContext ToRuntimeContext(CommandExecutionContext context) - => new( - context.WorkingDirectory, - context.Model, - context.PermissionMode, - context.OutputFormat, - context.PrimaryMode, - context.SessionId, - context.AgentId); } diff --git a/src/SharpClaw.Code.Commands/Handlers/DoctorCommandHandler.cs b/src/SharpClaw.Code.Commands/Handlers/DoctorCommandHandler.cs index d6ffde5..559a107 100644 --- a/src/SharpClaw.Code.Commands/Handlers/DoctorCommandHandler.cs +++ b/src/SharpClaw.Code.Commands/Handlers/DoctorCommandHandler.cs @@ -28,7 +28,7 @@ public Command BuildCommand(GlobalCliOptions globalOptions) command.SetAction(async (parseResult, cancellationToken) => { var context = globalOptions.Resolve(parseResult); - var result = await runtimeCommandService.RunDoctorAsync(ToRuntimeContext(context), cancellationToken); + var result = await runtimeCommandService.RunDoctorAsync(context.ToRuntimeCommandContext(), cancellationToken); await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken); return result.ExitCode; }); @@ -39,17 +39,8 @@ public Command BuildCommand(GlobalCliOptions globalOptions) /// public async Task ExecuteAsync(SlashCommandParseResult command, CommandExecutionContext context, CancellationToken cancellationToken) { - var result = await runtimeCommandService.RunDoctorAsync(ToRuntimeContext(context), cancellationToken); + var result = await runtimeCommandService.RunDoctorAsync(context.ToRuntimeCommandContext(), cancellationToken); await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken); return result.ExitCode; } - - private static RuntimeCommandContext ToRuntimeContext(CommandExecutionContext context) - => new( - context.WorkingDirectory, - context.Model, - context.PermissionMode, - context.OutputFormat, - context.PrimaryMode, - context.SessionId); } diff --git a/src/SharpClaw.Code.Commands/Handlers/EditorSlashCommandHandler.cs b/src/SharpClaw.Code.Commands/Handlers/EditorSlashCommandHandler.cs index f12b571..2bfa487 100644 --- a/src/SharpClaw.Code.Commands/Handlers/EditorSlashCommandHandler.cs +++ b/src/SharpClaw.Code.Commands/Handlers/EditorSlashCommandHandler.cs @@ -36,7 +36,7 @@ await outputRendererDispatcher.RenderCommandResultAsync( try { var result = await runtimeCommandService - .ExecutePromptAsync(composed.Trim(), ToRuntimeContext(context), cancellationToken) + .ExecutePromptAsync(composed.Trim(), context.ToRuntimeCommandContext(), cancellationToken) .ConfigureAwait(false); await outputRendererDispatcher.RenderTurnExecutionResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false); return 0; @@ -50,13 +50,4 @@ await outputRendererDispatcher.RenderCommandResultAsync( return 1; } } - - private static RuntimeCommandContext ToRuntimeContext(CommandExecutionContext context) - => new( - context.WorkingDirectory, - context.Model, - context.PermissionMode, - context.OutputFormat, - context.PrimaryMode, - context.SessionId); } diff --git a/src/SharpClaw.Code.Commands/Handlers/ExportSlashCommandHandler.cs b/src/SharpClaw.Code.Commands/Handlers/ExportSlashCommandHandler.cs index a25d8f9..2f4150d 100644 --- a/src/SharpClaw.Code.Commands/Handlers/ExportSlashCommandHandler.cs +++ b/src/SharpClaw.Code.Commands/Handlers/ExportSlashCommandHandler.cs @@ -48,7 +48,7 @@ private async Task ExportAsync( CancellationToken cancellationToken) { var result = await runtimeCommandService - .ExportSessionAsync(sessionId, format, null, ToRuntimeContext(context), cancellationToken) + .ExportSessionAsync(sessionId, format, null, context.ToRuntimeCommandContext(), cancellationToken) .ConfigureAwait(false); await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false); return result.ExitCode; @@ -62,13 +62,4 @@ await outputRendererDispatcher.RenderCommandResultAsync( cancellationToken).ConfigureAwait(false); return success ? 0 : 1; } - - private static RuntimeCommandContext ToRuntimeContext(CommandExecutionContext context) - => new( - context.WorkingDirectory, - context.Model, - context.PermissionMode, - context.OutputFormat, - context.PrimaryMode, - context.SessionId); } diff --git a/src/SharpClaw.Code.Commands/Handlers/IndexCommandHandler.cs b/src/SharpClaw.Code.Commands/Handlers/IndexCommandHandler.cs new file mode 100644 index 0000000..01b0f0f --- /dev/null +++ b/src/SharpClaw.Code.Commands/Handlers/IndexCommandHandler.cs @@ -0,0 +1,120 @@ +using System.CommandLine; +using System.Text.Json; +using SharpClaw.Code.Commands.Models; +using SharpClaw.Code.Commands.Options; +using SharpClaw.Code.Memory.Abstractions; +using SharpClaw.Code.Protocol.Commands; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Protocol.Serialization; + +namespace SharpClaw.Code.Commands; + +/// +/// Refreshes and queries the persisted workspace knowledge index. +/// +public sealed class IndexCommandHandler( + IWorkspaceIndexService workspaceIndexService, + IWorkspaceSearchService workspaceSearchService, + OutputRendererDispatcher outputRendererDispatcher) : ICommandHandler, ISlashCommandHandler +{ + /// + public string Name => "index"; + + /// + public string Description => "Refreshes, inspects, and queries the workspace knowledge index."; + + /// + public string CommandName => Name; + + /// + public Command BuildCommand(GlobalCliOptions globalOptions) + { + var command = new Command(Name, Description); + + var refresh = new Command("refresh", "Refreshes the workspace index."); + refresh.SetAction((parseResult, cancellationToken) => ExecuteRefreshAsync(globalOptions.Resolve(parseResult), cancellationToken)); + command.Subcommands.Add(refresh); + + var stats = new Command("stats", "Shows workspace index status."); + stats.SetAction((parseResult, cancellationToken) => ExecuteStatsAsync(globalOptions.Resolve(parseResult), cancellationToken)); + command.Subcommands.Add(stats); + + var query = new Command("query", "Searches the workspace index."); + var queryArgument = new Argument("query") { Description = "Search query." }; + var limitOption = new Option("--limit") { Description = "Maximum number of hits to return." }; + query.Arguments.Add(queryArgument); + query.Options.Add(limitOption); + query.SetAction((parseResult, cancellationToken) => ExecuteQueryAsync( + globalOptions.Resolve(parseResult), + parseResult.GetValue(queryArgument) ?? string.Empty, + parseResult.GetValue(limitOption), + cancellationToken)); + command.Subcommands.Add(query); + + command.SetAction((parseResult, cancellationToken) => ExecuteStatsAsync(globalOptions.Resolve(parseResult), cancellationToken)); + return command; + } + + /// + public Task ExecuteAsync(SlashCommandParseResult command, CommandExecutionContext context, CancellationToken cancellationToken) + { + if (command.Arguments.Length > 0 && string.Equals(command.Arguments[0], "refresh", StringComparison.OrdinalIgnoreCase)) + { + return ExecuteRefreshAsync(context, cancellationToken); + } + + if (command.Arguments.Length > 0 && string.Equals(command.Arguments[0], "query", StringComparison.OrdinalIgnoreCase)) + { + var query = string.Join(' ', command.Arguments.Skip(1)); + return ExecuteQueryAsync(context, query, null, cancellationToken); + } + + return ExecuteStatsAsync(context, cancellationToken); + } + + private async Task ExecuteRefreshAsync(CommandExecutionContext context, CancellationToken cancellationToken) + { + var result = await workspaceIndexService.RefreshAsync(context.WorkingDirectory, cancellationToken).ConfigureAwait(false); + return await RenderAsync(context, result, $"Indexed {result.IndexedFileCount} file(s).", cancellationToken).ConfigureAwait(false); + } + + private async Task ExecuteStatsAsync(CommandExecutionContext context, CancellationToken cancellationToken) + { + var result = await workspaceIndexService.GetStatusAsync(context.WorkingDirectory, cancellationToken).ConfigureAwait(false); + return await RenderAsync( + context, + result, + result.RefreshedAtUtc is null + ? "Workspace index has not been built yet." + : $"Workspace index refreshed {result.RefreshedAtUtc:O}.", + cancellationToken).ConfigureAwait(false); + } + + private async Task ExecuteQueryAsync( + CommandExecutionContext context, + string query, + int? limit, + CancellationToken cancellationToken) + { + var result = await workspaceSearchService + .SearchAsync(context.WorkingDirectory, new WorkspaceSearchRequest(query, limit), cancellationToken) + .ConfigureAwait(false); + return await RenderAsync(context, result, $"{result.Hits.Length} workspace hit(s).", cancellationToken).ConfigureAwait(false); + } + + private async Task RenderAsync( + CommandExecutionContext context, + TPayload payload, + string message, + CancellationToken cancellationToken) + { + var commandResult = new CommandResult( + true, + 0, + context.OutputFormat, + message, + JsonSerializer.Serialize(payload, ProtocolJsonContext.Default.Options)); + await outputRendererDispatcher.RenderCommandResultAsync(commandResult, context.OutputFormat, cancellationToken).ConfigureAwait(false); + return 0; + } +} diff --git a/src/SharpClaw.Code.Commands/Handlers/MemoryCommandHandler.cs b/src/SharpClaw.Code.Commands/Handlers/MemoryCommandHandler.cs new file mode 100644 index 0000000..554d1f4 --- /dev/null +++ b/src/SharpClaw.Code.Commands/Handlers/MemoryCommandHandler.cs @@ -0,0 +1,177 @@ +using System.CommandLine; +using System.Text.Json; +using SharpClaw.Code.Commands.Models; +using SharpClaw.Code.Commands.Options; +using SharpClaw.Code.Memory.Abstractions; +using SharpClaw.Code.Protocol.Commands; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Protocol.Serialization; + +namespace SharpClaw.Code.Commands; + +/// +/// Lists, saves, and deletes structured memory entries. +/// +public sealed class MemoryCommandHandler( + IPersistentMemoryStore persistentMemoryStore, + OutputRendererDispatcher outputRendererDispatcher) : ICommandHandler, ISlashCommandHandler +{ + /// + public string Name => "memory"; + + /// + public string Description => "Lists, saves, and deletes durable project and user memory entries."; + + /// + public string CommandName => Name; + + /// + public Command BuildCommand(GlobalCliOptions globalOptions) + { + var command = new Command(Name, Description); + + var list = new Command("list", "Lists memory entries."); + var scopeOption = new Option("--scope") { Description = "Optional memory scope filter." }; + var queryOption = new Option("--query") { Description = "Optional free-text filter." }; + var limitOption = new Option("--limit") { Description = "Maximum number of rows to return." }; + list.Options.Add(scopeOption); + list.Options.Add(queryOption); + list.Options.Add(limitOption); + list.SetAction((parseResult, cancellationToken) => ExecuteListAsync( + globalOptions.Resolve(parseResult), + parseResult.GetValue(scopeOption), + parseResult.GetValue(queryOption), + parseResult.GetValue(limitOption), + cancellationToken)); + command.Subcommands.Add(list); + + var save = new Command("save", "Saves a memory entry."); + var saveScope = new Option("--scope") { Description = "Memory scope.", DefaultValueFactory = _ => MemoryScope.Project }; + var sourceOption = new Option("--source") { Description = "Source label.", DefaultValueFactory = _ => "manual" }; + var contentArgument = new Argument("content") { Description = "Memory content." }; + save.Options.Add(saveScope); + save.Options.Add(sourceOption); + save.Arguments.Add(contentArgument); + save.SetAction((parseResult, cancellationToken) => ExecuteSaveAsync( + globalOptions.Resolve(parseResult), + parseResult.GetValue(saveScope), + parseResult.GetValue(sourceOption) ?? "manual", + parseResult.GetValue(contentArgument) ?? string.Empty, + cancellationToken)); + command.Subcommands.Add(save); + + var delete = new Command("delete", "Deletes a memory entry."); + var deleteScope = new Option("--scope") { Description = "Memory scope.", DefaultValueFactory = _ => MemoryScope.Project }; + var idArgument = new Argument("id") { Description = "Memory id." }; + delete.Options.Add(deleteScope); + delete.Arguments.Add(idArgument); + delete.SetAction((parseResult, cancellationToken) => ExecuteDeleteAsync( + globalOptions.Resolve(parseResult), + parseResult.GetValue(deleteScope), + parseResult.GetValue(idArgument) ?? string.Empty, + cancellationToken)); + command.Subcommands.Add(delete); + + command.SetAction((parseResult, cancellationToken) => ExecuteListAsync(globalOptions.Resolve(parseResult), null, null, null, cancellationToken)); + return command; + } + + /// + public Task ExecuteAsync(SlashCommandParseResult command, CommandExecutionContext context, CancellationToken cancellationToken) + { + if (command.Arguments.Length > 0 && string.Equals(command.Arguments[0], "save", StringComparison.OrdinalIgnoreCase)) + { + var parsedScope = MemoryScope.Project; + var hasExplicitScope = command.Arguments.Length > 1 && Enum.TryParse(command.Arguments[1], true, out parsedScope); + var scope = hasExplicitScope ? parsedScope : MemoryScope.Project; + var content = string.Join(' ', command.Arguments.Skip(hasExplicitScope ? 2 : 1)); + return ExecuteSaveAsync(context, scope, "manual", content, cancellationToken); + } + + if (command.Arguments.Length > 0 && string.Equals(command.Arguments[0], "delete", StringComparison.OrdinalIgnoreCase)) + { + var id = command.Arguments.Length > 1 ? command.Arguments[1] : string.Empty; + return ExecuteDeleteAsync(context, MemoryScope.Project, id, cancellationToken); + } + + var query = command.Arguments.Length > 1 && string.Equals(command.Arguments[0], "list", StringComparison.OrdinalIgnoreCase) + ? string.Join(' ', command.Arguments.Skip(1)) + : string.Join(' ', command.Arguments); + return ExecuteListAsync(context, null, string.IsNullOrWhiteSpace(query) ? null : query, null, cancellationToken); + } + + private async Task ExecuteListAsync( + CommandExecutionContext context, + MemoryScope? scope, + string? query, + int? limit, + CancellationToken cancellationToken) + { + var rows = await persistentMemoryStore + .ListAsync(context.WorkingDirectory, scope, query, Math.Clamp(limit.GetValueOrDefault(20), 1, 100), cancellationToken) + .ConfigureAwait(false); + return await RenderAsync(context, rows.ToList(), $"{rows.Count} memory entr{(rows.Count == 1 ? "y" : "ies")}.", cancellationToken).ConfigureAwait(false); + } + + private async Task ExecuteSaveAsync( + CommandExecutionContext context, + MemoryScope scope, + string source, + string content, + CancellationToken cancellationToken) + { + var now = DateTimeOffset.UtcNow; + var entry = new MemoryEntry( + Id: $"memory-{Guid.NewGuid():N}", + Scope: scope, + Content: content, + Source: source, + SourceSessionId: context.SessionId, + SourceTurnId: null, + Tags: [], + Confidence: null, + RelatedFilePath: null, + RelatedSymbolName: null, + CreatedAtUtc: now, + UpdatedAtUtc: now); + var saved = await persistentMemoryStore + .SaveAsync(scope == MemoryScope.Project ? context.WorkingDirectory : null, entry, cancellationToken) + .ConfigureAwait(false); + return await RenderAsync(context, saved, $"Saved {scope.ToString().ToLowerInvariant()} memory {saved.Id}.", cancellationToken).ConfigureAwait(false); + } + + private async Task ExecuteDeleteAsync( + CommandExecutionContext context, + MemoryScope scope, + string id, + CancellationToken cancellationToken) + { + var deleted = await persistentMemoryStore + .DeleteAsync(scope == MemoryScope.Project ? context.WorkingDirectory : null, scope, id, cancellationToken) + .ConfigureAwait(false); + var result = new CommandResult( + deleted, + deleted ? 0 : 1, + context.OutputFormat, + deleted ? $"Deleted memory {id}." : $"Memory {id} was not found.", + null); + await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false); + return result.ExitCode; + } + + private async Task RenderAsync( + CommandExecutionContext context, + TPayload payload, + string message, + CancellationToken cancellationToken) + { + var result = new CommandResult( + true, + 0, + context.OutputFormat, + message, + JsonSerializer.Serialize(payload, ProtocolJsonContext.Default.Options)); + await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false); + return 0; + } +} diff --git a/src/SharpClaw.Code.Commands/Handlers/ModelsCommandHandler.cs b/src/SharpClaw.Code.Commands/Handlers/ModelsCommandHandler.cs index 5cdc014..25b0d2f 100644 --- a/src/SharpClaw.Code.Commands/Handlers/ModelsCommandHandler.cs +++ b/src/SharpClaw.Code.Commands/Handlers/ModelsCommandHandler.cs @@ -1,12 +1,9 @@ using System.CommandLine; using System.Text.Json; -using Microsoft.Extensions.Options; using SharpClaw.Code.Commands.Models; using SharpClaw.Code.Commands.Options; using SharpClaw.Code.Providers.Abstractions; -using SharpClaw.Code.Providers.Configuration; using SharpClaw.Code.Protocol.Commands; -using SharpClaw.Code.Protocol.Models; using SharpClaw.Code.Protocol.Serialization; namespace SharpClaw.Code.Commands; @@ -15,11 +12,7 @@ namespace SharpClaw.Code.Commands; /// Lists the configured provider/model surface available to SharpClaw. /// public sealed class ModelsCommandHandler( - IEnumerable modelProviders, - IAuthFlowService authFlowService, - IOptions providerCatalogOptions, - IOptions anthropicOptions, - IOptions openAiCompatibleOptions, + IProviderCatalogService providerCatalogService, OutputRendererDispatcher outputRendererDispatcher) : ICommandHandler, ISlashCommandHandler { /// @@ -45,39 +38,16 @@ public Task ExecuteAsync(SlashCommandParseResult command, CommandExecutionC private async Task ExecuteAsync(CommandExecutionContext context, CancellationToken cancellationToken) { - var entries = new List(); - var aliasesByProvider = providerCatalogOptions.Value.ModelAliases - .GroupBy(static pair => pair.Value.ProviderName, StringComparer.OrdinalIgnoreCase) - .ToDictionary( - static group => group.Key, - static group => group.Select(pair => pair.Key).OrderBy(static alias => alias, StringComparer.OrdinalIgnoreCase).ToArray(), - StringComparer.OrdinalIgnoreCase); - - foreach (var provider in modelProviders.OrderBy(static provider => provider.ProviderName, StringComparer.OrdinalIgnoreCase)) - { - var auth = await authFlowService.GetStatusAsync(provider.ProviderName, cancellationToken).ConfigureAwait(false); - entries.Add( - new ProviderModelCatalogEntry( - provider.ProviderName, - ResolveDefaultModel(provider.ProviderName), - aliasesByProvider.TryGetValue(provider.ProviderName, out var aliases) ? aliases : [], - auth)); - } + var entries = await providerCatalogService.ListAsync(cancellationToken).ConfigureAwait(false); + var payload = entries.ToList(); var result = new CommandResult( true, 0, context.OutputFormat, - $"{entries.Count} provider model surface(s).", - JsonSerializer.Serialize(entries, ProtocolJsonContext.Default.ListProviderModelCatalogEntry)); + $"{payload.Count} provider model surface(s).", + JsonSerializer.Serialize(payload, ProtocolJsonContext.Default.ListProviderModelCatalogEntry)); await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false); return 0; } - - private string ResolveDefaultModel(string providerName) - => string.Equals(providerName, anthropicOptions.Value.ProviderName, StringComparison.OrdinalIgnoreCase) - ? anthropicOptions.Value.DefaultModel - : string.Equals(providerName, openAiCompatibleOptions.Value.ProviderName, StringComparison.OrdinalIgnoreCase) - ? openAiCompatibleOptions.Value.DefaultModel - : "default"; } diff --git a/src/SharpClaw.Code.Commands/Handlers/PromptCommandHandler.cs b/src/SharpClaw.Code.Commands/Handlers/PromptCommandHandler.cs index 024d9c2..3f22474 100644 --- a/src/SharpClaw.Code.Commands/Handlers/PromptCommandHandler.cs +++ b/src/SharpClaw.Code.Commands/Handlers/PromptCommandHandler.cs @@ -37,7 +37,7 @@ public Command BuildCommand(GlobalCliOptions globalOptions) var context = globalOptions.Resolve(parseResult); try { - var result = await runtimeCommandService.ExecutePromptAsync(prompt, ToRuntimeContext(context), cancellationToken); + var result = await runtimeCommandService.ExecutePromptAsync(prompt, context.ToRuntimeCommandContext(), cancellationToken); await outputRendererDispatcher.RenderTurnExecutionResultAsync(result, context.OutputFormat, cancellationToken); return 0; } @@ -53,17 +53,6 @@ await outputRendererDispatcher.RenderCommandResultAsync( return command; } - - private static RuntimeCommandContext ToRuntimeContext(CommandExecutionContext context) - => new( - context.WorkingDirectory, - context.Model, - context.PermissionMode, - context.OutputFormat, - context.PrimaryMode, - context.SessionId, - context.AgentId); - private static CommandResult CreateProviderFailureResult(ProviderExecutionException exception, OutputFormat outputFormat) => new( Succeeded: false, diff --git a/src/SharpClaw.Code.Commands/Handlers/RedoCommandHandler.cs b/src/SharpClaw.Code.Commands/Handlers/RedoCommandHandler.cs index 9c72aab..ed4bce1 100644 --- a/src/SharpClaw.Code.Commands/Handlers/RedoCommandHandler.cs +++ b/src/SharpClaw.Code.Commands/Handlers/RedoCommandHandler.cs @@ -31,7 +31,7 @@ public Command BuildCommand(GlobalCliOptions globalOptions) { var ctx = globalOptions.Resolve(parseResult); var sid = parseResult.GetValue(id); - var result = await runtimeCommandService.RedoAsync(sid, ToRuntimeContext(ctx), cancellationToken).ConfigureAwait(false); + var result = await runtimeCommandService.RedoAsync(sid, ctx.ToRuntimeCommandContext(), cancellationToken).ConfigureAwait(false); await outputRendererDispatcher.RenderCommandResultAsync(result, ctx.OutputFormat, cancellationToken).ConfigureAwait(false); return result.ExitCode; }); @@ -48,18 +48,9 @@ public Task ExecuteAsync(SlashCommandParseResult command, CommandExecutionC private async Task ExecuteRedoAsync(string? sessionId, CommandExecutionContext context, CancellationToken cancellationToken) { var result = await runtimeCommandService - .RedoAsync(sessionId, ToRuntimeContext(context), cancellationToken) + .RedoAsync(sessionId, context.ToRuntimeCommandContext(), cancellationToken) .ConfigureAwait(false); await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false); return result.ExitCode; } - - private static RuntimeCommandContext ToRuntimeContext(CommandExecutionContext context) - => new( - context.WorkingDirectory, - context.Model, - context.PermissionMode, - context.OutputFormat, - context.PrimaryMode, - context.SessionId); } diff --git a/src/SharpClaw.Code.Commands/Handlers/ServeCommandHandler.cs b/src/SharpClaw.Code.Commands/Handlers/ServeCommandHandler.cs index 5b17bf3..7308740 100644 --- a/src/SharpClaw.Code.Commands/Handlers/ServeCommandHandler.cs +++ b/src/SharpClaw.Code.Commands/Handlers/ServeCommandHandler.cs @@ -40,7 +40,7 @@ await outputRendererDispatcher.RenderCommandResultAsync( context.OutputFormat, cancellationToken).ConfigureAwait(false); await workspaceHttpServer - .RunAsync(context.WorkingDirectory, host, port, ToRuntimeContext(context), cancellationToken) + .RunAsync(context.WorkingDirectory, host, port, context.ToRuntimeCommandContext(), cancellationToken) .ConfigureAwait(false); return 0; }); @@ -66,17 +66,7 @@ await outputRendererDispatcher.RenderCommandResultAsync( new CommandResult(true, 0, context.OutputFormat, "Starting embedded SharpClaw server. Press Ctrl+C to stop.", null), context.OutputFormat, cancellationToken).ConfigureAwait(false); - await workspaceHttpServer.RunAsync(context.WorkingDirectory, host, port, ToRuntimeContext(context), cancellationToken).ConfigureAwait(false); + await workspaceHttpServer.RunAsync(context.WorkingDirectory, host, port, context.ToRuntimeCommandContext(), cancellationToken).ConfigureAwait(false); return 0; } - - private static RuntimeCommandContext ToRuntimeContext(CommandExecutionContext context) - => new( - context.WorkingDirectory, - context.Model, - context.PermissionMode, - context.OutputFormat, - context.PrimaryMode, - context.SessionId, - context.AgentId); } diff --git a/src/SharpClaw.Code.Commands/Handlers/SessionCommandHandler.cs b/src/SharpClaw.Code.Commands/Handlers/SessionCommandHandler.cs index a9418ae..e956e1b 100644 --- a/src/SharpClaw.Code.Commands/Handlers/SessionCommandHandler.cs +++ b/src/SharpClaw.Code.Commands/Handlers/SessionCommandHandler.cs @@ -39,7 +39,7 @@ public Command BuildCommand(GlobalCliOptions globalOptions) var context = globalOptions.Resolve(parseResult); var id = parseResult.GetValue(idOption); var result = await runtimeCommandService - .InspectSessionAsync(id, ToRuntimeContext(context), cancellationToken) + .InspectSessionAsync(id, context.ToRuntimeCommandContext(), cancellationToken) .ConfigureAwait(false); await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false); return result.ExitCode; @@ -57,7 +57,7 @@ public Command BuildCommand(GlobalCliOptions globalOptions) var context = globalOptions.Resolve(parseResult); var id = parseResult.GetValue(forkId); var result = await runtimeCommandService - .ForkSessionAsync(id, ToRuntimeContext(context), cancellationToken) + .ForkSessionAsync(id, context.ToRuntimeCommandContext(), cancellationToken) .ConfigureAwait(false); await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false); return result.ExitCode; @@ -85,7 +85,7 @@ public Command BuildCommand(GlobalCliOptions globalOptions) : SessionExportFormat.Markdown; var path = parseResult.GetValue(outPath); var result = await runtimeCommandService - .ExportSessionAsync(sid, fmt, path, ToRuntimeContext(context), cancellationToken) + .ExportSessionAsync(sid, fmt, path, context.ToRuntimeCommandContext(), cancellationToken) .ConfigureAwait(false); await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false); return result.ExitCode; @@ -97,7 +97,7 @@ public Command BuildCommand(GlobalCliOptions globalOptions) { var context = globalOptions.Resolve(parseResult); var result = await runtimeCommandService - .ListSessionsAsync(ToRuntimeContext(context), cancellationToken) + .ListSessionsAsync(context.ToRuntimeCommandContext(), cancellationToken) .ConfigureAwait(false); await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false); return result.ExitCode; @@ -112,7 +112,7 @@ public Command BuildCommand(GlobalCliOptions globalOptions) var context = globalOptions.Resolve(parseResult); var sid = parseResult.GetValue(attachId) ?? throw new InvalidOperationException("--id is required."); var result = await runtimeCommandService - .AttachSessionAsync(sid, ToRuntimeContext(context), cancellationToken) + .AttachSessionAsync(sid, context.ToRuntimeCommandContext(), cancellationToken) .ConfigureAwait(false); await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false); return result.ExitCode; @@ -124,7 +124,7 @@ public Command BuildCommand(GlobalCliOptions globalOptions) { var context = globalOptions.Resolve(parseResult); var result = await runtimeCommandService - .DetachSessionAsync(ToRuntimeContext(context), cancellationToken) + .DetachSessionAsync(context.ToRuntimeCommandContext(), cancellationToken) .ConfigureAwait(false); await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false); return result.ExitCode; @@ -142,7 +142,7 @@ public Command BuildCommand(GlobalCliOptions globalOptions) var sid = parseResult.GetValue(bundleId); var outp = parseResult.GetValue(bundleOut); var result = await runtimeCommandService - .ExportPortableSessionBundleAsync(sid, outp, ToRuntimeContext(context), cancellationToken) + .ExportPortableSessionBundleAsync(sid, outp, context.ToRuntimeCommandContext(), cancellationToken) .ConfigureAwait(false); await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false); return result.ExitCode; @@ -171,7 +171,7 @@ public Command BuildCommand(GlobalCliOptions globalOptions) var replace = parseResult.GetValue(importReplace); var attach = parseResult.GetValue(importAttach); var result = await runtimeCommandService - .ImportPortableSessionBundleAsync(from, replace, attach, ToRuntimeContext(context), cancellationToken) + .ImportPortableSessionBundleAsync(from, replace, attach, context.ToRuntimeCommandContext(), cancellationToken) .ConfigureAwait(false); await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false); return result.ExitCode; @@ -255,7 +255,7 @@ public Task ExecuteAsync(SlashCommandParseResult command, CommandExecutionC private async Task ExecuteInspectAsync(string? sessionId, CommandExecutionContext context, CancellationToken cancellationToken) { var result = await runtimeCommandService - .InspectSessionAsync(sessionId, ToRuntimeContext(context), cancellationToken) + .InspectSessionAsync(sessionId, context.ToRuntimeCommandContext(), cancellationToken) .ConfigureAwait(false); await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false); return result.ExitCode; @@ -264,7 +264,7 @@ private async Task ExecuteInspectAsync(string? sessionId, CommandExecutionC private async Task ExecuteForkAsync(string? sourceId, CommandExecutionContext context, CancellationToken cancellationToken) { var result = await runtimeCommandService - .ForkSessionAsync(sourceId, ToRuntimeContext(context), cancellationToken) + .ForkSessionAsync(sourceId, context.ToRuntimeCommandContext(), cancellationToken) .ConfigureAwait(false); await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false); return result.ExitCode; @@ -273,7 +273,7 @@ private async Task ExecuteForkAsync(string? sourceId, CommandExecutionConte private async Task ExecuteListAsync(CommandExecutionContext context, CancellationToken cancellationToken) { var result = await runtimeCommandService - .ListSessionsAsync(ToRuntimeContext(context), cancellationToken) + .ListSessionsAsync(context.ToRuntimeCommandContext(), cancellationToken) .ConfigureAwait(false); await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false); return result.ExitCode; @@ -282,7 +282,7 @@ private async Task ExecuteListAsync(CommandExecutionContext context, Cancel private async Task ExecuteAttachAsync(string sessionId, CommandExecutionContext context, CancellationToken cancellationToken) { var result = await runtimeCommandService - .AttachSessionAsync(sessionId, ToRuntimeContext(context), cancellationToken) + .AttachSessionAsync(sessionId, context.ToRuntimeCommandContext(), cancellationToken) .ConfigureAwait(false); await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false); return result.ExitCode; @@ -291,7 +291,7 @@ private async Task ExecuteAttachAsync(string sessionId, CommandExecutionCon private async Task ExecuteDetachAsync(CommandExecutionContext context, CancellationToken cancellationToken) { var result = await runtimeCommandService - .DetachSessionAsync(ToRuntimeContext(context), cancellationToken) + .DetachSessionAsync(context.ToRuntimeCommandContext(), cancellationToken) .ConfigureAwait(false); await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false); return result.ExitCode; @@ -304,7 +304,7 @@ private async Task ExecuteBundleAsync( CancellationToken cancellationToken) { var result = await runtimeCommandService - .ExportPortableSessionBundleAsync(sessionId, outputPath, ToRuntimeContext(context), cancellationToken) + .ExportPortableSessionBundleAsync(sessionId, outputPath, context.ToRuntimeCommandContext(), cancellationToken) .ConfigureAwait(false); await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false); return result.ExitCode; @@ -318,7 +318,7 @@ private async Task ExecuteImportAsync( CancellationToken cancellationToken) { var result = await runtimeCommandService - .ImportPortableSessionBundleAsync(bundleZipPath, replaceExisting, attachAfterImport, ToRuntimeContext(context), cancellationToken) + .ImportPortableSessionBundleAsync(bundleZipPath, replaceExisting, attachAfterImport, context.ToRuntimeCommandContext(), cancellationToken) .ConfigureAwait(false); await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false); return result.ExitCode; @@ -331,7 +331,7 @@ private async Task ExecuteExportAsync( CancellationToken cancellationToken) { var result = await runtimeCommandService - .ExportSessionAsync(sessionId, format, null, ToRuntimeContext(context), cancellationToken) + .ExportSessionAsync(sessionId, format, null, context.ToRuntimeCommandContext(), cancellationToken) .ConfigureAwait(false); await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false); return result.ExitCode; @@ -345,13 +345,4 @@ await outputRendererDispatcher.RenderCommandResultAsync( cancellationToken).ConfigureAwait(false); return success ? 0 : 1; } - - private static RuntimeCommandContext ToRuntimeContext(CommandExecutionContext context) - => new( - context.WorkingDirectory, - context.Model, - context.PermissionMode, - context.OutputFormat, - context.PrimaryMode, - context.SessionId); } diff --git a/src/SharpClaw.Code.Commands/Handlers/ShareCommandHandler.cs b/src/SharpClaw.Code.Commands/Handlers/ShareCommandHandler.cs index 0b8afe0..fdd3a45 100644 --- a/src/SharpClaw.Code.Commands/Handlers/ShareCommandHandler.cs +++ b/src/SharpClaw.Code.Commands/Handlers/ShareCommandHandler.cs @@ -31,7 +31,7 @@ public Command BuildCommand(GlobalCliOptions globalOptions) { var context = globalOptions.Resolve(parseResult); var id = parseResult.GetValue(idOption); - var result = await runtimeCommandService.ShareSessionAsync(id, ToRuntimeContext(context), cancellationToken).ConfigureAwait(false); + var result = await runtimeCommandService.ShareSessionAsync(id, context.ToRuntimeCommandContext(), cancellationToken).ConfigureAwait(false); await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false); return result.ExitCode; }); @@ -42,18 +42,8 @@ public Command BuildCommand(GlobalCliOptions globalOptions) public async Task ExecuteAsync(SlashCommandParseResult command, CommandExecutionContext context, CancellationToken cancellationToken) { var id = command.Arguments.Length > 0 ? command.Arguments[0] : null; - var result = await runtimeCommandService.ShareSessionAsync(id, ToRuntimeContext(context), cancellationToken).ConfigureAwait(false); + var result = await runtimeCommandService.ShareSessionAsync(id, context.ToRuntimeCommandContext(), cancellationToken).ConfigureAwait(false); await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false); return result.ExitCode; } - - private static RuntimeCommandContext ToRuntimeContext(CommandExecutionContext context) - => new( - context.WorkingDirectory, - context.Model, - context.PermissionMode, - context.OutputFormat, - context.PrimaryMode, - context.SessionId, - context.AgentId); } diff --git a/src/SharpClaw.Code.Commands/Handlers/StatusCommandHandler.cs b/src/SharpClaw.Code.Commands/Handlers/StatusCommandHandler.cs index be94b10..0ec69e4 100644 --- a/src/SharpClaw.Code.Commands/Handlers/StatusCommandHandler.cs +++ b/src/SharpClaw.Code.Commands/Handlers/StatusCommandHandler.cs @@ -28,7 +28,7 @@ public Command BuildCommand(GlobalCliOptions globalOptions) command.SetAction(async (parseResult, cancellationToken) => { var context = globalOptions.Resolve(parseResult); - var result = await runtimeCommandService.GetStatusAsync(ToRuntimeContext(context), cancellationToken); + var result = await runtimeCommandService.GetStatusAsync(context.ToRuntimeCommandContext(), cancellationToken); await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken); return result.ExitCode; }); @@ -39,17 +39,8 @@ public Command BuildCommand(GlobalCliOptions globalOptions) /// public async Task ExecuteAsync(SlashCommandParseResult command, CommandExecutionContext context, CancellationToken cancellationToken) { - var result = await runtimeCommandService.GetStatusAsync(ToRuntimeContext(context), cancellationToken); + var result = await runtimeCommandService.GetStatusAsync(context.ToRuntimeCommandContext(), cancellationToken); await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken); return result.ExitCode; } - - private static RuntimeCommandContext ToRuntimeContext(CommandExecutionContext context) - => new( - context.WorkingDirectory, - context.Model, - context.PermissionMode, - context.OutputFormat, - context.PrimaryMode, - context.SessionId); } diff --git a/src/SharpClaw.Code.Commands/Handlers/ToolPackagesCommandHandler.cs b/src/SharpClaw.Code.Commands/Handlers/ToolPackagesCommandHandler.cs new file mode 100644 index 0000000..afa50ce --- /dev/null +++ b/src/SharpClaw.Code.Commands/Handlers/ToolPackagesCommandHandler.cs @@ -0,0 +1,152 @@ +using System.CommandLine; +using System.Text.Json; +using SharpClaw.Code.Commands.Options; +using SharpClaw.Code.Protocol.Commands; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Protocol.Serialization; +using SharpClaw.Code.Tools.Abstractions; + +namespace SharpClaw.Code.Commands; + +/// +/// Lists and installs packaged tool bundles for the current workspace. +/// +public sealed class ToolPackagesCommandHandler( + IToolPackageService toolPackageService, + OutputRendererDispatcher outputRendererDispatcher) : ICommandHandler +{ + /// + public string Name => "tool-packages"; + + /// + public string Description => "Lists and installs packaged third-party tool bundles."; + + /// + public Command BuildCommand(GlobalCliOptions globalOptions) + { + var command = new Command(Name, Description); + command.Subcommands.Add(BuildListCommand(globalOptions)); + command.Subcommands.Add(BuildInstallCommand(globalOptions)); + command.SetAction((parseResult, cancellationToken) => ExecuteListAsync(globalOptions.Resolve(parseResult), cancellationToken)); + return command; + } + + private Command BuildListCommand(GlobalCliOptions globalOptions) + { + var command = new Command("list", "Lists installed packaged tool bundles."); + command.SetAction((parseResult, cancellationToken) => ExecuteListAsync(globalOptions.Resolve(parseResult), cancellationToken)); + return command; + } + + private Command BuildInstallCommand(GlobalCliOptions globalOptions) + { + var command = new Command("install", "Installs a packaged tool manifest."); + var manifestOption = new Option("--manifest") + { + Required = true, + Description = "Path to a serialized ToolPackageManifest JSON file." + }; + var installSourceOption = new Option("--install-source") + { + Description = "Install-source label recorded with the package.", + DefaultValueFactory = _ => "cli", + }; + var sourceReferenceOption = new Option("--source-reference") + { + Description = "Optional package directory, binary path, or .nupkg source reference." + }; + var packageSourceOption = new Option("--package-source") + { + Description = "Optional NuGet source feed URL." + }; + var disableOption = new Option("--disable") + { + Description = "Install the package without enabling its plugin surface." + }; + + command.Options.Add(manifestOption); + command.Options.Add(installSourceOption); + command.Options.Add(sourceReferenceOption); + command.Options.Add(packageSourceOption); + command.Options.Add(disableOption); + command.SetAction(async (parseResult, cancellationToken) => + { + var context = globalOptions.Resolve(parseResult); + var manifestPath = parseResult.GetValue(manifestOption) ?? throw new InvalidOperationException("The --manifest option is required."); + var installSource = parseResult.GetValue(installSourceOption) ?? "cli"; + var sourceReference = parseResult.GetValue(sourceReferenceOption); + var packageSource = parseResult.GetValue(packageSourceOption); + var disable = parseResult.GetValue(disableOption); + return await ExecuteInstallAsync( + manifestPath, + installSource, + sourceReference, + packageSource, + disable, + context, + cancellationToken).ConfigureAwait(false); + }); + return command; + } + + private async Task ExecuteListAsync( + SharpClaw.Code.Commands.Models.CommandExecutionContext context, + CancellationToken cancellationToken) + { + var packages = await toolPackageService.ListInstalledAsync(context.WorkingDirectory, cancellationToken).ConfigureAwait(false); + var result = new CommandResult( + true, + 0, + context.OutputFormat, + packages.Count == 0 ? "No tool packages are installed for this workspace." : $"{packages.Count} tool package(s).", + JsonSerializer.Serialize(packages.ToList(), ProtocolJsonContext.Default.ListInstalledToolPackage)); + await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false); + return 0; + } + + private async Task ExecuteInstallAsync( + string manifestPath, + string installSource, + string? sourceReference, + string? packageSource, + bool disable, + SharpClaw.Code.Commands.Models.CommandExecutionContext context, + CancellationToken cancellationToken) + { + var manifestJson = await File.ReadAllTextAsync(manifestPath, cancellationToken).ConfigureAwait(false); + var manifest = JsonSerializer.Deserialize(manifestJson, ProtocolJsonContext.Default.ToolPackageManifest) + ?? throw new InvalidOperationException($"Manifest '{manifestPath}' could not be parsed."); + var resolvedSourceReference = ResolveSourceReference(manifestPath, sourceReference, manifest.Package.PackageType); + var installed = await toolPackageService + .InstallAsync( + context.WorkingDirectory, + new ToolPackageInstallRequest( + Manifest: manifest, + InstallSource: installSource, + EnableAfterInstall: !disable, + SourceReference: resolvedSourceReference, + PackageSource: packageSource), + cancellationToken) + .ConfigureAwait(false); + var result = new CommandResult( + true, + 0, + context.OutputFormat, + $"Installed tool package '{installed.Manifest.Package.PackageId}' ({installed.Manifest.Package.Version}).", + JsonSerializer.Serialize(installed, ProtocolJsonContext.Default.InstalledToolPackage)); + await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false); + return 0; + } + + private static string? ResolveSourceReference(string manifestPath, string? sourceReference, string packageType) + { + if (!string.IsNullOrWhiteSpace(sourceReference)) + { + return sourceReference; + } + + return string.Equals(packageType, "local", StringComparison.OrdinalIgnoreCase) + ? Path.GetDirectoryName(Path.GetFullPath(manifestPath)) + : null; + } +} diff --git a/src/SharpClaw.Code.Commands/Handlers/UndoCommandHandler.cs b/src/SharpClaw.Code.Commands/Handlers/UndoCommandHandler.cs index 154f142..ea63703 100644 --- a/src/SharpClaw.Code.Commands/Handlers/UndoCommandHandler.cs +++ b/src/SharpClaw.Code.Commands/Handlers/UndoCommandHandler.cs @@ -31,7 +31,7 @@ public Command BuildCommand(GlobalCliOptions globalOptions) { var ctx = globalOptions.Resolve(parseResult); var sid = parseResult.GetValue(id); - var result = await runtimeCommandService.UndoAsync(sid, ToRuntimeContext(ctx), cancellationToken).ConfigureAwait(false); + var result = await runtimeCommandService.UndoAsync(sid, ctx.ToRuntimeCommandContext(), cancellationToken).ConfigureAwait(false); await outputRendererDispatcher.RenderCommandResultAsync(result, ctx.OutputFormat, cancellationToken).ConfigureAwait(false); return result.ExitCode; }); @@ -48,18 +48,9 @@ public Task ExecuteAsync(SlashCommandParseResult command, CommandExecutionC private async Task ExecuteUndoAsync(string? sessionId, CommandExecutionContext context, CancellationToken cancellationToken) { var result = await runtimeCommandService - .UndoAsync(sessionId, ToRuntimeContext(context), cancellationToken) + .UndoAsync(sessionId, context.ToRuntimeCommandContext(), cancellationToken) .ConfigureAwait(false); await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false); return result.ExitCode; } - - private static RuntimeCommandContext ToRuntimeContext(CommandExecutionContext context) - => new( - context.WorkingDirectory, - context.Model, - context.PermissionMode, - context.OutputFormat, - context.PrimaryMode, - context.SessionId); } diff --git a/src/SharpClaw.Code.Commands/Handlers/UnshareCommandHandler.cs b/src/SharpClaw.Code.Commands/Handlers/UnshareCommandHandler.cs index ec32c45..cc4ae03 100644 --- a/src/SharpClaw.Code.Commands/Handlers/UnshareCommandHandler.cs +++ b/src/SharpClaw.Code.Commands/Handlers/UnshareCommandHandler.cs @@ -31,7 +31,7 @@ public Command BuildCommand(GlobalCliOptions globalOptions) { var context = globalOptions.Resolve(parseResult); var id = parseResult.GetValue(idOption); - var result = await runtimeCommandService.UnshareSessionAsync(id, ToRuntimeContext(context), cancellationToken).ConfigureAwait(false); + var result = await runtimeCommandService.UnshareSessionAsync(id, context.ToRuntimeCommandContext(), cancellationToken).ConfigureAwait(false); await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false); return result.ExitCode; }); @@ -42,18 +42,8 @@ public Command BuildCommand(GlobalCliOptions globalOptions) public async Task ExecuteAsync(SlashCommandParseResult command, CommandExecutionContext context, CancellationToken cancellationToken) { var id = command.Arguments.Length > 0 ? command.Arguments[0] : null; - var result = await runtimeCommandService.UnshareSessionAsync(id, ToRuntimeContext(context), cancellationToken).ConfigureAwait(false); + var result = await runtimeCommandService.UnshareSessionAsync(id, context.ToRuntimeCommandContext(), cancellationToken).ConfigureAwait(false); await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false); return result.ExitCode; } - - private static RuntimeCommandContext ToRuntimeContext(CommandExecutionContext context) - => new( - context.WorkingDirectory, - context.Model, - context.PermissionMode, - context.OutputFormat, - context.PrimaryMode, - context.SessionId, - context.AgentId); } diff --git a/src/SharpClaw.Code.Commands/Handlers/UsageCommandHandler.cs b/src/SharpClaw.Code.Commands/Handlers/UsageCommandHandler.cs index f100794..dc02a4d 100644 --- a/src/SharpClaw.Code.Commands/Handlers/UsageCommandHandler.cs +++ b/src/SharpClaw.Code.Commands/Handlers/UsageCommandHandler.cs @@ -6,6 +6,7 @@ using SharpClaw.Code.Protocol.Models; using SharpClaw.Code.Protocol.Serialization; using SharpClaw.Code.Runtime.Abstractions; +using SharpClaw.Code.Telemetry.Abstractions; namespace SharpClaw.Code.Commands; @@ -14,6 +15,7 @@ namespace SharpClaw.Code.Commands; /// public sealed class UsageCommandHandler( IWorkspaceInsightsService workspaceInsightsService, + IUsageMeteringService usageMeteringService, OutputRendererDispatcher outputRendererDispatcher) : ICommandHandler, ISlashCommandHandler { /// @@ -29,13 +31,61 @@ public sealed class UsageCommandHandler( public Command BuildCommand(GlobalCliOptions globalOptions) { var command = new Command(Name, Description); + command.Subcommands.Add(BuildSummaryCommand(globalOptions)); + command.Subcommands.Add(BuildDetailCommand(globalOptions)); command.SetAction((parseResult, cancellationToken) => ExecuteAsync(globalOptions.Resolve(parseResult), cancellationToken)); return command; } /// public Task ExecuteAsync(SlashCommandParseResult command, CommandExecutionContext context, CancellationToken cancellationToken) - => ExecuteAsync(context, cancellationToken); + => command.Arguments.Length switch + { + 0 => ExecuteAsync(context, cancellationToken), + _ when string.Equals(command.Arguments[0], "summary", StringComparison.OrdinalIgnoreCase) + => ExecuteSummaryAsync(context, null, null, cancellationToken), + _ when string.Equals(command.Arguments[0], "detail", StringComparison.OrdinalIgnoreCase) + => ExecuteDetailAsync( + context, + null, + null, + command.Arguments.Length > 1 && int.TryParse(command.Arguments[1], out var limit) ? limit : null, + cancellationToken), + _ => ExecuteAsync(context, cancellationToken) + }; + + private Command BuildSummaryCommand(GlobalCliOptions globalOptions) + { + var command = new Command("summary", "Shows tenant-aware metering totals for the current workspace."); + var fromUtcOption = new Option("--from-utc") { Description = "Inclusive lower-bound timestamp in UTC (ISO-8601)." }; + var toUtcOption = new Option("--to-utc") { Description = "Exclusive upper-bound timestamp in UTC (ISO-8601)." }; + command.Options.Add(fromUtcOption); + command.Options.Add(toUtcOption); + command.SetAction((parseResult, cancellationToken) => ExecuteSummaryAsync( + globalOptions.Resolve(parseResult), + ParseTimestamp(parseResult.GetValue(fromUtcOption), "--from-utc"), + ParseTimestamp(parseResult.GetValue(toUtcOption), "--to-utc"), + cancellationToken)); + return command; + } + + private Command BuildDetailCommand(GlobalCliOptions globalOptions) + { + var command = new Command("detail", "Lists normalized usage metering records for the current workspace."); + var fromUtcOption = new Option("--from-utc") { Description = "Inclusive lower-bound timestamp in UTC (ISO-8601)." }; + var toUtcOption = new Option("--to-utc") { Description = "Exclusive upper-bound timestamp in UTC (ISO-8601)." }; + var limitOption = new Option("--limit") { Description = "Maximum number of records to return." }; + command.Options.Add(fromUtcOption); + command.Options.Add(toUtcOption); + command.Options.Add(limitOption); + command.SetAction((parseResult, cancellationToken) => ExecuteDetailAsync( + globalOptions.Resolve(parseResult), + ParseTimestamp(parseResult.GetValue(fromUtcOption), "--from-utc"), + ParseTimestamp(parseResult.GetValue(toUtcOption), "--to-utc"), + parseResult.GetValue(limitOption), + cancellationToken)); + return command; + } private async Task ExecuteAsync(CommandExecutionContext context, CancellationToken cancellationToken) { @@ -51,4 +101,74 @@ private async Task ExecuteAsync(CommandExecutionContext context, Cancellati await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false); return 0; } + + private async Task ExecuteSummaryAsync( + CommandExecutionContext context, + DateTimeOffset? fromUtc, + DateTimeOffset? toUtc, + CancellationToken cancellationToken) + { + var report = await usageMeteringService + .GetSummaryAsync(context.WorkingDirectory, CreateQuery(context, fromUtc, toUtc), cancellationToken) + .ConfigureAwait(false); + var result = new CommandResult( + true, + 0, + context.OutputFormat, + $"Usage summary: {report.TotalUsage.TotalTokens} tokens, {report.ProviderRequestCount} provider request(s), {report.ToolExecutionCount} tool execution(s), {report.TurnCount} turn(s).", + JsonSerializer.Serialize(report, ProtocolJsonContext.Default.UsageMeteringSummaryReport)); + await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false); + return 0; + } + + private async Task ExecuteDetailAsync( + CommandExecutionContext context, + DateTimeOffset? fromUtc, + DateTimeOffset? toUtc, + int? limit, + CancellationToken cancellationToken) + { + var report = await usageMeteringService + .GetDetailAsync( + context.WorkingDirectory, + CreateQuery(context, fromUtc, toUtc), + Math.Clamp(limit.GetValueOrDefault(50), 1, 500), + cancellationToken) + .ConfigureAwait(false); + var result = new CommandResult( + true, + 0, + context.OutputFormat, + $"Usage detail: {report.Records.Count} record(s).", + JsonSerializer.Serialize(report, ProtocolJsonContext.Default.UsageMeteringDetailReport)); + await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false); + return 0; + } + + private static UsageMeteringQuery CreateQuery( + CommandExecutionContext context, + DateTimeOffset? fromUtc, + DateTimeOffset? toUtc) + => new( + FromUtc: fromUtc, + ToUtc: toUtc, + TenantId: context.HostContext?.TenantId, + HostId: context.HostContext?.HostId, + WorkspaceRoot: context.WorkingDirectory, + SessionId: context.SessionId); + + private static DateTimeOffset? ParseTimestamp(string? value, string optionName) + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + if (DateTimeOffset.TryParse(value, out var parsed)) + { + return parsed; + } + + throw new InvalidOperationException($"Option '{optionName}' must be a valid UTC timestamp."); + } } diff --git a/src/SharpClaw.Code.Commands/Models/CommandExecutionContext.cs b/src/SharpClaw.Code.Commands/Models/CommandExecutionContext.cs index 2e00c4f..8b7a39e 100644 --- a/src/SharpClaw.Code.Commands/Models/CommandExecutionContext.cs +++ b/src/SharpClaw.Code.Commands/Models/CommandExecutionContext.cs @@ -1,4 +1,6 @@ using SharpClaw.Code.Protocol.Enums; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Runtime.Abstractions; namespace SharpClaw.Code.Commands.Models; @@ -12,6 +14,7 @@ namespace SharpClaw.Code.Commands.Models; /// Build, plan, or spec workflow from global CLI options. /// Optional explicit session id for prompts and session-scoped commands. /// Optional explicit agent id for prompt execution. +/// Optional embedded-host identity and tenant/storage context. public sealed record CommandExecutionContext( string WorkingDirectory, string? Model, @@ -19,4 +22,28 @@ public sealed record CommandExecutionContext( OutputFormat OutputFormat, PrimaryMode PrimaryMode, string? SessionId = null, - string? AgentId = null); + string? AgentId = null, + RuntimeHostContext? HostContext = null) +{ + /// + /// Converts the CLI command context into the runtime invocation context. + /// + /// Whether the current caller can participate in approval prompts. + /// Optional primary-mode override. + /// Optional agent id override. + /// The runtime command context. + public RuntimeCommandContext ToRuntimeCommandContext( + bool isInteractive = true, + PrimaryMode? primaryModeOverride = null, + string? agentIdOverride = null) + => new( + WorkingDirectory, + Model, + PermissionMode, + OutputFormat, + primaryModeOverride ?? PrimaryMode, + SessionId, + agentIdOverride ?? AgentId, + isInteractive, + HostContext); +} diff --git a/src/SharpClaw.Code.Commands/Options/GlobalCliOptions.cs b/src/SharpClaw.Code.Commands/Options/GlobalCliOptions.cs index 4e2e96d..ff84de4 100644 --- a/src/SharpClaw.Code.Commands/Options/GlobalCliOptions.cs +++ b/src/SharpClaw.Code.Commands/Options/GlobalCliOptions.cs @@ -2,6 +2,7 @@ using System.CommandLine.Parsing; using SharpClaw.Code.Commands.Models; using SharpClaw.Code.Protocol.Enums; +using SharpClaw.Code.Protocol.Models; namespace SharpClaw.Code.Commands.Options; @@ -59,6 +60,30 @@ public GlobalCliOptions() Description = "Selects the effective agent id for prompt execution.", Recursive = true }; + + HostIdOption = new Option("--host-id") + { + Description = "Sets the embedded host identifier for tenant-aware state and diagnostics.", + Recursive = true + }; + + TenantIdOption = new Option("--tenant-id") + { + Description = "Sets the tenant identifier for enterprise session, memory, and metering operations.", + Recursive = true + }; + + StorageRootOption = new Option("--storage-root") + { + Description = "Overrides the external storage root for embedded-host durable state.", + Recursive = true + }; + + SessionStoreOption = new Option("--session-store") + { + Description = "Selects the embedded session store backend: fileSystem or sqlite.", + Recursive = true + }; } /// @@ -96,10 +121,43 @@ public GlobalCliOptions() /// public Option AgentOption { get; } + /// + /// Gets the optional embedded host id option. + /// + public Option HostIdOption { get; } + + /// + /// Gets the optional tenant id option. + /// + public Option TenantIdOption { get; } + + /// + /// Gets the optional external storage root option. + /// + public Option StorageRootOption { get; } + + /// + /// Gets the optional embedded session store kind option. + /// + public Option SessionStoreOption { get; } + /// /// Gets all global options. /// - public IEnumerable + @@ -26,4 +27,4 @@ - \ No newline at end of file + diff --git a/tests/SharpClaw.Code.IntegrationTests/Smoke/CliCommandSurfaceTests.cs b/tests/SharpClaw.Code.IntegrationTests/Smoke/CliCommandSurfaceTests.cs index c0b9525..5fd719e 100644 --- a/tests/SharpClaw.Code.IntegrationTests/Smoke/CliCommandSurfaceTests.cs +++ b/tests/SharpClaw.Code.IntegrationTests/Smoke/CliCommandSurfaceTests.cs @@ -41,6 +41,7 @@ public async Task Root_command_should_expose_expected_commands_and_global_option "commands", "connect", "mcp", + "memory", "models", "plugins", "prompt", @@ -48,7 +49,9 @@ public async Task Root_command_should_expose_expected_commands_and_global_option "session", "share", "status", + "tool-packages", "doctor", + "index", "unshare", "version", "repl" @@ -62,7 +65,11 @@ public async Task Root_command_should_expose_expected_commands_and_global_option "--permission-mode", "--primary-mode", "--session", - "--agent" + "--agent", + "--host-id", + "--tenant-id", + "--storage-root", + "--session-store" ]); } } diff --git a/tests/SharpClaw.Code.ParityHarness/ParityScenarioTests.cs b/tests/SharpClaw.Code.ParityHarness/ParityScenarioTests.cs index a6b4789..05ba6fc 100644 --- a/tests/SharpClaw.Code.ParityHarness/ParityScenarioTests.cs +++ b/tests/SharpClaw.Code.ParityHarness/ParityScenarioTests.cs @@ -294,11 +294,12 @@ public async Task Recovery_after_timeout_marks_session_failed() using var provider = ParityTestHost.Create(replaceApprovals: null); var runtime = ParityTestHost.GetConversation(provider); var store = provider.GetRequiredService(); - using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(150)); var act = async () => { - await runtime.RunPromptAsync( + await RunPromptWithCancelAfterTurnStartAsync( + runtime, + store, new RunPromptRequest( Prompt: "slow", SessionId: null, @@ -309,7 +310,7 @@ await runtime.RunPromptAsync( { [ParityMetadataKeys.Scenario] = ParityProviderScenario.StreamSlow, }), - cts.Token); + TimeSpan.FromMilliseconds(150)); }; await act.Should().ThrowAsync(); @@ -318,6 +319,35 @@ await runtime.RunPromptAsync( session!.State.Should().Be(SessionLifecycleState.Failed); } + private async Task RunPromptWithCancelAfterTurnStartAsync( + IConversationRuntime runtime, + ISessionStore store, + RunPromptRequest request, + TimeSpan cancelAfter) + { + using var cts = new CancellationTokenSource(); + var runTask = runtime.RunPromptAsync(request, cts.Token); + await WaitForActiveTurnAsync(store, CancellationToken.None); + cts.CancelAfter(cancelAfter); + await runTask.ConfigureAwait(false); + } + + private async Task WaitForActiveTurnAsync(ISessionStore store, CancellationToken cancellationToken) + { + for (var attempt = 0; attempt < 100; attempt++) + { + var session = await store.GetLatestAsync(_workspace, cancellationToken).ConfigureAwait(false); + if (!string.IsNullOrWhiteSpace(session?.ActiveTurnId)) + { + return; + } + + await Task.Delay(50, cancellationToken).ConfigureAwait(false); + } + + throw new TimeoutException("The parity runtime did not activate a turn before cancellation was requested."); + } + [Fact] public async Task Tool_call_roundtrip_executes_loop_and_returns_final_text() { diff --git a/tests/SharpClaw.Code.UnitTests/Acp/AcpStdioHostTests.cs b/tests/SharpClaw.Code.UnitTests/Acp/AcpStdioHostTests.cs index 6a780ea..277a4ba 100644 --- a/tests/SharpClaw.Code.UnitTests/Acp/AcpStdioHostTests.cs +++ b/tests/SharpClaw.Code.UnitTests/Acp/AcpStdioHostTests.cs @@ -1,11 +1,15 @@ using System.Text; +using System.Text.Json; using FluentAssertions; using Microsoft.Extensions.Logging.Abstractions; using SharpClaw.Code.Acp; using SharpClaw.Code.Infrastructure.Services; +using SharpClaw.Code.Memory.Abstractions; using SharpClaw.Code.Protocol.Commands; using SharpClaw.Code.Protocol.Enums; using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Protocol.Serialization; +using SharpClaw.Code.Providers.Abstractions; using SharpClaw.Code.Runtime.Abstractions; using SharpClaw.Code.Sessions.Abstractions; @@ -16,6 +20,8 @@ namespace SharpClaw.Code.UnitTests.Acp; /// public sealed class AcpStdioHostTests { + private static readonly PathService PathService = new(); + [Fact] public async Task RunAsync_should_return_parse_error_for_invalid_json() { @@ -52,17 +58,192 @@ public async Task RunAsync_should_return_method_not_found_for_unknown_method() output.ToString().Should().Contain(@"""code"":-32601"); } - private static AcpStdioHost CreateHost() + [Fact] + public async Task RunAsync_should_flow_model_and_editor_context_into_prompt_requests() + { + var runtime = new StubConversationRuntime(); + var editorBuffer = new StubEditorContextBuffer(); + var host = CreateHost(runtime, editorBuffer); + using var initInput = new StringReader("""{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"clientCapabilities":{"approvalRequests":true}}}"""); + using var initOutput = new StringWriter(new StringBuilder()); + await host.RunAsync(initInput, initOutput, CancellationToken.None); + + using var promptInput = new StringReader("""{"jsonrpc":"2.0","id":2,"method":"session/prompt","params":{"cwd":"/tmp/workspace","sessionId":"session-1","model":"ollama/qwen2.5-coder","prompt":"Summarize","editorContext":{"workspaceRoot":"/tmp/workspace","currentFilePath":"/tmp/workspace/src/App.cs","selection":{"start":0,"end":9,"text":"Summarize"}}}}"""); + using var promptOutput = new StringWriter(new StringBuilder()); + await host.RunAsync(promptInput, promptOutput, CancellationToken.None); + + runtime.LastRequest.Should().NotBeNull(); + runtime.LastRequest!.Metadata.Should().ContainKey("model"); + runtime.LastRequest.Metadata!["model"].Should().Be("ollama/qwen2.5-coder"); + runtime.LastRequest.IsInteractive.Should().BeTrue(); + editorBuffer.LastPublished.Should().NotBeNull(); + editorBuffer.LastPublished!.CurrentFilePath.Should().Be("/tmp/workspace/src/App.cs"); + } + + [Fact] + public async Task RunAsync_should_return_provider_catalog_for_models_list() + { + var catalog = new StubProviderCatalogService + { + Entries = + [ + new ProviderModelCatalogEntry( + ProviderName: "openai-compatible", + DefaultModel: "gpt-4.1-mini", + Aliases: ["default"], + AuthStatus: new AuthStatus(null, false, "openai-compatible", null, null, []), + SupportsToolCalls: true, + SupportsEmbeddings: true, + AvailableModels: [], + LocalRuntimeProfiles: + [ + new LocalRuntimeProfileSummary( + Name: "ollama", + Kind: LocalRuntimeKind.Ollama, + BaseUrl: "http://127.0.0.1:11434/v1/", + DefaultChatModel: "qwen2.5-coder", + DefaultEmbeddingModel: "nomic-embed-text", + AuthMode: ProviderAuthMode.Optional, + IsHealthy: true, + HealthDetail: "1 model(s) discovered.", + AvailableModels: + [ + new ProviderDiscoveredModel("qwen2.5-coder", "qwen2.5-coder", true, false) + ]) + ]) + ] + }; + var host = CreateHost(providerCatalogService: catalog); + using var input = new StringReader("""{"jsonrpc":"2.0","id":"models","method":"models/list","params":{}}"""); + using var output = new StringWriter(new StringBuilder()); + + await host.RunAsync(input, output, CancellationToken.None); + + var payload = JsonSerializer.Deserialize( + ReadResponseResult(output, "models").ToJsonString(), + ProtocolJsonContext.Default.ListProviderModelCatalogEntry); + payload.Should().NotBeNull(); + payload![0].ProviderName.Should().Be("openai-compatible"); + payload[0].LocalRuntimeProfiles.Should().ContainSingle(profile => profile.Name == "ollama" && profile.IsHealthy); + } + + [Fact] + public async Task RunAsync_should_dispatch_workspace_index_and_search_requests() + { + var indexService = new StubWorkspaceIndexService(); + var searchService = new StubWorkspaceSearchService(); + var host = CreateHost(workspaceIndexService: indexService, workspaceSearchService: searchService); + + using var refreshInput = new StringReader("""{"jsonrpc":"2.0","id":"refresh","method":"workspace/index/refresh","params":{"cwd":"/tmp/workspace"}}"""); + using var refreshOutput = new StringWriter(new StringBuilder()); + await host.RunAsync(refreshInput, refreshOutput, CancellationToken.None); + + using var searchInput = new StringReader("""{"jsonrpc":"2.0","id":"search","method":"workspace/search","params":{"cwd":"/tmp/workspace","query":"WidgetService","limit":5,"includeSymbols":true,"includeSemantic":false}}"""); + using var searchOutput = new StringWriter(new StringBuilder()); + await host.RunAsync(searchInput, searchOutput, CancellationToken.None); + + indexService.LastWorkspaceRoot.Should().Be(NormalizePath("/tmp/workspace")); + searchService.LastWorkspaceRoot.Should().Be(NormalizePath("/tmp/workspace")); + searchService.LastRequest.Should().Be(new WorkspaceSearchRequest("WidgetService", 5, true, false)); + + var refresh = JsonSerializer.Deserialize( + ReadResponseResult(refreshOutput, "refresh").ToJsonString(), + ProtocolJsonContext.Default.WorkspaceIndexRefreshResult); + var search = JsonSerializer.Deserialize( + ReadResponseResult(searchOutput, "search").ToJsonString(), + ProtocolJsonContext.Default.WorkspaceSearchResult); + + refresh.Should().NotBeNull(); + refresh!.IndexedFileCount.Should().Be(3); + search.Should().NotBeNull(); + search!.Hits.Should().ContainSingle(hit => hit.SymbolName == "WidgetService"); + } + + [Fact] + public async Task RunAsync_should_round_trip_memory_save_list_and_delete_requests() + { + var memoryStore = new StubPersistentMemoryStore(); + var host = CreateHost(persistentMemoryStore: memoryStore); + + using var saveInput = new StringReader( + """{"jsonrpc":"2.0","id":"save","method":"memory/save","params":{"cwd":"/tmp/workspace","sessionId":"session-1","request":{"scope":"Project","content":"Keep prompts concise.","source":"manual","tags":["style"],"confidence":0.8,"relatedFilePath":"src/App.cs","relatedSymbolName":"App"}}}"""); + using var saveOutput = new StringWriter(new StringBuilder()); + await host.RunAsync(saveInput, saveOutput, CancellationToken.None); + + var saved = JsonSerializer.Deserialize( + ReadResponseResult(saveOutput, "save").ToJsonString(), + ProtocolJsonContext.Default.MemoryEntry); + saved.Should().NotBeNull(); + saved!.Scope.Should().Be(MemoryScope.Project); + saved.SourceSessionId.Should().Be("session-1"); + memoryStore.LastSaveWorkspaceRoot.Should().Be(NormalizePath("/tmp/workspace")); + + using var listInput = new StringReader("""{"jsonrpc":"2.0","id":"list","method":"memory/list","params":{"cwd":"/tmp/workspace","scope":"Project","query":"concise","limit":10}}"""); + using var listOutput = new StringWriter(new StringBuilder()); + await host.RunAsync(listInput, listOutput, CancellationToken.None); + + var rows = JsonSerializer.Deserialize( + ReadResponseResult(listOutput, "list").ToJsonString(), + ProtocolJsonContext.Default.ListMemoryEntry); + rows.Should().NotBeNull(); + rows.Should().ContainSingle(entry => entry.Id == saved.Id); + + using var deleteInput = new StringReader( + $@"{{""jsonrpc"":""2.0"",""id"":""delete"",""method"":""memory/delete"",""params"":{{""cwd"":""/tmp/workspace"",""scope"":""Project"",""id"":""{saved.Id}""}}}}"); + using var deleteOutput = new StringWriter(new StringBuilder()); + await host.RunAsync(deleteInput, deleteOutput, CancellationToken.None); + + memoryStore.LastDeleteWorkspaceRoot.Should().Be(NormalizePath("/tmp/workspace")); + memoryStore.LastDeleteScope.Should().Be(MemoryScope.Project); + ReadResponseResult(deleteOutput, "delete")["deleted"]!.GetValue().Should().BeTrue(); + } + + private static AcpStdioHost CreateHost( + StubConversationRuntime? runtime = null, + StubEditorContextBuffer? editorBuffer = null, + StubWorkspaceIndexService? workspaceIndexService = null, + StubWorkspaceSearchService? workspaceSearchService = null, + StubPersistentMemoryStore? persistentMemoryStore = null, + StubProviderCatalogService? providerCatalogService = null) => new( - new StubConversationRuntime(), + runtime ?? new StubConversationRuntime(), new StubAttachmentStore(), + editorBuffer ?? new StubEditorContextBuffer(), + workspaceIndexService ?? new StubWorkspaceIndexService(), + workspaceSearchService ?? new StubWorkspaceSearchService(), + persistentMemoryStore ?? new StubPersistentMemoryStore(), + providerCatalogService ?? new StubProviderCatalogService(), + new AcpApprovalCoordinator(), new PathService(), NullLogger.Instance); + private static System.Text.Json.Nodes.JsonNode ReadResponseResult(StringWriter output, string id) + { + foreach (var line in output.ToString().Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries)) + { + if (System.Text.Json.Nodes.JsonNode.Parse(line) is not System.Text.Json.Nodes.JsonObject payload) + { + continue; + } + + if (payload["id"]?.GetValue() == id) + { + return payload["result"]!; + } + } + + throw new InvalidOperationException($"Could not find JSON-RPC response with id '{id}'."); + } + + private static string NormalizePath(string path) + => PathService.GetFullPath(path); + private sealed class StubConversationRuntime : IConversationRuntime { + public RunPromptRequest? LastRequest { get; private set; } + public Task CreateSessionAsync(string workspacePath, PermissionMode permissionMode, OutputFormat outputFormat, CancellationToken cancellationToken) - => throw new NotSupportedException(); + => Task.FromResult(new ConversationSession("session-1", "Test", SessionLifecycleState.Active, permissionMode, outputFormat, workspacePath, workspacePath, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, null, null, null)); public Task ExecuteAsync(CancellationToken cancellationToken) => throw new NotSupportedException(); @@ -73,10 +254,20 @@ public Task ForkSessionAsync(string workspacePath, string? => throw new NotSupportedException(); public Task GetSessionAsync(string workspacePath, string sessionId, CancellationToken cancellationToken) - => throw new NotSupportedException(); + => Task.FromResult(new ConversationSession(sessionId, "Loaded", SessionLifecycleState.Active, PermissionMode.WorkspaceWrite, OutputFormat.Json, workspacePath, workspacePath, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, null, null, null)); public Task RunPromptAsync(RunPromptRequest request, CancellationToken cancellationToken) - => throw new NotSupportedException(); + { + LastRequest = request; + return Task.FromResult(new TurnExecutionResult( + new ConversationSession("session-1", "Prompt", SessionLifecycleState.Active, PermissionMode.WorkspaceWrite, OutputFormat.Json, request.WorkingDirectory ?? "/tmp/workspace", request.WorkingDirectory ?? "/tmp/workspace", DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, null, null, null), + new ConversationTurn("turn-1", "session-1", 1, request.Prompt, "ok", DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, "primary-coding-agent", null, null, null), + "ok", + [], + null, + null, + [])); + } } private sealed class StubAttachmentStore : IWorkspaceSessionAttachmentStore @@ -87,4 +278,100 @@ private sealed class StubAttachmentStore : IWorkspaceSessionAttachmentStore public Task SetAttachedSessionIdAsync(string workspacePath, string? sessionId, CancellationToken cancellationToken) => Task.CompletedTask; } + + private sealed class StubEditorContextBuffer : IEditorContextBuffer + { + public EditorContextPayload? LastPublished { get; private set; } + + public EditorContextPayload? Peek(string normalizedWorkspaceRoot) => null; + + public void Publish(EditorContextPayload payload) + { + LastPublished = payload; + } + + public EditorContextPayload? TryConsume(string normalizedWorkspaceRoot) => null; + } + + private sealed class StubWorkspaceIndexService : IWorkspaceIndexService + { + public string? LastWorkspaceRoot { get; private set; } + + public Task GetStatusAsync(string workspaceRoot, CancellationToken cancellationToken) + => Task.FromResult(new WorkspaceIndexStatus(workspaceRoot, null, 0, 0, 0, 0)); + + public Task RefreshAsync(string workspaceRoot, CancellationToken cancellationToken) + { + LastWorkspaceRoot = workspaceRoot; + return Task.FromResult(new WorkspaceIndexRefreshResult(workspaceRoot, DateTimeOffset.UtcNow, 3, 6, 2, 1, [])); + } + } + + private sealed class StubWorkspaceSearchService : IWorkspaceSearchService + { + public string? LastWorkspaceRoot { get; private set; } + + public WorkspaceSearchRequest? LastRequest { get; private set; } + + public Task SearchAsync(string workspaceRoot, WorkspaceSearchRequest request, CancellationToken cancellationToken) + { + LastWorkspaceRoot = workspaceRoot; + LastRequest = request; + return Task.FromResult(new WorkspaceSearchResult( + request.Query, + DateTimeOffset.UtcNow, + DateTimeOffset.UtcNow, + [new WorkspaceSearchHit("src/WidgetService.cs", WorkspaceSearchHitKind.Symbol, 1.0d, "WidgetService", "WidgetService", "class", 3, 3)])); + } + } + + private sealed class StubPersistentMemoryStore : IPersistentMemoryStore + { + private readonly List entries = []; + + public string? LastSaveWorkspaceRoot { get; private set; } + + public string? LastDeleteWorkspaceRoot { get; private set; } + + public MemoryScope? LastDeleteScope { get; private set; } + + public Task DeleteAsync(string? workspaceRoot, MemoryScope scope, string id, CancellationToken cancellationToken) + { + LastDeleteWorkspaceRoot = workspaceRoot; + LastDeleteScope = scope; + return Task.FromResult(entries.RemoveAll(entry => entry.Id == id) > 0); + } + + public Task> ListAsync(string? workspaceRoot, MemoryScope? scope, string? query, int limit, CancellationToken cancellationToken) + { + IEnumerable result = entries; + if (scope is not null) + { + result = result.Where(entry => entry.Scope == scope.Value); + } + + if (!string.IsNullOrWhiteSpace(query)) + { + result = result.Where(entry => entry.Content.Contains(query, StringComparison.OrdinalIgnoreCase)); + } + + return Task.FromResult>(result.Take(limit).ToArray()); + } + + public Task SaveAsync(string? workspaceRoot, MemoryEntry entry, CancellationToken cancellationToken) + { + LastSaveWorkspaceRoot = workspaceRoot; + entries.RemoveAll(existing => existing.Id == entry.Id); + entries.Add(entry); + return Task.FromResult(entry); + } + } + + private sealed class StubProviderCatalogService : IProviderCatalogService + { + public IReadOnlyList Entries { get; init; } = []; + + public Task> ListAsync(CancellationToken cancellationToken) + => Task.FromResult(Entries); + } } diff --git a/tests/SharpClaw.Code.UnitTests/Commands/FeatureCommandHandlersTests.cs b/tests/SharpClaw.Code.UnitTests/Commands/FeatureCommandHandlersTests.cs index a214152..4363b0d 100644 --- a/tests/SharpClaw.Code.UnitTests/Commands/FeatureCommandHandlersTests.cs +++ b/tests/SharpClaw.Code.UnitTests/Commands/FeatureCommandHandlersTests.cs @@ -1,12 +1,18 @@ +using System.CommandLine; using System.Text.Json; using FluentAssertions; using SharpClaw.Code.Commands; using SharpClaw.Code.Commands.Models; +using SharpClaw.Code.Commands.Options; +using SharpClaw.Code.Memory.Abstractions; using SharpClaw.Code.Protocol.Commands; using SharpClaw.Code.Protocol.Enums; using SharpClaw.Code.Protocol.Models; using SharpClaw.Code.Protocol.Serialization; +using SharpClaw.Code.Providers.Abstractions; using SharpClaw.Code.Runtime.Abstractions; +using SharpClaw.Code.Telemetry.Abstractions; +using SharpClaw.Code.Tools.Abstractions; namespace SharpClaw.Code.UnitTests.Commands; @@ -16,7 +22,7 @@ public sealed class FeatureCommandHandlersTests public async Task Usage_command_should_render_workspace_usage_payload() { var renderer = new RecordingRenderer(); - var handler = new UsageCommandHandler(new StubInsightsService(), new OutputRendererDispatcher([renderer])); + var handler = new UsageCommandHandler(new StubInsightsService(), new StubUsageMeteringService(), new OutputRendererDispatcher([renderer])); var context = new CommandExecutionContext("/workspace", null, PermissionMode.WorkspaceWrite, OutputFormat.Json, PrimaryMode.Build, "session-1"); var exitCode = await handler.ExecuteAsync(new SlashCommandParseResult(true, "usage", []), context, CancellationToken.None); @@ -27,6 +33,46 @@ public async Task Usage_command_should_render_workspace_usage_payload() payload!.WorkspaceTotal.TotalTokens.Should().Be(42); } + [Fact] + public async Task Usage_summary_should_render_metering_summary_payload_and_use_host_context() + { + var renderer = new RecordingRenderer(); + var metering = new StubUsageMeteringService(); + var handler = new UsageCommandHandler(new StubInsightsService(), metering, new OutputRendererDispatcher([renderer])); + var context = new CommandExecutionContext( + "/workspace", + null, + PermissionMode.WorkspaceWrite, + OutputFormat.Json, + PrimaryMode.Build, + "session-1", + HostContext: new RuntimeHostContext("host-a", "tenant-a", null, SessionStoreKind.Sqlite, true)); + + var exitCode = await handler.ExecuteAsync(new SlashCommandParseResult(true, "usage", ["summary"]), context, CancellationToken.None); + + exitCode.Should().Be(0); + var payload = JsonSerializer.Deserialize(renderer.LastResult!.DataJson!, ProtocolJsonContext.Default.UsageMeteringSummaryReport); + payload!.TotalUsage.TotalTokens.Should().Be(16); + metering.LastQuery.Should().NotBeNull(); + metering.LastQuery!.TenantId.Should().Be("tenant-a"); + metering.LastQuery.HostId.Should().Be("host-a"); + metering.LastQuery.SessionId.Should().Be("session-1"); + } + + [Fact] + public async Task Usage_detail_should_render_metering_detail_payload() + { + var renderer = new RecordingRenderer(); + var handler = new UsageCommandHandler(new StubInsightsService(), new StubUsageMeteringService(), new OutputRendererDispatcher([renderer])); + var context = new CommandExecutionContext("/workspace", null, PermissionMode.WorkspaceWrite, OutputFormat.Json, PrimaryMode.Build, "session-1"); + + var exitCode = await handler.ExecuteAsync(new SlashCommandParseResult(true, "usage", ["detail", "25"]), context, CancellationToken.None); + + exitCode.Should().Be(0); + var payload = JsonSerializer.Deserialize(renderer.LastResult!.DataJson!, ProtocolJsonContext.Default.UsageMeteringDetailReport); + payload!.Records.Should().ContainSingle(record => record.ToolName == "workspace_search"); + } + [Fact] public async Task Hooks_command_should_execute_named_test_from_slash_command() { @@ -42,6 +88,128 @@ public async Task Hooks_command_should_execute_named_test_from_slash_command() renderer.LastResult!.Succeeded.Should().BeTrue(); } + [Fact] + public async Task Models_command_should_render_provider_catalog_payload() + { + var renderer = new RecordingRenderer(); + var handler = new ModelsCommandHandler(new StubProviderCatalogService(), new OutputRendererDispatcher([renderer])); + var context = new CommandExecutionContext("/workspace", null, PermissionMode.WorkspaceWrite, OutputFormat.Json, PrimaryMode.Build); + + var exitCode = await handler.ExecuteAsync(new SlashCommandParseResult(true, "models", []), context, CancellationToken.None); + + exitCode.Should().Be(0); + var payload = JsonSerializer.Deserialize(renderer.LastResult!.DataJson!, ProtocolJsonContext.Default.ListProviderModelCatalogEntry); + payload.Should().ContainSingle(entry => entry.ProviderName == "openai-compatible" && entry.LocalRuntimeProfiles!.Length == 1); + } + + [Fact] + public async Task Index_command_should_render_workspace_search_payload() + { + var renderer = new RecordingRenderer(); + var handler = new IndexCommandHandler(new StubWorkspaceIndexService(), new StubWorkspaceSearchService(), new OutputRendererDispatcher([renderer])); + var context = new CommandExecutionContext("/workspace", null, PermissionMode.WorkspaceWrite, OutputFormat.Json, PrimaryMode.Build); + + var exitCode = await handler.ExecuteAsync(new SlashCommandParseResult(true, "index", ["query", "WidgetService"]), context, CancellationToken.None); + + exitCode.Should().Be(0); + var payload = JsonSerializer.Deserialize(renderer.LastResult!.DataJson!, ProtocolJsonContext.Default.WorkspaceSearchResult); + payload!.Hits.Should().ContainSingle(hit => hit.SymbolName == "WidgetService"); + } + + [Fact] + public async Task Memory_command_should_save_and_list_entries() + { + var renderer = new RecordingRenderer(); + var store = new StubPersistentMemoryStore(); + var handler = new MemoryCommandHandler(store, new OutputRendererDispatcher([renderer])); + var context = new CommandExecutionContext("/workspace", null, PermissionMode.WorkspaceWrite, OutputFormat.Json, PrimaryMode.Build, "session-1"); + + var saveExitCode = await handler.ExecuteAsync( + new SlashCommandParseResult(true, "memory", ["save", "User", "Prefer concise summaries"]), + context, + CancellationToken.None); + + saveExitCode.Should().Be(0); + store.Entries.Should().ContainSingle(entry => entry.Scope == MemoryScope.User); + + var listExitCode = await handler.ExecuteAsync( + new SlashCommandParseResult(true, "memory", ["list", "concise"]), + context, + CancellationToken.None); + + listExitCode.Should().Be(0); + var payload = JsonSerializer.Deserialize(renderer.LastResult!.DataJson!, ProtocolJsonContext.Default.ListMemoryEntry); + payload.Should().ContainSingle(entry => entry.Content.Contains("concise", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public async Task Tool_packages_list_command_should_render_installed_package_payload() + { + var renderer = new RecordingRenderer(); + var toolPackages = new StubToolPackageService(); + var globalOptions = new GlobalCliOptions(); + var handler = new ToolPackagesCommandHandler(toolPackages, new OutputRendererDispatcher([renderer])); + var exitCode = await InvokeCommandAsync(handler.BuildCommand(globalOptions), globalOptions, "tool-packages list --cwd /workspace --output-format json"); + + exitCode.Should().Be(0); + var payload = JsonSerializer.Deserialize(renderer.LastResult!.DataJson!, ProtocolJsonContext.Default.ListInstalledToolPackage); + payload.Should().ContainSingle(package => package.Manifest.Package.PackageId == "contoso.tools"); + } + + [Fact] + public async Task Tool_packages_install_command_should_render_installed_package_payload() + { + var renderer = new RecordingRenderer(); + var toolPackages = new StubToolPackageService(); + var globalOptions = new GlobalCliOptions(); + var handler = new ToolPackagesCommandHandler(toolPackages, new OutputRendererDispatcher([renderer])); + var manifestPath = Path.Combine(Path.GetTempPath(), $"tool-package-{Guid.NewGuid():N}.json"); + var manifest = new ToolPackageManifest( + new ToolPackageReference("contoso.tools", "1.2.3", "local", "bin/Contoso.Tools.dll", ["--serve"], "net10.0", ["tools"]), + "contoso", + "Contoso tools", + [new PackagedToolDescriptor("workspace_search", "Searches the workspace.", "{}")]); + + try + { + await File.WriteAllTextAsync( + manifestPath, + JsonSerializer.Serialize(manifest, ProtocolJsonContext.Default.ToolPackageManifest), + CancellationToken.None); + + var exitCode = await InvokeCommandAsync( + handler.BuildCommand(globalOptions), + globalOptions, + $"tool-packages install --manifest \"{manifestPath}\" --install-source cli --disable --cwd /workspace --output-format json"); + + exitCode.Should().Be(0); + toolPackages.LastInstallRequest.Should().NotBeNull(); + toolPackages.LastInstallRequest!.EnableAfterInstall.Should().BeFalse(); + toolPackages.LastInstallRequest.SourceReference.Should().Be(Path.GetDirectoryName(Path.GetFullPath(manifestPath))); + var payload = JsonSerializer.Deserialize(renderer.LastResult!.DataJson!, ProtocolJsonContext.Default.InstalledToolPackage); + payload!.Manifest.Package.PackageId.Should().Be("contoso.tools"); + } + finally + { + if (File.Exists(manifestPath)) + { + File.Delete(manifestPath); + } + } + } + + private static Task InvokeCommandAsync(Command command, GlobalCliOptions globalOptions, string commandLine) + { + var root = new RootCommand(); + foreach (var option in globalOptions.All) + { + root.Options.Add(option); + } + + root.Subcommands.Add(command); + return root.Parse(commandLine).InvokeAsync(); + } + private sealed class RecordingRenderer : IOutputRenderer { public OutputFormat Format => OutputFormat.Json; @@ -93,4 +261,163 @@ public Task TestAsync(string workspaceRoot, string hookName, str return Task.FromResult(new HookTestResult(hookName, HookTriggerKind.TurnCompleted, true, "Hook executed successfully.", DateTimeOffset.UtcNow)); } } + + private sealed class StubUsageMeteringService : IUsageMeteringService + { + public UsageMeteringQuery? LastQuery { get; private set; } + + public Task GetDetailAsync(string workspaceRoot, UsageMeteringQuery query, int limit, CancellationToken cancellationToken) + { + LastQuery = query; + return Task.FromResult(new UsageMeteringDetailReport( + query, + [ + new UsageMeteringRecord( + "meter-1", + UsageMeteringRecordKind.ToolExecution, + DateTimeOffset.UtcNow, + query.TenantId, + query.HostId, + workspaceRoot, + query.SessionId, + "turn-1", + ProviderName: "openai-compatible", + Model: "gpt-5.4-mini", + ToolName: "workspace_search", + ApprovalScope: ApprovalScope.ToolExecution, + Succeeded: true, + DurationMilliseconds: 20, + Usage: null, + Detail: "ok") + ])); + } + + public Task GetSummaryAsync(string workspaceRoot, UsageMeteringQuery query, CancellationToken cancellationToken) + { + LastQuery = query; + return Task.FromResult(new UsageMeteringSummaryReport( + query, + new UsageSnapshot(10, 6, 0, 16, 0.24m), + ProviderRequestCount: 1, + ToolExecutionCount: 1, + TurnCount: 1, + SessionEventCount: 0)); + } + } + + private sealed class StubProviderCatalogService : IProviderCatalogService + { + public Task> ListAsync(CancellationToken cancellationToken) + => Task.FromResult>( + [ + new ProviderModelCatalogEntry( + "openai-compatible", + "gpt-4.1-mini", + ["default"], + new AuthStatus(null, false, "openai-compatible", null, null, []), + SupportsToolCalls: true, + SupportsEmbeddings: true, + AvailableModels: + [ + new ProviderDiscoveredModel("gpt-4.1-mini", "gpt-4.1-mini", true, true) + ], + LocalRuntimeProfiles: + [ + new LocalRuntimeProfileSummary( + "ollama", + LocalRuntimeKind.Ollama, + "http://127.0.0.1:11434/v1/", + "qwen2.5-coder", + "nomic-embed-text", + ProviderAuthMode.Optional, + true, + "healthy", + []) + ]) + ]); + } + + private sealed class StubWorkspaceIndexService : IWorkspaceIndexService + { + public Task GetStatusAsync(string workspaceRoot, CancellationToken cancellationToken) + => Task.FromResult(new WorkspaceIndexStatus(workspaceRoot, DateTimeOffset.UtcNow, 4, 8, 2, 1)); + + public Task RefreshAsync(string workspaceRoot, CancellationToken cancellationToken) + => Task.FromResult(new WorkspaceIndexRefreshResult(workspaceRoot, DateTimeOffset.UtcNow, 4, 8, 2, 1, [])); + } + + private sealed class StubWorkspaceSearchService : IWorkspaceSearchService + { + public Task SearchAsync(string workspaceRoot, WorkspaceSearchRequest request, CancellationToken cancellationToken) + => Task.FromResult(new WorkspaceSearchResult( + request.Query, + DateTimeOffset.UtcNow, + DateTimeOffset.UtcNow, + [new WorkspaceSearchHit("src/WidgetService.cs", WorkspaceSearchHitKind.Symbol, 1d, "WidgetService", "WidgetService", "class", 3, 3)])); + } + + private sealed class StubPersistentMemoryStore : IPersistentMemoryStore + { + public List Entries { get; } = []; + + public Task DeleteAsync(string? workspaceRoot, MemoryScope scope, string id, CancellationToken cancellationToken) + => Task.FromResult(Entries.RemoveAll(entry => entry.Id == id && entry.Scope == scope) > 0); + + public Task> ListAsync(string? workspaceRoot, MemoryScope? scope, string? query, int limit, CancellationToken cancellationToken) + { + IEnumerable entries = Entries; + if (scope is not null) + { + entries = entries.Where(entry => entry.Scope == scope.Value); + } + + if (!string.IsNullOrWhiteSpace(query)) + { + entries = entries.Where(entry => entry.Content.Contains(query, StringComparison.OrdinalIgnoreCase)); + } + + return Task.FromResult>(entries.Take(limit).ToArray()); + } + + public Task SaveAsync(string? workspaceRoot, MemoryEntry entry, CancellationToken cancellationToken) + { + Entries.RemoveAll(existing => existing.Id == entry.Id); + Entries.Add(entry); + return Task.FromResult(entry); + } + } + + private sealed class StubToolPackageService : IToolPackageService + { + public ToolPackageInstallRequest? LastInstallRequest { get; private set; } + + public Task InstallAsync(string workspaceRoot, ToolPackageInstallRequest request, CancellationToken cancellationToken) + { + LastInstallRequest = request; + return Task.FromResult(new InstalledToolPackage( + request.Manifest, + DateTimeOffset.UtcNow, + request.InstallSource, + new ToolPackageResolvedInstall( + request.SourceReference, + request.PackageSource, + null, + null, + request.Manifest.Package.EntryAssembly, + request.Manifest.Package.EntryArguments))); + } + + public Task> ListInstalledAsync(string workspaceRoot, CancellationToken cancellationToken) + => Task.FromResult>( + [ + new InstalledToolPackage( + new ToolPackageManifest( + new ToolPackageReference("contoso.tools", "1.0.0", "local", "Contoso.Tools.dll", null, "net10.0", ["tools"]), + "contoso", + "Contoso tool bundle", + [new PackagedToolDescriptor("workspace_search", "Searches the workspace.", "{}")]), + DateTimeOffset.UtcNow, + "cli") + ]); + } } diff --git a/tests/SharpClaw.Code.UnitTests/Commands/ModeAndCliOptionsTests.cs b/tests/SharpClaw.Code.UnitTests/Commands/ModeAndCliOptionsTests.cs index c4c1790..654c024 100644 --- a/tests/SharpClaw.Code.UnitTests/Commands/ModeAndCliOptionsTests.cs +++ b/tests/SharpClaw.Code.UnitTests/Commands/ModeAndCliOptionsTests.cs @@ -5,6 +5,7 @@ using SharpClaw.Code.Commands.Options; using SharpClaw.Code.Protocol.Commands; using SharpClaw.Code.Protocol.Enums; +using SharpClaw.Code.Protocol.Models; namespace SharpClaw.Code.UnitTests.Commands; @@ -29,6 +30,28 @@ public void Global_cli_options_should_parse_spec_primary_mode() context.PrimaryMode.Should().Be(PrimaryMode.Spec); } + [Fact] + public void Global_cli_options_should_parse_embedded_host_context() + { + var options = new GlobalCliOptions(); + var command = new RootCommand(); + foreach (var option in options.All) + { + command.Options.Add(option); + } + + var storageRoot = Path.Combine(Path.GetTempPath(), "sharpclaw-state"); + var parseResult = command.Parse($"--tenant-id tenant-a --host-id host-a --storage-root \"{storageRoot}\" --session-store sqlite"); + var context = options.Resolve(parseResult); + + context.HostContext.Should().BeEquivalentTo(new RuntimeHostContext( + HostId: "host-a", + TenantId: "tenant-a", + StorageRoot: Path.GetFullPath(storageRoot), + SessionStoreKind: SessionStoreKind.Sqlite, + IsEmbeddedHost: true)); + } + [Fact] public async Task Mode_slash_command_should_set_spec_mode() { diff --git a/tests/SharpClaw.Code.UnitTests/McpPlugins/McpAndPluginLifecycleTests.cs b/tests/SharpClaw.Code.UnitTests/McpPlugins/McpAndPluginLifecycleTests.cs index 265f7cb..8f6b471 100644 --- a/tests/SharpClaw.Code.UnitTests/McpPlugins/McpAndPluginLifecycleTests.cs +++ b/tests/SharpClaw.Code.UnitTests/McpPlugins/McpAndPluginLifecycleTests.cs @@ -11,6 +11,7 @@ using SharpClaw.Code.Plugins.Services; using SharpClaw.Code.Protocol.Enums; using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.UnitTests.Support; namespace SharpClaw.Code.UnitTests.McpPlugins; @@ -107,6 +108,7 @@ private static PluginManager CreatePluginManager() new PluginManifestValidator(), new LocalFileSystem(), new PathService(), + TestRuntimeStorageResolver.Create(CreateTemporaryWorkspace()), new FixedClock()); /// diff --git a/tests/SharpClaw.Code.UnitTests/MemorySkillsGit/HashTextEmbeddingServiceTests.cs b/tests/SharpClaw.Code.UnitTests/MemorySkillsGit/HashTextEmbeddingServiceTests.cs new file mode 100644 index 0000000..0a2504b --- /dev/null +++ b/tests/SharpClaw.Code.UnitTests/MemorySkillsGit/HashTextEmbeddingServiceTests.cs @@ -0,0 +1,29 @@ +using FluentAssertions; +using SharpClaw.Code.Memory.Services; + +namespace SharpClaw.Code.UnitTests.MemorySkillsGit; + +/// +/// Covers deterministic local embedding behavior. +/// +public sealed class HashTextEmbeddingServiceTests +{ + [Fact] + public void Embed_should_be_deterministic_for_the_same_input() + { + var first = HashTextEmbeddingService.Embed("Widget prompts should stay concise."); + var second = HashTextEmbeddingService.Embed("Widget prompts should stay concise."); + + first.Should().Equal(second); + } + + [Fact] + public void Cosine_should_prefer_related_content() + { + var query = HashTextEmbeddingService.Embed("concise widget prompt"); + var related = HashTextEmbeddingService.Embed("Widget prompts should stay concise and repo specific."); + var unrelated = HashTextEmbeddingService.Embed("database migration and sql schema"); + + HashTextEmbeddingService.Cosine(query, related).Should().BeGreaterThan(HashTextEmbeddingService.Cosine(query, unrelated)); + } +} diff --git a/tests/SharpClaw.Code.UnitTests/MemorySkillsGit/WorkspaceKnowledgeServicesTests.cs b/tests/SharpClaw.Code.UnitTests/MemorySkillsGit/WorkspaceKnowledgeServicesTests.cs new file mode 100644 index 0000000..1304d06 --- /dev/null +++ b/tests/SharpClaw.Code.UnitTests/MemorySkillsGit/WorkspaceKnowledgeServicesTests.cs @@ -0,0 +1,110 @@ +using FluentAssertions; +using SharpClaw.Code.Infrastructure.Abstractions; +using SharpClaw.Code.Infrastructure.Services; +using SharpClaw.Code.Memory.Abstractions; +using SharpClaw.Code.Memory.Services; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.UnitTests.Support; + +namespace SharpClaw.Code.UnitTests.MemorySkillsGit; + +/// +/// Covers workspace indexing, hybrid search, and durable memory recall. +/// +public sealed class WorkspaceKnowledgeServicesTests : IDisposable +{ + private readonly string workspaceRoot = Path.Combine(Path.GetTempPath(), $"sharpclaw-knowledge-{Guid.NewGuid():N}"); + private readonly string userRoot = Path.Combine(Path.GetTempPath(), $"sharpclaw-user-{Guid.NewGuid():N}"); + private readonly LocalFileSystem fileSystem = new(); + private readonly PathService pathService = new(); + + [Fact] + public async Task Index_search_and_memory_services_should_round_trip_expected_data() + { + Directory.CreateDirectory(Path.Combine(workspaceRoot, "src")); + await File.WriteAllTextAsync( + Path.Combine(workspaceRoot, "src", "WidgetService.cs"), + """ + namespace Sample.App; + + public sealed class WidgetService + { + public string BuildWidgetPrompt(string name) => $"Widget prompt for {name}"; + } + """); + await File.WriteAllTextAsync( + Path.Combine(workspaceRoot, "README.md"), + "The widget prompt pipeline provides semantic workspace context."); + await File.WriteAllTextAsync( + Path.Combine(workspaceRoot, "src", "Sample.App.csproj"), + """ + + + + + + """); + + var store = CreateStore(); + var indexService = new WorkspaceIndexService(fileSystem, pathService, store); + var searchService = new WorkspaceSearchService(store); + var memoryStore = new PersistentMemoryStore(store); + var recallService = new MemoryRecallService(memoryStore); + + var refresh = await indexService.RefreshAsync(workspaceRoot, CancellationToken.None); + var search = await searchService.SearchAsync( + workspaceRoot, + new WorkspaceSearchRequest("WidgetService", 10), + CancellationToken.None); + + refresh.IndexedFileCount.Should().BeGreaterThan(0); + search.Hits.Should().Contain(hit => hit.Kind == WorkspaceSearchHitKind.Symbol && hit.SymbolName == "WidgetService"); + + var now = DateTimeOffset.UtcNow; + await memoryStore.SaveAsync( + workspaceRoot, + new MemoryEntry( + Id: "project-memory-1", + Scope: MemoryScope.Project, + Content: "Widget prompts should stay concise and repo-specific.", + Source: "unit-test", + SourceSessionId: null, + SourceTurnId: null, + Tags: ["widgets"], + Confidence: 0.9d, + RelatedFilePath: "src/WidgetService.cs", + RelatedSymbolName: "WidgetService", + CreatedAtUtc: now, + UpdatedAtUtc: now), + CancellationToken.None); + await memoryStore.SaveAsync( + null, + new MemoryEntry( + Id: "user-memory-1", + Scope: MemoryScope.User, + Content: "Prefer explicit engineering language over vague summaries.", + Source: "unit-test", + SourceSessionId: null, + SourceTurnId: null, + Tags: ["style"], + Confidence: 0.8d, + RelatedFilePath: null, + RelatedSymbolName: null, + CreatedAtUtc: now, + UpdatedAtUtc: now), + CancellationToken.None); + + var recalled = await recallService.RecallAsync(workspaceRoot, "Write a concise widget prompt summary.", 5, CancellationToken.None); + recalled.Should().Contain(entry => entry.Id == "project-memory-1"); + recalled.Should().Contain(entry => entry.Id == "user-memory-1"); + } + + public void Dispose() + { + TestDirectoryCleanup.DeleteIfExists(workspaceRoot, clearSqlitePools: true); + TestDirectoryCleanup.DeleteIfExists(userRoot, clearSqlitePools: true); + } + + private IWorkspaceKnowledgeStore CreateStore() + => new SqliteWorkspaceKnowledgeStore(fileSystem, pathService, TestRuntimeStorageResolver.Create(userRoot, pathService)); +} diff --git a/tests/SharpClaw.Code.UnitTests/Permissions/PermissionPolicyEngineTests.cs b/tests/SharpClaw.Code.UnitTests/Permissions/PermissionPolicyEngineTests.cs index 8fac07d..7f9c770 100644 --- a/tests/SharpClaw.Code.UnitTests/Permissions/PermissionPolicyEngineTests.cs +++ b/tests/SharpClaw.Code.UnitTests/Permissions/PermissionPolicyEngineTests.cs @@ -355,7 +355,8 @@ public Task RequestApprovalAsync( ResolvedBy: "test", Reason: "approved", ResolvedAtUtc: DateTimeOffset.UtcNow, - ExpiresAtUtc: DateTimeOffset.UtcNow.AddMinutes(10))); + ExpiresAtUtc: DateTimeOffset.UtcNow.AddMinutes(10), + RememberForSession: request.CanRememberDecision)); } } } diff --git a/tests/SharpClaw.Code.UnitTests/Providers/ProviderCatalogServiceTests.cs b/tests/SharpClaw.Code.UnitTests/Providers/ProviderCatalogServiceTests.cs new file mode 100644 index 0000000..14c9402 --- /dev/null +++ b/tests/SharpClaw.Code.UnitTests/Providers/ProviderCatalogServiceTests.cs @@ -0,0 +1,131 @@ +using System.Net; +using System.Net.Sockets; +using System.Text; +using FluentAssertions; +using Microsoft.Extensions.Options; +using SharpClaw.Code.Providers; +using SharpClaw.Code.Providers.Abstractions; +using SharpClaw.Code.Providers.Configuration; +using SharpClaw.Code.Providers.Models; +using SharpClaw.Code.Protocol.Models; + +namespace SharpClaw.Code.UnitTests.Providers; + +/// +/// Covers provider catalog discovery for local runtime profiles. +/// +public sealed class ProviderCatalogServiceTests +{ + [Fact] + public async Task ListAsync_should_surface_local_runtime_profiles_and_discovered_models() + { + await using var server = await LocalJsonServer.StartAsync(""" + {"data":[{"id":"qwen2.5-coder"},{"id":"nomic-embed-text"}]} + """); + + var openAiOptions = new OpenAiCompatibleProviderOptions + { + ProviderName = "openai-compatible", + DefaultModel = "gpt-4.1-mini", + DefaultEmbeddingModel = "text-embedding-3-small", + SupportsEmbeddings = true, + }; + openAiOptions.LocalRuntimes["ollama"] = new LocalRuntimeProfileOptions + { + Kind = LocalRuntimeKind.Ollama, + BaseUrl = $"{server.BaseUrl}v1/", + DefaultChatModel = "qwen2.5-coder", + DefaultEmbeddingModel = "nomic-embed-text", + AuthMode = ProviderAuthMode.Optional, + SupportsToolCalls = true, + SupportsEmbeddings = true, + }; + + var service = new ProviderCatalogService( + [new StubModelProvider("openai-compatible")], + new StubAuthFlowService(), + Options.Create(new ProviderCatalogOptions()), + Options.Create(new AnthropicProviderOptions()), + Options.Create(openAiOptions)); + + var entries = await service.ListAsync(CancellationToken.None); + + entries.Should().ContainSingle(); + var entry = entries[0]; + entry.ProviderName.Should().Be("openai-compatible"); + entry.SupportsEmbeddings.Should().BeTrue(); + entry.AvailableModels.Should().Contain(model => model.Id == "qwen2.5-coder" && model.SupportsTools); + entry.AvailableModels.Should().Contain(model => model.Id == "nomic-embed-text" && model.SupportsEmbeddings); + entry.LocalRuntimeProfiles.Should().ContainSingle(profile => + profile.Name == "ollama" + && profile.AuthMode == ProviderAuthMode.Optional + && profile.IsHealthy + && profile.AvailableModels.Length == 2); + } + + private sealed class StubAuthFlowService : IAuthFlowService + { + public Task GetStatusAsync(string providerName, CancellationToken cancellationToken) + => Task.FromResult(new AuthStatus(null, false, providerName, null, null, [])); + } + + private sealed class StubModelProvider(string providerName) : IModelProvider + { + public string ProviderName => providerName; + + public Task GetAuthStatusAsync(CancellationToken cancellationToken) + => Task.FromResult(new AuthStatus(null, false, providerName, null, null, [])); + + public Task StartStreamAsync(ProviderRequest request, CancellationToken cancellationToken) + => throw new NotSupportedException(); + } + + private sealed class LocalJsonServer(TcpListener listener, Task serverTask) : IAsyncDisposable + { + public string BaseUrl => $"http://127.0.0.1:{((IPEndPoint)listener.LocalEndpoint).Port}/"; + + public static Task StartAsync(string jsonPayload) + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + + var payloadBytes = Encoding.UTF8.GetBytes(jsonPayload); + var serverTask = Task.Run(async () => + { + using var client = await listener.AcceptTcpClientAsync(); + await using var stream = client.GetStream(); + using var reader = new StreamReader(stream, Encoding.ASCII, leaveOpen: true); + + while (!string.IsNullOrEmpty(await reader.ReadLineAsync())) + { + } + + var headers = Encoding.ASCII.GetBytes( + "HTTP/1.1 200 OK\r\n" + + "Content-Type: application/json\r\n" + + $"Content-Length: {payloadBytes.Length}\r\n" + + "Connection: close\r\n\r\n"); + await stream.WriteAsync(headers); + await stream.WriteAsync(payloadBytes); + await stream.FlushAsync(); + }); + + return Task.FromResult(new LocalJsonServer(listener, serverTask)); + } + + public async ValueTask DisposeAsync() + { + listener.Stop(); + try + { + await serverTask.ConfigureAwait(false); + } + catch (SocketException) + { + } + catch (ObjectDisposedException) + { + } + } + } +} diff --git a/tests/SharpClaw.Code.UnitTests/Providers/ProviderConfigurationBindingTests.cs b/tests/SharpClaw.Code.UnitTests/Providers/ProviderConfigurationBindingTests.cs index 3b45e32..d158b5a 100644 --- a/tests/SharpClaw.Code.UnitTests/Providers/ProviderConfigurationBindingTests.cs +++ b/tests/SharpClaw.Code.UnitTests/Providers/ProviderConfigurationBindingTests.cs @@ -5,6 +5,7 @@ using SharpClaw.Code.Infrastructure; using SharpClaw.Code.Providers; using SharpClaw.Code.Providers.Configuration; +using SharpClaw.Code.Protocol.Models; namespace SharpClaw.Code.UnitTests.Providers; @@ -28,7 +29,17 @@ public void AddSharpClawProviders_should_bind_options_from_configuration() ["SharpClaw:Providers:Anthropic:ApiKey"] = "anthropic-key", ["SharpClaw:Providers:Anthropic:BaseUrl"] = "https://anthropic.example.com", ["SharpClaw:Providers:OpenAiCompatible:ApiKey"] = "openai-key", - ["SharpClaw:Providers:OpenAiCompatible:BaseUrl"] = "https://openai.example.com/v1" + ["SharpClaw:Providers:OpenAiCompatible:BaseUrl"] = "https://openai.example.com/v1", + ["SharpClaw:Providers:OpenAiCompatible:AuthMode"] = "Optional", + ["SharpClaw:Providers:OpenAiCompatible:DefaultEmbeddingModel"] = "text-embedding-3-small", + ["SharpClaw:Providers:OpenAiCompatible:SupportsEmbeddings"] = "true", + ["SharpClaw:Providers:OpenAiCompatible:LocalRuntimes:ollama:Kind"] = "Ollama", + ["SharpClaw:Providers:OpenAiCompatible:LocalRuntimes:ollama:BaseUrl"] = "http://127.0.0.1:11434/v1/", + ["SharpClaw:Providers:OpenAiCompatible:LocalRuntimes:ollama:DefaultChatModel"] = "qwen2.5-coder", + ["SharpClaw:Providers:OpenAiCompatible:LocalRuntimes:ollama:DefaultEmbeddingModel"] = "nomic-embed-text", + ["SharpClaw:Providers:OpenAiCompatible:LocalRuntimes:ollama:AuthMode"] = "Optional", + ["SharpClaw:Providers:OpenAiCompatible:LocalRuntimes:ollama:SupportsToolCalls"] = "true", + ["SharpClaw:Providers:OpenAiCompatible:LocalRuntimes:ollama:SupportsEmbeddings"] = "true" }) .Build(); @@ -47,5 +58,12 @@ public void AddSharpClawProviders_should_bind_options_from_configuration() anthropic.BaseUrl.Should().Be("https://anthropic.example.com"); openAi.ApiKey.Should().Be("openai-key"); openAi.BaseUrl.Should().Be("https://openai.example.com/v1"); + openAi.AuthMode.Should().Be(ProviderAuthMode.Optional); + openAi.DefaultEmbeddingModel.Should().Be("text-embedding-3-small"); + openAi.SupportsEmbeddings.Should().BeTrue(); + openAi.LocalRuntimes.Should().ContainKey("ollama"); + openAi.LocalRuntimes["ollama"].Kind.Should().Be(LocalRuntimeKind.Ollama); + openAi.LocalRuntimes["ollama"].DefaultChatModel.Should().Be("qwen2.5-coder"); + openAi.LocalRuntimes["ollama"].DefaultEmbeddingModel.Should().Be("nomic-embed-text"); } } diff --git a/tests/SharpClaw.Code.UnitTests/Runtime/ConfiguredApprovalIdentityServiceTests.cs b/tests/SharpClaw.Code.UnitTests/Runtime/ConfiguredApprovalIdentityServiceTests.cs new file mode 100644 index 0000000..45daaf7 --- /dev/null +++ b/tests/SharpClaw.Code.UnitTests/Runtime/ConfiguredApprovalIdentityServiceTests.cs @@ -0,0 +1,71 @@ +using FluentAssertions; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Runtime.Configuration; +using SharpClaw.Code.Runtime.Server; + +namespace SharpClaw.Code.UnitTests.Runtime; + +/// +/// Verifies trusted-header approval identity resolution and status reporting. +/// +public sealed class ConfiguredApprovalIdentityServiceTests : IDisposable +{ + private readonly string workspaceRoot = Path.Combine(Path.GetTempPath(), "sharpclaw-approval-auth", Guid.NewGuid().ToString("N")); + + [Fact] + public async Task Trusted_header_mode_should_map_subject_tenant_roles_and_scopes() + { + Directory.CreateDirectory(workspaceRoot); + await File.WriteAllTextAsync( + Path.Combine(workspaceRoot, "sharpclaw.jsonc"), + """ + { + "server": { + "host": "127.0.0.1", + "port": 7345, + "approvalAuth": { + "mode": "trustedHeader", + "requireForAdmin": true, + "requireAuthenticatedApprovals": true + } + } + } + """); + + var service = new ConfiguredApprovalIdentityService(new SharpClawConfigService(new SharpClaw.Code.Infrastructure.Services.LocalFileSystem(), new SharpClaw.Code.Infrastructure.Services.PathService())); + var status = await service.GetStatusAsync(workspaceRoot, CancellationToken.None); + var principal = await service.ResolveAsync( + workspaceRoot, + new ApprovalIdentityRequest( + AuthorizationHeader: null, + Headers: new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["X-SharpClaw-User"] = "alice", + ["X-SharpClaw-Display-Name"] = "Alice Example", + ["X-SharpClaw-Tenant-Id"] = "tenant-a", + ["X-SharpClaw-Roles"] = "approver,admin", + ["X-SharpClaw-Scopes"] = "approvals:write approvals:read", + }), + new RuntimeHostContext("host-a", "tenant-a"), + CancellationToken.None); + + status.Mode.Should().Be(ApprovalAuthMode.TrustedHeader); + status.RequireForAdmin.Should().BeTrue(); + status.RequireAuthenticatedApprovals.Should().BeTrue(); + principal.Should().NotBeNull(); + principal!.SubjectId.Should().Be("alice"); + principal.DisplayName.Should().Be("Alice Example"); + principal.TenantId.Should().Be("tenant-a"); + principal.Roles.Should().BeEquivalentTo(["approver", "admin"]); + principal.Scopes.Should().BeEquivalentTo(["approvals:write", "approvals:read"]); + principal.AuthenticationType.Should().Be("trusted-header"); + } + + public void Dispose() + { + if (Directory.Exists(workspaceRoot)) + { + Directory.Delete(workspaceRoot, recursive: true); + } + } +} diff --git a/tests/SharpClaw.Code.UnitTests/Runtime/LocalRuntimeCatalogCheckTests.cs b/tests/SharpClaw.Code.UnitTests/Runtime/LocalRuntimeCatalogCheckTests.cs new file mode 100644 index 0000000..21f4b6a --- /dev/null +++ b/tests/SharpClaw.Code.UnitTests/Runtime/LocalRuntimeCatalogCheckTests.cs @@ -0,0 +1,61 @@ +using FluentAssertions; +using SharpClaw.Code.Providers.Abstractions; +using SharpClaw.Code.Protocol.Enums; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Protocol.Operational; +using SharpClaw.Code.Runtime.Diagnostics; +using SharpClaw.Code.Runtime.Diagnostics.Checks; + +namespace SharpClaw.Code.UnitTests.Runtime; + +/// +/// Covers status/doctor reporting for configured local runtime profiles. +/// +public sealed class LocalRuntimeCatalogCheckTests +{ + [Fact] + public async Task ExecuteAsync_should_report_profile_health_and_model_counts() + { + var check = new LocalRuntimeCatalogCheck(new StubProviderCatalogService()); + + var result = await check.ExecuteAsync( + new OperationalDiagnosticsContext("/workspace", null, PermissionMode.WorkspaceWrite), + CancellationToken.None); + + result.Status.Should().Be(OperationalCheckStatus.Warn); + result.Detail.Should().Contain("ollama (Ollama): unhealthy, 1 model(s)"); + result.Detail.Should().Contain("embedding default nomic-embed-text"); + } + + private sealed class StubProviderCatalogService : IProviderCatalogService + { + public Task> ListAsync(CancellationToken cancellationToken) + => Task.FromResult>( + [ + new ProviderModelCatalogEntry( + "openai-compatible", + "gpt-4.1-mini", + [], + new AuthStatus(null, false, "openai-compatible", null, null, []), + AvailableModels: + [ + new ProviderDiscoveredModel("qwen2.5-coder", "qwen2.5-coder", true, false) + ], + LocalRuntimeProfiles: + [ + new LocalRuntimeProfileSummary( + "ollama", + LocalRuntimeKind.Ollama, + "http://127.0.0.1:11434/v1/", + "qwen2.5-coder", + "nomic-embed-text", + ProviderAuthMode.Optional, + false, + "connection refused", + [ + new ProviderDiscoveredModel("qwen2.5-coder", "qwen2.5-coder", true, false) + ]) + ]) + ]); + } +} diff --git a/tests/SharpClaw.Code.UnitTests/Runtime/OperationalDiagnosticsCoordinatorTests.cs b/tests/SharpClaw.Code.UnitTests/Runtime/OperationalDiagnosticsCoordinatorTests.cs new file mode 100644 index 0000000..57aa307 --- /dev/null +++ b/tests/SharpClaw.Code.UnitTests/Runtime/OperationalDiagnosticsCoordinatorTests.cs @@ -0,0 +1,133 @@ +using FluentAssertions; +using SharpClaw.Code.Infrastructure.Abstractions; +using SharpClaw.Code.Infrastructure.Services; +using SharpClaw.Code.Mcp.Abstractions; +using SharpClaw.Code.Plugins.Abstractions; +using SharpClaw.Code.Plugins.Models; +using SharpClaw.Code.Protocol.Enums; +using SharpClaw.Code.Protocol.Events; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Protocol.Operational; +using SharpClaw.Code.Runtime.Abstractions; +using SharpClaw.Code.Runtime.Diagnostics; +using SharpClaw.Code.Sessions.Abstractions; + +namespace SharpClaw.Code.UnitTests.Runtime; + +/// +/// Covers status-report quick-check selection. +/// +public sealed class OperationalDiagnosticsCoordinatorTests +{ + [Fact] + public async Task BuildStatusReportAsync_should_include_local_runtime_health_in_quick_checks() + { + var coordinator = new OperationalDiagnosticsCoordinator( + [ + new StubOperationalCheck("workspace.access"), + new StubOperationalCheck("session.store"), + new StubOperationalCheck("mcp.registry"), + new StubOperationalCheck("plugins.registry"), + new StubOperationalCheck("approval.auth"), + new StubOperationalCheck("provider.local-runtimes", OperationalCheckStatus.Warn), + ], + new FixedClock(DateTimeOffset.Parse("2026-04-21T12:00:00Z")), + new PathService(), + new StubSessionStore(), + new StubMcpRegistry(), + new StubPluginManager(), + new StubEventStore(), + new StubWorkspaceDiagnosticsService()); + + var report = await coordinator.BuildStatusReportAsync( + new OperationalDiagnosticsInput("/workspace", null, PermissionMode.WorkspaceWrite, OutputFormat.Json), + CancellationToken.None); + + report.Checks.Should().Contain(check => check.Id == "provider.local-runtimes" && check.Status == OperationalCheckStatus.Warn); + } + + private sealed class StubOperationalCheck(string id, OperationalCheckStatus status = OperationalCheckStatus.Ok) : IOperationalCheck + { + public string Id { get; } = id; + + public Task ExecuteAsync(OperationalDiagnosticsContext context, CancellationToken cancellationToken) + => Task.FromResult(new OperationalCheckItem(Id, status, "ok", "detail")); + } + + private sealed class FixedClock(DateTimeOffset utcNow) : ISystemClock + { + public DateTimeOffset UtcNow { get; } = utcNow; + } + + private sealed class StubSessionStore : ISessionStore + { + public Task GetByIdAsync(string workspacePath, string sessionId, CancellationToken cancellationToken) + => Task.FromResult(null); + + public Task GetLatestAsync(string workspacePath, CancellationToken cancellationToken) + => Task.FromResult(null); + + public Task> ListAllAsync(string workspacePath, CancellationToken cancellationToken) + => Task.FromResult>([]); + + public Task SaveAsync(string workspacePath, ConversationSession session, CancellationToken cancellationToken) + => Task.CompletedTask; + } + + private sealed class StubMcpRegistry : IMcpRegistry + { + public Task GetAsync(string workspaceRoot, string serverId, CancellationToken cancellationToken) + => Task.FromResult(null); + + public Task> ListAsync(string workspaceRoot, CancellationToken cancellationToken) + => Task.FromResult>([]); + + public Task RegisterAsync(string workspaceRoot, McpServerDefinition definition, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task UpdateStatusAsync(string workspaceRoot, McpServerStatus status, CancellationToken cancellationToken) + => Task.CompletedTask; + } + + private sealed class StubPluginManager : IPluginManager + { + public Task DisableAsync(string workspaceRoot, string pluginId, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task EnableAsync(string workspaceRoot, string pluginId, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task ExecuteToolAsync(string workspaceRoot, string toolName, ToolExecutionRequest request, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task InstallAsync(string workspaceRoot, PluginInstallRequest request, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task> ListAsync(string workspaceRoot, CancellationToken cancellationToken) + => Task.FromResult>([]); + + public Task> ListToolDescriptorsAsync(string workspaceRoot, CancellationToken cancellationToken) + => Task.FromResult>([]); + + public Task UninstallAsync(string workspaceRoot, string pluginId, CancellationToken cancellationToken) + => Task.CompletedTask; + + public Task UpdateAsync(string workspaceRoot, PluginInstallRequest request, CancellationToken cancellationToken) + => throw new NotSupportedException(); + } + + private sealed class StubEventStore : IEventStore + { + public Task AppendAsync(string workspacePath, string sessionId, RuntimeEvent runtimeEvent, CancellationToken cancellationToken) + => Task.CompletedTask; + + public Task> ReadAllAsync(string workspacePath, string sessionId, CancellationToken cancellationToken) + => Task.FromResult>([]); + } + + private sealed class StubWorkspaceDiagnosticsService : IWorkspaceDiagnosticsService + { + public Task BuildSnapshotAsync(string workspaceRoot, CancellationToken cancellationToken) + => Task.FromResult(new WorkspaceDiagnosticsSnapshot(workspaceRoot, DateTimeOffset.UtcNow, [], [])); + } +} diff --git a/tests/SharpClaw.Code.UnitTests/Runtime/ShareAndCompactionServicesTests.cs b/tests/SharpClaw.Code.UnitTests/Runtime/ShareAndCompactionServicesTests.cs index 058c98b..e2efa4f 100644 --- a/tests/SharpClaw.Code.UnitTests/Runtime/ShareAndCompactionServicesTests.cs +++ b/tests/SharpClaw.Code.UnitTests/Runtime/ShareAndCompactionServicesTests.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.Options; using SharpClaw.Code.Infrastructure.Abstractions; using SharpClaw.Code.Infrastructure.Services; +using SharpClaw.Code.Memory.Abstractions; using SharpClaw.Code.Protocol.Enums; using SharpClaw.Code.Protocol.Events; using SharpClaw.Code.Protocol.Models; @@ -10,6 +11,7 @@ using SharpClaw.Code.Sessions.Storage; using SharpClaw.Code.Telemetry; using SharpClaw.Code.Telemetry.Services; +using SharpClaw.Code.UnitTests.Support; namespace SharpClaw.Code.UnitTests.Runtime; @@ -25,8 +27,9 @@ public async Task Share_and_compaction_services_persist_expected_session_metadat Directory.CreateDirectory(workspaceRoot); var clock = new FixedClock(DateTimeOffset.Parse("2026-04-13T15:00:00Z")); - var sessionStore = new FileSessionStore(fileSystem, pathService); - var eventStore = new NdjsonEventStore(fileSystem, pathService); + var storagePathResolver = TestRuntimeStorageResolver.Create(workspaceRoot, pathService); + var sessionStore = new FileSessionStore(fileSystem, storagePathResolver); + var eventStore = new NdjsonEventStore(fileSystem, storagePathResolver); var session = new ConversationSession( Id: "session-1", Title: "Initial title", @@ -108,15 +111,17 @@ await eventStore.AppendAsync( var shareService = new ShareSessionService( fileSystem, pathService, + storagePathResolver, clock, sessionStore, eventStore, new FixedConfigService(workspaceRoot), publisher, hooks); - var todoService = new TodoService(sessionStore, eventStore, fileSystem, pathService, clock); + var todoService = new TodoService(sessionStore, eventStore, fileSystem, pathService, storagePathResolver, clock); _ = await todoService.AddAsync(workspaceRoot, TodoScope.Session, "Follow up on diagnostics UX", session.Id, "primary-coding-agent", CancellationToken.None); - var compactionService = new ConversationCompactionService(sessionStore, eventStore, todoService, clock); + var memoryStore = new RecordingPersistentMemoryStore(); + var compactionService = new ConversationCompactionService(sessionStore, eventStore, todoService, memoryStore, clock); var share = await shareService.CreateShareAsync(workspaceRoot, session.Id, CancellationToken.None); var sharedSession = await sessionStore.GetByIdAsync(workspaceRoot, session.Id, CancellationToken.None); @@ -136,6 +141,7 @@ await eventStore.AppendAsync( compacted.Summary.Should().Contain("Recent requests:"); compacted.Summary.Should().Contain("Active tasks:"); compacted.Session.Title.Should().Contain("Add diagnostics support"); + memoryStore.Saved.Should().ContainSingle(entry => entry.Source == "session-compaction" && entry.SourceSessionId == session.Id); removed.Should().BeTrue(); unsharedSession!.Metadata.Should().NotContainKey(SharpClawWorkflowMetadataKeys.ShareId); hooks.Invocations.Should().Contain(invocation => invocation.Trigger == HookTriggerKind.ShareCreated); @@ -189,4 +195,21 @@ public Task> ListAsync(string workspaceRoot, Can public Task TestAsync(string workspaceRoot, string hookName, string payloadJson, CancellationToken cancellationToken) => Task.FromResult(new HookTestResult(hookName, HookTriggerKind.ShareCreated, true, "ok", DateTimeOffset.UtcNow)); } + + private sealed class RecordingPersistentMemoryStore : IPersistentMemoryStore + { + public List Saved { get; } = []; + + public Task SaveAsync(string? workspaceRoot, MemoryEntry entry, CancellationToken cancellationToken) + { + Saved.Add(entry); + return Task.FromResult(entry); + } + + public Task DeleteAsync(string? workspaceRoot, MemoryScope scope, string id, CancellationToken cancellationToken) + => Task.FromResult(false); + + public Task> ListAsync(string? workspaceRoot, MemoryScope? scope, string? query, int limit, CancellationToken cancellationToken) + => Task.FromResult>(Saved); + } } diff --git a/tests/SharpClaw.Code.UnitTests/Runtime/SharpClawConfigServiceTests.cs b/tests/SharpClaw.Code.UnitTests/Runtime/SharpClawConfigServiceTests.cs index e4e5cd3..58a91e2 100644 --- a/tests/SharpClaw.Code.UnitTests/Runtime/SharpClawConfigServiceTests.cs +++ b/tests/SharpClaw.Code.UnitTests/Runtime/SharpClawConfigServiceTests.cs @@ -2,6 +2,7 @@ using SharpClaw.Code.Infrastructure.Services; using SharpClaw.Code.Protocol.Models; using SharpClaw.Code.Runtime.Configuration; +using SharpClaw.Code.UnitTests.Support; namespace SharpClaw.Code.UnitTests.Runtime; @@ -93,9 +94,6 @@ public void Dispose() Environment.SetEnvironmentVariable("HOME", originalHome); Environment.SetEnvironmentVariable("USERPROFILE", originalUserProfile); Environment.SetEnvironmentVariable("APPDATA", originalAppData); - if (Directory.Exists(tempRoot)) - { - Directory.Delete(tempRoot, recursive: true); - } + TestDirectoryCleanup.DeleteIfExists(tempRoot); } } diff --git a/tests/SharpClaw.Code.UnitTests/Runtime/WorkspaceInsightsAndTodoServiceTests.cs b/tests/SharpClaw.Code.UnitTests/Runtime/WorkspaceInsightsAndTodoServiceTests.cs index d9727bb..127af37 100644 --- a/tests/SharpClaw.Code.UnitTests/Runtime/WorkspaceInsightsAndTodoServiceTests.cs +++ b/tests/SharpClaw.Code.UnitTests/Runtime/WorkspaceInsightsAndTodoServiceTests.cs @@ -7,6 +7,7 @@ using SharpClaw.Code.Runtime.Workflow; using SharpClaw.Code.Sessions.Storage; using SharpClaw.Code.Telemetry.Services; +using SharpClaw.Code.UnitTests.Support; namespace SharpClaw.Code.UnitTests.Runtime; @@ -21,11 +22,12 @@ public async Task Todo_service_and_workspace_insights_should_persist_and_report_ { Directory.CreateDirectory(workspaceRoot); var clock = new FixedClock(DateTimeOffset.Parse("2026-04-13T18:00:00Z")); - var sessionStore = new FileSessionStore(fileSystem, pathService); - var eventStore = new NdjsonEventStore(fileSystem, pathService); - var attachmentStore = new FileWorkspaceSessionAttachmentStore(fileSystem, pathService); + var storagePathResolver = TestRuntimeStorageResolver.Create(workspaceRoot, pathService); + var sessionStore = new FileSessionStore(fileSystem, storagePathResolver); + var eventStore = new NdjsonEventStore(fileSystem, storagePathResolver); + var attachmentStore = new FileWorkspaceSessionAttachmentStore(fileSystem, storagePathResolver); var usageTracker = new UsageTracker(); - var todoService = new TodoService(sessionStore, eventStore, fileSystem, pathService, clock); + var todoService = new TodoService(sessionStore, eventStore, fileSystem, pathService, storagePathResolver, clock); var insights = new WorkspaceInsightsService(sessionStore, eventStore, attachmentStore, usageTracker, pathService, todoService); var session = new ConversationSession( diff --git a/tests/SharpClaw.Code.UnitTests/Sessions/SessionStorageTests.cs b/tests/SharpClaw.Code.UnitTests/Sessions/SessionStorageTests.cs index c71f6e8..478e202 100644 --- a/tests/SharpClaw.Code.UnitTests/Sessions/SessionStorageTests.cs +++ b/tests/SharpClaw.Code.UnitTests/Sessions/SessionStorageTests.cs @@ -4,6 +4,7 @@ using SharpClaw.Code.Protocol.Events; using SharpClaw.Code.Protocol.Models; using SharpClaw.Code.Sessions.Storage; +using SharpClaw.Code.UnitTests.Support; namespace SharpClaw.Code.UnitTests.Sessions; @@ -16,6 +17,9 @@ public sealed class SessionStorageTests : IDisposable private readonly LocalFileSystem _fileSystem = new(); private readonly PathService _pathService = new(); + private SharpClaw.Code.Infrastructure.Abstractions.IRuntimeStoragePathResolver CreateStoragePathResolver() + => TestRuntimeStorageResolver.Create(_tempDir, _pathService); + public void Dispose() { if (Directory.Exists(_tempDir)) @@ -44,7 +48,7 @@ private ConversationSession CreateSession(string id, DateTimeOffset updatedAt) = [Fact] public async Task FileSessionStore_save_and_get_roundtrip() { - var store = new FileSessionStore(_fileSystem, _pathService); + var store = new FileSessionStore(_fileSystem, CreateStoragePathResolver()); var session = CreateSession("s1", DateTimeOffset.UtcNow); await store.SaveAsync(_tempDir, session, CancellationToken.None); @@ -59,7 +63,7 @@ public async Task FileSessionStore_save_and_get_roundtrip() [Fact] public async Task FileSessionStore_get_returns_null_when_missing() { - var store = new FileSessionStore(_fileSystem, _pathService); + var store = new FileSessionStore(_fileSystem, CreateStoragePathResolver()); var loaded = await store.GetByIdAsync(_tempDir, "nonexistent", CancellationToken.None); @@ -69,7 +73,7 @@ public async Task FileSessionStore_get_returns_null_when_missing() [Fact] public async Task FileSessionStore_get_latest_returns_most_recently_updated() { - var store = new FileSessionStore(_fileSystem, _pathService); + var store = new FileSessionStore(_fileSystem, CreateStoragePathResolver()); var older = CreateSession("s-old", DateTimeOffset.UtcNow.AddMinutes(-10)); var newer = CreateSession("s-new", DateTimeOffset.UtcNow); @@ -85,7 +89,7 @@ public async Task FileSessionStore_get_latest_returns_most_recently_updated() [Fact] public async Task FileSessionStore_get_latest_returns_null_when_empty() { - var store = new FileSessionStore(_fileSystem, _pathService); + var store = new FileSessionStore(_fileSystem, CreateStoragePathResolver()); var latest = await store.GetLatestAsync(_tempDir, CancellationToken.None); @@ -95,7 +99,7 @@ public async Task FileSessionStore_get_latest_returns_null_when_empty() [Fact] public async Task FileSessionStore_list_all_returns_sessions_descending() { - var store = new FileSessionStore(_fileSystem, _pathService); + var store = new FileSessionStore(_fileSystem, CreateStoragePathResolver()); var now = DateTimeOffset.UtcNow; await store.SaveAsync(_tempDir, CreateSession("s1", now.AddMinutes(-5)), CancellationToken.None); await store.SaveAsync(_tempDir, CreateSession("s2", now), CancellationToken.None); @@ -111,7 +115,7 @@ public async Task FileSessionStore_list_all_returns_sessions_descending() [Fact] public async Task FileSessionStore_save_overwrites_existing() { - var store = new FileSessionStore(_fileSystem, _pathService); + var store = new FileSessionStore(_fileSystem, CreateStoragePathResolver()); var session = CreateSession("s1", DateTimeOffset.UtcNow); await store.SaveAsync(_tempDir, session, CancellationToken.None); @@ -127,7 +131,7 @@ public async Task FileSessionStore_save_overwrites_existing() [Fact] public async Task NdjsonEventStore_append_and_read_roundtrip() { - var store = new NdjsonEventStore(_fileSystem, _pathService); + var store = new NdjsonEventStore(_fileSystem, CreateStoragePathResolver()); var evt = new UndoCompletedEvent( EventId: "e1", SessionId: "s1", @@ -150,7 +154,7 @@ public async Task NdjsonEventStore_append_and_read_roundtrip() [Fact] public async Task NdjsonEventStore_read_returns_empty_when_no_file() { - var store = new NdjsonEventStore(_fileSystem, _pathService); + var store = new NdjsonEventStore(_fileSystem, CreateStoragePathResolver()); var events = await store.ReadAllAsync(_tempDir, "nonexistent", CancellationToken.None); @@ -160,7 +164,7 @@ public async Task NdjsonEventStore_read_returns_empty_when_no_file() [Fact] public async Task NdjsonEventStore_appends_multiple_events() { - var store = new NdjsonEventStore(_fileSystem, _pathService); + var store = new NdjsonEventStore(_fileSystem, CreateStoragePathResolver()); var sessionsRoot = Path.Combine(_tempDir, ".sharpclaw", "sessions", "s1"); Directory.CreateDirectory(sessionsRoot); @@ -189,7 +193,7 @@ public async Task NdjsonEventStore_skips_malformed_lines() var eventsPath = Path.Combine(sessionsRoot, "events.ndjson"); // Write a valid event followed by garbage. - var store = new NdjsonEventStore(_fileSystem, _pathService); + var store = new NdjsonEventStore(_fileSystem, CreateStoragePathResolver()); await store.AppendAsync(_tempDir, "s1", new UndoCompletedEvent( EventId: "e1", SessionId: "s1", @@ -210,7 +214,7 @@ public async Task NdjsonEventStore_skips_malformed_lines() [Fact] public async Task FileCheckpointStore_save_and_get_latest_roundtrip() { - var store = new FileCheckpointStore(_fileSystem, _pathService); + var store = new FileCheckpointStore(_fileSystem, CreateStoragePathResolver()); var now = DateTimeOffset.UtcNow; var older = new RuntimeCheckpoint("cp-old", "s1", "t1", now.AddMinutes(-5), "checkpoint old", "state-old", null, null); var newer = new RuntimeCheckpoint("cp-new", "s1", "t2", now, "checkpoint new", "state-new", null, null); @@ -230,7 +234,7 @@ public async Task FileCheckpointStore_save_and_get_latest_roundtrip() [Fact] public async Task FileCheckpointStore_returns_null_when_no_checkpoints() { - var store = new FileCheckpointStore(_fileSystem, _pathService); + var store = new FileCheckpointStore(_fileSystem, CreateStoragePathResolver()); var latest = await store.GetLatestAsync(_tempDir, "nonexistent", CancellationToken.None); diff --git a/tests/SharpClaw.Code.UnitTests/Support/TestDirectoryCleanup.cs b/tests/SharpClaw.Code.UnitTests/Support/TestDirectoryCleanup.cs new file mode 100644 index 0000000..f0b8b24 --- /dev/null +++ b/tests/SharpClaw.Code.UnitTests/Support/TestDirectoryCleanup.cs @@ -0,0 +1,41 @@ +using Microsoft.Data.Sqlite; + +namespace SharpClaw.Code.UnitTests.Support; + +internal static class TestDirectoryCleanup +{ + public static void DeleteIfExists(string path, bool clearSqlitePools = false) + { + if (string.IsNullOrWhiteSpace(path)) + { + return; + } + + for (var attempt = 0; attempt < 10; attempt++) + { + if (!Directory.Exists(path)) + { + return; + } + + try + { + if (clearSqlitePools) + { + SqliteConnection.ClearAllPools(); + } + + Directory.Delete(path, recursive: true); + return; + } + catch (IOException) when (attempt < 9) + { + Thread.Sleep(100); + } + catch (UnauthorizedAccessException) when (attempt < 9) + { + Thread.Sleep(100); + } + } + } +} diff --git a/tests/SharpClaw.Code.UnitTests/Support/TestRuntimeStorageResolver.cs b/tests/SharpClaw.Code.UnitTests/Support/TestRuntimeStorageResolver.cs new file mode 100644 index 0000000..b6f7b8d --- /dev/null +++ b/tests/SharpClaw.Code.UnitTests/Support/TestRuntimeStorageResolver.cs @@ -0,0 +1,28 @@ +using SharpClaw.Code.Infrastructure.Abstractions; +using SharpClaw.Code.Infrastructure.Services; + +namespace SharpClaw.Code.UnitTests.Support; + +internal static class TestRuntimeStorageResolver +{ + public static IRuntimeStoragePathResolver Create(string userRoot, IPathService? pathService = null) + { + var effectivePathService = pathService ?? new PathService(); + return new RuntimeStoragePathResolver( + effectivePathService, + new FixedUserProfilePaths(userRoot, effectivePathService), + new RuntimeHostContextAccessor()); + } + + private sealed class FixedUserProfilePaths(string root, IPathService pathService) : IUserProfilePaths + { + public string GetUserCustomCommandsDirectory() + => pathService.Combine(root, "commands"); + + public string GetUserHomeDirectory() + => root; + + public string GetUserSharpClawRoot() + => root; + } +} diff --git a/tests/SharpClaw.Code.UnitTests/Telemetry/InProcessRuntimeEventStreamTests.cs b/tests/SharpClaw.Code.UnitTests/Telemetry/InProcessRuntimeEventStreamTests.cs new file mode 100644 index 0000000..3e15581 --- /dev/null +++ b/tests/SharpClaw.Code.UnitTests/Telemetry/InProcessRuntimeEventStreamTests.cs @@ -0,0 +1,43 @@ +using FluentAssertions; +using Microsoft.Extensions.Options; +using SharpClaw.Code.Protocol.Events; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Telemetry; +using SharpClaw.Code.Telemetry.Services; + +namespace SharpClaw.Code.UnitTests.Telemetry; + +/// +/// Verifies the in-process runtime event stream keeps a bounded recent-event buffer. +/// +public sealed class InProcessRuntimeEventStreamTests +{ + [Fact] + public async Task PublishAsync_should_trim_recent_envelopes_to_capacity() + { + var stream = new InProcessRuntimeEventStream(Options.Create(new TelemetryOptions + { + RuntimeEventRingBufferCapacity = 64, + })); + + for (var i = 0; i < 80; i++) + { + await stream.PublishAsync(CreateEnvelope($"evt-{i}"), CancellationToken.None); + } + + var snapshot = stream.GetRecentEnvelopesSnapshot(); + snapshot.Should().HaveCount(64); + snapshot.Select(item => item.Event.EventId).Should().Equal(Enumerable.Range(16, 64).Select(index => $"evt-{index}")); + } + + private static RuntimeEventEnvelope CreateEnvelope(string eventId) + => new( + EventType: nameof(UsageUpdatedEvent), + OccurredAtUtc: DateTimeOffset.UtcNow, + Event: new UsageUpdatedEvent( + EventId: eventId, + SessionId: "session-1", + TurnId: "turn-1", + OccurredAtUtc: DateTimeOffset.UtcNow, + Usage: new UsageSnapshot(1, 1, 0, 2, null))); +} diff --git a/tests/SharpClaw.Code.UnitTests/Telemetry/TelemetryPublisherTests.cs b/tests/SharpClaw.Code.UnitTests/Telemetry/TelemetryPublisherTests.cs index ecf4061..5e04bc3 100644 --- a/tests/SharpClaw.Code.UnitTests/Telemetry/TelemetryPublisherTests.cs +++ b/tests/SharpClaw.Code.UnitTests/Telemetry/TelemetryPublisherTests.cs @@ -87,4 +87,48 @@ public void JsonTraceExporter_should_emit_polymorphic_event_type() var json = exporter.SerializeEvents(events, writeIndented: false); json.Should().Contain("\"$eventType\":\"toolStarted\""); } + + /// + /// Ensures one failing external sink does not break telemetry publishing for the runtime. + /// + [Fact] + public async Task RuntimeEventPublisher_should_isolate_sink_failures() + { + var usageTracker = new UsageTracker(); + var recordingSink = new RecordingSink(); + var publisher = new RuntimeEventPublisher( + Options.Create(new TelemetryOptions { RuntimeEventRingBufferCapacity = 16 }), + usageTracker, + sinks: [new ThrowingSink(), recordingSink]); + + await publisher.PublishAsync( + new UsageUpdatedEvent( + EventId: "e2", + SessionId: "s1", + TurnId: "t1", + OccurredAtUtc: DateTimeOffset.UtcNow, + Usage: new UsageSnapshot(2, 3, 0, 5, 0.1m)), + new RuntimeEventPublishOptions("/tmp/ws", "s1", PersistToSessionStore: false), + CancellationToken.None); + + recordingSink.Envelopes.Should().ContainSingle(); + usageTracker.TryGetCumulative("s1")!.TotalTokens.Should().Be(5); + } + + private sealed class ThrowingSink : SharpClaw.Code.Telemetry.Abstractions.IRuntimeEventSink + { + public Task PublishAsync(RuntimeEventEnvelope envelope, CancellationToken cancellationToken) + => throw new InvalidOperationException("sink failed"); + } + + private sealed class RecordingSink : SharpClaw.Code.Telemetry.Abstractions.IRuntimeEventSink + { + public List Envelopes { get; } = []; + + public Task PublishAsync(RuntimeEventEnvelope envelope, CancellationToken cancellationToken) + { + Envelopes.Add(envelope); + return Task.CompletedTask; + } + } } diff --git a/tests/SharpClaw.Code.UnitTests/Telemetry/UsageMeteringServiceTests.cs b/tests/SharpClaw.Code.UnitTests/Telemetry/UsageMeteringServiceTests.cs new file mode 100644 index 0000000..0253e31 --- /dev/null +++ b/tests/SharpClaw.Code.UnitTests/Telemetry/UsageMeteringServiceTests.cs @@ -0,0 +1,168 @@ +using FluentAssertions; +using SharpClaw.Code.Infrastructure.Services; +using SharpClaw.Code.Protocol.Enums; +using SharpClaw.Code.Protocol.Events; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Runtime.Server; +using SharpClaw.Code.Telemetry.Services; +using SharpClaw.Code.UnitTests.Support; + +namespace SharpClaw.Code.UnitTests.Telemetry; + +/// +/// Verifies event-driven usage metering persistence and filtering. +/// +public sealed class UsageMeteringServiceTests : IDisposable +{ + private readonly string workspaceRoot = Path.Combine(Path.GetTempPath(), "sharpclaw-metering", Guid.NewGuid().ToString("N")); + private readonly string userRoot = Path.Combine(Path.GetTempPath(), "sharpclaw-metering-user", Guid.NewGuid().ToString("N")); + + [Fact] + public async Task Usage_metering_should_aggregate_usage_and_filter_by_time_window() + { + Directory.CreateDirectory(workspaceRoot); + Directory.CreateDirectory(userRoot); + + var store = new SqliteUsageMeteringStore( + new LocalFileSystem(), + new PathService(), + TestRuntimeStorageResolver.Create(userRoot)); + var metering = new UsageMeteringService(store); + var startedAt = DateTimeOffset.Parse("2026-04-16T18:00:00Z"); + var turn = new ConversationTurn( + Id: "turn-1", + SessionId: "session-1", + SequenceNumber: 1, + Input: "Build the feature", + Output: "Done.", + StartedAtUtc: startedAt, + CompletedAtUtc: startedAt.AddMilliseconds(120), + AgentId: "primary", + SlashCommandName: null, + Usage: new UsageSnapshot(10, 6, 0, 16, 0.24m), + Metadata: null); + var providerRequest = new ProviderRequest( + Id: "provider-1", + SessionId: "session-1", + TurnId: "turn-1", + ProviderName: "mock", + Model: "default", + Prompt: "Build the feature", + SystemPrompt: null, + OutputFormat: OutputFormat.Text, + Temperature: null, + Metadata: null); + + await metering.PublishAsync( + new RuntimeEventEnvelope( + EventType: nameof(ProviderStartedEvent), + OccurredAtUtc: startedAt, + Event: new ProviderStartedEvent("evt-provider-start", "session-1", "turn-1", startedAt, "mock", "default", providerRequest), + WorkspacePath: workspaceRoot, + SessionId: "session-1", + TenantId: "tenant-a", + HostId: "host-a"), + CancellationToken.None); + await metering.PublishAsync( + new RuntimeEventEnvelope( + EventType: nameof(ProviderCompletedEvent), + OccurredAtUtc: startedAt.AddMilliseconds(50), + Event: new ProviderCompletedEvent( + "evt-provider-complete", + "session-1", + "turn-1", + startedAt.AddMilliseconds(50), + "mock", + "default", + "provider-terminal", + "completed", + new UsageSnapshot(10, 6, 0, 16, 0.24m)), + WorkspacePath: workspaceRoot, + SessionId: "session-1", + TenantId: "tenant-a", + HostId: "host-a"), + CancellationToken.None); + await metering.PublishAsync( + new RuntimeEventEnvelope( + EventType: nameof(ToolStartedEvent), + OccurredAtUtc: startedAt.AddMilliseconds(60), + Event: new ToolStartedEvent( + "evt-tool-start", + "session-1", + "turn-1", + startedAt.AddMilliseconds(60), + new ToolExecutionRequest( + "tool-1", + "session-1", + "turn-1", + "write_file", + """{"path":"notes.txt","content":"ok"}""", + ApprovalScope.FileSystemWrite, + workspaceRoot, + RequiresApproval: true, + IsDestructive: true)), + WorkspacePath: workspaceRoot, + SessionId: "session-1", + TenantId: "tenant-a", + HostId: "host-a"), + CancellationToken.None); + await metering.PublishAsync( + new RuntimeEventEnvelope( + EventType: nameof(ToolCompletedEvent), + OccurredAtUtc: startedAt.AddMilliseconds(90), + Event: new ToolCompletedEvent( + "evt-tool-complete", + "session-1", + "turn-1", + startedAt.AddMilliseconds(90), + new ToolResult("tool-1", "write_file", true, OutputFormat.Text, "ok", null, 0, null, null)), + WorkspacePath: workspaceRoot, + SessionId: "session-1", + TenantId: "tenant-a", + HostId: "host-a"), + CancellationToken.None); + await metering.PublishAsync( + new RuntimeEventEnvelope( + EventType: nameof(TurnCompletedEvent), + OccurredAtUtc: startedAt.AddMilliseconds(120), + Event: new TurnCompletedEvent("evt-turn-complete", "session-1", "turn-1", startedAt.AddMilliseconds(120), turn, true, "success"), + WorkspacePath: workspaceRoot, + SessionId: "session-1", + TenantId: "tenant-a", + HostId: "host-a"), + CancellationToken.None); + + var summary = await metering.GetSummaryAsync( + workspaceRoot, + new UsageMeteringQuery(TenantId: "tenant-a", HostId: "host-a", WorkspaceRoot: workspaceRoot), + CancellationToken.None); + var detail = await metering.GetDetailAsync( + workspaceRoot, + new UsageMeteringQuery(WorkspaceRoot: workspaceRoot), + 20, + CancellationToken.None); + var filtered = await metering.GetSummaryAsync( + workspaceRoot, + new UsageMeteringQuery(FromUtc: startedAt.AddMilliseconds(55), WorkspaceRoot: workspaceRoot), + CancellationToken.None); + + summary.TotalUsage.TotalTokens.Should().Be(16); + summary.ProviderRequestCount.Should().Be(1); + summary.ToolExecutionCount.Should().Be(1); + summary.TurnCount.Should().Be(1); + detail.Records.Should().Contain(record => record.Kind == UsageMeteringRecordKind.ProviderUsage && record.DurationMilliseconds == 50); + detail.Records.Should().Contain(record => + record.Kind == UsageMeteringRecordKind.ToolExecution + && record.DurationMilliseconds == 30 + && record.ApprovalScope == ApprovalScope.FileSystemWrite); + filtered.TotalUsage.TotalTokens.Should().Be(0); + filtered.ProviderRequestCount.Should().Be(0); + filtered.ToolExecutionCount.Should().Be(1); + } + + public void Dispose() + { + TestDirectoryCleanup.DeleteIfExists(workspaceRoot, clearSqlitePools: true); + TestDirectoryCleanup.DeleteIfExists(userRoot, clearSqlitePools: true); + } +} diff --git a/tests/SharpClaw.Code.UnitTests/Telemetry/WebhookRuntimeEventSinkTests.cs b/tests/SharpClaw.Code.UnitTests/Telemetry/WebhookRuntimeEventSinkTests.cs new file mode 100644 index 0000000..5113820 --- /dev/null +++ b/tests/SharpClaw.Code.UnitTests/Telemetry/WebhookRuntimeEventSinkTests.cs @@ -0,0 +1,183 @@ +using System.Net; +using System.Net.Http; +using FluentAssertions; +using Microsoft.Extensions.Options; +using SharpClaw.Code.Protocol.Events; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Telemetry; +using SharpClaw.Code.Telemetry.Abstractions; +using SharpClaw.Code.Telemetry.Services; + +namespace SharpClaw.Code.UnitTests.Telemetry; + +/// +/// Verifies webhook runtime event delivery and retry behavior. +/// +public sealed class WebhookRuntimeEventSinkTests +{ + [Fact] + public async Task PublishAsync_should_skip_delivery_when_no_webhooks_are_configured() + { + var handler = new SequenceMessageHandler(HttpStatusCode.OK); + var sink = new WebhookRuntimeEventSink( + Options.Create(new TelemetryOptions()), + new HttpClient(handler), + new RecordingDelayStrategy()); + + await sink.PublishAsync(CreateEnvelope(), CancellationToken.None); + + handler.AttemptCount.Should().Be(0); + } + + [Fact] + public async Task PublishAsync_should_retry_after_a_failure_and_eventually_succeed() + { + var options = new TelemetryOptions + { + WebhookMaxAttempts = 3, + WebhookInitialBackoffMilliseconds = 25, + }; + options.EventWebhookUrls.Add("https://example.com/runtime-events"); + var handler = new SequenceMessageHandler(HttpStatusCode.InternalServerError, HttpStatusCode.OK); + var delayStrategy = new RecordingDelayStrategy(); + var sink = new WebhookRuntimeEventSink( + Options.Create(options), + new HttpClient(handler), + delayStrategy); + + await sink.PublishAsync(CreateEnvelope(), CancellationToken.None); + + handler.AttemptCount.Should().Be(2); + delayStrategy.Delays.Should().Equal(TimeSpan.FromMilliseconds(25)); + } + + [Fact] + public async Task PublishAsync_should_stop_after_the_configured_attempt_limit() + { + var options = new TelemetryOptions + { + WebhookMaxAttempts = 3, + WebhookInitialBackoffMilliseconds = 10, + }; + options.EventWebhookUrls.Add("https://example.com/runtime-events"); + var handler = new SequenceMessageHandler( + HttpStatusCode.InternalServerError, + HttpStatusCode.InternalServerError, + HttpStatusCode.InternalServerError); + var delayStrategy = new RecordingDelayStrategy(); + var sink = new WebhookRuntimeEventSink( + Options.Create(options), + new HttpClient(handler), + delayStrategy); + + await sink.PublishAsync(CreateEnvelope(), CancellationToken.None); + + handler.AttemptCount.Should().Be(3); + delayStrategy.Delays.Should().Equal( + TimeSpan.FromMilliseconds(10), + TimeSpan.FromMilliseconds(20)); + } + + [Fact] + public async Task PublishAsync_should_apply_exponential_backoff_between_attempts() + { + var options = new TelemetryOptions + { + WebhookMaxAttempts = 4, + WebhookInitialBackoffMilliseconds = 50, + }; + options.EventWebhookUrls.Add("https://example.com/runtime-events"); + var handler = new SequenceMessageHandler( + HttpStatusCode.InternalServerError, + HttpStatusCode.InternalServerError, + HttpStatusCode.InternalServerError, + HttpStatusCode.OK); + var delayStrategy = new RecordingDelayStrategy(); + var sink = new WebhookRuntimeEventSink( + Options.Create(options), + new HttpClient(handler), + delayStrategy); + + await sink.PublishAsync(CreateEnvelope(), CancellationToken.None); + + delayStrategy.Delays.Should().Equal( + TimeSpan.FromMilliseconds(50), + TimeSpan.FromMilliseconds(100), + TimeSpan.FromMilliseconds(200)); + } + + [Fact] + public async Task PublishAsync_should_propagate_cancellation_without_retrying() + { + var options = new TelemetryOptions + { + WebhookMaxAttempts = 3, + WebhookInitialBackoffMilliseconds = 50, + }; + options.EventWebhookUrls.Add("https://example.com/runtime-events"); + using var cancellationTokenSource = new CancellationTokenSource(); + await cancellationTokenSource.CancelAsync(); + var handler = new CancelingMessageHandler(cancellationTokenSource.Token); + var delayStrategy = new RecordingDelayStrategy(); + var sink = new WebhookRuntimeEventSink( + Options.Create(options), + new HttpClient(handler), + delayStrategy); + + await Assert.ThrowsAnyAsync(() => sink.PublishAsync(CreateEnvelope(), cancellationTokenSource.Token)); + + handler.AttemptCount.Should().Be(1); + delayStrategy.Delays.Should().BeEmpty(); + } + + private static RuntimeEventEnvelope CreateEnvelope() + => new( + EventType: nameof(UsageUpdatedEvent), + OccurredAtUtc: DateTimeOffset.UtcNow, + Event: new UsageUpdatedEvent( + EventId: "evt-usage", + SessionId: "session-1", + TurnId: "turn-1", + OccurredAtUtc: DateTimeOffset.UtcNow, + Usage: new UsageSnapshot(1, 2, 0, 3, 0.01m)), + WorkspacePath: "/workspace", + SessionId: "session-1", + TenantId: "tenant-a", + HostId: "host-a"); + + private sealed class RecordingDelayStrategy : IWebhookDelayStrategy + { + public List Delays { get; } = []; + + public Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken) + { + Delays.Add(delay); + return Task.CompletedTask; + } + } + + private sealed class SequenceMessageHandler(params HttpStatusCode[] responses) : HttpMessageHandler + { + private readonly Queue responses = new(responses); + + public int AttemptCount { get; private set; } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + AttemptCount++; + var statusCode = responses.Count == 0 ? HttpStatusCode.OK : responses.Dequeue(); + return Task.FromResult(new HttpResponseMessage(statusCode)); + } + } + + private sealed class CancelingMessageHandler(CancellationToken token) : HttpMessageHandler + { + public int AttemptCount { get; private set; } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + AttemptCount++; + return Task.FromCanceled(token); + } + } +} diff --git a/tests/SharpClaw.Code.UnitTests/Tools/ToolPackageServiceTests.cs b/tests/SharpClaw.Code.UnitTests/Tools/ToolPackageServiceTests.cs new file mode 100644 index 0000000..90377fc --- /dev/null +++ b/tests/SharpClaw.Code.UnitTests/Tools/ToolPackageServiceTests.cs @@ -0,0 +1,183 @@ +using System.IO.Compression; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using SharpClaw.Code.Plugins.Abstractions; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Tools; +using SharpClaw.Code.Tools.Abstractions; + +namespace SharpClaw.Code.UnitTests.Tools; + +/// +/// Verifies packaged tool manifests install into the workspace catalog and map onto plugins. +/// +public sealed class ToolPackageServiceTests : IDisposable +{ + private readonly string workspaceRoot = Path.Combine(Path.GetTempPath(), "sharpclaw-tool-packages", Guid.NewGuid().ToString("N")); + + [Fact] + public async Task Tool_package_service_should_install_local_packages_with_resolved_entry_metadata() + { + Directory.CreateDirectory(workspaceRoot); + var sourceRoot = CreatePackageSource("local", "bin/widget-tool.dll"); + using var serviceProvider = CreateServiceProvider(); + + var packageService = serviceProvider.GetRequiredService(); + var pluginManager = serviceProvider.GetRequiredService(); + var request = new ToolPackageInstallRequest( + new ToolPackageManifest( + new ToolPackageReference( + "acme.widgets", + "1.2.3", + "local", + "bin/widget-tool.dll", + EntryArguments: ["--mode", "serve"], + TargetFramework: "net10.0"), + "acme", + "Widget helper tools", + [new PackagedToolDescriptor("widget_lookup", "Looks up widgets", """{"type":"object"}""", Tags: ["widgets"])]), + InstallSource: "unit-test", + EnableAfterInstall: false, + SourceReference: sourceRoot); + + var installed = await packageService.InstallAsync(workspaceRoot, request, CancellationToken.None); + var listed = await packageService.ListInstalledAsync(workspaceRoot, CancellationToken.None); + var plugins = await pluginManager.ListAsync(workspaceRoot, CancellationToken.None); + + installed.Manifest.Package.PackageId.Should().Be("acme.widgets"); + installed.ResolvedInstall.Should().NotBeNull(); + installed.ResolvedInstall!.ResolvedEntryAssembly.Should().Be(Path.Combine(sourceRoot, "bin", "widget-tool.dll")); + installed.ResolvedInstall.ResolvedEntryArguments.Should().BeEquivalentTo(["--mode", "serve"]); + listed.Should().ContainSingle(package => package.Manifest.Package.PackageId == "acme.widgets"); + plugins.Should().ContainSingle(plugin => plugin.Descriptor.Id == "acme.widgets"); + } + + [Fact] + public async Task Tool_package_service_should_install_nuget_packages_from_local_archives() + { + Directory.CreateDirectory(workspaceRoot); + var archivePath = CreateNuGetArchive("nuget", "tools/echo.sh"); + using var serviceProvider = CreateServiceProvider(); + + var packageService = serviceProvider.GetRequiredService(); + var request = new ToolPackageInstallRequest( + new ToolPackageManifest( + new ToolPackageReference( + "acme.echo", + "2.0.0", + "nuget", + "tools/echo.sh", + TargetFramework: "net10.0"), + "acme", + "Echo tool package", + [new PackagedToolDescriptor("echo_tool", "Echoes content", """{"type":"object"}""")]), + InstallSource: "unit-test", + EnableAfterInstall: false, + SourceReference: archivePath, + PackageSource: "local-archive"); + + var installed = await packageService.InstallAsync(workspaceRoot, request, CancellationToken.None); + + installed.ResolvedInstall.Should().NotBeNull(); + installed.ResolvedInstall!.PackageFilePath.Should().NotBeNull(); + installed.ResolvedInstall.ExtractedPackageRoot.Should().NotBeNull(); + installed.ResolvedInstall.ResolvedEntryAssembly.Should().EndWith(Path.Combine("tools", "echo.sh")); + File.Exists(installed.ResolvedInstall.PackageFilePath!).Should().BeTrue(); + File.Exists(installed.ResolvedInstall.ResolvedEntryAssembly).Should().BeTrue(); + } + + [Fact] + public async Task Tool_package_service_should_reject_duplicate_tool_names_across_packages() + { + Directory.CreateDirectory(workspaceRoot); + var sourceRoot = CreatePackageSource("conflict", "tool.dll"); + using var serviceProvider = CreateServiceProvider(); + var packageService = serviceProvider.GetRequiredService(); + + await packageService.InstallAsync( + workspaceRoot, + new ToolPackageInstallRequest( + new ToolPackageManifest( + new ToolPackageReference("acme.first", "1.0.0", "local", "tool.dll", TargetFramework: "net10.0"), + "acme", + "First package", + [new PackagedToolDescriptor("shared_tool", "Shared tool", """{"type":"object"}""")]), + InstallSource: "unit-test", + EnableAfterInstall: false, + SourceReference: sourceRoot), + CancellationToken.None); + + var act = () => packageService.InstallAsync( + workspaceRoot, + new ToolPackageInstallRequest( + new ToolPackageManifest( + new ToolPackageReference("acme.second", "1.0.0", "local", "tool.dll", TargetFramework: "net10.0"), + "acme", + "Second package", + [new PackagedToolDescriptor("shared_tool", "Shared tool again", """{"type":"object"}""")]), + InstallSource: "unit-test", + EnableAfterInstall: false, + SourceReference: sourceRoot), + CancellationToken.None); + + await act.Should().ThrowAsync() + .WithMessage("*shared_tool*already installed*"); + } + + [Fact] + public async Task Tool_package_service_should_reject_unsupported_target_frameworks() + { + Directory.CreateDirectory(workspaceRoot); + var sourceRoot = CreatePackageSource("framework", "tool.dll"); + using var serviceProvider = CreateServiceProvider(); + var packageService = serviceProvider.GetRequiredService(); + + var act = () => packageService.InstallAsync( + workspaceRoot, + new ToolPackageInstallRequest( + new ToolPackageManifest( + new ToolPackageReference("acme.legacy", "1.0.0", "local", "tool.dll", TargetFramework: "net8.0"), + "acme", + "Legacy package", + [new PackagedToolDescriptor("legacy_tool", "Legacy tool", """{"type":"object"}""")]), + InstallSource: "unit-test", + EnableAfterInstall: false, + SourceReference: sourceRoot), + CancellationToken.None); + + await act.Should().ThrowAsync() + .WithMessage("*net8.0*net10.0*"); + } + + public void Dispose() + { + if (Directory.Exists(workspaceRoot)) + { + Directory.Delete(workspaceRoot, recursive: true); + } + } + + private static ServiceProvider CreateServiceProvider() + { + var services = new ServiceCollection(); + services.AddSharpClawTools(); + return services.BuildServiceProvider(); + } + + private string CreatePackageSource(string name, string relativeEntryAssembly) + { + var root = Path.Combine(workspaceRoot, name); + var fullEntryAssemblyPath = Path.Combine(root, relativeEntryAssembly.Replace('/', Path.DirectorySeparatorChar)); + Directory.CreateDirectory(Path.GetDirectoryName(fullEntryAssemblyPath)!); + File.WriteAllText(fullEntryAssemblyPath, "placeholder"); + return root; + } + + private string CreateNuGetArchive(string name, string relativeEntryAssembly) + { + var sourceRoot = CreatePackageSource(name, relativeEntryAssembly); + var archivePath = Path.Combine(workspaceRoot, $"{name}.nupkg"); + ZipFile.CreateFromDirectory(sourceRoot, archivePath); + return archivePath; + } +}