From b5cf8580b7ccecfc405a7f5f6a4d44e77e856286 Mon Sep 17 00:00:00 2001 From: "denizirgin@gmail.com" Date: Sun, 6 Sep 2026 00:43:05 +0300 Subject: [PATCH 01/62] fix(sdk): curate RPC and plugin operation names --- CHANGELOG.md | 9 ++++- src/OpenCode.Sdk/OpenCodeRoutes.cs | 38 +++++++++---------- src/OpenCode.Sdk/Plugins/PluginsClient.cs | 32 ++++++++-------- src/OpenCode.Sdk/Rpc/RpcClient.cs | 4 +- .../Plugins/PluginsClientLiveTests.cs | 24 ++++++------ .../Rpc/RpcClientLiveTests.cs | 8 ++-- .../Snapshots/PublicApi.verified.txt | 24 ++++++------ tools/curation.json | 20 ++++++++++ 8 files changed, 93 insertions(+), 66 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f0b13894..d62772f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,8 +23,8 @@ Each released version links straight to its GitHub Release tag. `EventRpc`, tried after the declared literal tags and before the unknown carrier, and `UnknownEvent` refuses an `rpc.*` tag rather than absorbing it. Four operations arrived — `v2.plugin.awaitActivation`, `v2.plugin.check`, and `v2.plugin.update` as - `PluginsClient.PostAwaitActivationAsync`, `PostCheckAsync`, and `PostUpdateAsync`, and - `v2.rpc.call` as `PostCallAsync` on the new `RpcClient` family. Upstream also removed and + `PluginsClient.AwaitPluginActivationAsync`, `CheckPluginUpdatesAsync`, and `UpdatePluginsAsync`, + and `v2.rpc.call` as `CallAsync` on the new `RpcClient` family. Upstream also removed and reshaped the plugin types, which nightly consumers will meet as compile breaks: the `plugin.added` event arm is gone, and `PluginAdded` and `PluginAddedData` with it; `IPluginInfo` and its `PluginInfoActive`, `PluginInfoFailed`, and `UnknownPluginInfo` variants are replaced by @@ -63,6 +63,11 @@ Each released version links straight to its GitHub Release tag. ### 🛠️ General +- **Breaking (nightly API):** `RpcClient.PostCallAsync` is now `CallAsync`; + `PluginsClient.PostAwaitActivationAsync`, `PostCheckAsync`, and `PostUpdateAsync` are now + `AwaitPluginActivationAsync`, `CheckPluginUpdatesAsync`, and `UpdatePluginsAsync`. Their + `OpenCodeRoutes` members use the corresponding names without `Async`; request/response types and + wire behavior are unchanged. - **Target frameworks:** `netstandard2.0`, `net472`, `net8.0`, `net9.0`, `net10.0` — for both packages. The suite runs on `net472` on Windows, real-process launcher tests included, and on `net8.0`/`net9.0`/`net10.0` on all three OSes. `netstandard2.0` is a consumption target rather diff --git a/src/OpenCode.Sdk/OpenCodeRoutes.cs b/src/OpenCode.Sdk/OpenCodeRoutes.cs index 84d062db..b78470ac 100644 --- a/src/OpenCode.Sdk/OpenCodeRoutes.cs +++ b/src/OpenCode.Sdk/OpenCodeRoutes.cs @@ -1186,17 +1186,17 @@ public static string RemovePersistentPty(string ptyId) public static class Plugins { /// - /// The 'GET /api/plugin' route template. + /// The 'POST /api/plugin/await-activation' route template. /// - public const string ListPluginsTemplate = "/api/plugin"; + public const string AwaitPluginActivationTemplate = "/api/plugin/await-activation"; /// - /// Builds the '/api/plugin' route. + /// Builds the '/api/plugin/await-activation' route. /// /// The request shaping the query. /// The escaped route. - public static string ListPlugins(PluginListRequest? request = null) + public static string AwaitPluginActivation(PluginAwaitActivationPostRequest? request = null) { - var path = "/api/plugin"; + var path = "/api/plugin/await-activation"; if (request is null) { return path; @@ -1208,17 +1208,17 @@ public static string ListPlugins(PluginListRequest? request = null) } /// - /// The 'POST /api/plugin/await-activation' route template. + /// The 'POST /api/plugin/check' route template. /// - public const string PostAwaitActivationTemplate = "/api/plugin/await-activation"; + public const string CheckPluginUpdatesTemplate = "/api/plugin/check"; /// - /// Builds the '/api/plugin/await-activation' route. + /// Builds the '/api/plugin/check' route. /// /// The request shaping the query. /// The escaped route. - public static string PostAwaitActivation(PluginAwaitActivationPostRequest? request = null) + public static string CheckPluginUpdates(PluginCheckPostRequest? request = null) { - var path = "/api/plugin/await-activation"; + var path = "/api/plugin/check"; if (request is null) { return path; @@ -1230,17 +1230,17 @@ public static string PostAwaitActivation(PluginAwaitActivationPostRequest? reque } /// - /// The 'POST /api/plugin/check' route template. + /// The 'GET /api/plugin' route template. /// - public const string PostCheckTemplate = "/api/plugin/check"; + public const string ListPluginsTemplate = "/api/plugin"; /// - /// Builds the '/api/plugin/check' route. + /// Builds the '/api/plugin' route. /// /// The request shaping the query. /// The escaped route. - public static string PostCheck(PluginCheckPostRequest? request = null) + public static string ListPlugins(PluginListRequest? request = null) { - var path = "/api/plugin/check"; + var path = "/api/plugin"; if (request is null) { return path; @@ -1254,13 +1254,13 @@ public static string PostCheck(PluginCheckPostRequest? request = null) /// /// The 'POST /api/plugin/update' route template. /// - public const string PostUpdateTemplate = "/api/plugin/update"; + public const string UpdatePluginsTemplate = "/api/plugin/update"; /// /// Builds the '/api/plugin/update' route. /// /// The request shaping the query. /// The escaped route. - public static string PostUpdate(PluginUpdatePostRequest? request = null) + public static string UpdatePlugins(PluginUpdatePostRequest? request = null) { var path = "/api/plugin/update"; if (request is null) @@ -1585,7 +1585,7 @@ public static class Rpc /// /// The 'POST /api/rpc/{rpcID}/{method}' route template. /// - public const string PostCallTemplate = "/api/rpc/{rpcID}/{method}"; + public const string CallTemplate = "/api/rpc/{rpcID}/{method}"; /// /// Builds the '/api/rpc/{rpcID}/{method}' route. /// @@ -1593,7 +1593,7 @@ public static class Rpc /// The 'method' route value. /// The request shaping the query. /// The escaped route. - public static string PostCall(string rpcId, string method, RpcCallPostRequest? request = null) + public static string Call(string rpcId, string method, RpcCallPostRequest? request = null) { ArgumentException.ThrowIfNullOrWhiteSpace(rpcId); if (rpcId is "." or "..") diff --git a/src/OpenCode.Sdk/Plugins/PluginsClient.cs b/src/OpenCode.Sdk/Plugins/PluginsClient.cs index 88ac3b81..10be5bd3 100644 --- a/src/OpenCode.Sdk/Plugins/PluginsClient.cs +++ b/src/OpenCode.Sdk/Plugins/PluginsClient.cs @@ -29,45 +29,45 @@ protected PluginsClient() private Pipeline Pipeline => _pipeline ?? throw MockSeam.CreateError("PluginsClient", "Pipeline"); /// - /// List plugins. Retrieve enabled server plugins and their current status. + /// Wait for plugin activation. Wait for configured plugin activation at a Location to settle, including missing-package installs. Completion does not imply every plugin succeeded or background resource discovery finished. Cancelling this wait does not cancel activation. /// /// The request shaping the query. /// The per-call options. /// The cancellation token. - /// The 'PluginListResponse' envelope. + /// The 'PluginAwaitActivationPostResponse' envelope. /// The API returned an error status (declared: 400, 401) and NoThrow was not selected. /// The server could not be reached or returned a malformed success body. - public virtual Task ListPluginsAsync(PluginListRequest? request = null, OpenCodeRequestOptions? requestOptions = null, CancellationToken cancellationToken = default) + public virtual Task AwaitPluginActivationAsync(PluginAwaitActivationPostRequest? request = null, OpenCodeRequestOptions? requestOptions = null, CancellationToken cancellationToken = default) { - return Pipeline.ExecuteAsync(HttpMethod.Get, OpenCodeRoutes.Plugins.ListPlugins(request), PluginListResponseAdapter.Instance, requestOptions, cancellationToken); + return Pipeline.ExecuteAsync(HttpMethod.Post, OpenCodeRoutes.Plugins.AwaitPluginActivation(request), PluginAwaitActivationPostResponseAdapter.Instance, requestOptions, cancellationToken); } /// - /// Wait for plugin activation. Wait for configured plugin activation at a Location to settle, including missing-package installs. Completion does not imply every plugin succeeded or background resource discovery finished. Cancelling this wait does not cancel activation. + /// Check plugin updates. Check one or all package plugins for available updates. /// - /// The request shaping the query. + /// The request body; an empty body is sent when omitted. /// The per-call options. /// The cancellation token. - /// The 'PluginAwaitActivationPostResponse' envelope. + /// The 'PluginCheckPostResponse' envelope. /// The API returned an error status (declared: 400, 401) and NoThrow was not selected. /// The server could not be reached or returned a malformed success body. - public virtual Task PostAwaitActivationAsync(PluginAwaitActivationPostRequest? request = null, OpenCodeRequestOptions? requestOptions = null, CancellationToken cancellationToken = default) + public virtual Task CheckPluginUpdatesAsync(PluginCheckPostRequest? request = null, OpenCodeRequestOptions? requestOptions = null, CancellationToken cancellationToken = default) { - return Pipeline.ExecuteAsync(HttpMethod.Post, OpenCodeRoutes.Plugins.PostAwaitActivation(request), PluginAwaitActivationPostResponseAdapter.Instance, requestOptions, cancellationToken); + return Pipeline.ExecuteAsync(HttpMethod.Post, OpenCodeRoutes.Plugins.CheckPluginUpdates(request), request ?? EmptyPluginCheckPostRequest, OpenCodeJsonContext.Default.PluginCheckPostRequest, PluginCheckPostResponseAdapter.Instance, requestOptions, cancellationToken); } /// - /// Check plugin updates. Check one or all package plugins for available updates. + /// List plugins. Retrieve enabled server plugins and their current status. /// - /// The request body; an empty body is sent when omitted. + /// The request shaping the query. /// The per-call options. /// The cancellation token. - /// The 'PluginCheckPostResponse' envelope. + /// The 'PluginListResponse' envelope. /// The API returned an error status (declared: 400, 401) and NoThrow was not selected. /// The server could not be reached or returned a malformed success body. - public virtual Task PostCheckAsync(PluginCheckPostRequest? request = null, OpenCodeRequestOptions? requestOptions = null, CancellationToken cancellationToken = default) + public virtual Task ListPluginsAsync(PluginListRequest? request = null, OpenCodeRequestOptions? requestOptions = null, CancellationToken cancellationToken = default) { - return Pipeline.ExecuteAsync(HttpMethod.Post, OpenCodeRoutes.Plugins.PostCheck(request), request ?? EmptyPluginCheckPostRequest, OpenCodeJsonContext.Default.PluginCheckPostRequest, PluginCheckPostResponseAdapter.Instance, requestOptions, cancellationToken); + return Pipeline.ExecuteAsync(HttpMethod.Get, OpenCodeRoutes.Plugins.ListPlugins(request), PluginListResponseAdapter.Instance, requestOptions, cancellationToken); } /// @@ -79,9 +79,9 @@ public virtual Task PostCheckAsync(PluginCheckPostReque /// The 'PluginUpdatePostResponse' envelope. /// The API returned an error status (declared: 400, 401, 503) and NoThrow was not selected. /// The server could not be reached or returned a malformed success body. - public virtual Task PostUpdateAsync(PluginUpdatePostRequest request, OpenCodeRequestOptions? requestOptions = null, CancellationToken cancellationToken = default) + public virtual Task UpdatePluginsAsync(PluginUpdatePostRequest request, OpenCodeRequestOptions? requestOptions = null, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(request); - return Pipeline.ExecuteAsync(HttpMethod.Post, OpenCodeRoutes.Plugins.PostUpdate(request), request, OpenCodeJsonContext.Default.PluginUpdatePostRequest, PluginUpdatePostResponseAdapter.Instance, requestOptions, cancellationToken); + return Pipeline.ExecuteAsync(HttpMethod.Post, OpenCodeRoutes.Plugins.UpdatePlugins(request), request, OpenCodeJsonContext.Default.PluginUpdatePostRequest, PluginUpdatePostResponseAdapter.Instance, requestOptions, cancellationToken); } } diff --git a/src/OpenCode.Sdk/Rpc/RpcClient.cs b/src/OpenCode.Sdk/Rpc/RpcClient.cs index 9e8f1938..cdc354a6 100644 --- a/src/OpenCode.Sdk/Rpc/RpcClient.cs +++ b/src/OpenCode.Sdk/Rpc/RpcClient.cs @@ -39,10 +39,10 @@ protected RpcClient() /// The 'RpcCallPostResponse' envelope. /// The API returned an error status (declared: 400, 401, 500) and NoThrow was not selected. /// The server could not be reached or returned a malformed success body. - public virtual Task PostCallAsync(string rpcId, string method, RpcCallPostRequest? request = null, OpenCodeRequestOptions? requestOptions = null, CancellationToken cancellationToken = default) + public virtual Task CallAsync(string rpcId, string method, RpcCallPostRequest? request = null, OpenCodeRequestOptions? requestOptions = null, CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrEmpty(rpcId); ArgumentException.ThrowIfNullOrEmpty(method); - return Pipeline.ExecuteAsync(HttpMethod.Post, OpenCodeRoutes.Rpc.PostCall(rpcId, method, request), request ?? EmptyRpcCallPostRequest, OpenCodeJsonContext.Default.RpcCallPostRequest, RpcCallPostResponseAdapter.Instance, requestOptions, cancellationToken); + return Pipeline.ExecuteAsync(HttpMethod.Post, OpenCodeRoutes.Rpc.Call(rpcId, method, request), request ?? EmptyRpcCallPostRequest, OpenCodeJsonContext.Default.RpcCallPostRequest, RpcCallPostResponseAdapter.Instance, requestOptions, cancellationToken); } } diff --git a/tests/OpenCode.Sdk.Tests/Plugins/PluginsClientLiveTests.cs b/tests/OpenCode.Sdk.Tests/Plugins/PluginsClientLiveTests.cs index 8b614d95..4b814ac6 100644 --- a/tests/OpenCode.Sdk.Tests/Plugins/PluginsClientLiveTests.cs +++ b/tests/OpenCode.Sdk.Tests/Plugins/PluginsClientLiveTests.cs @@ -35,12 +35,12 @@ public sealed class PluginsClientLiveTests(PinnedOpenCodeServerFixture server) [Test] [Timeout(60_000)] - public async Task PostAwaitActivationAsync_Should_Answer_204_Once_Activation_Settles( + public async Task AwaitPluginActivationAsync_Should_Answer_204_Once_Activation_Settles( CancellationToken cancellationToken) { using var client = server.CreateClient(); - var settled = await client.Plugins.PostAwaitActivationAsync(cancellationToken: cancellationToken); + var settled = await client.Plugins.AwaitPluginActivationAsync(cancellationToken: cancellationToken); await Assert.That(settled.Status).IsEqualTo(204); await Assert.That(settled.IsError).IsFalse(); @@ -57,7 +57,7 @@ public async Task ListPluginsAsync_Should_Report_The_Builtin_Inventory(Cancellat // plugin.list reads the inventory as it stands (handlers/plugin.ts:11-13) while check and // update settle activation first (:20, :60); a cold server lists nothing until the builtins // activate, so this test settles activation itself before it asserts the inventory. - _ = await client.Plugins.PostAwaitActivationAsync(cancellationToken: cancellationToken); + _ = await client.Plugins.AwaitPluginActivationAsync(cancellationToken: cancellationToken); var listed = await client.Plugins.ListPluginsAsync(cancellationToken: cancellationToken); @@ -78,12 +78,12 @@ public async Task ListPluginsAsync_Should_Report_The_Builtin_Inventory(Cancellat [Test] [Timeout(60_000)] - public async Task PostCheckAsync_Should_Answer_The_Inventory_When_No_Target_Is_Named( + public async Task CheckPluginUpdatesAsync_Should_Answer_The_Inventory_When_No_Target_Is_Named( CancellationToken cancellationToken) { using var client = server.CreateClient(); - var inventory = await client.Plugins.PostCheckAsync(cancellationToken: cancellationToken); + var inventory = await client.Plugins.CheckPluginUpdatesAsync(cancellationToken: cancellationToken); await Assert.That(inventory.Status).IsEqualTo(200); await Assert.That(inventory.IsError).IsFalse(); @@ -101,11 +101,12 @@ public async Task PostCheckAsync_Should_Answer_The_Inventory_When_No_Target_Is_N [Test] [Timeout(60_000)] - public async Task PostCheckAsync_Should_Refuse_A_Target_Outside_The_Inventory(CancellationToken cancellationToken) + public async Task CheckPluginUpdatesAsync_Should_Refuse_A_Target_Outside_The_Inventory( + CancellationToken cancellationToken) { using var client = server.CreateClient(); - var refused = await client.Plugins.PostCheckAsync( + var refused = await client.Plugins.CheckPluginUpdatesAsync( new PluginCheckPostRequest { Target = AbsentTarget }, OpenCodeRequestOptions.NoThrow, cancellationToken); await Assert.That(refused.Status).IsEqualTo(400); @@ -123,11 +124,11 @@ public async Task PostCheckAsync_Should_Refuse_A_Target_Outside_The_Inventory(Ca [Test] [Timeout(60_000)] - public async Task PostUpdateAsync_Should_Answer_204_When_No_Targets_Are_Named(CancellationToken cancellationToken) + public async Task UpdatePluginsAsync_Should_Answer_204_When_No_Targets_Are_Named(CancellationToken cancellationToken) { using var client = server.CreateClient(); - var updated = await client.Plugins.PostUpdateAsync( + var updated = await client.Plugins.UpdatePluginsAsync( new PluginUpdatePostRequest { Targets = [] }, cancellationToken: cancellationToken); await Assert.That(updated.Status).IsEqualTo(204); @@ -138,11 +139,12 @@ public async Task PostUpdateAsync_Should_Answer_204_When_No_Targets_Are_Named(Ca [Test] [Timeout(60_000)] - public async Task PostUpdateAsync_Should_Refuse_A_Target_Outside_The_Inventory(CancellationToken cancellationToken) + public async Task UpdatePluginsAsync_Should_Refuse_A_Target_Outside_The_Inventory( + CancellationToken cancellationToken) { using var client = server.CreateClient(); - var refused = await client.Plugins.PostUpdateAsync( + var refused = await client.Plugins.UpdatePluginsAsync( new PluginUpdatePostRequest { Targets = [AbsentTarget] }, OpenCodeRequestOptions.NoThrow, cancellationToken); await Assert.That(refused.Status).IsEqualTo(400); diff --git a/tests/OpenCode.Sdk.Tests/Rpc/RpcClientLiveTests.cs b/tests/OpenCode.Sdk.Tests/Rpc/RpcClientLiveTests.cs index 629fc324..b86be19b 100644 --- a/tests/OpenCode.Sdk.Tests/Rpc/RpcClientLiveTests.cs +++ b/tests/OpenCode.Sdk.Tests/Rpc/RpcClientLiveTests.cs @@ -29,12 +29,12 @@ public sealed class RpcClientLiveTests(PinnedOpenCodeServerFixture server) [Test] [Timeout(60_000)] - public async Task PostCallAsync_Should_Answer_The_Unavailable_Arm_On_The_NoThrow_Spine_When_No_Rpc_Is_Registered( + public async Task CallAsync_Should_Answer_The_Unavailable_Arm_On_The_NoThrow_Spine_When_No_Rpc_Is_Registered( CancellationToken cancellationToken) { using var client = server.CreateClient(); - var response = await client.Rpc.PostCallAsync( + var response = await client.Rpc.CallAsync( UnregisteredRpcId, Method, CallRequest(), OpenCodeRequestOptions.NoThrow, cancellationToken); await Assert.That(response.Status).IsEqualTo(400); @@ -58,13 +58,13 @@ public async Task PostCallAsync_Should_Answer_The_Unavailable_Arm_On_The_NoThrow [Test] [Timeout(60_000)] - public async Task PostCallAsync_Should_Throw_The_Unavailable_Arm_When_No_Rpc_Is_Registered( + public async Task CallAsync_Should_Throw_The_Unavailable_Arm_When_No_Rpc_Is_Registered( CancellationToken cancellationToken) { using var client = server.CreateClient(); var exception = await Assert - .That(async () => _ = await client.Rpc.PostCallAsync( + .That(async () => _ = await client.Rpc.CallAsync( UnregisteredRpcId, Method, CallRequest(), cancellationToken: cancellationToken)) .Throws(); diff --git a/tests/OpenCode.Sdk.Tests/Snapshots/PublicApi.verified.txt b/tests/OpenCode.Sdk.Tests/Snapshots/PublicApi.verified.txt index 56e12309..25183cf0 100644 --- a/tests/OpenCode.Sdk.Tests/Snapshots/PublicApi.verified.txt +++ b/tests/OpenCode.Sdk.Tests/Snapshots/PublicApi.verified.txt @@ -662,14 +662,14 @@ } public static class Plugins { + public const string AwaitPluginActivationTemplate = "/api/plugin/await-activation"; + public const string CheckPluginUpdatesTemplate = "/api/plugin/check"; public const string ListPluginsTemplate = "/api/plugin"; - public const string PostAwaitActivationTemplate = "/api/plugin/await-activation"; - public const string PostCheckTemplate = "/api/plugin/check"; - public const string PostUpdateTemplate = "/api/plugin/update"; + public const string UpdatePluginsTemplate = "/api/plugin/update"; + public static string AwaitPluginActivation(OpenCode.Sdk.PluginAwaitActivationPostRequest? request = null) { } + public static string CheckPluginUpdates(OpenCode.Sdk.Models.PluginCheckPostRequest? request = null) { } public static string ListPlugins(OpenCode.Sdk.PluginListRequest? request = null) { } - public static string PostAwaitActivation(OpenCode.Sdk.PluginAwaitActivationPostRequest? request = null) { } - public static string PostCheck(OpenCode.Sdk.Models.PluginCheckPostRequest? request = null) { } - public static string PostUpdate(OpenCode.Sdk.Models.PluginUpdatePostRequest? request = null) { } + public static string UpdatePlugins(OpenCode.Sdk.Models.PluginUpdatePostRequest? request = null) { } } public static class Projects { @@ -708,8 +708,8 @@ } public static class Rpc { - public const string PostCallTemplate = "/api/rpc/{rpcID}/{method}"; - public static string PostCall(string rpcId, string method, OpenCode.Sdk.Models.RpcCallPostRequest? request = null) { } + public const string CallTemplate = "/api/rpc/{rpcID}/{method}"; + public static string Call(string rpcId, string method, OpenCode.Sdk.Models.RpcCallPostRequest? request = null) { } } public static class Server { @@ -1155,10 +1155,10 @@ public class PluginsClient { protected PluginsClient() { } + public virtual System.Threading.Tasks.Task AwaitPluginActivationAsync(OpenCode.Sdk.PluginAwaitActivationPostRequest? request = null, OpenCode.Sdk.OpenCodeRequestOptions? requestOptions = null, System.Threading.CancellationToken cancellationToken = default) { } + public virtual System.Threading.Tasks.Task CheckPluginUpdatesAsync(OpenCode.Sdk.Models.PluginCheckPostRequest? request = null, OpenCode.Sdk.OpenCodeRequestOptions? requestOptions = null, System.Threading.CancellationToken cancellationToken = default) { } public virtual System.Threading.Tasks.Task ListPluginsAsync(OpenCode.Sdk.PluginListRequest? request = null, OpenCode.Sdk.OpenCodeRequestOptions? requestOptions = null, System.Threading.CancellationToken cancellationToken = default) { } - public virtual System.Threading.Tasks.Task PostAwaitActivationAsync(OpenCode.Sdk.PluginAwaitActivationPostRequest? request = null, OpenCode.Sdk.OpenCodeRequestOptions? requestOptions = null, System.Threading.CancellationToken cancellationToken = default) { } - public virtual System.Threading.Tasks.Task PostCheckAsync(OpenCode.Sdk.Models.PluginCheckPostRequest? request = null, OpenCode.Sdk.OpenCodeRequestOptions? requestOptions = null, System.Threading.CancellationToken cancellationToken = default) { } - public virtual System.Threading.Tasks.Task PostUpdateAsync(OpenCode.Sdk.Models.PluginUpdatePostRequest request, OpenCode.Sdk.OpenCodeRequestOptions? requestOptions = null, System.Threading.CancellationToken cancellationToken = default) { } + public virtual System.Threading.Tasks.Task UpdatePluginsAsync(OpenCode.Sdk.Models.PluginUpdatePostRequest request, OpenCode.Sdk.OpenCodeRequestOptions? requestOptions = null, System.Threading.CancellationToken cancellationToken = default) { } } public sealed class ProjectCurrentRequest : System.IEquatable { @@ -1358,7 +1358,7 @@ public class RpcClient { protected RpcClient() { } - public virtual System.Threading.Tasks.Task PostCallAsync(string rpcId, string method, OpenCode.Sdk.Models.RpcCallPostRequest? request = null, OpenCode.Sdk.OpenCodeRequestOptions? requestOptions = null, System.Threading.CancellationToken cancellationToken = default) { } + public virtual System.Threading.Tasks.Task CallAsync(string rpcId, string method, OpenCode.Sdk.Models.RpcCallPostRequest? request = null, OpenCode.Sdk.OpenCodeRequestOptions? requestOptions = null, System.Threading.CancellationToken cancellationToken = default) { } } public class ServerClient { diff --git a/tools/curation.json b/tools/curation.json index bfefae32..0fab0aca 100644 --- a/tools/curation.json +++ b/tools/curation.json @@ -329,6 +329,26 @@ "operationId": "v2.worktree.refresh", "methodName": "RefreshWorktreesAsync", "reason": "The operation's own verb is refresh; the mechanical POST prefix is transport detail, not part of the reviewed surface, exactly like v2.credential.update's own 'update' verb. The subject is the project's worktrees in the plural, matching v2.worktree.list's reviewed subject in the same client family." + }, + { + "operationId": "v2.rpc.call", + "methodName": "CallAsync", + "reason": "The RPC container already names the domain; the operation's own verb is call, and the mechanical POST prefix is transport detail." + }, + { + "operationId": "v2.plugin.awaitActivation", + "methodName": "AwaitPluginActivationAsync", + "reason": "The operation waits for plugin activation; the reviewed name states that completion boundary without the mechanical POST prefix." + }, + { + "operationId": "v2.plugin.check", + "methodName": "CheckPluginUpdatesAsync", + "reason": "The operation checks plugins for updates; the reviewed name keeps the plugin subject and names the update check rather than the transport verb." + }, + { + "operationId": "v2.plugin.update", + "methodName": "UpdatePluginsAsync", + "reason": "The operation updates plugins; the reviewed name keeps the plural plugin subject instead of the mechanical POST prefix." } ], "schemaNames": [ From f7e64b56318a50fcbcaae2add862a4a4f69f4752 Mon Sep 17 00:00:00 2001 From: "denizirgin@gmail.com" Date: Sun, 6 Sep 2026 01:53:45 +0300 Subject: [PATCH 02/62] test: prove launcher startup tree reaping --- .../Fixtures/Server/child-tree.js | 44 +++++ .../OpenCode.Sdk.Tests.csproj | 1 + .../OpenCodeServerLifecycleTests.cs | 49 ++--- .../Support/ServerStartupTreeHandshake.cs | 182 ++++++++++++++++++ .../Support/ServerStartupTreeScenario.cs | 140 ++++++++++++++ tests/Shared/FixtureLoader.cs | 8 +- 6 files changed, 398 insertions(+), 26 deletions(-) create mode 100644 tests/OpenCode.Sdk.Tests/Fixtures/Server/child-tree.js create mode 100644 tests/OpenCode.Sdk.Tests/Support/ServerStartupTreeHandshake.cs create mode 100644 tests/OpenCode.Sdk.Tests/Support/ServerStartupTreeScenario.cs diff --git a/tests/OpenCode.Sdk.Tests/Fixtures/Server/child-tree.js b/tests/OpenCode.Sdk.Tests/Fixtures/Server/child-tree.js new file mode 100644 index 00000000..5fc315fe --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Fixtures/Server/child-tree.js @@ -0,0 +1,44 @@ +const port = Number(process.env.OPENCODE_SDK_TEST_TREE_PORT); +const nonce = process.env.OPENCODE_SDK_TEST_TREE_NONCE; +const mode = process.env.OPENCODE_SDK_TEST_TREE_MODE; + +if (!Number.isInteger(port) || port <= 0 || !nonce || !mode) { + throw new Error("The child-tree fixture requires its port, nonce, and mode environment."); +} + +const child = Bun.spawn( + [process.execPath, "-e", "setTimeout(() => {}, 120000)"], + { stdin: "ignore", stdout: "ignore", stderr: "ignore" }, +); +const net = await import("node:net"); +const socket = net.createConnection({ host: "127.0.0.1", port }); + +await new Promise((resolve, reject) => { + let response = ""; + socket.setEncoding("utf8"); + socket.once("connect", () => { + socket.write(`${JSON.stringify({ nonce, rootPid: process.pid, childPid: child.pid })}\n`); + }); + socket.on("data", (chunk) => { + response += chunk; + const newline = response.indexOf("\n"); + if (newline < 0) return; + + if (response.slice(0, newline).trim() !== "ACK") { + reject(new Error("The child-tree fixture received an invalid acknowledgement.")); + return; + } + + resolve(); + }); + socket.once("error", reject); +}); + +socket.end(); +if (mode === "invalid-line") { + console.log("hello"); +} else if (mode !== "silent") { + throw new Error(`Unknown child-tree fixture mode '${mode}'.`); +} + +await new Promise(() => {}); diff --git a/tests/OpenCode.Sdk.Tests/OpenCode.Sdk.Tests.csproj b/tests/OpenCode.Sdk.Tests/OpenCode.Sdk.Tests.csproj index fcd23909..7d22a167 100644 --- a/tests/OpenCode.Sdk.Tests/OpenCode.Sdk.Tests.csproj +++ b/tests/OpenCode.Sdk.Tests/OpenCode.Sdk.Tests.csproj @@ -46,6 +46,7 @@ + diff --git a/tests/OpenCode.Sdk.Tests/OpenCodeServerLifecycleTests.cs b/tests/OpenCode.Sdk.Tests/OpenCodeServerLifecycleTests.cs index 019c9c97..bc576abc 100644 --- a/tests/OpenCode.Sdk.Tests/OpenCodeServerLifecycleTests.cs +++ b/tests/OpenCode.Sdk.Tests/OpenCodeServerLifecycleTests.cs @@ -1,3 +1,4 @@ +using OpenCode.Sdk.Tests.Support; using OpenCode.Sdk.TestSupport; using Testably.Abstractions; @@ -81,6 +82,7 @@ public async Task DisposeAsync_Should_End_The_Server_Through_The_Stdin_Lease(Can using var _ = runRoot; var processId = server.ProcessId; + await server.DisposeAsync(); await server.DisposeAsync(); await Assert.That(IsProcessRunning(processId)).IsFalse(); @@ -121,29 +123,31 @@ public async Task StartAsync_Should_Refuse_A_Server_That_Exits_Before_Readiness( [Timeout(120_000)] public async Task StartAsync_Should_Refuse_A_Server_That_Never_Reports_Readiness(CancellationToken cancellationToken) { - var failure = await Assert.That(async () => await OpenCodeServer.StartAsync( - new OpenCodeServerOptions - { - Command = ["bun", "-e", "setTimeout(() => {}, 120000)"], - ReadinessTimeout = TimeSpan.FromSeconds(2), - }, - cancellationToken)).Throws(); + await using var scenario = ServerStartupTreeScenario.BeginSilent( + TimeSpan.FromSeconds(15), cancellationToken); + await scenario.ObserveAndAcknowledgeAsync(cancellationToken); + + var failure = await Assert.That( + async () => _ = await scenario.WaitForStartupAsync(cancellationToken)).Throws(); await Assert.That(failure!.Message).Contains("did not report readiness"); + await Assert.That(scenario.RootProcess.HasExited).IsTrue(); + await Assert.That(scenario.ChildProcess.HasExited).IsTrue(); } [Test] [Timeout(120_000)] public async Task StartAsync_Should_Refuse_A_Non_Contract_First_Line(CancellationToken cancellationToken) { - var failure = await Assert.That(async () => await OpenCodeServer.StartAsync( - new OpenCodeServerOptions - { - Command = ["bun", "-e", "console.log('hello'); setTimeout(() => {}, 120000)"], - }, - cancellationToken)).Throws(); + await using var scenario = ServerStartupTreeScenario.BeginInvalidLine(cancellationToken); + await scenario.ObserveAndAcknowledgeAsync(cancellationToken); + + var failure = await Assert.That( + async () => _ = await scenario.WaitForStartupAsync(cancellationToken)).Throws(); await Assert.That(failure!.Message).Contains("readiness contract"); + await Assert.That(scenario.RootProcess.HasExited).IsTrue(); + await Assert.That(scenario.ChildProcess.HasExited).IsTrue(); } [Test] @@ -164,17 +168,16 @@ public async Task StartAsync_Should_Refuse_A_Missing_Executable(CancellationToke [Timeout(120_000)] public async Task StartAsync_Should_Surface_Caller_Cancellation(CancellationToken cancellationToken) { - // Linked to the test's own injected token so the manufactured 200ms caller-cancellation - // still composes with whatever the [Timeout] attribute (or an external test-run - // cancellation) also asks for, rather than replacing it outright. using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - cancellation.CancelAfter(TimeSpan.FromMilliseconds(200)); + await using var scenario = ServerStartupTreeScenario.BeginSilent( + TimeSpan.FromMinutes(2), cancellation.Token); + await scenario.ObserveAndAcknowledgeAsync(cancellationToken); - _ = await Assert.That(async () => await OpenCodeServer.StartAsync( - new OpenCodeServerOptions - { - Command = ["bun", "-e", "setTimeout(() => {}, 120000)"], - }, - cancellation.Token)).Throws(); + await cancellation.CancelAsync(); + + _ = await Assert.That( + async () => _ = await scenario.WaitForStartupAsync(cancellationToken)).Throws(); + await Assert.That(scenario.RootProcess.HasExited).IsTrue(); + await Assert.That(scenario.ChildProcess.HasExited).IsTrue(); } } diff --git a/tests/OpenCode.Sdk.Tests/Support/ServerStartupTreeHandshake.cs b/tests/OpenCode.Sdk.Tests/Support/ServerStartupTreeHandshake.cs new file mode 100644 index 00000000..2cbf3745 --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Support/ServerStartupTreeHandshake.cs @@ -0,0 +1,182 @@ +using System.Diagnostics; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Text.Json; + +namespace OpenCode.Sdk.Tests.Support; + +/// +/// Owns the loopback handshake that validates a nonce and captures the Bun root and child as live +/// process handles before acknowledging the startup peer. +/// +internal sealed class ServerStartupTreeHandshake : IDisposable +{ + private static readonly TimeSpan HandshakeTimeout = TimeSpan.FromSeconds(15); + + private readonly Task _observation; + private readonly TcpListener _listener; + private Process? _childProcess; + private Process? _rootProcess; + + public ServerStartupTreeHandshake(CancellationToken cancellationToken) + { + Nonce = Guid.NewGuid().ToString("N"); + _listener = new TcpListener(IPAddress.Loopback, 0); + _listener.Start(); + Port = ((IPEndPoint)_listener.LocalEndpoint).Port; + _observation = CaptureAndAcknowledgeAsync(cancellationToken); + } + + public Process ChildProcess => + _childProcess ?? throw new InvalidOperationException("The child process has not been captured."); + + public Process? CapturedChildProcess => _childProcess; + + public Process? CapturedRootProcess => _rootProcess; + + public string Nonce { get; } + + public int Port { get; } + + public Process RootProcess => + _rootProcess ?? throw new InvalidOperationException("The root process has not been captured."); + + public async Task ObserveAndAcknowledgeAsync(CancellationToken cancellationToken) + { + var observation = _observation; + await observation.WaitAsync(cancellationToken); + } + + public async Task AwaitCleanupAsync(CancellationToken cancellationToken) + { + try + { + var observation = _observation; + await observation.WaitAsync(cancellationToken); + } + catch (OperationCanceledException exception) + { + _ = exception; + } + catch (ObjectDisposedException exception) + { + _ = exception; + } + catch (SocketException exception) + { + _ = exception; + } + } + + public void Stop() => _listener.Stop(); + + public void Dispose() + { + _childProcess?.Dispose(); + _rootProcess?.Dispose(); +#if NET + _listener.Dispose(); +#endif + } + + private async Task CaptureAndAcknowledgeAsync(CancellationToken cancellationToken) + { + using var bound = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + bound.CancelAfter(HandshakeTimeout); + using var client = await AcceptTcpClientAsync(_listener, bound.Token); + using var stream = client.GetStream(); + using var reader = new StreamReader( + stream, + Encoding.UTF8, + detectEncodingFromByteOrderMarks: false, + bufferSize: 1024, + leaveOpen: true); + var line = await ReadLineAsync(reader, bound.Token) + ?? throw new InvalidOperationException("The child-tree handshake ended before process identity arrived."); + using var document = JsonDocument.Parse(line); + var root = CaptureProcess(document.RootElement, "rootPid"); + var transferred = false; + try + { + var child = CaptureProcess(document.RootElement, "childPid"); + try + { + var receivedNonce = document.RootElement.GetProperty("nonce").GetString(); + if (!string.Equals(receivedNonce, Nonce, StringComparison.Ordinal)) + { + throw new InvalidOperationException("The child-tree handshake nonce did not match this scenario."); + } + + if (root.Id == child.Id || root.HasExited || child.HasExited) + { + throw new InvalidOperationException("The child-tree handshake did not identify two live processes."); + } + + _rootProcess = root; + _childProcess = child; + transferred = true; + await WriteAcknowledgementAsync(stream, bound.Token); + } + finally + { + if (!transferred) + { + child.Dispose(); + } + } + } + finally + { + if (!transferred) + { + root.Dispose(); + } + } + } + + private static Process CaptureProcess(JsonElement handshake, string propertyName) + { + var processId = handshake.GetProperty(propertyName).GetInt32(); + try + { + return Process.GetProcessById(processId); + } + catch (ArgumentException exception) + { + throw new InvalidOperationException( + $"The child-tree handshake process '{propertyName}' was not alive when captured.", exception); + } + } + + private static Task AcceptTcpClientAsync( + TcpListener listener, + CancellationToken cancellationToken) + { +#if NET + return listener.AcceptTcpClientAsync(cancellationToken).AsTask(); +#else + return listener.AcceptTcpClientAsync().WaitAsync(cancellationToken); +#endif + } + + private static Task ReadLineAsync(StreamReader reader, CancellationToken cancellationToken) + { +#if NET + return reader.ReadLineAsync(cancellationToken).AsTask(); +#else + return reader.ReadLineAsync().WaitAsync(cancellationToken); +#endif + } + + private static async Task WriteAcknowledgementAsync(Stream stream, CancellationToken cancellationToken) + { + var acknowledgement = Encoding.ASCII.GetBytes("ACK\n"); +#if NET + await stream.WriteAsync(acknowledgement, cancellationToken); +#else + await stream.WriteAsync(acknowledgement, 0, acknowledgement.Length, cancellationToken); +#endif + await stream.FlushAsync(cancellationToken); + } +} diff --git a/tests/OpenCode.Sdk.Tests/Support/ServerStartupTreeScenario.cs b/tests/OpenCode.Sdk.Tests/Support/ServerStartupTreeScenario.cs new file mode 100644 index 00000000..e11ace75 --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Support/ServerStartupTreeScenario.cs @@ -0,0 +1,140 @@ +using System.ComponentModel; +using System.Diagnostics; +using System.Globalization; +using OpenCode.Sdk.TestSupport; + +namespace OpenCode.Sdk.Tests.Support; + +/// +/// Starts the launcher against a cooperating Bun process tree and owns the startup and bounded +/// cleanup around the handshake's verified process handles. +/// +internal sealed class ServerStartupTreeScenario : IAsyncDisposable +{ + private const string InvalidLineMode = "invalid-line"; + private const string SilentMode = "silent"; + + private static readonly TimeSpan CleanupTimeout = TimeSpan.FromSeconds(10); + + private readonly ServerStartupTreeHandshake _handshake; + private readonly Task _startup; + private readonly CancellationTokenSource _startupCancellation; + private int _disposed; + + private ServerStartupTreeScenario( + string mode, + TimeSpan readinessTimeout, + CancellationToken cancellationToken) + { + _startupCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + _handshake = new ServerStartupTreeHandshake(_startupCancellation.Token); + _startup = OpenCodeServer.StartAsync( + new OpenCodeServerOptions + { + Command = ["bun", "-e", new FixtureLoader().LoadText("Server.child-tree.js")], + Environment = new Dictionary(StringComparer.Ordinal) + { + ["OPENCODE_SDK_TEST_TREE_PORT"] = _handshake.Port.ToString(CultureInfo.InvariantCulture), + ["OPENCODE_SDK_TEST_TREE_NONCE"] = _handshake.Nonce, + ["OPENCODE_SDK_TEST_TREE_MODE"] = mode, + }, + ReadinessTimeout = readinessTimeout, + }, + _startupCancellation.Token); + } + + public Process ChildProcess => _handshake.ChildProcess; + + public Process RootProcess => _handshake.RootProcess; + + public static ServerStartupTreeScenario BeginInvalidLine(CancellationToken cancellationToken) => + new(InvalidLineMode, TimeSpan.FromMinutes(2), cancellationToken); + + public static ServerStartupTreeScenario BeginSilent( + TimeSpan readinessTimeout, + CancellationToken cancellationToken) => + new(SilentMode, readinessTimeout, cancellationToken); + + public Task ObserveAndAcknowledgeAsync(CancellationToken cancellationToken) => + _handshake.ObserveAndAcknowledgeAsync(cancellationToken); + + public async Task WaitForStartupAsync(CancellationToken cancellationToken) + { + var startup = _startup; + return await startup.WaitAsync(cancellationToken); + } + + public async ValueTask DisposeAsync() + { + if (Interlocked.Exchange(ref _disposed, 1) is 1) + { + return; + } + + using var cleanup = new CancellationTokenSource(CleanupTimeout); + await _startupCancellation.CancelAsync(); + _handshake.Stop(); + try + { + await _handshake.AwaitCleanupAsync(cleanup.Token); + await AwaitStartupCleanupAsync(cleanup.Token); + await EndCapturedProcessAsync(_handshake.CapturedChildProcess, cleanup.Token); + await EndCapturedProcessAsync(_handshake.CapturedRootProcess, cleanup.Token); + } + finally + { + _handshake.Dispose(); + _startupCancellation.Dispose(); + } + } + + private async Task AwaitStartupCleanupAsync(CancellationToken cancellationToken) + { + try + { + var startup = _startup; + var server = await startup.WaitAsync(cancellationToken); + await server.DisposeAsync(); + } + catch (OperationCanceledException exception) + { + _ = exception; + } + catch (OpenCodeServerException exception) + { + _ = exception; + } + } + + private static async Task EndCapturedProcessAsync(Process? process, CancellationToken cancellationToken) + { + if (process is null || process.HasExited) + { + return; + } + + try + { + process.Kill(); + } + catch (InvalidOperationException exception) + { + _ = exception; + return; + } + catch (Win32Exception exception) + { + _ = exception; + return; + } + + try + { + await process.WaitForExitAsync(cancellationToken); + } + catch (OperationCanceledException exception) + { + _ = exception; + } + } +} diff --git a/tests/Shared/FixtureLoader.cs b/tests/Shared/FixtureLoader.cs index bdf6b206..e1b9b251 100644 --- a/tests/Shared/FixtureLoader.cs +++ b/tests/Shared/FixtureLoader.cs @@ -3,17 +3,19 @@ namespace OpenCode.Sdk.TestSupport; -/// Loads embedded JSON fixtures from the compiling test assembly's Fixtures folder. +/// Loads embedded fixtures from the compiling test assembly's Fixtures folder. internal sealed class FixtureLoader { private readonly Assembly _assembly = typeof(FixtureLoader).Assembly; - public string LoadJson(string name) + public string LoadJson(string name) => LoadText(name); + + public string LoadText(string name) { ArgumentException.ThrowIfNullOrWhiteSpace(name); var resourceName = string.Concat(_assembly.GetName().Name, ".Fixtures.", name); var stream = _assembly.GetManifestResourceStream(resourceName) - ?? throw new ArgumentException($"Embedded JSON fixture '{name}' was not found.", nameof(name)); + ?? throw new ArgumentException($"Embedded fixture '{name}' was not found.", nameof(name)); using var reader = new BinaryReader(stream, Encoding.UTF8, leaveOpen: false); var content = Encoding.UTF8.GetString(reader.ReadBytes(checked((int)stream.Length))); From 240b3a49473000e9afd3c60f08ba573c967c9b81 Mon Sep 17 00:00:00 2001 From: "denizirgin@gmail.com" Date: Sun, 6 Sep 2026 02:17:36 +0300 Subject: [PATCH 03/62] test: harden startup tree fixture cleanup --- .../Fixtures/Server/child-tree.js | 67 ++++++----- .../OpenCodeServerLifecycleTests.cs | 39 ++++++ .../Support/ServerStartupTreeHandshake.cs | 31 ++--- .../Support/ServerStartupTreeScenario.cs | 111 ++++++++++++------ 4 files changed, 165 insertions(+), 83 deletions(-) diff --git a/tests/OpenCode.Sdk.Tests/Fixtures/Server/child-tree.js b/tests/OpenCode.Sdk.Tests/Fixtures/Server/child-tree.js index 5fc315fe..8f5e38a1 100644 --- a/tests/OpenCode.Sdk.Tests/Fixtures/Server/child-tree.js +++ b/tests/OpenCode.Sdk.Tests/Fixtures/Server/child-tree.js @@ -10,35 +10,46 @@ const child = Bun.spawn( [process.execPath, "-e", "setTimeout(() => {}, 120000)"], { stdin: "ignore", stdout: "ignore", stderr: "ignore" }, ); -const net = await import("node:net"); -const socket = net.createConnection({ host: "127.0.0.1", port }); - -await new Promise((resolve, reject) => { - let response = ""; - socket.setEncoding("utf8"); - socket.once("connect", () => { - socket.write(`${JSON.stringify({ nonce, rootPid: process.pid, childPid: child.pid })}\n`); - }); - socket.on("data", (chunk) => { - response += chunk; - const newline = response.indexOf("\n"); - if (newline < 0) return; +let socket; +try { + const net = await import("node:net"); + socket = net.createConnection({ host: "127.0.0.1", port }); + + await new Promise((resolve, reject) => { + let response = ""; + socket.setEncoding("utf8"); + socket.once("connect", () => { + socket.write(`${JSON.stringify({ nonce, rootPid: process.pid, childPid: child.pid })}\n`); + }); + socket.on("data", (chunk) => { + response += chunk; + const newline = response.indexOf("\n"); + if (newline < 0) return; - if (response.slice(0, newline).trim() !== "ACK") { - reject(new Error("The child-tree fixture received an invalid acknowledgement.")); - return; - } + if (response.slice(0, newline).trim() !== "ACK") { + reject(new Error("The child-tree fixture received an invalid acknowledgement.")); + return; + } - resolve(); + resolve(); + }); + socket.once("end", () => reject(new Error("The child-tree handshake ended before acknowledgement."))); + socket.once("error", reject); }); - socket.once("error", reject); -}); - -socket.end(); -if (mode === "invalid-line") { - console.log("hello"); -} else if (mode !== "silent") { - throw new Error(`Unknown child-tree fixture mode '${mode}'.`); -} -await new Promise(() => {}); + socket.end(); + if (mode === "invalid-line") { + console.log("hello"); + } else if (mode !== "silent") { + throw new Error(`Unknown child-tree fixture mode '${mode}'.`); + } + + await new Promise(() => {}); +} finally { + socket?.destroy(); + if (child.exitCode === null) { + child.kill(); + } + + await child.exited; +} diff --git a/tests/OpenCode.Sdk.Tests/OpenCodeServerLifecycleTests.cs b/tests/OpenCode.Sdk.Tests/OpenCodeServerLifecycleTests.cs index bc576abc..d7a13d52 100644 --- a/tests/OpenCode.Sdk.Tests/OpenCodeServerLifecycleTests.cs +++ b/tests/OpenCode.Sdk.Tests/OpenCodeServerLifecycleTests.cs @@ -150,6 +150,45 @@ public async Task StartAsync_Should_Refuse_A_Non_Contract_First_Line(Cancellatio await Assert.That(scenario.ChildProcess.HasExited).IsTrue(); } + [Test] + [Timeout(120_000)] + public async Task StartupTreeScenario_Should_Clean_Observed_Tree_When_Handshake_Fails( + CancellationToken cancellationToken) + { + var scenario = ServerStartupTreeScenario.BeginHandshakeFailure(cancellationToken); + InvalidOperationException? cleanupFailure = null; + try + { + var observationFailure = await Assert.That( + async () => await scenario.ObserveAndAcknowledgeAsync(cancellationToken)) + .Throws(); + _ = await Assert.That( + async () => _ = await scenario.WaitForStartupAsync(cancellationToken)) + .Throws(); + + await Assert.That(scenario.RootProcess.HasExited).IsTrue(); + await Assert.That(scenario.ChildProcess.HasExited).IsTrue(); + + await Assert.That(observationFailure!.Message) + .Contains("intentionally rejected for cleanup verification"); + } + finally + { + try + { + await scenario.DisposeAsync(); + } + catch (InvalidOperationException exception) + { + cleanupFailure = exception; + } + } + + await Assert.That(cleanupFailure).IsNotNull(); + await Assert.That(cleanupFailure!.Message) + .Contains("intentionally rejected for cleanup verification"); + } + [Test] [Timeout(120_000)] public async Task StartAsync_Should_Refuse_A_Missing_Executable(CancellationToken cancellationToken) diff --git a/tests/OpenCode.Sdk.Tests/Support/ServerStartupTreeHandshake.cs b/tests/OpenCode.Sdk.Tests/Support/ServerStartupTreeHandshake.cs index 2cbf3745..6be370e9 100644 --- a/tests/OpenCode.Sdk.Tests/Support/ServerStartupTreeHandshake.cs +++ b/tests/OpenCode.Sdk.Tests/Support/ServerStartupTreeHandshake.cs @@ -14,13 +14,15 @@ internal sealed class ServerStartupTreeHandshake : IDisposable { private static readonly TimeSpan HandshakeTimeout = TimeSpan.FromSeconds(15); + private readonly bool _acknowledge; private readonly Task _observation; private readonly TcpListener _listener; private Process? _childProcess; private Process? _rootProcess; - public ServerStartupTreeHandshake(CancellationToken cancellationToken) + public ServerStartupTreeHandshake(bool acknowledge, CancellationToken cancellationToken) { + _acknowledge = acknowledge; Nonce = Guid.NewGuid().ToString("N"); _listener = new TcpListener(IPAddress.Loopback, 0); _listener.Start(); @@ -48,27 +50,6 @@ public async Task ObserveAndAcknowledgeAsync(CancellationToken cancellationToken await observation.WaitAsync(cancellationToken); } - public async Task AwaitCleanupAsync(CancellationToken cancellationToken) - { - try - { - var observation = _observation; - await observation.WaitAsync(cancellationToken); - } - catch (OperationCanceledException exception) - { - _ = exception; - } - catch (ObjectDisposedException exception) - { - _ = exception; - } - catch (SocketException exception) - { - _ = exception; - } - } - public void Stop() => _listener.Stop(); public void Dispose() @@ -116,6 +97,12 @@ private async Task CaptureAndAcknowledgeAsync(CancellationToken cancellationToke _rootProcess = root; _childProcess = child; transferred = true; + if (!_acknowledge) + { + throw new InvalidOperationException( + "The child-tree handshake was intentionally rejected for cleanup verification."); + } + await WriteAcknowledgementAsync(stream, bound.Token); } finally diff --git a/tests/OpenCode.Sdk.Tests/Support/ServerStartupTreeScenario.cs b/tests/OpenCode.Sdk.Tests/Support/ServerStartupTreeScenario.cs index e11ace75..bc4bc647 100644 --- a/tests/OpenCode.Sdk.Tests/Support/ServerStartupTreeScenario.cs +++ b/tests/OpenCode.Sdk.Tests/Support/ServerStartupTreeScenario.cs @@ -1,6 +1,6 @@ -using System.ComponentModel; using System.Diagnostics; using System.Globalization; +using System.Runtime.ExceptionServices; using OpenCode.Sdk.TestSupport; namespace OpenCode.Sdk.Tests.Support; @@ -24,10 +24,11 @@ internal sealed class ServerStartupTreeScenario : IAsyncDisposable private ServerStartupTreeScenario( string mode, TimeSpan readinessTimeout, + bool acknowledge, CancellationToken cancellationToken) { _startupCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - _handshake = new ServerStartupTreeHandshake(_startupCancellation.Token); + _handshake = new ServerStartupTreeHandshake(acknowledge, _startupCancellation.Token); _startup = OpenCodeServer.StartAsync( new OpenCodeServerOptions { @@ -48,12 +49,15 @@ private ServerStartupTreeScenario( public Process RootProcess => _handshake.RootProcess; public static ServerStartupTreeScenario BeginInvalidLine(CancellationToken cancellationToken) => - new(InvalidLineMode, TimeSpan.FromMinutes(2), cancellationToken); + new(InvalidLineMode, TimeSpan.FromMinutes(2), acknowledge: true, cancellationToken); + + public static ServerStartupTreeScenario BeginHandshakeFailure(CancellationToken cancellationToken) => + new(SilentMode, TimeSpan.FromMinutes(2), acknowledge: false, cancellationToken); public static ServerStartupTreeScenario BeginSilent( TimeSpan readinessTimeout, CancellationToken cancellationToken) => - new(SilentMode, readinessTimeout, cancellationToken); + new(SilentMode, readinessTimeout, acknowledge: true, cancellationToken); public Task ObserveAndAcknowledgeAsync(CancellationToken cancellationToken) => _handshake.ObserveAndAcknowledgeAsync(cancellationToken); @@ -71,70 +75,111 @@ public async ValueTask DisposeAsync() return; } + var failures = new List(); using var cleanup = new CancellationTokenSource(CleanupTimeout); - await _startupCancellation.CancelAsync(); - _handshake.Stop(); try { - await _handshake.AwaitCleanupAsync(cleanup.Token); - await AwaitStartupCleanupAsync(cleanup.Token); - await EndCapturedProcessAsync(_handshake.CapturedChildProcess, cleanup.Token); - await EndCapturedProcessAsync(_handshake.CapturedRootProcess, cleanup.Token); + await CaptureFailureAsync(_startupCancellation.CancelAsync, failures); + CaptureFailure(_handshake.Stop, failures); + await CaptureFailureAsync( + () => _handshake.ObserveAndAcknowledgeAsync(cleanup.Token), failures); + await CaptureFailureAsync( + () => AwaitStartupCleanupAsync(cleanup.Token), failures); + await CaptureFailureAsync( + () => EndCapturedProcessAsync(_handshake.CapturedChildProcess, cleanup.Token), failures); + await CaptureFailureAsync( + () => EndCapturedProcessAsync(_handshake.CapturedRootProcess, cleanup.Token), failures); } finally { - _handshake.Dispose(); - _startupCancellation.Dispose(); + try + { + _handshake.Dispose(); + } + catch (Exception exception) + { + failures.Add(exception); + } + + try + { + _startupCancellation.Dispose(); + } + catch (Exception exception) + { + failures.Add(exception); + } } + + ThrowCleanupFailures(failures); } - private async Task AwaitStartupCleanupAsync(CancellationToken cancellationToken) + private static void CaptureFailure(Action cleanup, List failures) { try { - var startup = _startup; - var server = await startup.WaitAsync(cancellationToken); - await server.DisposeAsync(); + cleanup(); } - catch (OperationCanceledException exception) + catch (Exception exception) { - _ = exception; + failures.Add(exception); } - catch (OpenCodeServerException exception) + } + + private static async Task CaptureFailureAsync(Func cleanup, List failures) + { + try { - _ = exception; + await cleanup(); + } + catch (Exception exception) + { + failures.Add(exception); } } - private static async Task EndCapturedProcessAsync(Process? process, CancellationToken cancellationToken) + private static void ThrowCleanupFailures(List failures) { - if (process is null || process.HasExited) + if (failures.Count is 0) + { + return; + } + + if (failures.Count is 1) { + ExceptionDispatchInfo.Capture(failures[0]).Throw(); return; } + throw new AggregateException("Multiple failures occurred while cleaning the startup tree scenario.", failures); + } + + private async Task AwaitStartupCleanupAsync(CancellationToken cancellationToken) + { try { - process.Kill(); + var startup = _startup; + var server = await startup.WaitAsync(cancellationToken); + await server.DisposeAsync(); } - catch (InvalidOperationException exception) + catch (OperationCanceledException exception) when (_startup.IsCanceled) { _ = exception; - return; } - catch (Win32Exception exception) + catch (OpenCodeServerException exception) { _ = exception; - return; } + } - try - { - await process.WaitForExitAsync(cancellationToken); - } - catch (OperationCanceledException exception) + private static async Task EndCapturedProcessAsync(Process? process, CancellationToken cancellationToken) + { + if (process is null || process.HasExited) { - _ = exception; + return; } + + process.Kill(); + await process.WaitForExitAsync(cancellationToken); } } From 2de3fb78629bf35e456f87a18ff0eb5da1151e80 Mon Sep 17 00:00:00 2001 From: "denizirgin@gmail.com" Date: Sun, 6 Sep 2026 02:56:22 +0300 Subject: [PATCH 04/62] test: own startup tree cleanup through control leases --- .../Fixtures/Server/child-tree.js | 92 ++++++--- .../OpenCodeServerLifecycleTests.cs | 67 +++++-- .../Support/ServerStartupTreeHandshake.cs | 183 +++++++----------- .../Support/ServerStartupTreeLease.cs | 141 ++++++++++++++ .../Support/ServerStartupTreeScenario.cs | 138 ++++++++----- 5 files changed, 411 insertions(+), 210 deletions(-) create mode 100644 tests/OpenCode.Sdk.Tests/Support/ServerStartupTreeLease.cs diff --git a/tests/OpenCode.Sdk.Tests/Fixtures/Server/child-tree.js b/tests/OpenCode.Sdk.Tests/Fixtures/Server/child-tree.js index 8f5e38a1..bf53ce7d 100644 --- a/tests/OpenCode.Sdk.Tests/Fixtures/Server/child-tree.js +++ b/tests/OpenCode.Sdk.Tests/Fixtures/Server/child-tree.js @@ -6,50 +6,88 @@ if (!Number.isInteger(port) || port <= 0 || !nonce || !mode) { throw new Error("The child-tree fixture requires its port, nonce, and mode environment."); } -const child = Bun.spawn( - [process.execPath, "-e", "setTimeout(() => {}, 120000)"], - { stdin: "ignore", stdout: "ignore", stderr: "ignore" }, -); -let socket; -try { +// Each peer owns its own TCP connection to the test. In particular the child does not +// inherit a parent-owned pipe or a timer: killing only the root leaves this child alive. +async function connectLease() { const net = await import("node:net"); - socket = net.createConnection({ host: "127.0.0.1", port }); - - await new Promise((resolve, reject) => { + const socket = net.createConnection({ + host: "127.0.0.1", + port: Number(process.env.OPENCODE_SDK_TEST_TREE_PORT), + }); + let finishAcknowledgement; + const acknowledged = new Promise((resolve) => { + finishAcknowledgement = resolve; let response = ""; socket.setEncoding("utf8"); - socket.once("connect", () => { - socket.write(`${JSON.stringify({ nonce, rootPid: process.pid, childPid: child.pid })}\n`); - }); socket.on("data", (chunk) => { response += chunk; const newline = response.indexOf("\n"); if (newline < 0) return; - - if (response.slice(0, newline).trim() !== "ACK") { - reject(new Error("The child-tree fixture received an invalid acknowledgement.")); - return; - } - - resolve(); + resolve(response.slice(0, newline).trim() === "ACK"); }); - socket.once("end", () => reject(new Error("The child-tree handshake ended before acknowledgement."))); + }); + const released = new Promise((resolve) => { + const release = () => { + finishAcknowledgement(false); + resolve(); + }; + socket.once("end", release); + socket.once("close", release); + socket.once("error", release); + }); + await new Promise((resolve, reject) => { + socket.once("connect", resolve); socket.once("error", reject); }); + return { socket, acknowledged, released }; +} + +async function runChild() { + const lease = await connectLease(); + try { + lease.socket.write(`${JSON.stringify({ + nonce: process.env.OPENCODE_SDK_TEST_TREE_NONCE, + role: "child", + pid: process.pid, + })}\n`); + if (!await lease.acknowledged) { + throw new Error("The child-tree lease ended without a valid acknowledgement."); + } + await lease.released; + } finally { + lease.socket.destroy(); + } +} + +// Establish the root connection first so the test can assign both roles without a +// connection-routing framework. The child cannot connect until after this succeeds. +const lease = await connectLease(); +let child; +try { + child = Bun.spawn( + [process.execPath, "-e", `${connectLease.toString()}; await (${runChild.toString()})();`], + { stdin: "ignore", stdout: "ignore", stderr: "ignore" }, + ); + lease.socket.write(`${JSON.stringify({ + nonce, role: "root", pid: process.pid, childPid: child.pid, + })}\n`); + if (!await lease.acknowledged) { + throw new Error("The child-tree lease ended without a valid acknowledgement."); + } - socket.end(); if (mode === "invalid-line") { console.log("hello"); } else if (mode !== "silent") { throw new Error(`Unknown child-tree fixture mode '${mode}'.`); } - await new Promise(() => {}); + await lease.released; } finally { - socket?.destroy(); - if (child.exitCode === null) { - child.kill(); + lease.socket.destroy(); + // This is the root's direct, owned Bun child. Pre-ACK rejection must reap it even + // while the independently held child lease is still open on the test side. + if (child) { + if (child.exitCode === null) child.kill(); + await child.exited; } - - await child.exited; } diff --git a/tests/OpenCode.Sdk.Tests/OpenCodeServerLifecycleTests.cs b/tests/OpenCode.Sdk.Tests/OpenCodeServerLifecycleTests.cs index d7a13d52..5f7d903e 100644 --- a/tests/OpenCode.Sdk.Tests/OpenCodeServerLifecycleTests.cs +++ b/tests/OpenCode.Sdk.Tests/OpenCodeServerLifecycleTests.cs @@ -123,31 +123,39 @@ public async Task StartAsync_Should_Refuse_A_Server_That_Exits_Before_Readiness( [Timeout(120_000)] public async Task StartAsync_Should_Refuse_A_Server_That_Never_Reports_Readiness(CancellationToken cancellationToken) { - await using var scenario = ServerStartupTreeScenario.BeginSilent( - TimeSpan.FromSeconds(15), cancellationToken); - await scenario.ObserveAndAcknowledgeAsync(cancellationToken); + await using var scenario = ServerStartupTreeScenario.CreateSilent( + TimeSpan.FromSeconds(15)); + await scenario.StartAndObserveAsync(cancellationToken); var failure = await Assert.That( async () => _ = await scenario.WaitForStartupAsync(cancellationToken)).Throws(); await Assert.That(failure!.Message).Contains("did not report readiness"); - await Assert.That(scenario.RootProcess.HasExited).IsTrue(); - await Assert.That(scenario.ChildProcess.HasExited).IsTrue(); + // Take both immediate observations before any diagnostic I/O or fallback cleanup. + var rootExited = scenario.RootProcess.HasExited; + var childExited = scenario.ChildProcess.HasExited; + var evidence = rootExited && childExited ? string.Empty : await scenario.DescribeProcessesAsync(); + await Assert.That(rootExited).IsTrue().Because(evidence); + await Assert.That(childExited).IsTrue().Because(evidence); } [Test] [Timeout(120_000)] public async Task StartAsync_Should_Refuse_A_Non_Contract_First_Line(CancellationToken cancellationToken) { - await using var scenario = ServerStartupTreeScenario.BeginInvalidLine(cancellationToken); - await scenario.ObserveAndAcknowledgeAsync(cancellationToken); + await using var scenario = ServerStartupTreeScenario.CreateInvalidLine(); + await scenario.StartAndObserveAsync(cancellationToken); var failure = await Assert.That( async () => _ = await scenario.WaitForStartupAsync(cancellationToken)).Throws(); await Assert.That(failure!.Message).Contains("readiness contract"); - await Assert.That(scenario.RootProcess.HasExited).IsTrue(); - await Assert.That(scenario.ChildProcess.HasExited).IsTrue(); + // Take both immediate observations before any diagnostic I/O or fallback cleanup. + var rootExited = scenario.RootProcess.HasExited; + var childExited = scenario.ChildProcess.HasExited; + var evidence = rootExited && childExited ? string.Empty : await scenario.DescribeProcessesAsync(); + await Assert.That(rootExited).IsTrue().Because(evidence); + await Assert.That(childExited).IsTrue().Because(evidence); } [Test] @@ -155,12 +163,12 @@ public async Task StartAsync_Should_Refuse_A_Non_Contract_First_Line(Cancellatio public async Task StartupTreeScenario_Should_Clean_Observed_Tree_When_Handshake_Fails( CancellationToken cancellationToken) { - var scenario = ServerStartupTreeScenario.BeginHandshakeFailure(cancellationToken); + await using var scenario = ServerStartupTreeScenario.CreateHandshakeFailure(); InvalidOperationException? cleanupFailure = null; try { var observationFailure = await Assert.That( - async () => await scenario.ObserveAndAcknowledgeAsync(cancellationToken)) + async () => await scenario.StartAndObserveAsync(cancellationToken)) .Throws(); _ = await Assert.That( async () => _ = await scenario.WaitForStartupAsync(cancellationToken)) @@ -189,6 +197,29 @@ await Assert.That(cleanupFailure!.Message) .Contains("intentionally rejected for cleanup verification"); } + [Test] + [Timeout(120_000)] + public async Task StartupTreeScenario_Should_Release_Each_Owned_Lease_Independently( + CancellationToken cancellationToken) + { + await using var scenario = ServerStartupTreeScenario.CreateSilent(TimeSpan.FromMinutes(2)); + await scenario.StartAndObserveAsync(cancellationToken); + await Assert.That(scenario.RootProcess.HasExited).IsFalse(); + await Assert.That(scenario.ChildProcess.HasExited).IsFalse(); + + // Exercise cooperative cleanup while startup is still waiting. This is fixture evidence, + // not launcher-reaping evidence: the latter tests keep both leases open until assertions. + scenario.ReleaseChildLease(); + await scenario.ChildProcess.WaitForExitAsync(cancellationToken); + await Assert.That(scenario.ChildProcess.HasExited).IsTrue(); + await Assert.That(scenario.RootProcess.HasExited).IsFalse(); + + scenario.ReleaseRootLease(); + _ = await Assert.That(async () => _ = await scenario.WaitForStartupAsync(cancellationToken)) + .Throws(); + await Assert.That(scenario.RootProcess.HasExited).IsTrue(); + } + [Test] [Timeout(120_000)] public async Task StartAsync_Should_Refuse_A_Missing_Executable(CancellationToken cancellationToken) @@ -208,15 +239,19 @@ public async Task StartAsync_Should_Refuse_A_Missing_Executable(CancellationToke public async Task StartAsync_Should_Surface_Caller_Cancellation(CancellationToken cancellationToken) { using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - await using var scenario = ServerStartupTreeScenario.BeginSilent( - TimeSpan.FromMinutes(2), cancellation.Token); - await scenario.ObserveAndAcknowledgeAsync(cancellationToken); + await using var scenario = ServerStartupTreeScenario.CreateSilent( + TimeSpan.FromMinutes(2)); + await scenario.StartAndObserveAsync(cancellation.Token); await cancellation.CancelAsync(); _ = await Assert.That( async () => _ = await scenario.WaitForStartupAsync(cancellationToken)).Throws(); - await Assert.That(scenario.RootProcess.HasExited).IsTrue(); - await Assert.That(scenario.ChildProcess.HasExited).IsTrue(); + // Take both immediate observations before any diagnostic I/O or fallback cleanup. + var rootExited = scenario.RootProcess.HasExited; + var childExited = scenario.ChildProcess.HasExited; + var evidence = rootExited && childExited ? string.Empty : await scenario.DescribeProcessesAsync(); + await Assert.That(rootExited).IsTrue().Because(evidence); + await Assert.That(childExited).IsTrue().Because(evidence); } } diff --git a/tests/OpenCode.Sdk.Tests/Support/ServerStartupTreeHandshake.cs b/tests/OpenCode.Sdk.Tests/Support/ServerStartupTreeHandshake.cs index 6be370e9..a4826922 100644 --- a/tests/OpenCode.Sdk.Tests/Support/ServerStartupTreeHandshake.cs +++ b/tests/OpenCode.Sdk.Tests/Support/ServerStartupTreeHandshake.cs @@ -1,169 +1,116 @@ using System.Diagnostics; +using System.IO.Abstractions; using System.Net; using System.Net.Sockets; -using System.Text; -using System.Text.Json; namespace OpenCode.Sdk.Tests.Support; /// -/// Owns the loopback handshake that validates a nonce and captures the Bun root and child as live -/// process handles before acknowledging the startup peer. +/// Owns independent root and child control leases, authenticated and observed live before ACK. +/// Neither lease is released by a successful handshake or a launcher's failure result. /// internal sealed class ServerStartupTreeHandshake : IDisposable { private static readonly TimeSpan HandshakeTimeout = TimeSpan.FromSeconds(15); - private readonly bool _acknowledge; - private readonly Task _observation; - private readonly TcpListener _listener; - private Process? _childProcess; - private Process? _rootProcess; + private TcpListener? _listener; + private ServerStartupTreeLease? _child; + private ServerStartupTreeLease? _root; + private readonly IFileSystem _fileSystem; - public ServerStartupTreeHandshake(bool acknowledge, CancellationToken cancellationToken) - { - _acknowledge = acknowledge; - Nonce = Guid.NewGuid().ToString("N"); - _listener = new TcpListener(IPAddress.Loopback, 0); - _listener.Start(); - Port = ((IPEndPoint)_listener.LocalEndpoint).Port; - _observation = CaptureAndAcknowledgeAsync(cancellationToken); - } + public ServerStartupTreeHandshake(IFileSystem fileSystem) => _fileSystem = fileSystem; - public Process ChildProcess => - _childProcess ?? throw new InvalidOperationException("The child process has not been captured."); + public Process ChildProcess => Child.Process; - public Process? CapturedChildProcess => _childProcess; + public Process RootProcess => Root.Process; - public Process? CapturedRootProcess => _rootProcess; + public string Nonce { get; private set; } = string.Empty; - public string Nonce { get; } + public int Port { get; private set; } - public int Port { get; } + private TcpListener Listener => + _listener ?? throw new InvalidOperationException("The handshake listener has not been started."); - public Process RootProcess => - _rootProcess ?? throw new InvalidOperationException("The root process has not been captured."); + private ServerStartupTreeLease Child => + _child ?? throw new InvalidOperationException("The child lease has not been captured."); - public async Task ObserveAndAcknowledgeAsync(CancellationToken cancellationToken) - { - var observation = _observation; - await observation.WaitAsync(cancellationToken); - } - - public void Stop() => _listener.Stop(); + private ServerStartupTreeLease Root => + _root ?? throw new InvalidOperationException("The root lease has not been captured."); - public void Dispose() + public void Start() { - _childProcess?.Dispose(); - _rootProcess?.Dispose(); -#if NET - _listener.Dispose(); -#endif + Nonce = Guid.NewGuid().ToString("N"); + _listener = new TcpListener(IPAddress.Loopback, 0); + Listener.Start(); + Port = ((IPEndPoint)Listener.LocalEndpoint).Port; } - private async Task CaptureAndAcknowledgeAsync(CancellationToken cancellationToken) + public async Task CaptureAndAcknowledgeAsync(bool acknowledge, CancellationToken cancellationToken) { using var bound = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); bound.CancelAfter(HandshakeTimeout); - using var client = await AcceptTcpClientAsync(_listener, bound.Token); - using var stream = client.GetStream(); - using var reader = new StreamReader( - stream, - Encoding.UTF8, - detectEncodingFromByteOrderMarks: false, - bufferSize: 1024, - leaveOpen: true); - var line = await ReadLineAsync(reader, bound.Token) - ?? throw new InvalidOperationException("The child-tree handshake ended before process identity arrived."); - using var document = JsonDocument.Parse(line); - var root = CaptureProcess(document.RootElement, "rootPid"); - var transferred = false; + _root = new ServerStartupTreeLease(await AcceptTcpClientAsync(bound.Token), _fileSystem); try { - var child = CaptureProcess(document.RootElement, "childPid"); - try - { - var receivedNonce = document.RootElement.GetProperty("nonce").GetString(); - if (!string.Equals(receivedNonce, Nonce, StringComparison.Ordinal)) - { - throw new InvalidOperationException("The child-tree handshake nonce did not match this scenario."); - } - - if (root.Id == child.Id || root.HasExited || child.HasExited) - { - throw new InvalidOperationException("The child-tree handshake did not identify two live processes."); - } - - _rootProcess = root; - _childProcess = child; - transferred = true; - if (!_acknowledge) - { - throw new InvalidOperationException( - "The child-tree handshake was intentionally rejected for cleanup verification."); - } - - await WriteAcknowledgementAsync(stream, bound.Token); - } - finally + var childPid = await Root.AuthenticateAsync(Nonce, "root", bound.Token); + _child = new ServerStartupTreeLease(await AcceptTcpClientAsync(bound.Token), _fileSystem); + _ = await Child.AuthenticateAsync(Nonce, "child", bound.Token); + if (RootProcess.Id == ChildProcess.Id || childPid != ChildProcess.Id + || RootProcess.HasExited || ChildProcess.HasExited) { - if (!transferred) - { - child.Dispose(); - } + throw new InvalidOperationException("The handshake did not identify the live root and its child."); } - } - finally - { - if (!transferred) + + if (!acknowledge) { - root.Dispose(); + throw new InvalidOperationException( + "The child-tree handshake was intentionally rejected for cleanup verification."); } - } - } - private static Process CaptureProcess(JsonElement handshake, string propertyName) - { - var processId = handshake.GetProperty(propertyName).GetInt32(); - try - { - return Process.GetProcessById(processId); + await Child.AcknowledgeAsync(bound.Token); + await Root.AcknowledgeAsync(bound.Token); } - catch (ArgumentException exception) + catch { - throw new InvalidOperationException( - $"The child-tree handshake process '{propertyName}' was not alive when captured.", exception); + // Keep the child lease open: pre-ACK rejection must prove the root reaps its own + // child, without child-lease fallback supplying the result. + Root.Release(); + throw; } } - private static Task AcceptTcpClientAsync( - TcpListener listener, - CancellationToken cancellationToken) - { -#if NET - return listener.AcceptTcpClientAsync(cancellationToken).AsTask(); -#else - return listener.AcceptTcpClientAsync().WaitAsync(cancellationToken); -#endif - } + public void Stop() => _listener?.Stop(); + + public void ReleaseChild() => _child?.Release(); + + public void ReleaseRoot() => _root?.Release(); + + public async Task DescribeProcessesAsync(CancellationToken cancellationToken) => + $"Root: {await Root.DescribeProcessAsync(cancellationToken)}; " + + $"child: {await Child.DescribeProcessAsync(cancellationToken)}"; - private static Task ReadLineAsync(StreamReader reader, CancellationToken cancellationToken) + public Task WaitForChildExitAsync(CancellationToken cancellationToken) => + _child?.WaitForExitAsync(cancellationToken) ?? Task.CompletedTask; + + public Task WaitForRootExitAsync(CancellationToken cancellationToken) => + _root?.WaitForExitAsync(cancellationToken) ?? Task.CompletedTask; + + public void Dispose() { + _child?.Dispose(); + _root?.Dispose(); + _listener?.Stop(); #if NET - return reader.ReadLineAsync(cancellationToken).AsTask(); -#else - return reader.ReadLineAsync().WaitAsync(cancellationToken); + _listener?.Dispose(); #endif } - private static async Task WriteAcknowledgementAsync(Stream stream, CancellationToken cancellationToken) + private Task AcceptTcpClientAsync(CancellationToken cancellationToken) { - var acknowledgement = Encoding.ASCII.GetBytes("ACK\n"); #if NET - await stream.WriteAsync(acknowledgement, cancellationToken); + return Listener.AcceptTcpClientAsync(cancellationToken).AsTask(); #else - await stream.WriteAsync(acknowledgement, 0, acknowledgement.Length, cancellationToken); + return Listener.AcceptTcpClientAsync().WaitAsync(cancellationToken); #endif - await stream.FlushAsync(cancellationToken); } } diff --git a/tests/OpenCode.Sdk.Tests/Support/ServerStartupTreeLease.cs b/tests/OpenCode.Sdk.Tests/Support/ServerStartupTreeLease.cs new file mode 100644 index 00000000..6691fe3e --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Support/ServerStartupTreeLease.cs @@ -0,0 +1,141 @@ +using System.Diagnostics; +using System.Globalization; +using System.IO.Abstractions; +using System.Net.Sockets; +using System.Text; +using System.Text.Json; + +namespace OpenCode.Sdk.Tests.Support; + +/// +/// Owns one authenticated fixture connection and its process observation. Releasing the connection +/// asks that peer to exit; teardown never signals a PID that could have been recycled. +/// +internal sealed class ServerStartupTreeLease : IDisposable +{ + private readonly TcpClient _client; + private readonly IFileSystem _fileSystem; + private Process? _process; + private string? _observedLinuxState; + + public ServerStartupTreeLease(TcpClient client, IFileSystem fileSystem) + { + _client = client; + _fileSystem = fileSystem; + } + + public Process Process => + _process ?? throw new InvalidOperationException("The fixture process has not been captured."); + + public async Task AuthenticateAsync(string nonce, string role, CancellationToken cancellationToken) + { + using var reader = new StreamReader( + _client.GetStream(), Encoding.UTF8, detectEncodingFromByteOrderMarks: false, + bufferSize: 1024, leaveOpen: true); +#if NET + var line = await reader.ReadLineAsync(cancellationToken); +#else + var line = await reader.ReadLineAsync().WaitAsync(cancellationToken); +#endif + using var document = JsonDocument.Parse(line + ?? throw new InvalidOperationException("The fixture connection ended before identity arrived.")); + var identity = document.RootElement; + if (!string.Equals(identity.GetProperty("nonce").GetString(), nonce, StringComparison.Ordinal) + || !string.Equals(identity.GetProperty("role").GetString(), role, StringComparison.Ordinal)) + { + throw new InvalidOperationException("The fixture connection did not match this scenario and role."); + } + + _process = Process.GetProcessById(identity.GetProperty("pid").GetInt32()); + if (Environment.OSVersion.Platform == PlatformID.Win32NT) + { + // Windows retains this native handle on the Process until disposal. Unix Process.Handle + // is not an OS process-identity handle and is deliberately not used as one. + _ = _process.Handle; + } + + if (_process.HasExited) + { + throw new InvalidOperationException("The authenticated fixture process was not alive when captured."); + } + + _observedLinuxState = await ReadLinuxStateAsync(cancellationToken); + + return identity.TryGetProperty("childPid", out var childPid) ? childPid.GetInt32() : null; + } + + public async Task AcknowledgeAsync(CancellationToken cancellationToken) + { + var stream = _client.GetStream(); + var acknowledgement = Encoding.ASCII.GetBytes("ACK\n"); +#if NET + await stream.WriteAsync(acknowledgement, cancellationToken); +#else + await stream.WriteAsync(acknowledgement, 0, acknowledgement.Length, cancellationToken); +#endif + await stream.FlushAsync(cancellationToken); + } + + public void Release() => _client.Dispose(); + + public async Task DescribeProcessAsync(CancellationToken cancellationToken) => + $"PID {Process.Id.ToString(CultureInfo.InvariantCulture)}; observed /proc stat: {_observedLinuxState}; " + + $"current /proc stat: {await ReadLinuxStateAsync(cancellationToken)}"; + + public async Task WaitForExitAsync(CancellationToken cancellationToken) + { + if (_process is { } process) + { + // On Unix this is a conservative PID observation only: reuse can fail/timeout this + // check, but cannot authorize killing the replacement. The lease owns cleanup. + await process.WaitForExitAsync(cancellationToken); + } + } + + public void Dispose() + { + _client.Dispose(); + _process?.Dispose(); + } + + private async Task ReadLinuxStateAsync(CancellationToken cancellationToken) + { +#if NET + if (!OperatingSystem.IsLinux()) +#else + if (!System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform( + System.Runtime.InteropServices.OSPlatform.Linux)) +#endif + { + return "unavailable on this OS"; + } + + try + { + // One bounded read, never a retry or cleanup authority. Linux stat includes state, + // PPID and start ticks, so failure evidence can distinguish a zombie from a live + // survivor or a recycled PID by comparing this with the pre-ACK snapshot. + using var stream = _fileSystem.File.OpenText( + "/proc/" + Process.Id.ToString(CultureInfo.InvariantCulture) + "/stat"); + var buffer = new char[4096]; +#if NET + var count = await stream.ReadAsync(buffer.AsMemory(), cancellationToken); +#else + var count = await stream.ReadAsync(buffer, 0, buffer.Length).WaitAsync(cancellationToken); +#endif + return new string(buffer, 0, count).Trim(); + } + catch (IOException exception) + { + return exception.Message; + } + catch (UnauthorizedAccessException exception) + { + return exception.Message; + } + catch (OperationCanceledException exception) + { + return exception.Message; + } + } +} diff --git a/tests/OpenCode.Sdk.Tests/Support/ServerStartupTreeScenario.cs b/tests/OpenCode.Sdk.Tests/Support/ServerStartupTreeScenario.cs index bc4bc647..eb6de213 100644 --- a/tests/OpenCode.Sdk.Tests/Support/ServerStartupTreeScenario.cs +++ b/tests/OpenCode.Sdk.Tests/Support/ServerStartupTreeScenario.cs @@ -2,12 +2,13 @@ using System.Globalization; using System.Runtime.ExceptionServices; using OpenCode.Sdk.TestSupport; +using Testably.Abstractions; namespace OpenCode.Sdk.Tests.Support; /// /// Starts the launcher against a cooperating Bun process tree and owns the startup and bounded -/// cleanup around the handshake's verified process handles. +/// cleanup around the handshake's independently owned control leases. /// internal sealed class ServerStartupTreeScenario : IAsyncDisposable { @@ -16,19 +17,48 @@ internal sealed class ServerStartupTreeScenario : IAsyncDisposable private static readonly TimeSpan CleanupTimeout = TimeSpan.FromSeconds(10); - private readonly ServerStartupTreeHandshake _handshake; - private readonly Task _startup; - private readonly CancellationTokenSource _startupCancellation; + private readonly ServerStartupTreeHandshake _handshake = new(new RealFileSystem()); + private readonly string _mode; + private readonly TimeSpan _readinessTimeout; + private readonly bool _acknowledge; + private Task? _startup; + private Task? _observation; + private CancellationTokenSource? _startupCancellation; + private readonly List _cleanupFailures = []; + private string? _diagnosticEvidence; private int _disposed; private ServerStartupTreeScenario( string mode, TimeSpan readinessTimeout, - bool acknowledge, - CancellationToken cancellationToken) + bool acknowledge) + { + _mode = mode; + _readinessTimeout = readinessTimeout; + _acknowledge = acknowledge; + } + + public Process ChildProcess => _handshake.ChildProcess; + + public Process RootProcess => _handshake.RootProcess; + + /// + /// Composes inert scenario state. Process/network work starts through StartAndObserveAsync. + /// + public static ServerStartupTreeScenario CreateInvalidLine() => + new(InvalidLineMode, TimeSpan.FromMinutes(2), acknowledge: true); + + public static ServerStartupTreeScenario CreateHandshakeFailure() => + new(SilentMode, TimeSpan.FromMinutes(2), acknowledge: false); + + public static ServerStartupTreeScenario CreateSilent(TimeSpan readinessTimeout) => + new(SilentMode, readinessTimeout, acknowledge: true); + + public async Task StartAndObserveAsync(CancellationToken cancellationToken) { _startupCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - _handshake = new ServerStartupTreeHandshake(acknowledge, _startupCancellation.Token); + _handshake.Start(); + _observation = _handshake.CaptureAndAcknowledgeAsync(_acknowledge, _startupCancellation.Token); _startup = OpenCodeServer.StartAsync( new OpenCodeServerOptions { @@ -37,34 +67,29 @@ private ServerStartupTreeScenario( { ["OPENCODE_SDK_TEST_TREE_PORT"] = _handshake.Port.ToString(CultureInfo.InvariantCulture), ["OPENCODE_SDK_TEST_TREE_NONCE"] = _handshake.Nonce, - ["OPENCODE_SDK_TEST_TREE_MODE"] = mode, + ["OPENCODE_SDK_TEST_TREE_MODE"] = _mode, }, - ReadinessTimeout = readinessTimeout, + ReadinessTimeout = _readinessTimeout, }, _startupCancellation.Token); + var observation = _observation; + await observation.WaitAsync(cancellationToken); } - public Process ChildProcess => _handshake.ChildProcess; - - public Process RootProcess => _handshake.RootProcess; - - public static ServerStartupTreeScenario BeginInvalidLine(CancellationToken cancellationToken) => - new(InvalidLineMode, TimeSpan.FromMinutes(2), acknowledge: true, cancellationToken); - - public static ServerStartupTreeScenario BeginHandshakeFailure(CancellationToken cancellationToken) => - new(SilentMode, TimeSpan.FromMinutes(2), acknowledge: false, cancellationToken); + public void ReleaseChildLease() => _handshake.ReleaseChild(); - public static ServerStartupTreeScenario BeginSilent( - TimeSpan readinessTimeout, - CancellationToken cancellationToken) => - new(SilentMode, readinessTimeout, acknowledge: true, cancellationToken); + public void ReleaseRootLease() => _handshake.ReleaseRoot(); - public Task ObserveAndAcknowledgeAsync(CancellationToken cancellationToken) => - _handshake.ObserveAndAcknowledgeAsync(cancellationToken); + public async Task DescribeProcessesAsync() + { + using var diagnosticBound = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + _diagnosticEvidence = await _handshake.DescribeProcessesAsync(diagnosticBound.Token); + return _diagnosticEvidence; + } public async Task WaitForStartupAsync(CancellationToken cancellationToken) { - var startup = _startup; + var startup = _startup ?? throw new InvalidOperationException("The scenario has not been started."); return await startup.WaitAsync(cancellationToken); } @@ -75,20 +100,26 @@ public async ValueTask DisposeAsync() return; } - var failures = new List(); using var cleanup = new CancellationTokenSource(CleanupTimeout); try { - await CaptureFailureAsync(_startupCancellation.CancelAsync, failures); - CaptureFailure(_handshake.Stop, failures); - await CaptureFailureAsync( - () => _handshake.ObserveAndAcknowledgeAsync(cleanup.Token), failures); + // Close both owned connections even if pending observation failed. Releasing these + // leases is fallback cleanup and only happens after the test's immediate assertions. + CaptureFailure(_handshake.ReleaseChild); + CaptureFailure(_handshake.ReleaseRoot); + if (_startupCancellation is { } startupCancellation) + { + await CaptureFailureAsync(startupCancellation.CancelAsync); + } + + CaptureFailure(_handshake.Stop); + await CaptureFailureAsync(() => AwaitObservationCleanupAsync(cleanup.Token)); await CaptureFailureAsync( - () => AwaitStartupCleanupAsync(cleanup.Token), failures); + () => AwaitStartupCleanupAsync(cleanup.Token)); await CaptureFailureAsync( - () => EndCapturedProcessAsync(_handshake.CapturedChildProcess, cleanup.Token), failures); + () => _handshake.WaitForChildExitAsync(cleanup.Token)); await CaptureFailureAsync( - () => EndCapturedProcessAsync(_handshake.CapturedRootProcess, cleanup.Token), failures); + () => _handshake.WaitForRootExitAsync(cleanup.Token)); } finally { @@ -98,23 +129,23 @@ await CaptureFailureAsync( } catch (Exception exception) { - failures.Add(exception); + _cleanupFailures.Add(exception); } try { - _startupCancellation.Dispose(); + _startupCancellation?.Dispose(); } catch (Exception exception) { - failures.Add(exception); + _cleanupFailures.Add(exception); } } - ThrowCleanupFailures(failures); + ThrowCleanupFailures(_cleanupFailures); } - private static void CaptureFailure(Action cleanup, List failures) + private void CaptureFailure(Action cleanup) { try { @@ -122,11 +153,11 @@ private static void CaptureFailure(Action cleanup, List failures) } catch (Exception exception) { - failures.Add(exception); + _cleanupFailures.Add(exception); } } - private static async Task CaptureFailureAsync(Func cleanup, List failures) + private async Task CaptureFailureAsync(Func cleanup) { try { @@ -134,17 +165,23 @@ private static async Task CaptureFailureAsync(Func cleanup, List failures) + private void ThrowCleanupFailures(List failures) { if (failures.Count is 0) { return; } + if (_diagnosticEvidence is { } evidence) + { + throw new AggregateException( + "Startup tree cleanup failed after immediate exit observations failed. " + evidence, failures); + } + if (failures.Count is 1) { ExceptionDispatchInfo.Capture(failures[0]).Throw(); @@ -159,10 +196,15 @@ private async Task AwaitStartupCleanupAsync(CancellationToken cancellationToken) try { var startup = _startup; + if (startup is null) + { + return; + } + var server = await startup.WaitAsync(cancellationToken); await server.DisposeAsync(); } - catch (OperationCanceledException exception) when (_startup.IsCanceled) + catch (OperationCanceledException exception) when (_startup?.IsCanceled is true) { _ = exception; } @@ -172,14 +214,12 @@ private async Task AwaitStartupCleanupAsync(CancellationToken cancellationToken) } } - private static async Task EndCapturedProcessAsync(Process? process, CancellationToken cancellationToken) + private async Task AwaitObservationCleanupAsync(CancellationToken cancellationToken) { - if (process is null || process.HasExited) + var observation = _observation; + if (observation is not null) { - return; + await observation.WaitAsync(cancellationToken); } - - process.Kill(); - await process.WaitForExitAsync(cancellationToken); } } From 664fa6c33ddcf61fde68939a5834291b22dede2d Mon Sep 17 00:00:00 2001 From: "denizirgin@gmail.com" Date: Sun, 6 Sep 2026 03:59:22 +0300 Subject: [PATCH 05/62] test: prove normal PTY live lifecycle --- .github/workflows/ci.yml | 4 +- .../Fixtures/Ptys/terminal-peer.js | 17 + .../OpenCode.Sdk.Tests.csproj | 1 + .../Ptys/PtySessionLiveTests.cs | 319 ++++++++++++++++++ .../Support/PtyLiveScenario.cs | 206 +++++++++++ .../Support/PtyLiveTranscript.cs | 288 ++++++++++++++++ tests/Shared/FixtureLoader.cs | 8 +- tests/Shared/PinnedOpenCodeServerFixture.cs | 18 + 8 files changed, 856 insertions(+), 5 deletions(-) create mode 100644 tests/OpenCode.Sdk.Tests/Fixtures/Ptys/terminal-peer.js create mode 100644 tests/OpenCode.Sdk.Tests/Ptys/PtySessionLiveTests.cs create mode 100644 tests/OpenCode.Sdk.Tests/Support/PtyLiveScenario.cs create mode 100644 tests/OpenCode.Sdk.Tests/Support/PtyLiveTranscript.cs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6f313e18..9e3cd4dd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -69,8 +69,8 @@ jobs: path: ~/.bun/install/cache key: ${{ runner.os }}-bun-${{ hashFiles('external/opencode/bun.lock') }} - # --ignore-scripts skips the root postinstall (a node-pty fixup); M4's scenarios create - # no pty, and skipping scripts keeps the install deterministic and fast. + # --ignore-scripts skips the root postinstall, which repairs the separate Node PTY + # implementation. Normal PTYs in this lane use Bun's bun-pty implementation. - name: Install pinned server dependencies run: bun install --frozen-lockfile --ignore-scripts working-directory: external/opencode diff --git a/tests/OpenCode.Sdk.Tests/Fixtures/Ptys/terminal-peer.js b/tests/OpenCode.Sdk.Tests/Fixtures/Ptys/terminal-peer.js new file mode 100644 index 00000000..75ad8e2f --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Fixtures/Ptys/terminal-peer.js @@ -0,0 +1,17 @@ +const readline = await import("node:readline"); + +const input = readline.createInterface({ + input: process.stdin, + crlfDelay: Infinity, + terminal: false, +}); + +input.on("line", (line) => { + const match = /^RUN ([0-9a-f]+)$/.exec(line); + if (match) process.stdout.write(`\nACK:${match[1]}\n`); +}); + +// A leading delimiter keeps the record distinct from terminal-mode controls emitted by a PTY +// host before the child produces its first byte. +process.stdout.write("\nREADY\n"); +await new Promise((resolve) => input.once("close", resolve)); diff --git a/tests/OpenCode.Sdk.Tests/OpenCode.Sdk.Tests.csproj b/tests/OpenCode.Sdk.Tests/OpenCode.Sdk.Tests.csproj index fcd23909..7d22a167 100644 --- a/tests/OpenCode.Sdk.Tests/OpenCode.Sdk.Tests.csproj +++ b/tests/OpenCode.Sdk.Tests/OpenCode.Sdk.Tests.csproj @@ -46,6 +46,7 @@ + diff --git a/tests/OpenCode.Sdk.Tests/Ptys/PtySessionLiveTests.cs b/tests/OpenCode.Sdk.Tests/Ptys/PtySessionLiveTests.cs new file mode 100644 index 00000000..19f8b83c --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Ptys/PtySessionLiveTests.cs @@ -0,0 +1,319 @@ +using System.Globalization; +using OpenCode.Sdk.Models; +using OpenCode.Sdk.Tests.Support; +using OpenCode.Sdk.TestSupport; + +namespace OpenCode.Sdk.Tests; + +/// +/// Proves the normal PTY family's HTTP lifecycle and live WebSocket replay contract against the +/// accepted server pin. A repository-owned Bun peer acknowledges only executed input, so terminal +/// echo cannot satisfy the assertion. +/// +[ClassDataSource(Shared = SharedType.PerTestSession)] +[NotInParallel(ParallelConstraintKeys.ServerProcess)] +public sealed class PtySessionLiveTests(PinnedOpenCodeServerFixture server) +{ + private const string Command = "bun"; + + private const string InitialTitle = "sdk normal pty live"; + + private const string ReadyRecord = "READY"; + + private const string UpdatedTitle = "sdk normal pty live updated"; + + private readonly FixtureLoader _fixtures = new(); + private readonly PtyLiveScenario _scenario = new(server); + + [Test] + [Timeout(120_000)] + public async Task Pty_Should_Create_Execute_Replay_Resume_And_Remove(CancellationToken cancellationToken) + { + Exception? primaryFailure = null; + try + { + await _scenario.InitializeAsync(cancellationToken); + var http = await AssertHttpLifecycleAsync(cancellationToken); + var socket = await AssertSocketLifecycleAsync(http.Terminal, http.PtyId, cancellationToken); + + Console.WriteLine( + "pty-live: mode=" + _scenario.Mode + + " create=" + Number(http.CreateStatus) + + " id=" + http.PtyId + + " pid=" + Number(http.Pid) + + " token-http=" + Number(http.TokenStatus) + + " socket=basic" + + " replay-cursor=" + Number(socket.ReplayCursor) + + " resume-cursor=" + Number(socket.ResumeCursor) + + " ack-a=" + socket.AcknowledgementA + + " ack-b=" + socket.AcknowledgementB + + " remove=" + Number(socket.RemoveStatus) + + " get-after=" + Number(socket.GetAfterStatus) + + " remove-after=" + Number(socket.RemoveAfterStatus) + + " listed-after=" + Number(socket.ListedAfter)); + } + catch (Exception exception) + { + primaryFailure = exception; + } + + await _scenario.CleanupAsync(primaryFailure); + } + + private async Task AssertHttpLifecycleAsync(CancellationToken cancellationToken) + { + var created = await _scenario.Client.Ptys.CreatePtyAsync( + new PtyCreateRequest + { + Command = Command, + Args = ["-e", _fixtures.LoadText("Ptys.terminal-peer.js")], + Cwd = _scenario.Directory, + Title = InitialTitle, + Location = _scenario.Location, + }, + cancellationToken: cancellationToken); + + var ptyId = created.Pty.Id; + var terminal = _scenario.Client.Ptys.GetPtyClient(ptyId); + _scenario.Own(terminal); + + await Assert.That(created.Status).IsEqualTo(200); + await Assert.That(created.IsError).IsFalse(); + await Assert.That(created.Pty.Status).IsEqualTo(PtyStatus.Running); + await Assert.That(created.Pty.Id.Length).IsGreaterThan(0); + await Assert.That(created.Pty.Pid).IsGreaterThan(0); + await Assert.That(created.Pty.Command).IsEqualTo(Command); + await Assert.That(created.Pty.Cwd).IsEqualTo(_scenario.Directory); + + var listed = await _scenario.Client.Ptys.ListPtysAsync( + new PtyListRequest { Location = _scenario.Location }, + cancellationToken: cancellationToken); + await Assert.That(listed.Status).IsEqualTo(200); + await Assert.That(listed.Ptys.Select(static pty => pty.Id).ToArray()).Contains(ptyId); + + var fetched = await terminal.GetPtyAsync( + new PtyRequest { Location = _scenario.Location }, + cancellationToken: cancellationToken); + await Assert.That(fetched.Status).IsEqualTo(200); + await Assert.That(fetched.Pty.Id).IsEqualTo(ptyId); + + var updated = await terminal.PutUpdateAsync( + new PtyUpdatePutRequest { Title = UpdatedTitle, Location = _scenario.Location }, + cancellationToken: cancellationToken); + await Assert.That(updated.Status).IsEqualTo(200); + await Assert.That(updated.Update.Title).IsEqualTo(UpdatedTitle); + + var fetchedAfterUpdate = await terminal.GetPtyAsync( + new PtyRequest { Location = _scenario.Location }, + cancellationToken: cancellationToken); + await Assert.That(fetchedAfterUpdate.Pty.Id).IsEqualTo(ptyId); + await Assert.That(fetchedAfterUpdate.Pty.Title).IsEqualTo(UpdatedTitle); + + var token = await terminal.CreateConnectTokenAsync( + new PtyConnectTokenPostRequest { Location = _scenario.Location }, + cancellationToken: cancellationToken); + await Assert.That(token.Status).IsEqualTo(200); + await Assert.That(token.IsError).IsFalse(); + await Assert.That(token.ConnectToken.Ticket.Length).IsGreaterThan(0); + await Assert.That(token.ConnectToken.ExpiresIn).IsGreaterThan(0); + + return new PtyHttpEvidence + { + Terminal = terminal, + PtyId = ptyId, + Pid = created.Pty.Pid, + CreateStatus = created.Status, + TokenStatus = token.Status, + }; + } + + private async Task AssertSocketLifecycleAsync( + PtyClient terminal, + string ptyId, + CancellationToken cancellationToken) + { + var acknowledgementA = await AssertInitialExecutionAsync(terminal, cancellationToken); + var replay = await AssertReplayAndResumeAsync(terminal, acknowledgementA, cancellationToken); + var removal = await AssertRemovalAsync( + terminal, ptyId, replay.Session, replay.Transcript, cancellationToken); + + return new PtySocketEvidence + { + AcknowledgementA = acknowledgementA, + AcknowledgementB = replay.AcknowledgementB, + ReplayCursor = replay.ReplayCursor, + ResumeCursor = replay.ResumeCursor, + RemoveStatus = removal.RemoveStatus, + GetAfterStatus = removal.GetAfterStatus, + RemoveAfterStatus = removal.RemoveAfterStatus, + ListedAfter = removal.ListedAfter, + }; + } + + private async Task AssertInitialExecutionAsync( + PtyClient terminal, + CancellationToken cancellationToken) + { + var nonce = Guid.NewGuid().ToString("N"); + var acknowledgement = "ACK:" + nonce; + var session = _scenario.Own(await terminal.ConnectAsync( + new PtyConnectOptions { Location = _scenario.Location }, cancellationToken)); + var transcript = new PtyLiveTranscript(); + _ = await transcript.ReadThroughReplayAsync(session, [ReadyRecord], cancellationToken); + await session.WriteAsync("RUN " + nonce + "\r", cancellationToken); + await transcript.ReadUntilRecordAsync(session, acknowledgement, cancellationToken); + await session.DisposeAsync(); + return acknowledgement; + } + + private async Task AssertReplayAndResumeAsync( + PtyClient terminal, + string acknowledgementA, + CancellationToken cancellationToken) + { + var replay = _scenario.Own(await terminal.ConnectAsync( + new PtyConnectOptions { Location = _scenario.Location }, cancellationToken)); + var replayTranscript = new PtyLiveTranscript(); + var replayCursor = await replayTranscript.ReadThroughReplayAsync(replay, [], cancellationToken); + await Assert.That(replayTranscript.ContainsRecord(ReadyRecord)).IsTrue(); + await Assert.That(replayTranscript.ContainsRecord(acknowledgementA)).IsTrue(); + + var nonceB = Guid.NewGuid().ToString("N"); + var acknowledgementB = "ACK:" + nonceB; + await replay.WriteAsync("RUN " + nonceB + "\r", cancellationToken); + await replayTranscript.ReadUntilRecordAsync(replay, acknowledgementB, cancellationToken); + await replay.DisposeAsync(); + + var resumed = _scenario.Own(await terminal.ConnectAsync( + new PtyConnectOptions { Location = _scenario.Location, Cursor = replayCursor }, cancellationToken)); + var resumedTranscript = new PtyLiveTranscript(); + var resumeCursor = await resumedTranscript.ReadThroughReplayAsync(resumed, [], cancellationToken); + await Assert.That(resumedTranscript.ContainsRecord(acknowledgementB)).IsTrue(); + await Assert.That(resumedTranscript.ContainsRecord(ReadyRecord)).IsFalse(); + await Assert.That(resumedTranscript.ContainsRecord(acknowledgementA)).IsFalse(); + + return new PtyReplayEvidence + { + Session = resumed, + Transcript = resumedTranscript, + AcknowledgementB = acknowledgementB, + ReplayCursor = replayCursor, + ResumeCursor = resumeCursor, + }; + } + + private async Task AssertRemovalAsync( + PtyClient terminal, + string ptyId, + PtySession session, + PtyLiveTranscript transcript, + CancellationToken cancellationToken) + { + var completion = transcript.ReadToCompletionAsync(session, cancellationToken); + _scenario.Observe(completion); + var removed = await terminal.RemovePtyAsync( + new PtyRemoveRequest { Location = _scenario.Location }, + cancellationToken: cancellationToken); + if (removed.Status is 204) + { + _scenario.MarkRemoved(); + } + + await Assert.That(removed.Status).IsEqualTo(204); + await completion; + + var missingGet = await terminal.GetPtyAsync( + new PtyRequest { Location = _scenario.Location }, + OpenCodeRequestOptions.NoThrow, + cancellationToken); + await AssertNotFoundAsync(missingGet, ptyId); + + var missingRemove = await terminal.RemovePtyAsync( + new PtyRemoveRequest { Location = _scenario.Location }, + OpenCodeRequestOptions.NoThrow, + cancellationToken); + await AssertNotFoundAsync(missingRemove, ptyId); + + var afterRemoval = await _scenario.Client.Ptys.ListPtysAsync( + new PtyListRequest { Location = _scenario.Location }, + cancellationToken: cancellationToken); + await Assert.That(afterRemoval.Ptys.Select(static pty => pty.Id).ToArray()).DoesNotContain(ptyId); + + return new PtyRemovalEvidence + { + RemoveStatus = removed.Status, + GetAfterStatus = missingGet.Status, + RemoveAfterStatus = missingRemove.Status, + ListedAfter = afterRemoval.Ptys.Count, + }; + } + + private static async Task AssertNotFoundAsync(OpenCodeResponse response, string ptyId) + { + await Assert.That(response.Status).IsEqualTo(404); + await Assert.That(response.IsError).IsTrue(); + await Assert.That(response.Error).IsTypeOf(); + var error = response.Error as PtyNotFoundError; + await Assert.That(error?.PtyId).IsEqualTo(ptyId); + } + + private static string Number(int value) => value.ToString(CultureInfo.InvariantCulture); + + private static string Number(long value) => value.ToString(CultureInfo.InvariantCulture); + + private sealed record PtyHttpEvidence + { + public required PtyClient Terminal { get; init; } + + public required string PtyId { get; init; } + + public required long Pid { get; init; } + + public required int CreateStatus { get; init; } + + public required int TokenStatus { get; init; } + } + + private sealed record PtyReplayEvidence + { + public required PtySession Session { get; init; } + + public required PtyLiveTranscript Transcript { get; init; } + + public required string AcknowledgementB { get; init; } + + public required long ReplayCursor { get; init; } + + public required long ResumeCursor { get; init; } + } + + private sealed record PtyRemovalEvidence + { + public required int RemoveStatus { get; init; } + + public required int GetAfterStatus { get; init; } + + public required int RemoveAfterStatus { get; init; } + + public required int ListedAfter { get; init; } + } + + private sealed record PtySocketEvidence + { + public required string AcknowledgementA { get; init; } + + public required string AcknowledgementB { get; init; } + + public required long ReplayCursor { get; init; } + + public required long ResumeCursor { get; init; } + + public required int RemoveStatus { get; init; } + + public required int GetAfterStatus { get; init; } + + public required int RemoveAfterStatus { get; init; } + + public required int ListedAfter { get; init; } + } +} diff --git a/tests/OpenCode.Sdk.Tests/Support/PtyLiveScenario.cs b/tests/OpenCode.Sdk.Tests/Support/PtyLiveScenario.cs new file mode 100644 index 00000000..cb3c4425 --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Support/PtyLiveScenario.cs @@ -0,0 +1,206 @@ +using System.Globalization; +using System.Runtime.ExceptionServices; +using OpenCode.Sdk.TestSupport; + +namespace OpenCode.Sdk.Tests.Support; + +/// +/// Owns the normal PTY live test's server-relative location and cleanup. External mode resolves +/// its directory from that server; owned mode creates a workspace under the fixture's run root. +/// +internal sealed class PtyLiveScenario +{ + private static readonly TimeSpan CleanupTimeout = TimeSpan.FromSeconds(10); + + private readonly List _pendingOperations = []; + private readonly List _sessions = []; + private readonly PinnedOpenCodeServerFixture _server; + private OpenCodeClient? _client; + private string? _directory; + private LocationSelector? _location; + private string? _mode; + private TestWorkspace? _workspace; + private PtyClient? _terminal; + private bool _removed; + private int _cleaned; + + public PtyLiveScenario(PinnedOpenCodeServerFixture server) + { + ArgumentNullException.ThrowIfNull(server); + _server = server; + } + + public OpenCodeClient Client => + _client ?? throw new InvalidOperationException("The PTY live scenario has not initialized."); + + public string Directory => + _directory ?? throw new InvalidOperationException("The PTY live scenario has not initialized."); + + public LocationSelector Location => + _location ?? throw new InvalidOperationException("The PTY live scenario has not initialized."); + + public string Mode => + _mode ?? throw new InvalidOperationException("The PTY live scenario has not initialized."); + + public async Task InitializeAsync(CancellationToken cancellationToken) + { + if (_client is not null) + { + throw new InvalidOperationException("The PTY live scenario has already initialized."); + } + + if (_server.IsExternal) + { + _client = _server.CreateClient(); + var response = await _client.GetLocationAsync(cancellationToken: cancellationToken); + _directory = response.ResolvedLocation.Directory; + if (string.IsNullOrWhiteSpace(_directory)) + { + throw new InvalidOperationException("The external server returned a blank location directory."); + } + + _location = new LocationSelector { Directory = _directory }; + _mode = "external"; + return; + } + + _workspace = _server.CreateWorkspace(); + _directory = _workspace.Path; + _location = new LocationSelector { Directory = _directory }; + _client = _server.CreateClient(_location); + _mode = "owned"; + } + + public PtySession Own(PtySession session) + { + ArgumentNullException.ThrowIfNull(session); + _sessions.Add(session); + return session; + } + + public void Own(PtyClient terminal) + { + ArgumentNullException.ThrowIfNull(terminal); + _terminal = terminal; + } + + public void Observe(Task operation) + { + ArgumentNullException.ThrowIfNull(operation); + _pendingOperations.Add(operation); + } + + public void MarkRemoved() => _removed = true; + + public async Task CleanupAsync(Exception? primaryFailure) + { + if (Interlocked.Exchange(ref _cleaned, 1) is 1) + { + ExceptionDispatchInfo.Capture( + primaryFailure ?? new InvalidOperationException("The PTY live scenario was cleaned twice.")).Throw(); + } + + var failures = new List(); + using var cleanup = new CancellationTokenSource(CleanupTimeout); + await RemoveTerminalAsync(failures, cleanup.Token); + await ObservePendingOperationsAsync(failures, cleanup.Token); + await DisposeSessionsAsync(failures); + DisposeOwners(failures); + + if (primaryFailure is not null) + { + if (failures.Count > 0) + { + primaryFailure.Data["PtyLiveScenario.CleanupFailures"] = new AggregateException(failures); + } + + ExceptionDispatchInfo.Capture(primaryFailure).Throw(); + } + + if (failures.Count is 1) + { + ExceptionDispatchInfo.Capture(failures[0]).Throw(); + } + + if (failures.Count > 1) + { + throw new AggregateException("Multiple failures occurred while cleaning the PTY live scenario.", failures); + } + } + + private async Task RemoveTerminalAsync(List failures, CancellationToken cancellationToken) + { + if (_removed || _terminal is null) + { + return; + } + + try + { + var removed = await _terminal.RemovePtyAsync( + new PtyRemoveRequest { Location = Location }, + OpenCodeRequestOptions.NoThrow, + cancellationToken); + if (removed.Status is not 204 and not 404) + { + failures.Add(new InvalidOperationException( + "PTY cleanup answered HTTP " + removed.Status.ToString(CultureInfo.InvariantCulture) + ".")); + } + } + catch (Exception exception) + { + failures.Add(exception); + } + } + + private async Task ObservePendingOperationsAsync(List failures, CancellationToken cancellationToken) + { + foreach (var operation in _pendingOperations) + { + try + { + await operation.WaitAsync(cancellationToken); + } + catch (Exception exception) + { + failures.Add(exception); + } + } + } + + private async Task DisposeSessionsAsync(List failures) + { + foreach (var session in _sessions) + { + try + { + await session.DisposeAsync(); + } + catch (Exception exception) + { + failures.Add(exception); + } + } + } + + private void DisposeOwners(List failures) + { + try + { + _client?.Dispose(); + } + catch (Exception exception) + { + failures.Add(exception); + } + + try + { + _workspace?.Dispose(); + } + catch (Exception exception) + { + failures.Add(exception); + } + } +} diff --git a/tests/OpenCode.Sdk.Tests/Support/PtyLiveTranscript.cs b/tests/OpenCode.Sdk.Tests/Support/PtyLiveTranscript.cs new file mode 100644 index 00000000..8a590635 --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Support/PtyLiveTranscript.cs @@ -0,0 +1,288 @@ +using System.Globalization; +using System.Text; + +namespace OpenCode.Sdk.Tests.Support; + +/// +/// Observes one live PTY connection as complete newline-delimited records and server cursor +/// boundaries. Output is retained only to a fixed bound so a failed peer cannot grow the test +/// process without limit. +/// +internal sealed class PtyLiveTranscript +{ + private const int MaximumCharacters = 65_536; + + private const int MaximumFailureCharacters = 2_048; + + private static readonly TimeSpan ObservationTimeout = TimeSpan.FromSeconds(20); + + private readonly StringBuilder _currentRecord = new(); + private readonly List _records = []; + private readonly StringBuilder _text = new(); + private TerminalControl _control; + private long? _cursor; + private bool _skipLineFeed; + + public bool ContainsRecord(string record) + { + ArgumentNullException.ThrowIfNull(record); + return _records.Contains(record, StringComparer.Ordinal); + } + + /// + /// Reads until the server's replay cursor and every named record have arrived. The cursor may + /// precede a record produced just after attachment, which is the expected initial READY race. + /// + public async Task ReadThroughReplayAsync( + PtySession session, + IReadOnlyList requiredRecords, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(session); + ArgumentNullException.ThrowIfNull(requiredRecords); + + if (Reached(_cursor, requiredRecords)) + { + return _cursor!.Value; + } + + using var observation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + observation.CancelAfter(ObservationTimeout); + try + { + await foreach (var frame in session.ReadAsync(observation.Token)) + { + Observe(frame); + if (Reached(_cursor, requiredRecords)) + { + return _cursor!.Value; + } + } + } + catch (OperationCanceledException) when ( + observation.IsCancellationRequested && !cancellationToken.IsCancellationRequested) + { + throw TimedOut("the replay cursor and records " + DescribeTargets(requiredRecords)); + } + + throw ClosedEarly("the replay cursor and records " + DescribeTargets(requiredRecords)); + } + + /// Reads until one exact complete record arrives. + public async Task ReadUntilRecordAsync( + PtySession session, + string record, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(session); + ArgumentNullException.ThrowIfNull(record); + + if (ContainsRecord(record)) + { + return; + } + + using var observation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + observation.CancelAfter(ObservationTimeout); + try + { + await foreach (var frame in session.ReadAsync(observation.Token)) + { + Observe(frame); + if (ContainsRecord(record)) + { + return; + } + } + } + catch (OperationCanceledException) when ( + observation.IsCancellationRequested && !cancellationToken.IsCancellationRequested) + { + throw TimedOut("record '" + record + "'"); + } + + throw ClosedEarly("record '" + record + "'"); + } + + /// Reads until the server closes normally; abnormal closes still fail through the SDK. + public async Task ReadToCompletionAsync(PtySession session, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(session); + + await foreach (var frame in session.ReadAsync(cancellationToken)) + { + Observe(frame); + } + } + + public string Describe() => + "records=[" + string.Join(", ", _records.Select(static record => "'" + record + "'")) + + "] cursor=" + (_cursor?.ToString(CultureInfo.InvariantCulture) ?? "") + + " text=" + Excerpt(); + + private static string DescribeTargets(IReadOnlyList records) => + "[" + string.Join(", ", records.Select(static record => "'" + record + "'")) + "]"; + + private bool Reached(long? cursor, IReadOnlyList records) => + cursor is not null && records.All(ContainsRecord); + + private void Observe(PtyFrame frame) + { + if (frame is PtyCursorFrame cursor) + { + _cursor = cursor.Cursor; + return; + } + + if (frame is PtyOutputFrame output) + { + Append(output.Text); + } + } + + private void Append(string text) + { + if (_text.Length + text.Length > MaximumCharacters) + { + throw new InvalidOperationException( + "The live PTY transcript exceeded " + MaximumCharacters.ToString(CultureInfo.InvariantCulture) + + " characters before reaching its target. " + Describe()); + } + + _ = _text.Append(text); + foreach (var character in text) + { + if (ConsumeTerminalControl(character)) + { + continue; + } + + if (character is '\r') + { + CompleteRecord(); + _skipLineFeed = true; + continue; + } + + if (character is '\n') + { + if (!_skipLineFeed) + { + CompleteRecord(); + } + + _skipLineFeed = false; + continue; + } + + _skipLineFeed = false; + _ = _currentRecord.Append(character); + } + } + + /// + /// Removes only syntactically delimited CSI and OSC controls from the record view while the + /// raw transcript above retains every byte-derived character. Windows ConPTY emits these + /// controls around otherwise plain child output, including immediately before READY. + /// + private bool ConsumeTerminalControl(char character) + { + if (_control is TerminalControl.None) + { + if (character is '\u001b') + { + _control = TerminalControl.Escape; + return true; + } + + return false; + } + + if (_control is TerminalControl.Escape) + { + _control = character switch + { + '[' => TerminalControl.ControlSequence, + ']' => TerminalControl.OperatingSystemCommand, + _ => TerminalControl.None, + }; + return true; + } + + if (_control is TerminalControl.ControlSequence) + { + if (character is >= '@' and <= '~') + { + _control = TerminalControl.None; + if (MovesToAnotherLine(character) && _currentRecord.Length > 0) + { + CompleteRecord(); + } + } + + return true; + } + + if (_control is TerminalControl.OperatingSystemCommand) + { + if (character is '\u0007') + { + _control = TerminalControl.None; + } + else if (character is '\u001b') + { + _control = TerminalControl.OperatingSystemCommandEscape; + } + + return true; + } + + _control = character is '\\' + ? TerminalControl.None + : TerminalControl.OperatingSystemCommand; + return true; + } + + private static bool MovesToAnotherLine(char finalCharacter) => + finalCharacter is 'E' or 'F' or 'H' or 'd' or 'f'; + + private void CompleteRecord() + { + _records.Add(_currentRecord.ToString()); + _ = _currentRecord.Clear(); + } + + private InvalidOperationException ClosedEarly(string target) => + new("The live PTY closed normally before it produced " + target + ". " + Describe()); + + private TimeoutException TimedOut(string target) => + new("The live PTY did not produce " + target + " within " + + ObservationTimeout.TotalSeconds.ToString("F0", CultureInfo.InvariantCulture) + "s. " + Describe()); + + private string Excerpt() + { + var text = _text.ToString(); + var start = Math.Max(0, text.Length - MaximumFailureCharacters); + var escaped = new StringBuilder(text.Length - start); + foreach (var character in text[start..]) + { + _ = character switch + { + '\r' => escaped.Append("\\r"), + '\n' => escaped.Append("\\n"), + _ => escaped.Append(character), + }; + } + + return escaped.ToString(); + } + + private enum TerminalControl + { + None, + Escape, + ControlSequence, + OperatingSystemCommand, + OperatingSystemCommandEscape, + } +} diff --git a/tests/Shared/FixtureLoader.cs b/tests/Shared/FixtureLoader.cs index bdf6b206..e1b9b251 100644 --- a/tests/Shared/FixtureLoader.cs +++ b/tests/Shared/FixtureLoader.cs @@ -3,17 +3,19 @@ namespace OpenCode.Sdk.TestSupport; -/// Loads embedded JSON fixtures from the compiling test assembly's Fixtures folder. +/// Loads embedded fixtures from the compiling test assembly's Fixtures folder. internal sealed class FixtureLoader { private readonly Assembly _assembly = typeof(FixtureLoader).Assembly; - public string LoadJson(string name) + public string LoadJson(string name) => LoadText(name); + + public string LoadText(string name) { ArgumentException.ThrowIfNullOrWhiteSpace(name); var resourceName = string.Concat(_assembly.GetName().Name, ".Fixtures.", name); var stream = _assembly.GetManifestResourceStream(resourceName) - ?? throw new ArgumentException($"Embedded JSON fixture '{name}' was not found.", nameof(name)); + ?? throw new ArgumentException($"Embedded fixture '{name}' was not found.", nameof(name)); using var reader = new BinaryReader(stream, Encoding.UTF8, leaveOpen: false); var content = Encoding.UTF8.GetString(reader.ReadBytes(checked((int)stream.Length))); diff --git a/tests/Shared/PinnedOpenCodeServerFixture.cs b/tests/Shared/PinnedOpenCodeServerFixture.cs index 32c59aeb..96fa85b3 100644 --- a/tests/Shared/PinnedOpenCodeServerFixture.cs +++ b/tests/Shared/PinnedOpenCodeServerFixture.cs @@ -79,6 +79,24 @@ internal PinnedOpenCodeServerFixture(ExternalServerEndpoint external) public Uri Endpoint => _external?.Endpoint ?? Adapter.Endpoint; + internal bool IsExternal + { + get + { + if (_external is not null) + { + return true; + } + + if (_adapter is null) + { + throw new InvalidOperationException("The fixture has not initialized."); + } + + return false; + } + } + internal CliWrapServerAdapter Adapter => _adapter ?? throw new InvalidOperationException("The fixture has not initialized."); From 6cfcd4d7330f24f5c9dcda647b232ac347d63ae6 Mon Sep 17 00:00:00 2001 From: "denizirgin@gmail.com" Date: Sun, 6 Sep 2026 09:02:39 +0300 Subject: [PATCH 06/62] test: preserve PTY replay evidence for removal --- tests/OpenCode.Sdk.Tests/Ptys/PtySessionLiveTests.cs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/tests/OpenCode.Sdk.Tests/Ptys/PtySessionLiveTests.cs b/tests/OpenCode.Sdk.Tests/Ptys/PtySessionLiveTests.cs index 19f8b83c..c0e5111c 100644 --- a/tests/OpenCode.Sdk.Tests/Ptys/PtySessionLiveTests.cs +++ b/tests/OpenCode.Sdk.Tests/Ptys/PtySessionLiveTests.cs @@ -134,8 +134,7 @@ private async Task AssertSocketLifecycleAsync( { var acknowledgementA = await AssertInitialExecutionAsync(terminal, cancellationToken); var replay = await AssertReplayAndResumeAsync(terminal, acknowledgementA, cancellationToken); - var removal = await AssertRemovalAsync( - terminal, ptyId, replay.Session, replay.Transcript, cancellationToken); + var removal = await AssertRemovalAsync(terminal, ptyId, replay, cancellationToken); return new PtySocketEvidence { @@ -205,11 +204,10 @@ private async Task AssertReplayAndResumeAsync( private async Task AssertRemovalAsync( PtyClient terminal, string ptyId, - PtySession session, - PtyLiveTranscript transcript, + PtyReplayEvidence replay, CancellationToken cancellationToken) { - var completion = transcript.ReadToCompletionAsync(session, cancellationToken); + var completion = replay.Transcript.ReadToCompletionAsync(replay.Session, cancellationToken); _scenario.Observe(completion); var removed = await terminal.RemovePtyAsync( new PtyRemoveRequest { Location = _scenario.Location }, From b8571b338691284c3ca1b48c5136fb7834575d9e Mon Sep 17 00:00:00 2001 From: "denizirgin@gmail.com" Date: Sun, 6 Sep 2026 09:15:54 +0300 Subject: [PATCH 07/62] test: cover live location and session isolation --- docs/guide/getting-started.md | 7 +- .../LocationTargetingLiveTests.cs | 151 ++++++++++++++++++ 2 files changed, 157 insertions(+), 1 deletion(-) create mode 100644 tests/OpenCode.Sdk.Tests/LocationTargetingLiveTests.cs diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index 2cd59de8..bfb7ce04 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -45,7 +45,12 @@ using var client = new OpenCodeClient(new OpenCodeClientOptions | `Endpoint` | `Uri?` | The server's base address. Required. | | `Password` | `string?` | The HTTP Basic password. `null` sends anonymous requests, which is what a server started without authentication expects; an empty or whitespace value is refused at construction. | | `Username` | `string` | The Basic username. Defaults to `opencode` — the only username the pinned server accepts — so leave it alone unless upstream changes. | -| `Location` | `LocationSelector?` | The ambient project directory/workspace every call resolves against, overridable per call. | +| `Location` | `LocationSelector?` | The ambient directory/workspace header values, overridable per call; only operations that resolve location from those headers use them. | + +Session creation selects its location from `SessionCreateRequest.Location`; leaving it unset uses +the server's working directory. Session listing filters with `SessionListRequest.Directory` or +`Project`, independently of these headers. See the [location contract](../architecture/client-runtime.md#location) +for ambient and per-call header behavior. > **🔑 The SDK reads no environment variables of its own.** `OPENCODE_SERVER_PASSWORD` above is > *your* code reading *your* environment — exactly how opencode's own CLI layers it. Options are diff --git a/tests/OpenCode.Sdk.Tests/LocationTargetingLiveTests.cs b/tests/OpenCode.Sdk.Tests/LocationTargetingLiveTests.cs new file mode 100644 index 00000000..d4d2f039 --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/LocationTargetingLiveTests.cs @@ -0,0 +1,151 @@ +using System.Runtime.ExceptionServices; +using OpenCode.Sdk.Models; +using OpenCode.Sdk.TestSupport; + +namespace OpenCode.Sdk.Tests; + +/// +/// Proves the location channels against the real pinned server: ambient client headers resolve +/// each workspace through location.get, while session creation and listing use their own +/// explicit body and query fields. +/// +[ClassDataSource(Shared = SharedType.PerTestSession)] +[NotInParallel(ParallelConstraintKeys.ServerProcess)] +public sealed class LocationTargetingLiveTests(SimulatedDriveServerFixture server) +{ + private static readonly TimeSpan CleanupTimeout = TimeSpan.FromSeconds(10); + + [Test] + [Timeout(180_000)] + public async Task GetLocationAsync_Should_Target_Each_Client_Workspace_And_Its_Own_Session( + CancellationToken cancellationToken) + { + using var workspaceA = server.CreateWorkspace(); + using var workspaceB = server.CreateWorkspace(); + using var clientA = server.CreateClient(new LocationSelector { Directory = workspaceA.Path }); + using var clientB = server.CreateClient(new LocationSelector { Directory = workspaceB.Path }); + SessionInfo? sessionA = null; + SessionInfo? sessionB = null; + var ownedSessions = new List(); + Exception? primaryFailure = null; + + try + { + var resolvedA = (await clientA.GetLocationAsync(cancellationToken: cancellationToken)).ResolvedLocation; + var resolvedB = (await clientB.GetLocationAsync(cancellationToken: cancellationToken)).ResolvedLocation; + + sessionA = (await clientA.Sessions.CreateSessionAsync( + new SessionCreateRequest + { + Title = "location-targeting-a", + Location = new LocationRef + { + Directory = resolvedA.Directory, + WorkspaceId = resolvedA.WorkspaceId, + }, + }, + cancellationToken: cancellationToken)).Session; + ownedSessions.Add(new OwnedSession(clientA, sessionA.Id)); + sessionB = (await clientB.Sessions.CreateSessionAsync( + new SessionCreateRequest + { + Title = "location-targeting-b", + Location = new LocationRef + { + Directory = resolvedB.Directory, + WorkspaceId = resolvedB.WorkspaceId, + }, + }, + cancellationToken: cancellationToken)).Session; + ownedSessions.Add(new OwnedSession(clientB, sessionB.Id)); + + await Assert.That(resolvedA.Directory).IsEqualTo(workspaceA.Path); + await Assert.That(resolvedB.Directory).IsEqualTo(workspaceB.Path); + await Assert.That(resolvedA.Directory).IsNotEqualTo(resolvedB.Directory); + await Assert.That(sessionA.Location.Directory).IsEqualTo(resolvedA.Directory); + await Assert.That(sessionA.Location.WorkspaceId).IsEqualTo(resolvedA.WorkspaceId); + await Assert.That(sessionB.Location.Directory).IsEqualTo(resolvedB.Directory); + await Assert.That(sessionB.Location.WorkspaceId).IsEqualTo(resolvedB.WorkspaceId); + await Assert.That(sessionA.ProjectId).IsEqualTo(resolvedA.Project.Id); + await Assert.That(sessionB.ProjectId).IsEqualTo(resolvedB.Project.Id); + + var listedA = await clientA.Sessions.ListSessionsAsync( + new SessionListRequest { Directory = resolvedA.Directory }, cancellationToken: cancellationToken); + var listedB = await clientB.Sessions.ListSessionsAsync( + new SessionListRequest { Directory = resolvedB.Directory }, cancellationToken: cancellationToken); + var idsA = listedA.Sessions.Select(static session => session.Id).ToArray(); + var idsB = listedB.Sessions.Select(static session => session.Id).ToArray(); + + await Assert.That(idsA).Contains(sessionA.Id); + await Assert.That(idsA).DoesNotContain(sessionB.Id); + await Assert.That(idsB).Contains(sessionB.Id); + await Assert.That(idsB).DoesNotContain(sessionA.Id); + + Console.WriteLine( + "location-live: a=" + resolvedA.Directory + + " project=" + resolvedA.Project.Id + + " session=" + sessionA.Id + + "; b=" + resolvedB.Directory + + " project=" + resolvedB.Project.Id + + " session=" + sessionB.Id); + } + catch (Exception exception) + { + primaryFailure = exception; + } + finally + { + await CleanupSessionsAsync(ownedSessions, primaryFailure); + } + } + + private static async Task CleanupSessionsAsync( + IReadOnlyList sessions, + Exception? primaryFailure) + { + var failures = new List(); + using var cleanup = new CancellationTokenSource(CleanupTimeout); + foreach (var session in sessions) + { + await RemoveSessionAsync(session, failures, cleanup.Token); + } + + if (primaryFailure is not null) + { + if (failures.Count > 0) + { + primaryFailure.Data["LocationTargetingLiveTests.CleanupFailures"] = new AggregateException(failures); + } + + ExceptionDispatchInfo.Capture(primaryFailure).Throw(); + } + + if (failures.Count is 1) + { + ExceptionDispatchInfo.Capture(failures[0]).Throw(); + } + + if (failures.Count > 1) + { + throw new AggregateException("Multiple failures occurred while removing the owned sessions.", failures); + } + } + + private static async Task RemoveSessionAsync( + OwnedSession session, + List failures, + CancellationToken cancellationToken) + { + try + { + _ = await session.Client.Sessions.GetSessionClient(session.Id).RemoveSessionAsync( + cancellationToken: cancellationToken); + } + catch (Exception exception) + { + failures.Add(exception); + } + } + + private sealed record OwnedSession(OpenCodeClient Client, string Id); +} From 8ab56b9025fedcabda2be8b5ac565a1fb343b8aa Mon Sep 17 00:00:00 2001 From: "denizirgin@gmail.com" Date: Sun, 6 Sep 2026 09:44:58 +0300 Subject: [PATCH 08/62] test: cover live message cursor pagination --- .../SessionMessagePaginationLiveTests.cs | 149 ++++++++++++++++ .../Support/SimulatedSessionTurn.cs | 161 ++++++++++++++++++ 2 files changed, 310 insertions(+) create mode 100644 tests/OpenCode.Sdk.Tests/Sessions/SessionMessagePaginationLiveTests.cs create mode 100644 tests/OpenCode.Sdk.Tests/Support/SimulatedSessionTurn.cs diff --git a/tests/OpenCode.Sdk.Tests/Sessions/SessionMessagePaginationLiveTests.cs b/tests/OpenCode.Sdk.Tests/Sessions/SessionMessagePaginationLiveTests.cs new file mode 100644 index 00000000..1cbff46f --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Sessions/SessionMessagePaginationLiveTests.cs @@ -0,0 +1,149 @@ +using System.Globalization; +using System.Text.Json; +using OpenCode.Sdk.Models; +using OpenCode.Sdk.Tests.Support; +using OpenCode.Sdk.TestSupport; + +namespace OpenCode.Sdk.Tests.Sessions; + +[ClassDataSource(Shared = SharedType.PerTestSession)] +[NotInParallel(ParallelConstraintKeys.ServerProcess)] +public sealed class SessionMessagePaginationLiveTests(SimulatedDriveServerFixture server) +{ + private const string Prompt = "task-two pagination prompt"; + private const string Reply = "Task two pagination reply."; + + [Test] + [Timeout(180_000)] + public async Task ListMessagesAsync_Should_Round_Trip_The_Server_Cursor_And_Match_The_Paginator(CancellationToken cancellationToken) + { + using var workspace = server.CreateWorkspace(); + using var client = server.CreateClient(new LocationSelector { Directory = workspace.Path }); + var sessionId = await CreateOwnedSessionAsync(client, workspace.Path, cancellationToken); + var session = client.Sessions.GetSessionClient(sessionId); + var turnCompleted = false; + + try + { + var turn = new SimulatedSessionTurn(server, client, session, sessionId); + _ = await turn.CompleteAsync(Prompt, Reply, cancellationToken); + turnCompleted = true; + + var manual = new List(); + var request = new MessageListRequest { Limit = "1", Order = ListOrder.Ascending }; + while (true) + { + var page = await session.ListMessagesAsync(request, cancellationToken: cancellationToken); + if (page.Messages.Count == 0) + { + await Assert.That(page.Cursor.Next).IsNull(); + break; + } + + await Assert.That(page.Messages).Count().IsEqualTo(1); + await Assert.That(page.Cursor.Next).IsNotNull(); + manual.Add(page.Messages[0]); + request = new MessageListRequest { Limit = "1", Cursor = page.Cursor.Next }; + } + + var manualUsers = manual.OfType().Where(message => message.Text == Prompt).ToList(); + var manualAssistants = manual.OfType() + .Where(message => message.Content.OfType().Any(text => text.Text == Reply)) + .ToList(); + await Assert.That(manualUsers).Count().IsEqualTo(1); + await Assert.That(manualAssistants).Count().IsEqualTo(1); + await Assert.That(manual.IndexOf(manualUsers[0])).IsLessThan(manual.IndexOf(manualAssistants[0])); + + var enumerated = await EnumerateMessagesAsync(session, cancellationToken); + + var serializer = new GeneratedJsonSerializer(); + var manualIdentity = new List<(string Type, string Id)>(); + var enumeratedIdentity = new List<(string Type, string Id)>(); + foreach (var (source, target) in new[] { (manual, manualIdentity), (enumerated, enumeratedIdentity) }) + { + foreach (var message in source) + { + using var document = JsonDocument.Parse(serializer.Serialize(message)); + target.Add(( + document.RootElement.GetProperty("type").GetString()!, + document.RootElement.GetProperty("id").GetString()!)); + } + } + + await Assert.That(enumeratedIdentity.SequenceEqual(manualIdentity)).IsTrue(); + var enumeratedUsers = enumerated.OfType().Where(message => message.Text == Prompt).ToList(); + var enumeratedAssistants = enumerated.OfType() + .Where(message => message.Content.OfType().Any(text => text.Text == Reply)) + .ToList(); + await Assert.That(enumeratedUsers).Count().IsEqualTo(1); + await Assert.That(enumeratedAssistants).Count().IsEqualTo(1); + await Assert.That(enumerated.IndexOf(enumeratedUsers[0])).IsLessThan(enumerated.IndexOf(enumeratedAssistants[0])); + + var invalid = await session.ListMessagesAsync( + new MessageListRequest { Cursor = "garbage" }, OpenCodeRequestOptions.NoThrow, cancellationToken); + await Assert.That(invalid.Status).IsEqualTo(400); + await Assert.That(invalid.Error).IsTypeOf(); + var error = invalid.Error as InvalidCursorError; + await Assert.That(error?.Tag).IsEqualTo("InvalidCursorError"); + await Assert.That(error?.Message).IsEqualTo("Invalid cursor"); + + Console.WriteLine( + "message-pagination-live: populated-pages=" + Number(manual.Count) + " terminal-page=empty " + + "paginator-items=" + Number(enumerated.Count) + " invalid-cursor-status=" + Number(invalid.Status) + + " invalid-cursor-tag=" + error?.Tag); + } + finally + { + await CleanupAsync(session, turnCompleted); + } + } + + private static async Task CreateOwnedSessionAsync( + OpenCodeClient client, + string directory, + CancellationToken cancellationToken) + { + var created = await client.Sessions.CreateSessionAsync( + new SessionCreateRequest + { + Title = "message-pagination-live", + Location = new LocationRef { Directory = directory }, + Model = new ModelRef { Id = "sim-model", ProviderId = "sim" }, + }, + cancellationToken: cancellationToken); + return created.Session.Id; + } + + private static async Task CleanupAsync(SessionClient session, bool turnCompleted) + { + using var cleanup = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + try + { + if (!turnCompleted) + { + _ = await session.PostInterruptAsync(cancellationToken: cleanup.Token); + } + } + finally + { + _ = await session.RemoveSessionAsync(cancellationToken: cleanup.Token); + } + } + + private static async Task> EnumerateMessagesAsync( + SessionClient session, + CancellationToken cancellationToken) + { + var messages = new List(); + await foreach (var message in session.EnumerateMessagesAsync( + new MessageListRequest { Limit = "1", Order = ListOrder.Ascending }, + cancellationToken)) + { + messages.Add(message); + } + + return messages; + } + + private static string Number(int value) => value.ToString(CultureInfo.InvariantCulture); +} diff --git a/tests/OpenCode.Sdk.Tests/Support/SimulatedSessionTurn.cs b/tests/OpenCode.Sdk.Tests/Support/SimulatedSessionTurn.cs new file mode 100644 index 00000000..11277ea6 --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Support/SimulatedSessionTurn.cs @@ -0,0 +1,161 @@ +using OpenCode.Sdk.Models; +using OpenCode.Sdk.TestSupport; + +namespace OpenCode.Sdk.Tests.Support; + +internal sealed class SimulatedSessionTurn( + SimulatedDriveServerFixture server, + OpenCodeClient client, + SessionClient session, + string sessionId) +{ + private const string ChatCompletionsUrl = "https://api.openai.com/v1/chat/completions"; + private const string SimulatedModelId = "sim-model"; + private static readonly TimeSpan EventWait = TimeSpan.FromSeconds(120); + private static readonly TimeSpan RequestWait = TimeSpan.FromSeconds(60); + + public async Task CompleteAsync( + string prompt, + string reply, + CancellationToken cancellationToken) + { + using var eventWindow = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + using var cleanupSignal = new CancellationTokenSource(); + eventWindow.CancelAfter(EventWait); + var connected = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var observed = new List(); + var eventTask = Task.Run( + () => ObserveCompletionAsync( + reply, connected, observed, eventWindow.Token, cancellationToken, cleanupSignal.Token), + cancellationToken); + + try + { + var attached = await Task.WhenAny(connected.Task, eventTask); + if (attached == eventTask) + { + _ = await eventTask; + throw new InvalidOperationException("The event subscription ended before server.connected."); + } + + _ = await session.PostPromptAsync( + new SessionPromptPostRequest { Text = prompt }, + cancellationToken: cancellationToken); + + var invocation = await DriveAsync( + () => server.Controller.WaitForRequestAsync(RequestWait), + "waiting for the model request"); + RequireSimulatedInvocation(invocation); + + await DriveAsync( + () => server.Controller.ChunkTextAsync(invocation.Id, reply), + "scripting the reply"); + await DriveAsync( + () => server.Controller.FinishAsync(invocation.Id), + "finishing the model turn"); + + return await eventTask; + } + finally + { + await cleanupSignal.CancelAsync(); + await eventWindow.CancelAsync(); + try + { + _ = await eventTask; + } + catch (OperationCanceledException exception) when (!cancellationToken.IsCancellationRequested) + { + // The operation failed before the event window could finish. The primary failure + // is already propagating; record the reader's expected cleanup cancellation. + Console.WriteLine( + "simulated-session-turn: event reader cancelled during failure cleanup: " + + exception.GetType().Name); + } + } + } + + private async Task ObserveCompletionAsync( + string reply, + TaskCompletionSource connected, + List observed, + CancellationToken eventToken, + CancellationToken callerToken, + CancellationToken cleanupToken) + { + var sawOwnedText = false; + try + { + await foreach (var @event in client.Events.SubscribeAsync(eventToken)) + { + observed.Add(@event.Type); + if (@event is EventServerConnected) + { + _ = connected.TrySetResult(true); + } + + if (@event is SessionTextEnded textEnded + && textEnded.Data.SessionId == sessionId + && textEnded.Data.Text == reply) + { + sawOwnedText = true; + continue; + } + + if (sawOwnedText + && @event is SessionExecutionSucceeded succeeded + && succeeded.Data.SessionId == sessionId) + { + return succeeded; + } + } + } + catch (OperationCanceledException exception) + when (!callerToken.IsCancellationRequested && !cleanupToken.IsCancellationRequested) + { + throw new TimeoutException( + $"The scripted turn did not complete within the event window. Events: {Quote(observed)}.", + exception); + } + + throw new InvalidOperationException( + $"The event subscription ended before the scripted turn completed. Events: {Quote(observed)}."); + } + + private static void RequireSimulatedInvocation(DriveInvocation invocation) + { + if (invocation.Model != SimulatedModelId || invocation.Url != ChatCompletionsUrl) + { + throw new InvalidOperationException( + $"Expected model '{SimulatedModelId}' at '{ChatCompletionsUrl}', but received model " + + $"'{invocation.Model}' at '{invocation.Url}'."); + } + } + + private static async Task DriveAsync(Func> operation, string description) + { + try + { + return await operation(); + } + catch (OperationCanceledException exception) + { + throw new TimeoutException($"The drive controller was torn down while {description}.", exception); + } + } + + private static async Task DriveAsync(Func operation, string description) + { + try + { + await operation(); + } + catch (OperationCanceledException exception) + { + throw new TimeoutException($"The drive controller was torn down while {description}.", exception); + } + } + + private static string Quote(IEnumerable values) => + string.Join(", ", values.Select(value => $"'{value}'")); +} From 874ab0ac7d84184cf2fa4b39e00ad9d85b0664e4 Mon Sep 17 00:00:00 2001 From: "denizirgin@gmail.com" Date: Sun, 6 Sep 2026 09:59:40 +0300 Subject: [PATCH 09/62] test: isolate live session cleanup budgets --- .../LocationTargetingLiveTests.cs | 9 +- .../SessionMessagePaginationLiveTests.cs | 133 ++++++++++++++++-- 2 files changed, 122 insertions(+), 20 deletions(-) diff --git a/tests/OpenCode.Sdk.Tests/LocationTargetingLiveTests.cs b/tests/OpenCode.Sdk.Tests/LocationTargetingLiveTests.cs index d4d2f039..a82c3157 100644 --- a/tests/OpenCode.Sdk.Tests/LocationTargetingLiveTests.cs +++ b/tests/OpenCode.Sdk.Tests/LocationTargetingLiveTests.cs @@ -104,10 +104,9 @@ private static async Task CleanupSessionsAsync( Exception? primaryFailure) { var failures = new List(); - using var cleanup = new CancellationTokenSource(CleanupTimeout); foreach (var session in sessions) { - await RemoveSessionAsync(session, failures, cleanup.Token); + await RemoveSessionAsync(session, failures); } if (primaryFailure is not null) @@ -133,13 +132,13 @@ private static async Task CleanupSessionsAsync( private static async Task RemoveSessionAsync( OwnedSession session, - List failures, - CancellationToken cancellationToken) + List failures) { + using var cleanup = new CancellationTokenSource(CleanupTimeout); try { _ = await session.Client.Sessions.GetSessionClient(session.Id).RemoveSessionAsync( - cancellationToken: cancellationToken); + cancellationToken: cleanup.Token); } catch (Exception exception) { diff --git a/tests/OpenCode.Sdk.Tests/Sessions/SessionMessagePaginationLiveTests.cs b/tests/OpenCode.Sdk.Tests/Sessions/SessionMessagePaginationLiveTests.cs index 1cbff46f..e16490a1 100644 --- a/tests/OpenCode.Sdk.Tests/Sessions/SessionMessagePaginationLiveTests.cs +++ b/tests/OpenCode.Sdk.Tests/Sessions/SessionMessagePaginationLiveTests.cs @@ -1,4 +1,5 @@ using System.Globalization; +using System.Runtime.ExceptionServices; using System.Text.Json; using OpenCode.Sdk.Models; using OpenCode.Sdk.Tests.Support; @@ -12,6 +13,7 @@ public sealed class SessionMessagePaginationLiveTests(SimulatedDriveServerFixtur { private const string Prompt = "task-two pagination prompt"; private const string Reply = "Task two pagination reply."; + private static readonly TimeSpan CleanupTimeout = TimeSpan.FromSeconds(15); [Test] [Timeout(180_000)] @@ -22,6 +24,7 @@ public async Task ListMessagesAsync_Should_Round_Trip_The_Server_Cursor_And_Matc var sessionId = await CreateOwnedSessionAsync(client, workspace.Path, cancellationToken); var session = client.Sessions.GetSessionClient(sessionId); var turnCompleted = false; + Exception? primaryFailure = null; try { @@ -47,9 +50,7 @@ public async Task ListMessagesAsync_Should_Round_Trip_The_Server_Cursor_And_Matc } var manualUsers = manual.OfType().Where(message => message.Text == Prompt).ToList(); - var manualAssistants = manual.OfType() - .Where(message => message.Content.OfType().Any(text => text.Text == Reply)) - .ToList(); + var manualAssistants = OwnedReplies(manual); await Assert.That(manualUsers).Count().IsEqualTo(1); await Assert.That(manualAssistants).Count().IsEqualTo(1); await Assert.That(manual.IndexOf(manualUsers[0])).IsLessThan(manual.IndexOf(manualAssistants[0])); @@ -72,15 +73,12 @@ public async Task ListMessagesAsync_Should_Round_Trip_The_Server_Cursor_And_Matc await Assert.That(enumeratedIdentity.SequenceEqual(manualIdentity)).IsTrue(); var enumeratedUsers = enumerated.OfType().Where(message => message.Text == Prompt).ToList(); - var enumeratedAssistants = enumerated.OfType() - .Where(message => message.Content.OfType().Any(text => text.Text == Reply)) - .ToList(); + var enumeratedAssistants = OwnedReplies(enumerated); await Assert.That(enumeratedUsers).Count().IsEqualTo(1); await Assert.That(enumeratedAssistants).Count().IsEqualTo(1); await Assert.That(enumerated.IndexOf(enumeratedUsers[0])).IsLessThan(enumerated.IndexOf(enumeratedAssistants[0])); - var invalid = await session.ListMessagesAsync( - new MessageListRequest { Cursor = "garbage" }, OpenCodeRequestOptions.NoThrow, cancellationToken); + var invalid = await session.ListMessagesAsync(new MessageListRequest { Cursor = "garbage" }, OpenCodeRequestOptions.NoThrow, cancellationToken); await Assert.That(invalid.Status).IsEqualTo(400); await Assert.That(invalid.Error).IsTypeOf(); var error = invalid.Error as InvalidCursorError; @@ -92,12 +90,43 @@ public async Task ListMessagesAsync_Should_Round_Trip_The_Server_Cursor_And_Matc "paginator-items=" + Number(enumerated.Count) + " invalid-cursor-status=" + Number(invalid.Status) + " invalid-cursor-tag=" + error?.Tag); } + catch (Exception exception) + { + primaryFailure = exception; + } finally { - await CleanupAsync(session, turnCompleted); + await CleanupAsync(session, turnCompleted, primaryFailure); } } + [Test] + public async Task CleanupAsync_Should_Give_Removal_A_Fresh_Budget_And_Preserve_All_Failures() + { + var primaryFailure = new InvalidOperationException("primary failure"); + var removalFailure = new InvalidOperationException("removal failure"); + var removalTokenWasCancelled = true; + + var thrown = await Assert.That(async () => await CleanupOperationsAsync( + WaitForCancellationAsync, + token => + { + removalTokenWasCancelled = token.IsCancellationRequested; + return Task.FromException(removalFailure); + }, + TimeSpan.FromMilliseconds(50), + primaryFailure)) + .Throws(); + + await Assert.That(thrown).IsSameReferenceAs(primaryFailure); + await Assert.That(removalTokenWasCancelled).IsFalse(); + var cleanupFailures = primaryFailure.Data[CleanupFailuresKey] as AggregateException; + await Assert.That(cleanupFailures).IsNotNull(); + await Assert.That(cleanupFailures!.InnerExceptions.Count).IsEqualTo(2); + await Assert.That(cleanupFailures.InnerExceptions[0]).IsTypeOf(); + await Assert.That(cleanupFailures.InnerExceptions[1]).IsSameReferenceAs(removalFailure); + } + private static async Task CreateOwnedSessionAsync( OpenCodeClient client, string directory, @@ -114,19 +143,89 @@ private static async Task CreateOwnedSessionAsync( return created.Session.Id; } - private static async Task CleanupAsync(SessionClient session, bool turnCompleted) + private const string CleanupFailuresKey = "SessionMessagePaginationLiveTests.CleanupFailures"; + + private static async Task CleanupAsync( + SessionClient session, + bool turnCompleted, + Exception? primaryFailure) + { + Func? interrupt = turnCompleted + ? null + : async token => + { + _ = await session.PostInterruptAsync(cancellationToken: token); + }; + await CleanupOperationsAsync( + interrupt, + async token => + { + _ = await session.RemoveSessionAsync(cancellationToken: token); + }, + CleanupTimeout, + primaryFailure); + } + + private static async Task CleanupOperationsAsync( + Func? interrupt, + Func remove, + TimeSpan timeout, + Exception? primaryFailure) { - using var cleanup = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + var failures = new List(); + if (interrupt is not null) + { + await CaptureCleanupFailureAsync(interrupt, timeout, failures); + } + + await CaptureCleanupFailureAsync(remove, timeout, failures); + ThrowCleanupFailures(primaryFailure, failures); + } + + private static async Task CaptureCleanupFailureAsync( + Func operation, + TimeSpan timeout, + List failures) + { + using var cleanup = new CancellationTokenSource(timeout); try { - if (!turnCompleted) + await operation(cleanup.Token); + } + catch (Exception exception) + { + failures.Add(exception); + } + } + + private static async Task WaitForCancellationAsync(CancellationToken cancellationToken) + { + var cancellation = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var registration = cancellationToken.Register( + () => _ = cancellation.TrySetCanceled(cancellationToken)); + _ = await cancellation.Task; + } + + private static void ThrowCleanupFailures(Exception? primaryFailure, List failures) + { + if (primaryFailure is not null) + { + if (failures.Count > 0) { - _ = await session.PostInterruptAsync(cancellationToken: cleanup.Token); + primaryFailure.Data[CleanupFailuresKey] = new AggregateException(failures); } + + ExceptionDispatchInfo.Capture(primaryFailure).Throw(); } - finally + + if (failures.Count is 1) { - _ = await session.RemoveSessionAsync(cancellationToken: cleanup.Token); + ExceptionDispatchInfo.Capture(failures[0]).Throw(); + } + + if (failures.Count > 1) + { + throw new AggregateException("Multiple failures occurred while cleaning the session.", failures); } } @@ -145,5 +244,9 @@ private static async Task> EnumerateMessagesAsync( return messages; } + private static List OwnedReplies(IEnumerable messages) => + [.. messages.OfType() + .Where(message => message.Content.OfType().Any(text => text.Text == Reply))]; + private static string Number(int value) => value.ToString(CultureInfo.InvariantCulture); } From a346e697e94687ec5061488530ddf5937ca38751 Mon Sep 17 00:00:00 2001 From: "denizirgin@gmail.com" Date: Sun, 6 Sep 2026 10:39:40 +0300 Subject: [PATCH 10/62] test: cover durable session log replay --- .editorconfig | 8 + docs/guide/streaming.md | 35 +++- .../Server/persistent-simulation-host.js | 72 +++++++ .../Sessions/SessionLogLiveTests.cs | 197 ++++++++++++++++++ .../SessionMessagePaginationLiveTests.cs | 85 +------- .../Support/OwnedOperation.cs | 92 ++++++++ .../Support/OwnedOperationTests.cs | 26 +++ .../Support/OwnedSessionCleanup.cs | 115 ++++++++++ .../Support/SessionLogTranscript.cs | 143 +++++++++++++ .../PersistentSimulationServerCommand.cs | 29 +++ tests/Shared/SimulatedDriveServerFixture.cs | 6 +- 11 files changed, 719 insertions(+), 89 deletions(-) create mode 100644 tests/OpenCode.Sdk.Tests/Fixtures/Server/persistent-simulation-host.js create mode 100644 tests/OpenCode.Sdk.Tests/Sessions/SessionLogLiveTests.cs create mode 100644 tests/OpenCode.Sdk.Tests/Support/OwnedOperation.cs create mode 100644 tests/OpenCode.Sdk.Tests/Support/OwnedOperationTests.cs create mode 100644 tests/OpenCode.Sdk.Tests/Support/OwnedSessionCleanup.cs create mode 100644 tests/OpenCode.Sdk.Tests/Support/SessionLogTranscript.cs create mode 100644 tests/Shared/PersistentSimulationServerCommand.cs diff --git a/.editorconfig b/.editorconfig index 0250574b..4124ea1b 100644 --- a/.editorconfig +++ b/.editorconfig @@ -903,6 +903,14 @@ dotnet_diagnostic.CA2000.severity = none [tests/OpenCode.Sdk.Tests/Support/ScriptedTerminalWebSocket.cs] dotnet_diagnostic.VSTHRD003.severity = none +# OwnedOperation starts and retains the live turn task, exposes its only normal await, and owns the +# bounded cancel-and-observe cleanup path. If that budget expires, its registered completion +# observer consumes and reports the eventual fault; the deterministic support test exercises that +# branch. VSTHRD003 cannot follow the start factory into this owner and labels its own retained task +# as foreign. Winner: the explicit operation-lifetime owner; VSTHRD003 remains enabled elsewhere. +[tests/OpenCode.Sdk.Tests/Support/OwnedOperation*.cs] +dotnet_diagnostic.VSTHRD003.severity = none + # CliWrapServerAdapter parks on TaskCompletionSource handles the adapter itself completes — the # stdin lease that holds the child's ownership pipe open until disposal, and the first stdout # line that carries the readiness contract — plus the CliWrap execution task it started. That diff --git a/docs/guide/streaming.md b/docs/guide/streaming.md index 3408addb..d13825ef 100644 --- a/docs/guide/streaming.md +++ b/docs/guide/streaming.md @@ -1,5 +1,7 @@ # 📡 Streaming +Date: 2026-09-06 + Two server-sent event streams, both surfaced as `IAsyncEnumerable` and both riding the same transport as ordinary calls: the **global event bus** for everything happening in the server process, and the **per-session log** for one conversation. @@ -48,7 +50,7 @@ cursor, no replay, and no resume channel: gap you need to know about. After a failure, refresh whatever state you care about with ordinary calls, then subscribe again. -If you need durable history for one conversation, the per-session log is the stream that has it. +If the server profile retains session history, the per-session log is the stream that can replay it. ## 📜 A single session's log @@ -69,22 +71,33 @@ await foreach (var item in session.GetLogAsync(new SessionLogRequest { Follow = | Member | Type | Meaning | |---|---|---| | `Follow` | `QueryBoolean?` | `True` keeps the stream open and keeps delivering; otherwise the stream ends when the existing log has been sent. | -| `After` | `string?` | Continue after an item you have already seen — the explicit continuation channel the global bus does not have. | +| `After` | `string?` | Continue after an aggregate sequence you have already processed — the explicit continuation channel the global bus does not have. | -So a consumer that survives a restart records the last item it processed and asks for what came -after it: +The cursor is an exclusive numeric aggregate sequence, encoded as a string. Record the +`Durable.Seq` from the concrete durable event you processed, then format it with invariant culture: ```csharp -var request = new SessionLogRequest -{ - After = lastSeenId, - Follow = QueryBoolean.True, -}; +using System.Globalization; + +static SessionLogRequest FollowAfter(SessionExecutionSucceeded lastProcessed) => + new() + { + After = lastProcessed.Durable.Seq.ToString(CultureInfo.InvariantCulture), + Follow = QueryBoolean.True, + }; ``` +With `Follow = False`, a server that persists the requested history replays the available durable +events and ends with one `EventLogSynced`. The marker reports the captured aggregate watermark. +With `Follow = True`, read through any replayed events until that marker arrives; the same stream +then delivers live events committed after the attachment boundary. The marker is a transition +boundary, not a durable event to save as the next `After` value. + > **📎 A note on guarantees**: `after` is a *request*, not a durability promise. How much history -> the server retains, and for how long, is upstream's business and is not something this SDK can -> state on its behalf. Treat a returned gap as possible and reconcile with ordinary reads. +> the server persists or retains, and for how long, is upstream's business and is not something +> this SDK can state on its behalf. The stream does not promise replay after a server restart. +> Treat a returned gap as possible and reconcile with ordinary reads. See the canonical +> [server-sent events rules](../architecture/client-runtime.md#server-sent-events). ## 🧩 Unknown events do not break your consumer diff --git a/tests/OpenCode.Sdk.Tests/Fixtures/Server/persistent-simulation-host.js b/tests/OpenCode.Sdk.Tests/Fixtures/Server/persistent-simulation-host.js new file mode 100644 index 00000000..4f3b8997 --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Fixtures/Server/persistent-simulation-host.js @@ -0,0 +1,72 @@ +import { NodeServices } from "@effect/platform-node"; +import { LayerNode } from "@opencode-ai/util/effect/layer-node"; +import { Global } from "@opencode-ai/util/global"; +import { AppProcess } from "@opencode-ai/util/process"; +import { start } from "@opencode-ai/server/process"; +import { Effect, References } from "effect"; +import { HttpServer } from "effect/unstable/http"; + +const password = process.env.OPENCODE_PASSWORD ?? process.env.OPENCODE_SERVER_PASSWORD; +if (!password) throw new Error("Missing server password"); + +// Match the CLI's stdio credential boundary: tools spawned by the server never inherit its lease. +delete process.env.OPENCODE_PASSWORD; +delete process.env.OPENCODE_SERVER_PASSWORD; + +const truthy = (value) => value === "1" || value?.toLowerCase() === "true"; + +const waitForStdinClose = Effect.callback((resume) => { + const close = () => resume(Effect.void); + process.stdin.once("end", close); + process.stdin.once("close", close); + process.stdin.resume(); + if (process.stdin.readableEnded || process.stdin.destroyed) close(); + return Effect.sync(() => { + process.stdin.off("end", close); + process.stdin.off("close", close); + process.stdin.pause(); + }); +}); + +const application = Effect.scoped( + Effect.gen(function* () { + const server = yield* start({ + app: { name: "cli", version: "local", channel: "local" }, + hostname: "127.0.0.1", + port: 0, + password, + simulation: true, + database: { path: process.env.OPENCODE_DB ?? "opencode-local.db" }, + events: { persist: true }, + models: { + url: process.env.OPENCODE_MODELS_URL, + file: process.env.OPENCODE_MODELS_PATH, + fetch: !truthy(process.env.OPENCODE_DISABLE_MODELS_FETCH), + }, + config: { + directory: process.env.OPENCODE_CONFIG_DIR, + project: !truthy( + process.env.OPENCODE_CONFIG_PROJECT_DISABLE ?? process.env.OPENCODE_DISABLE_PROJECT_CONFIG, + ), + file: process.env.OPENCODE_CONFIG, + content: process.env.OPENCODE_CONFIG_CONTENT, + }, + windows: { gitbash: process.env.OPENCODE_GIT_BASH_PATH }, + fs: { + filewatcher: !truthy(process.env.OPENCODE_FILEWATCHER_DISABLE ?? process.env.OPENCODE_DISABLE_FILEWATCHER), + fff: + process.env.OPENCODE_DISABLE_FFF === undefined + ? process.platform !== "win32" + : !truthy(process.env.OPENCODE_DISABLE_FFF), + }, + }); + console.log(JSON.stringify({ url: HttpServer.formatAddress(server.address) })); + yield* waitForStdinClose; + }), +).pipe( + Effect.provideService(References.MinimumLogLevel, "None"), + Effect.provide(LayerNode.compile(LayerNode.group([Global.node, AppProcess.node]))), + Effect.provide(NodeServices.layer), +); + +await Effect.runPromise(application); diff --git a/tests/OpenCode.Sdk.Tests/Sessions/SessionLogLiveTests.cs b/tests/OpenCode.Sdk.Tests/Sessions/SessionLogLiveTests.cs new file mode 100644 index 00000000..c7b96435 --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Sessions/SessionLogLiveTests.cs @@ -0,0 +1,197 @@ +using System.Globalization; +using OpenCode.Sdk.Models; +using OpenCode.Sdk.Tests.Support; +using OpenCode.Sdk.TestSupport; + +namespace OpenCode.Sdk.Tests.Sessions; + +[ClassDataSource(Shared = SharedType.PerTestSession)] +[NotInParallel(ParallelConstraintKeys.ServerProcess)] +public sealed class SessionLogLiveTests(SimulatedDriveServerFixture server) +{ + private const string ReplayPrompt = "task-three replay prompt"; + private const string ReplayReply = "Task three replay reply."; + private const string FollowFirstPrompt = "task-three follow first prompt"; + private const string FollowFirstReply = "Task three follow first reply."; + private const string FollowSecondPrompt = "task-three follow second prompt"; + private const string FollowSecondReply = "Task three follow second reply."; + private static readonly TimeSpan CleanupTimeout = TimeSpan.FromSeconds(15); + + [Test] + [Timeout(180_000)] + public async Task GetLogAsync_Should_Replay_A_Completed_Turn_And_Resume_Exclusively( + CancellationToken cancellationToken) + { + using var workspace = server.CreateWorkspace(); + using var client = server.CreateClient(new LocationSelector { Directory = workspace.Path }); + var sessionId = await CreateOwnedSessionAsync(client, workspace.Path, "session-log-replay", cancellationToken); + var session = client.Sessions.GetSessionClient(sessionId); + var cleanup = new OwnedSessionCleanup(session, CleanupTimeout); + Exception? primaryFailure = null; + + try + { + var turn = new SimulatedSessionTurn(server, client, session, sessionId); + var terminal = await turn.CompleteAsync(ReplayPrompt, ReplayReply, cancellationToken); + cleanup.MarkTurnCompleted(); + + var transcript = new SessionLogTranscript(session); + var replay = await transcript.ReadToEndAsync( + new SessionLogRequest { Follow = QueryBoolean.False }, + cancellationToken); + var markers = replay.OfType().ToList(); + await Assert.That(markers).Count().IsEqualTo(1); + await Assert.That(replay[^1]).IsSameReferenceAs(markers[0]); + await Assert.That(markers[0].AggregateId).IsEqualTo(sessionId); + await Assert.That(markers[0].Seq).IsNotNull(); + await Assert.That(replay.Take(replay.Count - 1).All(item => item is ISessionEventDurable)).IsTrue(); + await Assert.That(replay.OfType().Any(created => created.Data.SessionId == sessionId)).IsTrue(); + + var ownedTexts = replay.OfType() + .Where(text => text.Data.SessionId == sessionId && text.Data.Text == ReplayReply) + .ToList(); + var terminalMatches = replay.OfType() + .Where(succeeded => succeeded.Id == terminal.Id && succeeded.Durable.Seq == terminal.Durable.Seq) + .ToList(); + await Assert.That(ownedTexts).Count().IsEqualTo(1); + await Assert.That(terminalMatches).Count().IsEqualTo(1); + var ownedText = ownedTexts[0]; + var ownedSucceeded = terminalMatches[0]; + var replayTextIndex = FindIndex(replay, ownedText.Id, ownedText.Durable.Seq); + var replaySucceededIndex = FindIndex(replay, ownedSucceeded.Id, ownedSucceeded.Durable.Seq); + await Assert.That(replaySucceededIndex).IsGreaterThan(replayTextIndex); + var anchors = replay.OfType() + .Where(started => started.Data.SessionId == sessionId && started.Durable.Seq < ownedText.Durable.Seq) + .OrderByDescending(started => started.Durable.Seq) + .ToList(); + await Assert.That(anchors).IsNotEmpty(); + var anchor = anchors[0]; + + var resumed = await transcript.ReadToEndAsync( + new SessionLogRequest + { + After = anchor.Durable.Seq.ToString(CultureInfo.InvariantCulture), + Follow = QueryBoolean.False, + }, + cancellationToken); + var resumedMarkers = resumed.OfType().ToList(); + await Assert.That(resumedMarkers).Count().IsEqualTo(1); + await Assert.That(resumed[^1]).IsSameReferenceAs(resumedMarkers[0]); + await Assert.That(resumed.Take(resumed.Count - 1).All(item => item is ISessionEventDurable)).IsTrue(); + await Assert.That(resumed.Any(item => item is SessionExecutionStarted started && started.Id == anchor.Id)).IsFalse(); + var resumedTextIndex = FindIndex(resumed, ownedText.Id, ownedText.Durable.Seq); + var resumedSucceededIndex = FindIndex(resumed, ownedSucceeded.Id, ownedSucceeded.Durable.Seq); + await Assert.That(resumedTextIndex).IsGreaterThanOrEqualTo(0); + await Assert.That(resumedSucceededIndex).IsGreaterThan(resumedTextIndex); + await Assert.That(resumedMarkers[0].AggregateId).IsEqualTo(sessionId); + await Assert.That(resumedMarkers[0].Seq).IsNotNull(); + await Assert.That(resumedMarkers[0].Seq!.Value).IsGreaterThanOrEqualTo(ownedSucceeded.Durable.Seq); + + } + catch (Exception exception) + { + primaryFailure = exception; + } + finally + { + await cleanup.CompleteAsync(primaryFailure); + } + } + + [Test] + [Timeout(180_000)] + public async Task GetLogAsync_Should_Follow_A_Turn_After_The_Synced_Marker( + CancellationToken cancellationToken) + { + using var workspace = server.CreateWorkspace(); + using var client = server.CreateClient(new LocationSelector { Directory = workspace.Path }); + var sessionId = await CreateOwnedSessionAsync(client, workspace.Path, "session-log-follow", cancellationToken); + var session = client.Sessions.GetSessionClient(sessionId); + var cleanup = new OwnedSessionCleanup(session, CleanupTimeout); + var transcript = new SessionLogTranscript(session); + cleanup.Own(transcript.DisposeAsync); + using var turnCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + Exception? primaryFailure = null; + + try + { + var firstTurn = new SimulatedSessionTurn(server, client, session, sessionId); + _ = await firstTurn.CompleteAsync(FollowFirstPrompt, FollowFirstReply, cancellationToken); + cleanup.MarkTurnCompleted(); + var finite = await transcript.ReadToEndAsync( + new SessionLogRequest { Follow = QueryBoolean.False }, + cancellationToken); + var finiteMarkers = finite.OfType().ToList(); + await Assert.That(finiteMarkers).Count().IsEqualTo(1); + await Assert.That(finite[^1]).IsSameReferenceAs(finiteMarkers[0]); + await Assert.That(finiteMarkers[0].Seq).IsNotNull(); + + await transcript.AttachAsync( + finiteMarkers[0].Seq!.Value.ToString(CultureInfo.InvariantCulture), + cancellationToken); + await Assert.That(transcript.Marker).IsNotNull(); + await Assert.That(transcript.Marker!.AggregateId).IsEqualTo(sessionId); + await Assert.That(transcript.ReplayItems.OfType() + .Any(text => text.Data.Text == FollowSecondReply)).IsFalse(); + + cleanup.MarkTurnStarted(); + var followedTurn = new SimulatedSessionTurn(server, client, session, sessionId); + var secondTurn = cleanup.Own( + () => followedTurn.CompleteAsync(FollowSecondPrompt, FollowSecondReply, turnCancellation.Token), + turnCancellation.CancelAsync); + await transcript.ReadTurnAsync(sessionId, FollowSecondReply); + var terminal = await secondTurn.CompleteAsync(); + cleanup.MarkTurnCompleted(); + + await Assert.That(transcript.TextEnded).IsNotNull(); + await Assert.That(transcript.Succeeded).IsNotNull(); + await Assert.That(transcript.Succeeded!.Id).IsEqualTo(terminal.Id); + await Assert.That(transcript.Succeeded.Durable.Seq).IsEqualTo(terminal.Durable.Seq); + var textIndex = transcript.LiveItems.ToList().IndexOf(transcript.TextEnded!); + var succeededIndex = transcript.LiveItems.ToList().IndexOf(transcript.Succeeded); + await Assert.That(textIndex).IsGreaterThanOrEqualTo(0); + await Assert.That(succeededIndex).IsGreaterThan(textIndex); + + Console.WriteLine( + "session-log-follow-live: finite-watermark=" + Number(finiteMarkers[0].Seq!.Value) + + " replay-before-marker=" + Number(transcript.ReplayItems.Count) + + " followed-text-seq=" + Number(transcript.TextEnded!.Durable.Seq) + + " followed-succeeded-seq=" + Number(transcript.Succeeded.Durable.Seq)); + } + catch (Exception exception) + { + primaryFailure = exception; + } + finally + { + await cleanup.CompleteAsync(primaryFailure); + } + } + + private static async Task CreateOwnedSessionAsync( + OpenCodeClient client, + string directory, + string title, + CancellationToken cancellationToken) + { + var created = await client.Sessions.CreateSessionAsync( + new SessionCreateRequest + { + Title = title, + Location = new LocationRef { Directory = directory }, + Model = new ModelRef { Id = "sim-model", ProviderId = "sim" }, + }, + cancellationToken: cancellationToken); + return created.Session.Id; + } + + private static int FindIndex(List items, string id, long sequence) => + items.FindIndex(item => item switch + { + SessionTextEnded text => text.Id == id && text.Durable.Seq == sequence, + SessionExecutionSucceeded succeeded => succeeded.Id == id && succeeded.Durable.Seq == sequence, + _ => false, + }); + + private static string Number(long value) => value.ToString(CultureInfo.InvariantCulture); +} diff --git a/tests/OpenCode.Sdk.Tests/Sessions/SessionMessagePaginationLiveTests.cs b/tests/OpenCode.Sdk.Tests/Sessions/SessionMessagePaginationLiveTests.cs index e16490a1..d4b49902 100644 --- a/tests/OpenCode.Sdk.Tests/Sessions/SessionMessagePaginationLiveTests.cs +++ b/tests/OpenCode.Sdk.Tests/Sessions/SessionMessagePaginationLiveTests.cs @@ -1,5 +1,4 @@ using System.Globalization; -using System.Runtime.ExceptionServices; using System.Text.Json; using OpenCode.Sdk.Models; using OpenCode.Sdk.Tests.Support; @@ -96,7 +95,7 @@ public async Task ListMessagesAsync_Should_Round_Trip_The_Server_Cursor_And_Matc } finally { - await CleanupAsync(session, turnCompleted, primaryFailure); + await CompleteCleanupAsync(session, turnCompleted, primaryFailure); } } @@ -107,20 +106,20 @@ public async Task CleanupAsync_Should_Give_Removal_A_Fresh_Budget_And_Preserve_A var removalFailure = new InvalidOperationException("removal failure"); var removalTokenWasCancelled = true; - var thrown = await Assert.That(async () => await CleanupOperationsAsync( + var cleanup = new OwnedSessionCleanup( WaitForCancellationAsync, token => { removalTokenWasCancelled = token.IsCancellationRequested; return Task.FromException(removalFailure); }, - TimeSpan.FromMilliseconds(50), - primaryFailure)) + TimeSpan.FromMilliseconds(50)); + var thrown = await Assert.That(async () => await cleanup.CompleteAsync(primaryFailure)) .Throws(); await Assert.That(thrown).IsSameReferenceAs(primaryFailure); await Assert.That(removalTokenWasCancelled).IsFalse(); - var cleanupFailures = primaryFailure.Data[CleanupFailuresKey] as AggregateException; + var cleanupFailures = primaryFailure.Data[OwnedSessionCleanup.FailuresKey] as AggregateException; await Assert.That(cleanupFailures).IsNotNull(); await Assert.That(cleanupFailures!.InnerExceptions.Count).IsEqualTo(2); await Assert.That(cleanupFailures.InnerExceptions[0]).IsTypeOf(); @@ -143,59 +142,18 @@ private static async Task CreateOwnedSessionAsync( return created.Session.Id; } - private const string CleanupFailuresKey = "SessionMessagePaginationLiveTests.CleanupFailures"; - - private static async Task CleanupAsync( + private static async Task CompleteCleanupAsync( SessionClient session, bool turnCompleted, Exception? primaryFailure) { - Func? interrupt = turnCompleted - ? null - : async token => - { - _ = await session.PostInterruptAsync(cancellationToken: token); - }; - await CleanupOperationsAsync( - interrupt, - async token => - { - _ = await session.RemoveSessionAsync(cancellationToken: token); - }, - CleanupTimeout, - primaryFailure); - } - - private static async Task CleanupOperationsAsync( - Func? interrupt, - Func remove, - TimeSpan timeout, - Exception? primaryFailure) - { - var failures = new List(); - if (interrupt is not null) + var cleanup = new OwnedSessionCleanup(session, CleanupTimeout); + if (turnCompleted) { - await CaptureCleanupFailureAsync(interrupt, timeout, failures); + cleanup.MarkTurnCompleted(); } - await CaptureCleanupFailureAsync(remove, timeout, failures); - ThrowCleanupFailures(primaryFailure, failures); - } - - private static async Task CaptureCleanupFailureAsync( - Func operation, - TimeSpan timeout, - List failures) - { - using var cleanup = new CancellationTokenSource(timeout); - try - { - await operation(cleanup.Token); - } - catch (Exception exception) - { - failures.Add(exception); - } + await cleanup.CompleteAsync(primaryFailure); } private static async Task WaitForCancellationAsync(CancellationToken cancellationToken) @@ -206,29 +164,6 @@ private static async Task WaitForCancellationAsync(CancellationToken cancellatio _ = await cancellation.Task; } - private static void ThrowCleanupFailures(Exception? primaryFailure, List failures) - { - if (primaryFailure is not null) - { - if (failures.Count > 0) - { - primaryFailure.Data[CleanupFailuresKey] = new AggregateException(failures); - } - - ExceptionDispatchInfo.Capture(primaryFailure).Throw(); - } - - if (failures.Count is 1) - { - ExceptionDispatchInfo.Capture(failures[0]).Throw(); - } - - if (failures.Count > 1) - { - throw new AggregateException("Multiple failures occurred while cleaning the session.", failures); - } - } - private static async Task> EnumerateMessagesAsync( SessionClient session, CancellationToken cancellationToken) diff --git a/tests/OpenCode.Sdk.Tests/Support/OwnedOperation.cs b/tests/OpenCode.Sdk.Tests/Support/OwnedOperation.cs new file mode 100644 index 00000000..6dedcfe7 --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Support/OwnedOperation.cs @@ -0,0 +1,92 @@ +namespace OpenCode.Sdk.Tests.Support; + +internal sealed class OwnedOperation +{ + private readonly Func _cancel; + private readonly Task _completion; + private readonly TaskCompletionSource _lateObservation = new( + TaskCreationOptions.RunContinuationsAsynchronously); + private int _observed; + private int _observerRegistered; + + public OwnedOperation(Func> start, Func cancel) + { + ArgumentNullException.ThrowIfNull(start); + ArgumentNullException.ThrowIfNull(cancel); + _cancel = cancel; + _completion = start(); + } + + public async Task CompleteAsync() + { + try + { + return await _completion; + } + finally + { + _ = Interlocked.Exchange(ref _observed, 1); + } + } + + public async Task CleanupAsync(CancellationToken cancellationToken) + { + if (Volatile.Read(ref _observed) is 1) + { + return; + } + + try + { + if (!_completion.IsCompleted) + { + await _cancel().WaitAsync(cancellationToken); + } + + _ = await _completion.WaitAsync(cancellationToken); + _ = Interlocked.Exchange(ref _observed, 1); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + ObserveLateFault(); + throw; + } + catch + { + if (_completion.IsCompleted) + { + _ = Interlocked.Exchange(ref _observed, 1); + } + else + { + ObserveLateFault(); + } + + throw; + } + } + + internal async Task WaitForLateObservationAsync(CancellationToken cancellationToken) => + await _lateObservation.Task.WaitAsync(cancellationToken); + + private void ObserveLateFault() + { + if (Interlocked.Exchange(ref _observerRegistered, 1) is 1) + { + return; + } + + _ = _completion.ContinueWith( + completed => + { + var aggregate = completed.Exception; + var failure = aggregate?.InnerExceptions.Count is 1 + ? aggregate.InnerExceptions[0] + : aggregate; + _ = _lateObservation.TrySetResult(failure); + }, + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + } +} diff --git a/tests/OpenCode.Sdk.Tests/Support/OwnedOperationTests.cs b/tests/OpenCode.Sdk.Tests/Support/OwnedOperationTests.cs new file mode 100644 index 00000000..74043398 --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Support/OwnedOperationTests.cs @@ -0,0 +1,26 @@ +namespace OpenCode.Sdk.Tests.Support; + +public sealed class OwnedOperationTests +{ + [Test] + public async Task CleanupAsync_Should_Report_Timeout_And_Observe_A_Late_Failure() + { + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var lateFailure = new InvalidOperationException("late operation failure"); + var cleanup = new OwnedSessionCleanup( + _ => Task.CompletedTask, + _ => Task.CompletedTask, + TimeSpan.Zero); + cleanup.MarkTurnCompleted(); + var operation = cleanup.Own(() => completion.Task, () => Task.CompletedTask); + + var thrown = await Assert.That(async () => await cleanup.CompleteAsync(null)) + .Throws(); + completion.SetException(lateFailure); + using var observationBudget = new CancellationTokenSource(TimeSpan.FromSeconds(1)); + var observed = await operation.WaitForLateObservationAsync(observationBudget.Token); + + await Assert.That(thrown!.CancellationToken.IsCancellationRequested).IsTrue(); + await Assert.That(observed).IsSameReferenceAs(lateFailure); + } +} diff --git a/tests/OpenCode.Sdk.Tests/Support/OwnedSessionCleanup.cs b/tests/OpenCode.Sdk.Tests/Support/OwnedSessionCleanup.cs new file mode 100644 index 00000000..d78e2a81 --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Support/OwnedSessionCleanup.cs @@ -0,0 +1,115 @@ +using System.Runtime.ExceptionServices; + +namespace OpenCode.Sdk.Tests.Support; + +internal sealed class OwnedSessionCleanup +{ + internal const string FailuresKey = "OwnedSessionCleanup.Failures"; + + private readonly Func _interrupt; + private readonly Func _remove; + private readonly List> _resources = []; + private readonly TimeSpan _timeout; + private bool _turnCompleted; + + public OwnedSessionCleanup(SessionClient session, TimeSpan timeout) + : this( + async token => + { + _ = await session.PostInterruptAsync(cancellationToken: token); + }, + async token => + { + _ = await session.RemoveSessionAsync(cancellationToken: token); + }, + timeout) + { + ArgumentNullException.ThrowIfNull(session); + } + + internal OwnedSessionCleanup( + Func interrupt, + Func remove, + TimeSpan timeout) + { + ArgumentNullException.ThrowIfNull(interrupt); + ArgumentNullException.ThrowIfNull(remove); + _interrupt = interrupt; + _remove = remove; + _timeout = timeout; + } + + public void MarkTurnCompleted() => _turnCompleted = true; + + public void MarkTurnStarted() => _turnCompleted = false; + + public void Own(Func resourceCleanup) + { + ArgumentNullException.ThrowIfNull(resourceCleanup); + _resources.Add(resourceCleanup); + } + + public OwnedOperation Own(Func> start, Func cancel) + { + ArgumentNullException.ThrowIfNull(start); + ArgumentNullException.ThrowIfNull(cancel); + var operation = new OwnedOperation(start, cancel); + _resources.Add(operation.CleanupAsync); + return operation; + } + + public async Task CompleteAsync(Exception? primaryFailure) + { + var failures = new List(); + foreach (var cleanup in _resources) + { + await CaptureFailureAsync(cleanup, failures); + } + + if (!_turnCompleted) + { + await CaptureFailureAsync(_interrupt, failures); + } + + await CaptureFailureAsync(_remove, failures); + ThrowFailures(primaryFailure, failures); + } + + private async Task CaptureFailureAsync( + Func operation, + List failures) + { + using var budget = new CancellationTokenSource(_timeout); + try + { + await operation(budget.Token); + } + catch (Exception exception) + { + failures.Add(exception); + } + } + + private static void ThrowFailures(Exception? primaryFailure, List failures) + { + if (primaryFailure is not null) + { + if (failures.Count > 0) + { + primaryFailure.Data[FailuresKey] = new AggregateException(failures); + } + + ExceptionDispatchInfo.Capture(primaryFailure).Throw(); + } + + if (failures.Count is 1) + { + ExceptionDispatchInfo.Capture(failures[0]).Throw(); + } + + if (failures.Count > 1) + { + throw new AggregateException("Multiple failures occurred while cleaning the session.", failures); + } + } +} diff --git a/tests/OpenCode.Sdk.Tests/Support/SessionLogTranscript.cs b/tests/OpenCode.Sdk.Tests/Support/SessionLogTranscript.cs new file mode 100644 index 00000000..ee854573 --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Support/SessionLogTranscript.cs @@ -0,0 +1,143 @@ +using OpenCode.Sdk.Models; + +namespace OpenCode.Sdk.Tests.Support; + +internal sealed class SessionLogTranscript(SessionClient session) +{ + private static readonly TimeSpan FollowWindow = TimeSpan.FromSeconds(120); + private readonly List _liveItems = []; + private readonly List _replayItems = []; + private CancellationToken _callerToken; + private IAsyncEnumerator? _enumerator; + private CancellationTokenSource? _window; + + public IReadOnlyList LiveItems => _liveItems; + + public EventLogSynced? Marker { get; private set; } + + public IReadOnlyList ReplayItems => _replayItems; + + public SessionExecutionSucceeded? Succeeded { get; private set; } + + public SessionTextEnded? TextEnded { get; private set; } + + public async Task> ReadToEndAsync( + SessionLogRequest request, + CancellationToken cancellationToken) + { + var items = new List(); + await foreach (var item in session.GetLogAsync(request, cancellationToken)) + { + items.Add(item); + } + + return items; + } + + public async Task AttachAsync(string after, CancellationToken cancellationToken) + { + if (_enumerator is not null) + { + throw new InvalidOperationException("The session log transcript is already attached."); + } + + _callerToken = cancellationToken; + _window = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + _window.CancelAfter(FollowWindow); + _enumerator = session.GetLogAsync( + new SessionLogRequest { After = after, Follow = QueryBoolean.True }, + _window.Token) + .GetAsyncEnumerator(_window.Token); + while (await MoveNextAsync("the log.synced attachment marker")) + { + if (_enumerator.Current is EventLogSynced marker) + { + Marker = marker; + return; + } + + _replayItems.Add(_enumerator.Current); + } + + throw EndedBefore("the log.synced attachment marker"); + } + + public async Task ReadTurnAsync(string sessionId, string reply) + { + if (Marker is null || _enumerator is null) + { + throw new InvalidOperationException("The session log transcript is not attached."); + } + + var sawOwnedText = false; + while (await MoveNextAsync("the followed turn completed")) + { + var item = _enumerator.Current; + _liveItems.Add(item); + if (item is SessionTextEnded text + && text.Data.SessionId == sessionId + && text.Data.Text == reply) + { + TextEnded = text; + sawOwnedText = true; + continue; + } + + if (sawOwnedText + && item is SessionExecutionSucceeded succeeded + && succeeded.Data.SessionId == sessionId) + { + Succeeded = succeeded; + return; + } + } + + throw EndedBefore("the followed turn completed"); + } + + public async Task DisposeAsync(CancellationToken cancellationToken) + { + var enumerator = Interlocked.Exchange(ref _enumerator, null); + var window = Interlocked.Exchange(ref _window, null); + if (window is null) + { + return; + } + + await window.CancelAsync(); + try + { + if (enumerator is not null) + { + await enumerator.DisposeAsync().AsTask().WaitAsync(cancellationToken); + } + } + finally + { + window.Dispose(); + } + } + + private async Task MoveNextAsync(string target) + { + var enumerator = _enumerator + ?? throw new InvalidOperationException("The session log transcript is not attached."); + try + { + return await enumerator.MoveNextAsync(); + } + catch (OperationCanceledException exception) + when (!_callerToken.IsCancellationRequested && _window!.IsCancellationRequested) + { + throw new TimeoutException( + $"The session log timed out before {target}. Items: {QuoteObservedItems()}.", + exception); + } + } + + private InvalidOperationException EndedBefore(string target) => + new($"The session log ended before {target}. Items: {QuoteObservedItems()}."); + + private string QuoteObservedItems() => + string.Join(", ", _replayItems.Concat(_liveItems).Select(item => $"'{item.Type}'")); +} diff --git a/tests/Shared/PersistentSimulationServerCommand.cs b/tests/Shared/PersistentSimulationServerCommand.cs new file mode 100644 index 00000000..1b3b7559 --- /dev/null +++ b/tests/Shared/PersistentSimulationServerCommand.cs @@ -0,0 +1,29 @@ +using System.IO.Abstractions; + +namespace OpenCode.Sdk.TestSupport; + +/// +/// Resolves the exact-pin simulation host profile that retains durable events for session-log +/// coverage. The ordinary CLI server profile deliberately remains unchanged. +/// +internal sealed class PersistentSimulationServerCommand +{ + private const string HostFixture = "Server.persistent-simulation-host.js"; + private readonly FixtureLoader _fixtures = new(); + private readonly PinnedServerCommand _pinned; + + public PersistentSimulationServerCommand(IFileSystem fileSystem) + { + ArgumentNullException.ThrowIfNull(fileSystem); + _pinned = new PinnedServerCommand(fileSystem); + } + + public string RepositoryRoot => _pinned.RepositoryRoot; + + public IReadOnlyList Resolve() + { + // Retain the standard resolver's fail-fast checks for the exact-pin source and install. + _ = _pinned.Resolve(); + return ["bun", "-e", _fixtures.LoadText(HostFixture)]; + } +} diff --git a/tests/Shared/SimulatedDriveServerFixture.cs b/tests/Shared/SimulatedDriveServerFixture.cs index 4563bb5e..aa874075 100644 --- a/tests/Shared/SimulatedDriveServerFixture.cs +++ b/tests/Shared/SimulatedDriveServerFixture.cs @@ -75,8 +75,8 @@ public async Task InitializeAsync() private async Task StartAsync(TestRunRoot runRoot) { var registry = runRoot.CreateSubdirectory("drive"); - var pinnedCommand = new PinnedServerCommand(_fileSystem); - var command = pinnedCommand.Resolve(); + var persistentHost = new PersistentSimulationServerCommand(_fileSystem); + var command = persistentHost.Resolve(); using var gate = await DrivePortGate.AcquireAsync(_fileSystem, GateTimeout); var manifest = DriveManifest.Write(_fileSystem, registry); var environment = ServerIsolation.Environment(_fileSystem, runRoot.Path); @@ -95,7 +95,7 @@ private async Task StartAsync(TestRunRoot runRoot) // global root the server touches stays isolated through the environment above // regardless of this directory. _fileSystem.Path.Combine( - pinnedCommand.RepositoryRoot, "external", "opencode", "packages", "cli"), + persistentHost.RepositoryRoot, "external", "opencode", "packages", "cli"), ReadinessTimeout, // Captured the instant the adapter object exists so a startup failure still has From 256ef8cf290f0de452a21b239a62d58bd4ddcfad Mon Sep 17 00:00:00 2001 From: "denizirgin@gmail.com" Date: Sun, 6 Sep 2026 11:05:33 +0300 Subject: [PATCH 11/62] test: preserve live log cleanup diagnostics --- .editorconfig | 11 +- .../Server/persistent-simulation-host.js | 11 +- .../PersistentSimulationHostTests.cs | 21 ++++ .../Sessions/SessionLogLiveTests.cs | 13 +-- .../Support/LateCleanupFailureReport.cs | 36 ++++++ .../Support/OwnedOperation.cs | 92 --------------- .../Support/OwnedOperationTests.cs | 26 ----- .../Support/OwnedSessionCleanup.cs | 59 +++++++--- .../Support/SessionLogTranscript.cs | 31 +++++- .../Support/SessionLogTranscriptTests.cs | 73 ++++++++++++ tests/Shared/CliWrapServerAdapter.cs | 40 +++++++ tests/Shared/SimulatedDriveServerFixture.cs | 105 ++++++++++++++++-- 12 files changed, 348 insertions(+), 170 deletions(-) create mode 100644 tests/OpenCode.Sdk.Tests/PersistentSimulationHostTests.cs create mode 100644 tests/OpenCode.Sdk.Tests/Support/LateCleanupFailureReport.cs delete mode 100644 tests/OpenCode.Sdk.Tests/Support/OwnedOperation.cs delete mode 100644 tests/OpenCode.Sdk.Tests/Support/OwnedOperationTests.cs create mode 100644 tests/OpenCode.Sdk.Tests/Support/SessionLogTranscriptTests.cs diff --git a/.editorconfig b/.editorconfig index 4124ea1b..a96fdda6 100644 --- a/.editorconfig +++ b/.editorconfig @@ -903,12 +903,11 @@ dotnet_diagnostic.CA2000.severity = none [tests/OpenCode.Sdk.Tests/Support/ScriptedTerminalWebSocket.cs] dotnet_diagnostic.VSTHRD003.severity = none -# OwnedOperation starts and retains the live turn task, exposes its only normal await, and owns the -# bounded cancel-and-observe cleanup path. If that budget expires, its registered completion -# observer consumes and reports the eventual fault; the deterministic support test exercises that -# branch. VSTHRD003 cannot follow the start factory into this owner and labels its own retained task -# as foreign. Winner: the explicit operation-lifetime owner; VSTHRD003 remains enabled elsewhere. -[tests/OpenCode.Sdk.Tests/Support/OwnedOperation*.cs] +# LateCleanupFailureReport owns the TaskCompletionSource completed only by its registered +# fault-observation continuations. The bridge lets a deterministic test await an otherwise-late +# cleanup failure without sleeping. Winner: the bounded cleanup reporting contract; VSTHRD003 +# remains enabled elsewhere. +[tests/OpenCode.Sdk.Tests/Support/LateCleanupFailureReport.cs] dotnet_diagnostic.VSTHRD003.severity = none # CliWrapServerAdapter parks on TaskCompletionSource handles the adapter itself completes — the diff --git a/tests/OpenCode.Sdk.Tests/Fixtures/Server/persistent-simulation-host.js b/tests/OpenCode.Sdk.Tests/Fixtures/Server/persistent-simulation-host.js index 4f3b8997..b75392b6 100644 --- a/tests/OpenCode.Sdk.Tests/Fixtures/Server/persistent-simulation-host.js +++ b/tests/OpenCode.Sdk.Tests/Fixtures/Server/persistent-simulation-host.js @@ -1,9 +1,10 @@ import { NodeServices } from "@effect/platform-node"; import { LayerNode } from "@opencode-ai/util/effect/layer-node"; import { Global } from "@opencode-ai/util/global"; +import { Observability } from "@opencode-ai/util/observability"; import { AppProcess } from "@opencode-ai/util/process"; import { start } from "@opencode-ai/server/process"; -import { Effect, References } from "effect"; +import { Effect } from "effect"; import { HttpServer } from "effect/unstable/http"; const password = process.env.OPENCODE_PASSWORD ?? process.env.OPENCODE_SERVER_PASSWORD; @@ -30,6 +31,7 @@ const waitForStdinClose = Effect.callback((resume) => { const application = Effect.scoped( Effect.gen(function* () { + yield* Effect.logInfo("persistent simulation host starting"); const server = yield* start({ app: { name: "cli", version: "local", channel: "local" }, hostname: "127.0.0.1", @@ -60,12 +62,15 @@ const application = Effect.scoped( : !truthy(process.env.OPENCODE_DISABLE_FFF), }, }); - console.log(JSON.stringify({ url: HttpServer.formatAddress(server.address) })); + const url = HttpServer.formatAddress(server.address); + console.log(JSON.stringify({ url })); + yield* Effect.logWarning("persistent simulation host ready", { url }); yield* waitForStdinClose; + yield* Effect.logInfo("persistent simulation host stdin closed"); }), ).pipe( - Effect.provideService(References.MinimumLogLevel, "None"), Effect.provide(LayerNode.compile(LayerNode.group([Global.node, AppProcess.node]))), + Effect.provide(Observability.layer({ client: "cli", version: "local", channel: "local" })), Effect.provide(NodeServices.layer), ); diff --git a/tests/OpenCode.Sdk.Tests/PersistentSimulationHostTests.cs b/tests/OpenCode.Sdk.Tests/PersistentSimulationHostTests.cs new file mode 100644 index 00000000..e484cf0a --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/PersistentSimulationHostTests.cs @@ -0,0 +1,21 @@ +using OpenCode.Sdk.TestSupport; + +namespace OpenCode.Sdk.Tests; + +[ClassDataSource(Shared = SharedType.PerTestSession)] +[NotInParallel(ParallelConstraintKeys.ServerProcess)] +public sealed class PersistentSimulationHostTests(SimulatedDriveServerFixture server) +{ + [Test] + [Timeout(60_000)] + public async Task Host_Should_Route_Diagnostics_Away_From_Readiness_Stdout( + CancellationToken _) + { + var beforeReady = server.PreReadinessDiagnostic; + var afterReady = server.PostReadinessDiagnostic; + + await Assert.That(beforeReady).Contains("level=INFO"); + await Assert.That(afterReady).Contains("level=WARN"); + Console.WriteLine("persistent-host-diagnostics: " + beforeReady + " | " + afterReady); + } +} diff --git a/tests/OpenCode.Sdk.Tests/Sessions/SessionLogLiveTests.cs b/tests/OpenCode.Sdk.Tests/Sessions/SessionLogLiveTests.cs index c7b96435..de3e3515 100644 --- a/tests/OpenCode.Sdk.Tests/Sessions/SessionLogLiveTests.cs +++ b/tests/OpenCode.Sdk.Tests/Sessions/SessionLogLiveTests.cs @@ -109,8 +109,7 @@ public async Task GetLogAsync_Should_Follow_A_Turn_After_The_Synced_Marker( var session = client.Sessions.GetSessionClient(sessionId); var cleanup = new OwnedSessionCleanup(session, CleanupTimeout); var transcript = new SessionLogTranscript(session); - cleanup.Own(transcript.DisposeAsync); - using var turnCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + cleanup.Own("session log enumerator", transcript.DisposeAsync); Exception? primaryFailure = null; try @@ -136,12 +135,12 @@ await Assert.That(transcript.ReplayItems.OfType() cleanup.MarkTurnStarted(); var followedTurn = new SimulatedSessionTurn(server, client, session, sessionId); - var secondTurn = cleanup.Own( - () => followedTurn.CompleteAsync(FollowSecondPrompt, FollowSecondReply, turnCancellation.Token), - turnCancellation.CancelAsync); - await transcript.ReadTurnAsync(sessionId, FollowSecondReply); - var terminal = await secondTurn.CompleteAsync(); + var terminal = await followedTurn.CompleteAsync( + FollowSecondPrompt, + FollowSecondReply, + cancellationToken); cleanup.MarkTurnCompleted(); + await transcript.ReadTurnAsync(sessionId, FollowSecondReply); await Assert.That(transcript.TextEnded).IsNotNull(); await Assert.That(transcript.Succeeded).IsNotNull(); diff --git a/tests/OpenCode.Sdk.Tests/Support/LateCleanupFailureReport.cs b/tests/OpenCode.Sdk.Tests/Support/LateCleanupFailureReport.cs new file mode 100644 index 00000000..b10b9f4e --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Support/LateCleanupFailureReport.cs @@ -0,0 +1,36 @@ +using System.Collections.Concurrent; + +namespace OpenCode.Sdk.Tests.Support; + +internal sealed class LateCleanupFailureReport +{ + private readonly ConcurrentQueue> _failures = new(); + private readonly TaskCompletionSource> _firstFailure = new( + TaskCreationOptions.RunContinuationsAsynchronously); + private int _registrations; + + public bool HasRegistrations => Volatile.Read(ref _registrations) > 0; + + public IReadOnlyList> Failures => [.. _failures]; + + public void Observe(string name, Task operation) + { + _ = Interlocked.Increment(ref _registrations); + _ = operation.ContinueWith( + completed => + { + foreach (var failure in completed.Exception!.Flatten().InnerExceptions) + { + var entry = new KeyValuePair(name, failure); + _failures.Enqueue(entry); + _ = _firstFailure.TrySetResult(entry); + } + }, + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously | TaskContinuationOptions.OnlyOnFaulted, + TaskScheduler.Default); + } + + public async Task> WaitForFailureAsync(CancellationToken cancellationToken) => + await _firstFailure.Task.WaitAsync(cancellationToken); +} diff --git a/tests/OpenCode.Sdk.Tests/Support/OwnedOperation.cs b/tests/OpenCode.Sdk.Tests/Support/OwnedOperation.cs deleted file mode 100644 index 6dedcfe7..00000000 --- a/tests/OpenCode.Sdk.Tests/Support/OwnedOperation.cs +++ /dev/null @@ -1,92 +0,0 @@ -namespace OpenCode.Sdk.Tests.Support; - -internal sealed class OwnedOperation -{ - private readonly Func _cancel; - private readonly Task _completion; - private readonly TaskCompletionSource _lateObservation = new( - TaskCreationOptions.RunContinuationsAsynchronously); - private int _observed; - private int _observerRegistered; - - public OwnedOperation(Func> start, Func cancel) - { - ArgumentNullException.ThrowIfNull(start); - ArgumentNullException.ThrowIfNull(cancel); - _cancel = cancel; - _completion = start(); - } - - public async Task CompleteAsync() - { - try - { - return await _completion; - } - finally - { - _ = Interlocked.Exchange(ref _observed, 1); - } - } - - public async Task CleanupAsync(CancellationToken cancellationToken) - { - if (Volatile.Read(ref _observed) is 1) - { - return; - } - - try - { - if (!_completion.IsCompleted) - { - await _cancel().WaitAsync(cancellationToken); - } - - _ = await _completion.WaitAsync(cancellationToken); - _ = Interlocked.Exchange(ref _observed, 1); - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - ObserveLateFault(); - throw; - } - catch - { - if (_completion.IsCompleted) - { - _ = Interlocked.Exchange(ref _observed, 1); - } - else - { - ObserveLateFault(); - } - - throw; - } - } - - internal async Task WaitForLateObservationAsync(CancellationToken cancellationToken) => - await _lateObservation.Task.WaitAsync(cancellationToken); - - private void ObserveLateFault() - { - if (Interlocked.Exchange(ref _observerRegistered, 1) is 1) - { - return; - } - - _ = _completion.ContinueWith( - completed => - { - var aggregate = completed.Exception; - var failure = aggregate?.InnerExceptions.Count is 1 - ? aggregate.InnerExceptions[0] - : aggregate; - _ = _lateObservation.TrySetResult(failure); - }, - CancellationToken.None, - TaskContinuationOptions.ExecuteSynchronously, - TaskScheduler.Default); - } -} diff --git a/tests/OpenCode.Sdk.Tests/Support/OwnedOperationTests.cs b/tests/OpenCode.Sdk.Tests/Support/OwnedOperationTests.cs deleted file mode 100644 index 74043398..00000000 --- a/tests/OpenCode.Sdk.Tests/Support/OwnedOperationTests.cs +++ /dev/null @@ -1,26 +0,0 @@ -namespace OpenCode.Sdk.Tests.Support; - -public sealed class OwnedOperationTests -{ - [Test] - public async Task CleanupAsync_Should_Report_Timeout_And_Observe_A_Late_Failure() - { - var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var lateFailure = new InvalidOperationException("late operation failure"); - var cleanup = new OwnedSessionCleanup( - _ => Task.CompletedTask, - _ => Task.CompletedTask, - TimeSpan.Zero); - cleanup.MarkTurnCompleted(); - var operation = cleanup.Own(() => completion.Task, () => Task.CompletedTask); - - var thrown = await Assert.That(async () => await cleanup.CompleteAsync(null)) - .Throws(); - completion.SetException(lateFailure); - using var observationBudget = new CancellationTokenSource(TimeSpan.FromSeconds(1)); - var observed = await operation.WaitForLateObservationAsync(observationBudget.Token); - - await Assert.That(thrown!.CancellationToken.IsCancellationRequested).IsTrue(); - await Assert.That(observed).IsSameReferenceAs(lateFailure); - } -} diff --git a/tests/OpenCode.Sdk.Tests/Support/OwnedSessionCleanup.cs b/tests/OpenCode.Sdk.Tests/Support/OwnedSessionCleanup.cs index d78e2a81..644f3c56 100644 --- a/tests/OpenCode.Sdk.Tests/Support/OwnedSessionCleanup.cs +++ b/tests/OpenCode.Sdk.Tests/Support/OwnedSessionCleanup.cs @@ -5,10 +5,12 @@ namespace OpenCode.Sdk.Tests.Support; internal sealed class OwnedSessionCleanup { internal const string FailuresKey = "OwnedSessionCleanup.Failures"; + internal const string LateFailuresKey = "OwnedSessionCleanup.LateFailures"; private readonly Func _interrupt; private readonly Func _remove; - private readonly List> _resources = []; + private readonly LateCleanupFailureReport _lateFailures = new(); + private readonly List>> _resources = []; private readonly TimeSpan _timeout; private bool _turnCompleted; @@ -43,19 +45,11 @@ internal OwnedSessionCleanup( public void MarkTurnStarted() => _turnCompleted = false; - public void Own(Func resourceCleanup) + public void Own(string name, Func resourceCleanup) { + ArgumentException.ThrowIfNullOrWhiteSpace(name); ArgumentNullException.ThrowIfNull(resourceCleanup); - _resources.Add(resourceCleanup); - } - - public OwnedOperation Own(Func> start, Func cancel) - { - ArgumentNullException.ThrowIfNull(start); - ArgumentNullException.ThrowIfNull(cancel); - var operation = new OwnedOperation(start, cancel); - _resources.Add(operation.CleanupAsync); - return operation; + _resources.Add(new KeyValuePair>(name, resourceCleanup)); } public async Task CompleteAsync(Exception? primaryFailure) @@ -63,34 +57,51 @@ public async Task CompleteAsync(Exception? primaryFailure) var failures = new List(); foreach (var cleanup in _resources) { - await CaptureFailureAsync(cleanup, failures); + await CaptureFailureAsync(cleanup.Key, cleanup.Value, failures); } if (!_turnCompleted) { - await CaptureFailureAsync(_interrupt, failures); + await CaptureFailureAsync("session interrupt", _interrupt, failures); } - await CaptureFailureAsync(_remove, failures); + await CaptureFailureAsync("session removal", _remove, failures); ThrowFailures(primaryFailure, failures); } private async Task CaptureFailureAsync( + string name, Func operation, List failures) { using var budget = new CancellationTokenSource(_timeout); + Task? pending = null; try { - await operation(budget.Token); + pending = operation(budget.Token); + await pending.WaitAsync(budget.Token); } catch (Exception exception) { failures.Add(exception); + if (pending is { IsCompleted: false }) + { + _lateFailures.Observe(name, pending); + } + else if (pending?.Exception is { } aggregate) + { + foreach (var failure in aggregate.Flatten().InnerExceptions) + { + if (!ReferenceEquals(failure, exception)) + { + failures.Add(failure); + } + } + } } } - private static void ThrowFailures(Exception? primaryFailure, List failures) + private void ThrowFailures(Exception? primaryFailure, List failures) { if (primaryFailure is not null) { @@ -99,17 +110,29 @@ private static void ThrowFailures(Exception? primaryFailure, List fai primaryFailure.Data[FailuresKey] = new AggregateException(failures); } + AttachLateFailureReport(primaryFailure); ExceptionDispatchInfo.Capture(primaryFailure).Throw(); } if (failures.Count is 1) { + AttachLateFailureReport(failures[0]); ExceptionDispatchInfo.Capture(failures[0]).Throw(); } if (failures.Count > 1) { - throw new AggregateException("Multiple failures occurred while cleaning the session.", failures); + var aggregate = new AggregateException("Multiple failures occurred while cleaning the session.", failures); + AttachLateFailureReport(aggregate); + throw aggregate; + } + } + + private void AttachLateFailureReport(Exception exception) + { + if (_lateFailures.HasRegistrations) + { + exception.Data[LateFailuresKey] = _lateFailures; } } } diff --git a/tests/OpenCode.Sdk.Tests/Support/SessionLogTranscript.cs b/tests/OpenCode.Sdk.Tests/Support/SessionLogTranscript.cs index ee854573..3bcee8a1 100644 --- a/tests/OpenCode.Sdk.Tests/Support/SessionLogTranscript.cs +++ b/tests/OpenCode.Sdk.Tests/Support/SessionLogTranscript.cs @@ -2,15 +2,32 @@ namespace OpenCode.Sdk.Tests.Support; -internal sealed class SessionLogTranscript(SessionClient session) +internal sealed class SessionLogTranscript { private static readonly TimeSpan FollowWindow = TimeSpan.FromSeconds(120); private readonly List _liveItems = []; private readonly List _replayItems = []; + private readonly SessionClient? _session; private CancellationToken _callerToken; private IAsyncEnumerator? _enumerator; private CancellationTokenSource? _window; + public SessionLogTranscript(SessionClient session) + { + ArgumentNullException.ThrowIfNull(session); + _session = session; + } + + internal SessionLogTranscript( + IAsyncEnumerator enumerator, + CancellationTokenSource window) + { + ArgumentNullException.ThrowIfNull(enumerator); + ArgumentNullException.ThrowIfNull(window); + _enumerator = enumerator; + _window = window; + } + public IReadOnlyList LiveItems => _liveItems; public EventLogSynced? Marker { get; private set; } @@ -26,7 +43,7 @@ public async Task> ReadToEndAsync( CancellationToken cancellationToken) { var items = new List(); - await foreach (var item in session.GetLogAsync(request, cancellationToken)) + await foreach (var item in Session.GetLogAsync(request, cancellationToken)) { items.Add(item); } @@ -44,7 +61,7 @@ public async Task AttachAsync(string after, CancellationToken cancellationToken) _callerToken = cancellationToken; _window = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); _window.CancelAfter(FollowWindow); - _enumerator = session.GetLogAsync( + _enumerator = Session.GetLogAsync( new SessionLogRequest { After = after, Follow = QueryBoolean.True }, _window.Token) .GetAsyncEnumerator(_window.Token); @@ -95,7 +112,7 @@ public async Task ReadTurnAsync(string sessionId, string reply) throw EndedBefore("the followed turn completed"); } - public async Task DisposeAsync(CancellationToken cancellationToken) + public async Task DisposeAsync(CancellationToken _) { var enumerator = Interlocked.Exchange(ref _enumerator, null); var window = Interlocked.Exchange(ref _window, null); @@ -104,12 +121,11 @@ public async Task DisposeAsync(CancellationToken cancellationToken) return; } - await window.CancelAsync(); try { if (enumerator is not null) { - await enumerator.DisposeAsync().AsTask().WaitAsync(cancellationToken); + await enumerator.DisposeAsync(); } } finally @@ -118,6 +134,9 @@ public async Task DisposeAsync(CancellationToken cancellationToken) } } + private SessionClient Session => + _session ?? throw new InvalidOperationException("The injected transcript has no session client."); + private async Task MoveNextAsync(string target) { var enumerator = _enumerator diff --git a/tests/OpenCode.Sdk.Tests/Support/SessionLogTranscriptTests.cs b/tests/OpenCode.Sdk.Tests/Support/SessionLogTranscriptTests.cs new file mode 100644 index 00000000..7dc44f62 --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Support/SessionLogTranscriptTests.cs @@ -0,0 +1,73 @@ +using OpenCode.Sdk.Models; + +namespace OpenCode.Sdk.Tests.Support; + +public sealed class SessionLogTranscriptTests +{ + [Test] + public async Task Cleanup_Should_Bound_Stalled_Disposal_And_Retain_Its_Late_Failure() + { + var disposal = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var enumerator = new ControlledAsyncEnumerator(disposal.Task); + using var window = new CancellationTokenSource(); + var transcript = new SessionLogTranscript(enumerator, window); + var primaryFailure = new InvalidOperationException("primary failure"); + var removalFailure = new InvalidOperationException("removal failure"); + var lateDisposalFailure = new InvalidOperationException("late disposal failure"); + var removalTokenWasCancelled = true; + var cleanup = new OwnedSessionCleanup( + _ => Task.CompletedTask, + token => + { + removalTokenWasCancelled = token.IsCancellationRequested; + return Task.FromException(removalFailure); + }, + TimeSpan.FromMilliseconds(50)); + cleanup.MarkTurnCompleted(); + cleanup.Own("session log enumerator", transcript.DisposeAsync); + + var thrown = await Assert.That(async () => await cleanup.CompleteAsync(primaryFailure)) + .Throws(); + + await Assert.That(thrown).IsSameReferenceAs(primaryFailure); + await Assert.That(enumerator.DisposeCalls).IsEqualTo(1); + await Assert.That(removalTokenWasCancelled).IsFalse(); + var failures = primaryFailure.Data[OwnedSessionCleanup.FailuresKey] as AggregateException; + await Assert.That(failures).IsNotNull(); + await Assert.That(failures!.InnerExceptions).Count().IsEqualTo(2); + await Assert.That(failures.InnerExceptions[0]).IsTypeOf(); + await Assert.That(failures.InnerExceptions[1]).IsSameReferenceAs(removalFailure); + var late = primaryFailure.Data[OwnedSessionCleanup.LateFailuresKey] as LateCleanupFailureReport; + await Assert.That(late).IsNotNull(); + + disposal.SetException(lateDisposalFailure); + using var observationBudget = new CancellationTokenSource(TimeSpan.FromSeconds(1)); + var observed = await late.WaitForFailureAsync(observationBudget.Token); + + await Assert.That(observed.Key).IsEqualTo("session log enumerator"); + await Assert.That(observed.Value).IsSameReferenceAs(lateDisposalFailure); + await Assert.That(late.Failures).Count().IsEqualTo(1); + await Assert.That(late.Failures[0].Value).IsSameReferenceAs(lateDisposalFailure); + _ = await Assert.That(window.Cancel).Throws(); + Console.WriteLine( + "session-log-cleanup: immediate-failures=" + failures.InnerExceptions.Count.ToString( + System.Globalization.CultureInfo.InvariantCulture) + + " removal-token-cancelled=" + removalTokenWasCancelled.ToString() + + " late-operation=" + observed.Key); + } + + private sealed class ControlledAsyncEnumerator(Task disposal) : IAsyncEnumerator + { + public ISessionLogItem Current => throw new InvalidOperationException("No item is available."); + + public int DisposeCalls { get; private set; } + + public ValueTask DisposeAsync() + { + DisposeCalls++; + return new ValueTask(disposal); + } + + public ValueTask MoveNextAsync() => new(false); + } +} diff --git a/tests/Shared/CliWrapServerAdapter.cs b/tests/Shared/CliWrapServerAdapter.cs index 46867d40..c5387f93 100644 --- a/tests/Shared/CliWrapServerAdapter.cs +++ b/tests/Shared/CliWrapServerAdapter.cs @@ -40,6 +40,7 @@ internal sealed class CliWrapServerAdapter : IAsyncDisposable private readonly Lock _logGate = new(); private readonly Queue _stdout = new(); private readonly Queue _stderr = new(); + private TaskCompletionSource _stderrChanged = NewSignal(); private TimeSpan _gracefulShutdownTimeout = DefaultGracefulShutdownTimeout; private Task? _execution; private Uri? _endpoint; @@ -162,6 +163,36 @@ public string DescribeLogs() } } + public bool HasErrorLine(string fragment) + { + ArgumentException.ThrowIfNullOrWhiteSpace(fragment); + lock (_logGate) + { + return _stderr.Any(line => line.Contains(fragment, StringComparison.Ordinal)); + } + } + + public async Task WaitForErrorLineAsync(string fragment, CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(fragment); + while (true) + { + Task signal; + lock (_logGate) + { + var match = _stderr.FirstOrDefault(line => line.Contains(fragment, StringComparison.Ordinal)); + if (match is not null) + { + return match; + } + + signal = _stderrChanged.Task; + } + + await signal.WaitAsync(cancellationToken); + } + } + public void WriteLogs(IFileSystem fileSystem, string directory) { _ = fileSystem.Directory.CreateDirectory(directory); @@ -249,6 +280,7 @@ private void OnOutputLine(string line) private void OnErrorLine(string line) { + TaskCompletionSource changed; lock (_logGate) { _stderr.Enqueue(line); @@ -256,9 +288,17 @@ private void OnErrorLine(string line) { _ = _stderr.Dequeue(); } + + changed = _stderrChanged; + _stderrChanged = NewSignal(); } + + _ = changed.TrySetResult(null); } + private static TaskCompletionSource NewSignal() => + new(TaskCreationOptions.RunContinuationsAsynchronously); + private static string GeneratePassword() { var bytes = new byte[32]; diff --git a/tests/Shared/SimulatedDriveServerFixture.cs b/tests/Shared/SimulatedDriveServerFixture.cs index aa874075..c206301e 100644 --- a/tests/Shared/SimulatedDriveServerFixture.cs +++ b/tests/Shared/SimulatedDriveServerFixture.cs @@ -1,3 +1,4 @@ +using System.Runtime.ExceptionServices; using Testably.Abstractions; using TUnit.Core.Interfaces; @@ -35,6 +36,8 @@ public sealed class SimulatedDriveServerFixture : IAsyncInitializer, IAsyncDispo private readonly RealFileSystem _fileSystem = new(); private CliWrapServerAdapter? _adapter; private DriveController? _controller; + private string? _postReadinessDiagnostic; + private string? _preReadinessDiagnostic; private TestRunRoot? _runRoot; private bool _retainLogs; @@ -49,6 +52,12 @@ public sealed class SimulatedDriveServerFixture : IAsyncInitializer, IAsyncDispo internal TestRunRoot RunRoot => _runRoot ?? throw new InvalidOperationException("The fixture has not initialized."); + internal string PostReadinessDiagnostic => + _postReadinessDiagnostic ?? throw new InvalidOperationException("The fixture has not captured readiness diagnostics."); + + internal string PreReadinessDiagnostic => + _preReadinessDiagnostic ?? throw new InvalidOperationException("The fixture has not captured readiness diagnostics."); + public async Task InitializeAsync() { _runRoot = new TestRunRoot(_fileSystem); @@ -84,6 +93,7 @@ private async Task StartAsync(TestRunRoot runRoot) environment["OPENCODE_DRIVE"] = manifest.InstanceName; environment["DRIVE_REGISTRY_DIR"] = registry; environment["OPENCODE_CONFIG_CONTENT"] = SimulationConfig; + environment["OPENCODE_PRINT_LOGS"] = "1"; _adapter = await CliWrapServerAdapter.StartAsync( command, environment, @@ -103,6 +113,19 @@ private async Task StartAsync(TestRunRoot runRoot) // failure path but never returns it (PinnedOpenCodeServerFixture, same reason). onConstructed: created => _adapter = created); + // Capture the two startup milestones while they are current. The adapter intentionally + // retains only a bounded stderr tail, so a later test class must not depend on finding old + // lines after other per-session consumers have produced more diagnostics. + using (var diagnosticWindow = new CancellationTokenSource(ControllerTimeout)) + { + _preReadinessDiagnostic = await _adapter.WaitForErrorLineAsync( + "persistent simulation host starting", + diagnosticWindow.Token); + _postReadinessDiagnostic = await _adapter.WaitForErrorLineAsync( + "persistent simulation host ready", + diagnosticWindow.Token); + } + // The backend control socket is already listening when the readiness line is printed - // simulation builds the network layer eagerly at server start (backend/index.ts, // simulated-provider.ts:274-286) - so this connects once and fails the whole fixture @@ -128,9 +151,7 @@ public async ValueTask DisposeAsync() Environment.GetEnvironmentVariable("OPENCODE_SDK_TESTS_KEEP_LOGS"), "1", StringComparison.Ordinal); - // The controller's teardown is the one that can misbehave - it awaits a receive loop it - // does not fully own - and ending the child process is the step that must never be - // skipped, so the adapter and the run root are torn down in a finally rather than after. + var failures = new List(); try { if (_controller is not null) @@ -138,23 +159,83 @@ public async ValueTask DisposeAsync() await _controller.DisposeAsync(); } } - finally + catch (Exception exception) { - if (_adapter is not null) + failures.Add(exception); + } + + if (_adapter is not null) + { + try + { + await _adapter.DisposeAsync(); + } + catch (Exception exception) { - if (keep && _runRoot is not null) + failures.Add(exception); + } + + CaptureDiagnosticFailure(_adapter, failures); + + if (keep && _runRoot is not null) + { + try { _adapter.WriteLogs(_fileSystem, _fileSystem.Path.Combine(_runRoot.Path, "logs")); Console.WriteLine($"Simulated server logs retained under: {_runRoot.Path}"); } - - await _adapter.DisposeAsync(); + catch (Exception exception) + { + failures.Add(exception); + } } + } - if (!keep) - { - _runRoot?.Dispose(); - } + if (!keep) + { + _runRoot?.Dispose(); + } + + if (failures.Count is 1) + { + ExceptionDispatchInfo.Capture(failures[0]).Throw(); + } + + if (failures.Count > 1) + { + throw new AggregateException("Multiple simulated server teardown failures occurred.", failures); + } + } + + private void CaptureDiagnosticFailure( + CliWrapServerAdapter adapter, + List failures) + { + var missing = new List(); + if (_preReadinessDiagnostic is null) + { + missing.Add("persistent simulation host starting"); + } + + if (_postReadinessDiagnostic is null) + { + missing.Add("persistent simulation host ready"); } + + if (!adapter.HasErrorLine("persistent simulation host stdin closed")) + { + missing.Add("persistent simulation host stdin closed"); + } + + if (missing.Count > 0) + { + failures.Add(new InvalidOperationException( + "The persistent simulation host did not retain diagnostics: " + string.Join(", ", missing) + ".")); + return; + } + + Console.WriteLine( + "Persistent simulation host retained pre-readiness, post-readiness, and stdin-EOF diagnostics for process " + + adapter.ProcessId.ToString(System.Globalization.CultureInfo.InvariantCulture) + "."); } } From 29c07fb02f21e5aacdacf3969688103a8c7a6611 Mon Sep 17 00:00:00 2001 From: "denizirgin@gmail.com" Date: Sun, 6 Sep 2026 11:20:21 +0300 Subject: [PATCH 12/62] test: isolate simulation host log level --- tests/Shared/SimulatedDriveServerFixture.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/Shared/SimulatedDriveServerFixture.cs b/tests/Shared/SimulatedDriveServerFixture.cs index c206301e..6e1360f1 100644 --- a/tests/Shared/SimulatedDriveServerFixture.cs +++ b/tests/Shared/SimulatedDriveServerFixture.cs @@ -93,6 +93,7 @@ private async Task StartAsync(TestRunRoot runRoot) environment["OPENCODE_DRIVE"] = manifest.InstanceName; environment["DRIVE_REGISTRY_DIR"] = registry; environment["OPENCODE_CONFIG_CONTENT"] = SimulationConfig; + environment["OPENCODE_LOG_LEVEL"] = "INFO"; environment["OPENCODE_PRINT_LOGS"] = "1"; _adapter = await CliWrapServerAdapter.StartAsync( command, From 54c724f370647f56fee53d72840a75bad9964040 Mon Sep 17 00:00:00 2001 From: "denizirgin@gmail.com" Date: Sun, 6 Sep 2026 11:44:59 +0300 Subject: [PATCH 13/62] test: cover live permission grant lifecycle --- .../Sessions/SessionPermissionLiveTests.cs | 213 ++++++++++++++++++ tests/Shared/SimulatedDriveServerFixture.cs | 12 +- tests/Shared/SimulationConfigSeed.cs | 99 ++++++++ 3 files changed, 313 insertions(+), 11 deletions(-) create mode 100644 tests/OpenCode.Sdk.Tests/Sessions/SessionPermissionLiveTests.cs create mode 100644 tests/Shared/SimulationConfigSeed.cs diff --git a/tests/OpenCode.Sdk.Tests/Sessions/SessionPermissionLiveTests.cs b/tests/OpenCode.Sdk.Tests/Sessions/SessionPermissionLiveTests.cs new file mode 100644 index 00000000..04e30888 --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Sessions/SessionPermissionLiveTests.cs @@ -0,0 +1,213 @@ +using OpenCode.Sdk.Models; +using OpenCode.Sdk.Tests.Support; +using OpenCode.Sdk.TestSupport; + +namespace OpenCode.Sdk.Tests.Sessions; + +[ClassDataSource(Shared = SharedType.PerTestSession)] +[NotInParallel(ParallelConstraintKeys.ServerProcess)] +public sealed class SessionPermissionLiveTests(SimulatedDriveServerFixture server) +{ + private const string PermissionResource = "resource:one"; + private static readonly TimeSpan CleanupTimeout = TimeSpan.FromSeconds(15); + + [Test] + [Timeout(180_000)] + public async Task CreatePermissionAsync_Should_Save_An_Always_Grant_And_Consume_The_Request( + CancellationToken cancellationToken) + { + using var workspace = server.CreateWorkspace(); + using var client = server.CreateClient(new LocationSelector { Directory = workspace.Path }); + var location = (await client.GetLocationAsync(cancellationToken: cancellationToken)).ResolvedLocation; + await AssertPermissionProbeAsync(client, location.Directory, cancellationToken); + + var sessionId = await CreateOwnedSessionAsync(client, workspace.Path, cancellationToken); + var session = client.Sessions.GetSessionClient(sessionId); + var cleanupState = new PermissionCleanupState(); + var cleanup = CreateCleanup(session, client, location.Project.Id, cleanupState); + Exception? primaryFailure = null; + + try + { + var createdPermission = await session.CreatePermissionAsync( + new SessionPermissionCreateRequest + { + Action = SimulationConfigSeed.PermissionProbeAction, + Resources = [PermissionResource], + Save = [PermissionResource], + Agent = SimulationConfigSeed.PermissionProbeAgentId, + }, + cancellationToken: cancellationToken); + cleanupState.PermissionId = createdPermission.Permission.Id; + + await Assert.That(createdPermission.Permission.Effect).IsEqualTo(PermissionEffect.Ask); + + var pending = (await session.GetPermissionAsync( + cleanupState.PermissionId, + cancellationToken: cancellationToken)).Permission; + await AssertPermissionRequestAsync(pending, cleanupState.PermissionId, sessionId); + + var listed = await session.ListRequestsAsync(cancellationToken: cancellationToken); + var listedMatches = listed.Requests.Where(request => request.Id == cleanupState.PermissionId).ToList(); + await Assert.That(listedMatches).Count().IsEqualTo(1); + await AssertPermissionRequestAsync(listedMatches[0], cleanupState.PermissionId, sessionId); + + var replied = await session.PostPermissionReplyAsync( + cleanupState.PermissionId, + new SessionPermissionReplyPostRequest { Reply = PermissionReply.Always }, + cancellationToken: cancellationToken); + await Assert.That(replied.Status).IsEqualTo(204); + + var consumed = await session.GetPermissionAsync( + cleanupState.PermissionId, + OpenCodeRequestOptions.NoThrow, + cancellationToken); + await Assert.That(consumed.Status).IsEqualTo(404); + await Assert.That(consumed.Error).IsTypeOf(); + var notFound = consumed.Error as PermissionNotFoundError; + await Assert.That(notFound?.RequestId).IsEqualTo(cleanupState.PermissionId); + + var saved = await client.Permissions.ListSavedAsync( + new PermissionSavedListRequest { ProjectId = location.Project.Id }, + cancellationToken: cancellationToken); + var savedMatches = OwnedSavedPermissions(saved.Saved, location.Project.Id); + await Assert.That(savedMatches).Count().IsEqualTo(1); + cleanupState.SavedId = savedMatches[0].Id; + + var removed = await client.Permissions.RemoveSavedAsync( + cleanupState.SavedId, + cancellationToken: cancellationToken); + await Assert.That(removed.Status).IsEqualTo(204); + + var afterRemoval = await client.Permissions.ListSavedAsync( + new PermissionSavedListRequest { ProjectId = location.Project.Id }, + cancellationToken: cancellationToken); + await Assert.That(afterRemoval.Saved.Any(info => info.Id == cleanupState.SavedId)).IsFalse(); + await Assert.That(OwnedSavedPermissions(afterRemoval.Saved, location.Project.Id)).IsEmpty(); + + WriteEvidence(consumed.Status, removed.Status); + } + catch (Exception exception) + { + primaryFailure = exception; + } + finally + { + await cleanup.CompleteAsync(primaryFailure); + } + } + + private static async Task AssertPermissionProbeAsync( + OpenCodeClient client, + string directory, + CancellationToken cancellationToken) + { + var activation = await client.Plugins.AwaitPluginActivationAsync( + new PluginAwaitActivationPostRequest + { + Location = new LocationSelector { Directory = directory }, + }, + cancellationToken: cancellationToken); + await Assert.That(activation.Status).IsEqualTo(204); + var agent = (await client.Agents.GetAgentAsync( + SimulationConfigSeed.PermissionProbeAgentId, + cancellationToken: cancellationToken)).Agent; + await Assert.That(agent.Id).IsEqualTo(SimulationConfigSeed.PermissionProbeAgentId); + await Assert.That(agent.Permissions.Any(rule => + rule.Action == SimulationConfigSeed.PermissionProbeAction && + rule.Resource == "*" && + rule.Effect == PermissionEffect.Ask)).IsTrue(); + } + + private static async Task CreateOwnedSessionAsync( + OpenCodeClient client, + string directory, + CancellationToken cancellationToken) + { + var created = await client.Sessions.CreateSessionAsync( + new SessionCreateRequest + { + Title = "session-permission-live", + Agent = SimulationConfigSeed.PermissionProbeAgentId, + Location = new LocationRef { Directory = directory }, + }, + cancellationToken: cancellationToken); + return created.Session.Id; + } + + private static OwnedSessionCleanup CreateCleanup( + SessionClient session, + OpenCodeClient client, + string projectId, + PermissionCleanupState state) + { + var cleanup = new OwnedSessionCleanup(session, CleanupTimeout); + cleanup.MarkTurnCompleted(); + cleanup.Own( + "pending permission request", + async token => + { + if (state.PermissionId is not null) + { + _ = await session.PostPermissionReplyAsync( + state.PermissionId, + new SessionPermissionReplyPostRequest { Reply = PermissionReply.Reject }, + OpenCodeRequestOptions.NoThrow, + token); + } + }); + cleanup.Own( + "saved permission discovery", + async token => + { + var saved = await client.Permissions.ListSavedAsync( + new PermissionSavedListRequest { ProjectId = projectId }, + cancellationToken: token); + state.SavedId ??= OwnedSavedPermissions(saved.Saved, projectId).SingleOrDefault()?.Id; + }); + cleanup.Own( + "saved permission removal", + async token => + { + if (state.SavedId is not null) + { + _ = await client.Permissions.RemoveSavedAsync(state.SavedId, cancellationToken: token); + } + }); + return cleanup; + } + + private static async Task AssertPermissionRequestAsync( + PermissionRequest request, + string permissionId, + string sessionId) + { + await Assert.That(request.Id).IsEqualTo(permissionId); + await Assert.That(request.SessionId).IsEqualTo(sessionId); + await Assert.That(request.Action).IsEqualTo(SimulationConfigSeed.PermissionProbeAction); + await Assert.That(request.Resources).IsEquivalentTo([PermissionResource]); + await Assert.That(request.Save).IsEquivalentTo([PermissionResource]); + } + + private static List OwnedSavedPermissions( + IEnumerable saved, + string projectId) => + [.. saved.Where(info => + info.ProjectId == projectId && + info.Action == SimulationConfigSeed.PermissionProbeAction && + info.Resource == PermissionResource)]; + + private static void WriteEvidence(int consumedStatus, int removedStatus) => + Console.WriteLine( + "session-permission-live: effect=ask pending-consumed-status=" + consumedStatus.ToString( + System.Globalization.CultureInfo.InvariantCulture) + + " saved-grant-removed-status=" + removedStatus.ToString( + System.Globalization.CultureInfo.InvariantCulture)); + + private sealed class PermissionCleanupState + { + public string? PermissionId { get; set; } + + public string? SavedId { get; set; } + } +} diff --git a/tests/Shared/SimulatedDriveServerFixture.cs b/tests/Shared/SimulatedDriveServerFixture.cs index 6e1360f1..a3b8379b 100644 --- a/tests/Shared/SimulatedDriveServerFixture.cs +++ b/tests/Shared/SimulatedDriveServerFixture.cs @@ -23,16 +23,6 @@ public sealed class SimulatedDriveServerFixture : IAsyncInitializer, IAsyncDispo /// private static readonly TimeSpan GateTimeout = TimeSpan.FromMinutes(15); - /// - /// Schema: config.ts:106 ('providers'), config/provider.ts:59-65; the builtin package - /// resolves with no npm install (core provider.ts:69) and pins the chat route to the exact - /// URL the simulation network claims (openai-compatible.ts settings.baseURL + - /// openai-compatible-chat.ts:21 '/chat/completions' == openai-chat.ts:35-36 - /// DEFAULT_BASE_URL + PATH, the pair backend/openai.ts:105 matches on). - /// - private const string SimulationConfig = - """{"providers":{"sim":{"name":"Simulated","package":"@opencode-ai/ai/providers/openai-compatible","settings":{"baseURL":"https://api.openai.com/v1","apiKey":"drive-lease"},"models":{"sim-model":{"name":"Simulated Model"}}}}}"""; - private readonly RealFileSystem _fileSystem = new(); private CliWrapServerAdapter? _adapter; private DriveController? _controller; @@ -92,7 +82,7 @@ private async Task StartAsync(TestRunRoot runRoot) environment["OPENCODE_SIMULATE"] = "1"; environment["OPENCODE_DRIVE"] = manifest.InstanceName; environment["DRIVE_REGISTRY_DIR"] = registry; - environment["OPENCODE_CONFIG_CONTENT"] = SimulationConfig; + environment["OPENCODE_CONFIG_CONTENT"] = SimulationConfigSeed.Json; environment["OPENCODE_LOG_LEVEL"] = "INFO"; environment["OPENCODE_PRINT_LOGS"] = "1"; _adapter = await CliWrapServerAdapter.StartAsync( diff --git a/tests/Shared/SimulationConfigSeed.cs b/tests/Shared/SimulationConfigSeed.cs new file mode 100644 index 00000000..28ca832d --- /dev/null +++ b/tests/Shared/SimulationConfigSeed.cs @@ -0,0 +1,99 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using OpenCode.Sdk.Models; + +namespace OpenCode.Sdk.TestSupport; + +/// +/// The test-owned typed configuration shared by every simulated server consumer. The provider +/// follows config.ts providers and config/provider.ts, resolves the builtin +/// openai-compatible package without an install, and pins model traffic to the exact +/// https://api.openai.com/v1/chat/completions route claimed by the Drive network. The +/// permission agent gives live permission tests one explicit ask rule without changing ordinary +/// session defaults. +/// +internal sealed partial class SimulationConfigSeed +{ + internal const string PermissionProbeAction = "sdk.live.permission"; + internal const string PermissionProbeAgentId = "permission-probe"; + + internal static string Json { get; } = JsonSerializer.Serialize( + new Configuration + { + Agents = new Dictionary(StringComparer.Ordinal) + { + [PermissionProbeAgentId] = new AgentConfiguration + { + Permissions = + [ + new PermissionRule + { + Action = PermissionProbeAction, + Resource = "*", + Effect = PermissionEffect.Ask, + }, + ], + }, + }, + Providers = new Dictionary(StringComparer.Ordinal) + { + ["sim"] = new ProviderConfiguration + { + Name = "Simulated", + Package = "@opencode-ai/ai/providers/openai-compatible", + Settings = new ProviderSettings + { + BaseUrl = "https://api.openai.com/v1", + ApiKey = "drive-lease", + }, + Models = new Dictionary(StringComparer.Ordinal) + { + ["sim-model"] = new ModelConfiguration { Name = "Simulated Model" }, + }, + }, + }, + }, + SerializationContext.Default.Configuration); + + private sealed record Configuration + { + public required IReadOnlyDictionary Agents { get; init; } + + public required IReadOnlyDictionary Providers { get; init; } + } + + private sealed record AgentConfiguration + { + public required IReadOnlyList Permissions { get; init; } + } + + private sealed record ProviderConfiguration + { + public required string Name { get; init; } + + public required string Package { get; init; } + + public required ProviderSettings Settings { get; init; } + + public required IReadOnlyDictionary Models { get; init; } + } + + private sealed record ProviderSettings + { + [JsonPropertyName("baseURL")] + public required string BaseUrl { get; init; } + + public required string ApiKey { get; init; } + } + + private sealed record ModelConfiguration + { + public required string Name { get; init; } + } + + [JsonSourceGenerationOptions( + GenerationMode = JsonSourceGenerationMode.Metadata, + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] + [JsonSerializable(typeof(Configuration))] + private sealed partial class SerializationContext : JsonSerializerContext; +} From a657e121aaf202118e3d7f219b34a3e8f57e08ef Mon Sep 17 00:00:00 2001 From: "denizirgin@gmail.com" Date: Sun, 6 Sep 2026 12:09:08 +0300 Subject: [PATCH 14/62] test: expose live permission assertions --- .../Sessions/SessionPermissionLiveTests.cs | 122 ++++++------------ 1 file changed, 36 insertions(+), 86 deletions(-) diff --git a/tests/OpenCode.Sdk.Tests/Sessions/SessionPermissionLiveTests.cs b/tests/OpenCode.Sdk.Tests/Sessions/SessionPermissionLiveTests.cs index 04e30888..a0d0c963 100644 --- a/tests/OpenCode.Sdk.Tests/Sessions/SessionPermissionLiveTests.cs +++ b/tests/OpenCode.Sdk.Tests/Sessions/SessionPermissionLiveTests.cs @@ -19,69 +19,69 @@ public async Task CreatePermissionAsync_Should_Save_An_Always_Grant_And_Consume_ using var workspace = server.CreateWorkspace(); using var client = server.CreateClient(new LocationSelector { Directory = workspace.Path }); var location = (await client.GetLocationAsync(cancellationToken: cancellationToken)).ResolvedLocation; - await AssertPermissionProbeAsync(client, location.Directory, cancellationToken); - - var sessionId = await CreateOwnedSessionAsync(client, workspace.Path, cancellationToken); - var session = client.Sessions.GetSessionClient(sessionId); + var agentId = SimulationConfigSeed.PermissionProbeAgentId; + var permissionAction = SimulationConfigSeed.PermissionProbeAction; + var activation = await client.Plugins.AwaitPluginActivationAsync(cancellationToken: cancellationToken); + await Assert.That(activation.Status).IsEqualTo(204); + var agent = (await client.Agents.GetAgentAsync(agentId, cancellationToken: cancellationToken)).Agent; + await Assert.That(agent.Id).IsEqualTo(agentId); + await Assert.That(agent.Permissions.Any(rule => (rule.Action, rule.Resource, rule.Effect) == (permissionAction, "*", PermissionEffect.Ask))).IsTrue(); + var createdSession = await client.Sessions.CreateSessionAsync(new SessionCreateRequest + { + Title = "session-permission-live", + Agent = agentId, + Location = new LocationRef { Directory = workspace.Path }, + }, cancellationToken: cancellationToken); + var session = client.Sessions.GetSessionClient(createdSession.Session.Id); var cleanupState = new PermissionCleanupState(); var cleanup = CreateCleanup(session, client, location.Project.Id, cleanupState); Exception? primaryFailure = null; try { - var createdPermission = await session.CreatePermissionAsync( - new SessionPermissionCreateRequest - { - Action = SimulationConfigSeed.PermissionProbeAction, - Resources = [PermissionResource], - Save = [PermissionResource], - Agent = SimulationConfigSeed.PermissionProbeAgentId, - }, - cancellationToken: cancellationToken); + var createdPermission = await session.CreatePermissionAsync(new SessionPermissionCreateRequest + { + Action = permissionAction, + Resources = [PermissionResource], + Save = [PermissionResource], + Agent = agentId, + }, cancellationToken: cancellationToken); cleanupState.PermissionId = createdPermission.Permission.Id; - await Assert.That(createdPermission.Permission.Effect).IsEqualTo(PermissionEffect.Ask); - - var pending = (await session.GetPermissionAsync( - cleanupState.PermissionId, + var pending = (await session.GetPermissionAsync(cleanupState.PermissionId, cancellationToken: cancellationToken)).Permission; - await AssertPermissionRequestAsync(pending, cleanupState.PermissionId, sessionId); - var listed = await session.ListRequestsAsync(cancellationToken: cancellationToken); var listedMatches = listed.Requests.Where(request => request.Id == cleanupState.PermissionId).ToList(); await Assert.That(listedMatches).Count().IsEqualTo(1); - await AssertPermissionRequestAsync(listedMatches[0], cleanupState.PermissionId, sessionId); + foreach (var observed in new[] { pending, listedMatches[0] }) + { + await Assert.That(observed.Id).IsEqualTo(cleanupState.PermissionId); + await Assert.That(observed.SessionId).IsEqualTo(createdSession.Session.Id); + await Assert.That(observed.Action).IsEqualTo(permissionAction); + await Assert.That(observed.Resources).IsEquivalentTo([PermissionResource]); + await Assert.That(observed.Save).IsEquivalentTo([PermissionResource]); + } var replied = await session.PostPermissionReplyAsync( - cleanupState.PermissionId, - new SessionPermissionReplyPostRequest { Reply = PermissionReply.Always }, + cleanupState.PermissionId, new SessionPermissionReplyPostRequest { Reply = PermissionReply.Always }, cancellationToken: cancellationToken); await Assert.That(replied.Status).IsEqualTo(204); - - var consumed = await session.GetPermissionAsync( - cleanupState.PermissionId, - OpenCodeRequestOptions.NoThrow, - cancellationToken); + var consumed = await session.GetPermissionAsync(cleanupState.PermissionId, OpenCodeRequestOptions.NoThrow, cancellationToken); await Assert.That(consumed.Status).IsEqualTo(404); await Assert.That(consumed.Error).IsTypeOf(); - var notFound = consumed.Error as PermissionNotFoundError; - await Assert.That(notFound?.RequestId).IsEqualTo(cleanupState.PermissionId); + await Assert.That((consumed.Error as PermissionNotFoundError)?.RequestId).IsEqualTo(cleanupState.PermissionId); var saved = await client.Permissions.ListSavedAsync( - new PermissionSavedListRequest { ProjectId = location.Project.Id }, - cancellationToken: cancellationToken); + new PermissionSavedListRequest { ProjectId = location.Project.Id }, cancellationToken: cancellationToken); var savedMatches = OwnedSavedPermissions(saved.Saved, location.Project.Id); await Assert.That(savedMatches).Count().IsEqualTo(1); cleanupState.SavedId = savedMatches[0].Id; - var removed = await client.Permissions.RemoveSavedAsync( - cleanupState.SavedId, - cancellationToken: cancellationToken); + cleanupState.SavedId, cancellationToken: cancellationToken); await Assert.That(removed.Status).IsEqualTo(204); var afterRemoval = await client.Permissions.ListSavedAsync( - new PermissionSavedListRequest { ProjectId = location.Project.Id }, - cancellationToken: cancellationToken); + new PermissionSavedListRequest { ProjectId = location.Project.Id }, cancellationToken: cancellationToken); await Assert.That(afterRemoval.Saved.Any(info => info.Id == cleanupState.SavedId)).IsFalse(); await Assert.That(OwnedSavedPermissions(afterRemoval.Saved, location.Project.Id)).IsEmpty(); @@ -97,44 +97,6 @@ public async Task CreatePermissionAsync_Should_Save_An_Always_Grant_And_Consume_ } } - private static async Task AssertPermissionProbeAsync( - OpenCodeClient client, - string directory, - CancellationToken cancellationToken) - { - var activation = await client.Plugins.AwaitPluginActivationAsync( - new PluginAwaitActivationPostRequest - { - Location = new LocationSelector { Directory = directory }, - }, - cancellationToken: cancellationToken); - await Assert.That(activation.Status).IsEqualTo(204); - var agent = (await client.Agents.GetAgentAsync( - SimulationConfigSeed.PermissionProbeAgentId, - cancellationToken: cancellationToken)).Agent; - await Assert.That(agent.Id).IsEqualTo(SimulationConfigSeed.PermissionProbeAgentId); - await Assert.That(agent.Permissions.Any(rule => - rule.Action == SimulationConfigSeed.PermissionProbeAction && - rule.Resource == "*" && - rule.Effect == PermissionEffect.Ask)).IsTrue(); - } - - private static async Task CreateOwnedSessionAsync( - OpenCodeClient client, - string directory, - CancellationToken cancellationToken) - { - var created = await client.Sessions.CreateSessionAsync( - new SessionCreateRequest - { - Title = "session-permission-live", - Agent = SimulationConfigSeed.PermissionProbeAgentId, - Location = new LocationRef { Directory = directory }, - }, - cancellationToken: cancellationToken); - return created.Session.Id; - } - private static OwnedSessionCleanup CreateCleanup( SessionClient session, OpenCodeClient client, @@ -177,18 +139,6 @@ private static OwnedSessionCleanup CreateCleanup( return cleanup; } - private static async Task AssertPermissionRequestAsync( - PermissionRequest request, - string permissionId, - string sessionId) - { - await Assert.That(request.Id).IsEqualTo(permissionId); - await Assert.That(request.SessionId).IsEqualTo(sessionId); - await Assert.That(request.Action).IsEqualTo(SimulationConfigSeed.PermissionProbeAction); - await Assert.That(request.Resources).IsEquivalentTo([PermissionResource]); - await Assert.That(request.Save).IsEquivalentTo([PermissionResource]); - } - private static List OwnedSavedPermissions( IEnumerable saved, string projectId) => From 4d358e18f17f1745d4fc483facfb790c5b9b41bc Mon Sep 17 00:00:00 2001 From: "denizirgin@gmail.com" Date: Sun, 6 Sep 2026 12:33:59 +0300 Subject: [PATCH 15/62] fix(tests): retain late cleanup diagnostics on net472 --- .../Support/LateCleanupFailureReport.cs | 2 ++ tests/OpenCode.Sdk.Tests/Support/OwnedSessionCleanup.cs | 5 ++++- .../Support/SessionLogTranscriptTests.cs | 8 +++++++- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/tests/OpenCode.Sdk.Tests/Support/LateCleanupFailureReport.cs b/tests/OpenCode.Sdk.Tests/Support/LateCleanupFailureReport.cs index b10b9f4e..fc00fe1c 100644 --- a/tests/OpenCode.Sdk.Tests/Support/LateCleanupFailureReport.cs +++ b/tests/OpenCode.Sdk.Tests/Support/LateCleanupFailureReport.cs @@ -11,6 +11,8 @@ internal sealed class LateCleanupFailureReport public bool HasRegistrations => Volatile.Read(ref _registrations) > 0; + public IReadOnlyCollection> DiagnosticFailures => _failures; + public IReadOnlyList> Failures => [.. _failures]; public void Observe(string name, Task operation) diff --git a/tests/OpenCode.Sdk.Tests/Support/OwnedSessionCleanup.cs b/tests/OpenCode.Sdk.Tests/Support/OwnedSessionCleanup.cs index 644f3c56..26b46fcf 100644 --- a/tests/OpenCode.Sdk.Tests/Support/OwnedSessionCleanup.cs +++ b/tests/OpenCode.Sdk.Tests/Support/OwnedSessionCleanup.cs @@ -14,6 +14,9 @@ internal sealed class OwnedSessionCleanup private readonly TimeSpan _timeout; private bool _turnCompleted; + internal LateCleanupFailureReport? LateFailures => + _lateFailures.HasRegistrations ? _lateFailures : null; + public OwnedSessionCleanup(SessionClient session, TimeSpan timeout) : this( async token => @@ -132,7 +135,7 @@ private void AttachLateFailureReport(Exception exception) { if (_lateFailures.HasRegistrations) { - exception.Data[LateFailuresKey] = _lateFailures; + exception.Data[LateFailuresKey] = _lateFailures.DiagnosticFailures; } } } diff --git a/tests/OpenCode.Sdk.Tests/Support/SessionLogTranscriptTests.cs b/tests/OpenCode.Sdk.Tests/Support/SessionLogTranscriptTests.cs index 7dc44f62..36e7d7fa 100644 --- a/tests/OpenCode.Sdk.Tests/Support/SessionLogTranscriptTests.cs +++ b/tests/OpenCode.Sdk.Tests/Support/SessionLogTranscriptTests.cs @@ -37,7 +37,10 @@ public async Task Cleanup_Should_Bound_Stalled_Disposal_And_Retain_Its_Late_Fail await Assert.That(failures!.InnerExceptions).Count().IsEqualTo(2); await Assert.That(failures.InnerExceptions[0]).IsTypeOf(); await Assert.That(failures.InnerExceptions[1]).IsSameReferenceAs(removalFailure); - var late = primaryFailure.Data[OwnedSessionCleanup.LateFailuresKey] as LateCleanupFailureReport; + var diagnosticFailures = primaryFailure.Data[OwnedSessionCleanup.LateFailuresKey] + as IReadOnlyCollection>; + await Assert.That(diagnosticFailures).IsNotNull(); + var late = cleanup.LateFailures; await Assert.That(late).IsNotNull(); disposal.SetException(lateDisposalFailure); @@ -48,6 +51,9 @@ public async Task Cleanup_Should_Bound_Stalled_Disposal_And_Retain_Its_Late_Fail await Assert.That(observed.Value).IsSameReferenceAs(lateDisposalFailure); await Assert.That(late.Failures).Count().IsEqualTo(1); await Assert.That(late.Failures[0].Value).IsSameReferenceAs(lateDisposalFailure); + await Assert.That(diagnosticFailures!).Count().IsEqualTo(1); + await Assert.That(diagnosticFailures!.Single().Key).IsEqualTo("session log enumerator"); + await Assert.That(diagnosticFailures.Single().Value).IsSameReferenceAs(lateDisposalFailure); _ = await Assert.That(window.Cancel).Throws(); Console.WriteLine( "session-log-cleanup: immediate-failures=" + failures.InnerExceptions.Count.ToString( From 32005d0f2c79d02173ac20bbdc4e4dde7262bddc Mon Sep 17 00:00:00 2001 From: "denizirgin@gmail.com" Date: Sun, 6 Sep 2026 12:34:06 +0300 Subject: [PATCH 16/62] test: cover live form lifecycle --- .../Sessions/SessionFormLiveTests.cs | 198 ++++++++++++++++++ 1 file changed, 198 insertions(+) create mode 100644 tests/OpenCode.Sdk.Tests/Sessions/SessionFormLiveTests.cs diff --git a/tests/OpenCode.Sdk.Tests/Sessions/SessionFormLiveTests.cs b/tests/OpenCode.Sdk.Tests/Sessions/SessionFormLiveTests.cs new file mode 100644 index 00000000..e0ce6b08 --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Sessions/SessionFormLiveTests.cs @@ -0,0 +1,198 @@ +using OpenCode.Sdk.Models; +using OpenCode.Sdk.Tests.Support; +using OpenCode.Sdk.TestSupport; + +namespace OpenCode.Sdk.Tests.Sessions; + +[ClassDataSource(Shared = SharedType.PerTestSession)] +[NotInParallel(ParallelConstraintKeys.ServerProcess)] +public sealed class SessionFormLiveTests(SimulatedDriveServerFixture server) +{ + private const string FieldKey = "response"; + private const string AnswerTitle = "SDK live answer"; + private const string CancelTitle = "SDK live cancel"; + private const string AnswerText = "accepted"; + private static readonly TimeSpan CleanupTimeout = TimeSpan.FromSeconds(15); + + [Test] + [Timeout(180_000)] + public async Task PostFormReplyAsync_Should_Persist_Answered_State_And_Reject_A_Second_Reply( + CancellationToken cancellationToken) + { + using var workspace = server.CreateWorkspace(); + using var client = server.CreateClient(new LocationSelector { Directory = workspace.Path }); + var createdSession = await client.Sessions.CreateSessionAsync(new SessionCreateRequest + { + Title = "session-form-answer-live", + Location = new LocationRef { Directory = workspace.Path }, + }, cancellationToken: cancellationToken); + var session = client.Sessions.GetSessionClient(createdSession.Session.Id); + var formId = CreateOwnedFormId("answer"); + var cleanup = new OwnedSessionCleanup(session, CleanupTimeout); + cleanup.MarkTurnCompleted(); + cleanup.Own("owned answer form", token => SettleFormForCleanupAsync(session, formId, token)); + Exception? primaryFailure = null; + + try + { + var created = await session.CreateFormAsync(new SessionFormCreateRequest + { + Id = formId, + Title = AnswerTitle, + Fields = + [ + new FormStringField + { + Key = FieldKey, + Required = true, + MinLength = 1, + }, + ], + }, cancellationToken: cancellationToken); + await Assert.That(created.Form.Id).IsEqualTo(formId); + await Assert.That(created.Form.SessionId).IsEqualTo(createdSession.Session.Id); + await Assert.That(created.Form.Title).IsEqualTo(AnswerTitle); + await Assert.That(created.Form.Fields.Single()).IsTypeOf(); + + var sessionPending = await session.ListFormsAsync(cancellationToken: cancellationToken); + var locationPending = await client.Forms.ListRequestsAsync(cancellationToken: cancellationToken); + await Assert.That(sessionPending.Forms.Any(form => form.Id == formId)).IsTrue(); + await Assert.That(locationPending.Requests.Any(form => form.Id == formId)).IsTrue(); + var pending = await session.GetFormStateAsync(formId, cancellationToken: cancellationToken); + await Assert.That(pending.FormState).IsTypeOf(); + + var replyRequest = new SessionFormReplyPostRequest + { + Answer = new Dictionary(StringComparer.Ordinal) + { + [FieldKey] = FormValue.FromText(AnswerText), + }, + }; + var replied = await session.PostFormReplyAsync(formId, replyRequest, cancellationToken: cancellationToken); + await Assert.That(replied.Status).IsEqualTo(204); + var terminal = await session.GetFormStateAsync(formId, cancellationToken: cancellationToken); + await Assert.That(terminal.FormState).IsTypeOf(); + var answered = (FormStateAnswered)terminal.FormState; + await Assert.That(answered.Answer[FieldKey].Kind).IsEqualTo(FormValueKind.Text); + await Assert.That(answered.Answer[FieldKey].Text).IsEqualTo(AnswerText); + + sessionPending = await session.ListFormsAsync(cancellationToken: cancellationToken); + locationPending = await client.Forms.ListRequestsAsync(cancellationToken: cancellationToken); + await Assert.That(sessionPending.Forms.Any(form => form.Id == formId)).IsFalse(); + await Assert.That(locationPending.Requests.Any(form => form.Id == formId)).IsFalse(); + var repeated = await session.PostFormReplyAsync( + formId, replyRequest, OpenCodeRequestOptions.NoThrow, cancellationToken); + await Assert.That(repeated.Status).IsEqualTo(409); + await Assert.That(repeated.Error).IsTypeOf(); + await Assert.That((repeated.Error as FormAlreadySettledError)?.Id).IsEqualTo(formId); + + Console.WriteLine( + "session-form-answer-live: pending=observed answered=" + answered.Answer[FieldKey].Kind + + " repeated-reply=" + Number(repeated.Status)); + } + catch (Exception exception) + { + primaryFailure = exception; + } + finally + { + await cleanup.CompleteAsync(primaryFailure); + } + } + + [Test] + [Timeout(180_000)] + public async Task PostFormCancelAsync_Should_Persist_Cancelled_State_And_Report_Settled_And_Missing_Forms( + CancellationToken cancellationToken) + { + using var workspace = server.CreateWorkspace(); + using var client = server.CreateClient(new LocationSelector { Directory = workspace.Path }); + var createdSession = await client.Sessions.CreateSessionAsync(new SessionCreateRequest + { + Title = "session-form-cancel-live", + Location = new LocationRef { Directory = workspace.Path }, + }, cancellationToken: cancellationToken); + var session = client.Sessions.GetSessionClient(createdSession.Session.Id); + var formId = CreateOwnedFormId("cancel"); + var missingFormId = CreateOwnedFormId("missing"); + var cleanup = new OwnedSessionCleanup(session, CleanupTimeout); + cleanup.MarkTurnCompleted(); + cleanup.Own("owned cancel form", token => SettleFormForCleanupAsync(session, formId, token)); + Exception? primaryFailure = null; + + try + { + var created = await session.CreateFormAsync(new SessionFormCreateRequest + { + Id = formId, + Title = CancelTitle, + Fields = + [ + new FormStringField + { + Key = FieldKey, + Required = true, + MinLength = 1, + }, + ], + }, cancellationToken: cancellationToken); + await Assert.That(created.Status).IsEqualTo(200); + await Assert.That(created.Form.Id).IsEqualTo(formId); + await Assert.That(created.Form.SessionId).IsEqualTo(createdSession.Session.Id); + await Assert.That(created.Form.Title).IsEqualTo(CancelTitle); + await Assert.That(created.Form.Fields).Count().IsEqualTo(1); + await Assert.That(created.Form.Fields[0]).IsTypeOf(); + + var cancelled = await session.PostFormCancelAsync(formId, cancellationToken: cancellationToken); + await Assert.That(cancelled.Status).IsEqualTo(204); + var terminal = await session.GetFormStateAsync(formId, cancellationToken: cancellationToken); + await Assert.That(terminal.FormState).IsTypeOf(); + + var repeated = await session.PostFormCancelAsync( + formId, OpenCodeRequestOptions.NoThrow, cancellationToken); + await Assert.That(repeated.Status).IsEqualTo(409); + await Assert.That(repeated.Error).IsTypeOf(); + await Assert.That((repeated.Error as FormAlreadySettledError)?.Id).IsEqualTo(formId); + + var missing = await session.GetFormAsync( + missingFormId, OpenCodeRequestOptions.NoThrow, cancellationToken); + await Assert.That(missing.Status).IsEqualTo(404); + await Assert.That(missing.Error).IsTypeOf(); + await Assert.That((missing.Error as FormNotFoundError)?.Id).IsEqualTo(missingFormId); + + Console.WriteLine( + "session-form-cancel-live: cancelled=" + terminal.FormState.Status + + " repeated-cancel=" + Number(repeated.Status) + + " missing=" + Number(missing.Status)); + } + catch (Exception exception) + { + primaryFailure = exception; + } + finally + { + await cleanup.CompleteAsync(primaryFailure); + } + } + + private static string CreateOwnedFormId(string role) => + "frm_sdk_" + role + "_" + Guid.NewGuid().ToString("N"); + + private static string Number(int value) => + value.ToString(System.Globalization.CultureInfo.InvariantCulture); + + private static async Task SettleFormForCleanupAsync( + SessionClient session, + string formId, + CancellationToken cancellationToken) + { + var response = await session.PostFormCancelAsync( + formId, OpenCodeRequestOptions.NoThrow, cancellationToken); + if (response.Status is not (204 or 404 or 409)) + { + throw new InvalidOperationException( + "Owned form cleanup returned unexpected status " + + response.Status.ToString(System.Globalization.CultureInfo.InvariantCulture) + "."); + } + } +} From 59eda59465339e01b9acbe1e09ece279b530ee9d Mon Sep 17 00:00:00 2001 From: "denizirgin@gmail.com" Date: Sun, 6 Sep 2026 13:37:41 +0300 Subject: [PATCH 17/62] fix(tests): bound live-resource cleanup and retain failures --- docs/guide/getting-started.md | 2 + .../SessionMessagePaginationLiveTests.cs | 2 +- .../Sessions/SessionPermissionLiveTests.cs | 13 +- .../Support/EventDiagnosticScenario.cs | 63 +++++++ .../Support/EventDiagnosticSummary.cs | 53 ++++++ .../Support/LateCleanupFailureReport.cs | 32 +++- .../Support/OwnedCleanup.cs | 155 ++++++++++++++++ .../Support/OwnedCleanupOperation.cs | 26 +++ .../Support/OwnedCleanupTests.cs | 104 +++++++++++ .../Support/OwnedEventReader.cs | 83 +++++++++ .../Support/OwnedEventReaderTests.cs | 174 ++++++++++++++++++ .../Support/OwnedSessionCleanup.cs | 101 +--------- .../Support/PendingPermissionCleanup.cs | 32 ++++ .../Support/PendingPermissionCleanupTests.cs | 114 ++++++++++++ .../Support/ScriptedTurnEvents.cs | 74 ++++++++ .../Support/ScriptedTurnEventsTests.cs | 91 +++++++++ .../Support/SessionLogTranscript.cs | 35 +++- .../Support/SessionLogTranscriptTests.cs | 60 +++++- .../Support/SimulatedSessionTurn.cs | 80 +------- 19 files changed, 1107 insertions(+), 187 deletions(-) create mode 100644 tests/OpenCode.Sdk.Tests/Support/EventDiagnosticScenario.cs create mode 100644 tests/OpenCode.Sdk.Tests/Support/EventDiagnosticSummary.cs create mode 100644 tests/OpenCode.Sdk.Tests/Support/OwnedCleanup.cs create mode 100644 tests/OpenCode.Sdk.Tests/Support/OwnedCleanupOperation.cs create mode 100644 tests/OpenCode.Sdk.Tests/Support/OwnedCleanupTests.cs create mode 100644 tests/OpenCode.Sdk.Tests/Support/OwnedEventReader.cs create mode 100644 tests/OpenCode.Sdk.Tests/Support/OwnedEventReaderTests.cs create mode 100644 tests/OpenCode.Sdk.Tests/Support/PendingPermissionCleanup.cs create mode 100644 tests/OpenCode.Sdk.Tests/Support/PendingPermissionCleanupTests.cs create mode 100644 tests/OpenCode.Sdk.Tests/Support/ScriptedTurnEvents.cs create mode 100644 tests/OpenCode.Sdk.Tests/Support/ScriptedTurnEventsTests.cs diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index bfb7ce04..bbd2e4b9 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -1,5 +1,7 @@ # 🚀 Getting started +Date: 2026-09-06 + Install the package, point a client at a server, and make three calls. Ten minutes, and the last one talks to a model. diff --git a/tests/OpenCode.Sdk.Tests/Sessions/SessionMessagePaginationLiveTests.cs b/tests/OpenCode.Sdk.Tests/Sessions/SessionMessagePaginationLiveTests.cs index d4b49902..4a6672ba 100644 --- a/tests/OpenCode.Sdk.Tests/Sessions/SessionMessagePaginationLiveTests.cs +++ b/tests/OpenCode.Sdk.Tests/Sessions/SessionMessagePaginationLiveTests.cs @@ -122,7 +122,7 @@ public async Task CleanupAsync_Should_Give_Removal_A_Fresh_Budget_And_Preserve_A var cleanupFailures = primaryFailure.Data[OwnedSessionCleanup.FailuresKey] as AggregateException; await Assert.That(cleanupFailures).IsNotNull(); await Assert.That(cleanupFailures!.InnerExceptions.Count).IsEqualTo(2); - await Assert.That(cleanupFailures.InnerExceptions[0]).IsTypeOf(); + await Assert.That(cleanupFailures.InnerExceptions[0]).IsTypeOf(); await Assert.That(cleanupFailures.InnerExceptions[1]).IsSameReferenceAs(removalFailure); } diff --git a/tests/OpenCode.Sdk.Tests/Sessions/SessionPermissionLiveTests.cs b/tests/OpenCode.Sdk.Tests/Sessions/SessionPermissionLiveTests.cs index a0d0c963..e0f252cb 100644 --- a/tests/OpenCode.Sdk.Tests/Sessions/SessionPermissionLiveTests.cs +++ b/tests/OpenCode.Sdk.Tests/Sessions/SessionPermissionLiveTests.cs @@ -104,20 +104,11 @@ private static OwnedSessionCleanup CreateCleanup( PermissionCleanupState state) { var cleanup = new OwnedSessionCleanup(session, CleanupTimeout); + var pending = new PendingPermissionCleanup(session); cleanup.MarkTurnCompleted(); cleanup.Own( "pending permission request", - async token => - { - if (state.PermissionId is not null) - { - _ = await session.PostPermissionReplyAsync( - state.PermissionId, - new SessionPermissionReplyPostRequest { Reply = PermissionReply.Reject }, - OpenCodeRequestOptions.NoThrow, - token); - } - }); + token => pending.RejectAsync(state.PermissionId, token)); cleanup.Own( "saved permission discovery", async token => diff --git a/tests/OpenCode.Sdk.Tests/Support/EventDiagnosticScenario.cs b/tests/OpenCode.Sdk.Tests/Support/EventDiagnosticScenario.cs new file mode 100644 index 00000000..598b4d96 --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Support/EventDiagnosticScenario.cs @@ -0,0 +1,63 @@ +using System.Globalization; +using System.Runtime.CompilerServices; +using OpenCode.Sdk.Models; + +namespace OpenCode.Sdk.Tests.Support; + +internal sealed class EventDiagnosticScenario : SessionClient +{ + public Exception? Failure { get; init; } + + public bool IncludeMarker { get; init; } + + public bool Stall { get; init; } + + public TaskCompletionSource? Pending { get; private set; } + + public override async IAsyncEnumerable GetLogAsync( + SessionLogRequest? request = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await Task.CompletedTask; + if (IncludeMarker) + { + yield return new EventLogSynced { AggregateId = "ses_owned", Seq = 1 }; + } + + foreach (var item in Observations()) + { + cancellationToken.ThrowIfCancellationRequested(); + yield return item; + } + + if (Failure is not null) + { + throw Failure; + } + } + + public async IAsyncEnumerable EventsAsync() + { + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + Pending = completion; + foreach (var item in Observations()) + { + yield return item; + } + + if (Stall) + { + _ = await completion.Task; + } + + if (Failure is not null) + { + throw Failure; + } + } + + private static IEnumerable Observations() => Enumerable.Range(0, 100) + .Select(index => new Observation("event-" + index.ToString("D4", CultureInfo.InvariantCulture) + + "-" + new string('x', 4000))); + + private sealed record Observation(string Type) : ISessionLogItem, IEvent; +} diff --git a/tests/OpenCode.Sdk.Tests/Support/EventDiagnosticSummary.cs b/tests/OpenCode.Sdk.Tests/Support/EventDiagnosticSummary.cs new file mode 100644 index 00000000..88f99060 --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Support/EventDiagnosticSummary.cs @@ -0,0 +1,53 @@ +using System.Globalization; + +namespace OpenCode.Sdk.Tests.Support; + +internal sealed class EventDiagnosticSummary +{ + internal const string DataKey = "ObservedEvents"; + private const int EdgeCount = 8; + private const int LabelLength = 96; + private readonly List _first = []; + private readonly Queue _tail = new(); + private long _count; + private readonly Lock _gate = new(); + + public void Add(string label) + { + ArgumentNullException.ThrowIfNull(label); + lock (_gate) + { + // Diagnostics retain eight first and eight last labels, each capped at 96 characters. + // Successful transcript storage is independent and remains complete. + label = label.Length <= LabelLength ? label : label[..(LabelLength - 3)] + "..."; + label = label.Replace('\r', ' ').Replace('\n', ' '); + _count++; + if (_first.Count < EdgeCount) + { + _first.Add(label); + return; + } + + if (_tail.Count == EdgeCount) + { + _ = _tail.Dequeue(); + } + + _tail.Enqueue(label); + } + } + + public override string ToString() + { + lock (_gate) + { + var omitted = _count - _first.Count - _tail.Count; + var middle = omitted > 0 + ? new[] { "... " + omitted.ToString(CultureInfo.InvariantCulture) + " events omitted ..." } + : []; + return string.Join(", ", _first.Select(Quote).Concat(middle).Concat(_tail.Select(Quote))); + } + } + + private static string Quote(string value) => "'" + value + "'"; +} diff --git a/tests/OpenCode.Sdk.Tests/Support/LateCleanupFailureReport.cs b/tests/OpenCode.Sdk.Tests/Support/LateCleanupFailureReport.cs index fc00fe1c..ff724a36 100644 --- a/tests/OpenCode.Sdk.Tests/Support/LateCleanupFailureReport.cs +++ b/tests/OpenCode.Sdk.Tests/Support/LateCleanupFailureReport.cs @@ -4,10 +4,11 @@ namespace OpenCode.Sdk.Tests.Support; internal sealed class LateCleanupFailureReport { - private readonly ConcurrentQueue> _failures = new(); + private ConcurrentQueue> _failures = new(); private readonly TaskCompletionSource> _firstFailure = new( TaskCreationOptions.RunContinuationsAsynchronously); private int _registrations; + private readonly List _observers = []; public bool HasRegistrations => Volatile.Read(ref _registrations) > 0; @@ -15,24 +16,45 @@ internal sealed class LateCleanupFailureReport public IReadOnlyList> Failures => [.. _failures]; - public void Observe(string name, Task operation) + public void InheritDiagnostics(Exception? primaryFailure) + { + if (primaryFailure?.Data[OwnedCleanup.LateFailuresKey] + is ConcurrentQueue> previous) + { + // Called before registering work. Nested owners append to the same attached queue. + _failures = previous; + } + } + + public void Observe(string name, Task operation, Func? expectedCancellation = null) { _ = Interlocked.Increment(ref _registrations); - _ = operation.ContinueWith( + var observer = operation.ContinueWith( completed => { - foreach (var failure in completed.Exception!.Flatten().InnerExceptions) + var failures = completed.Exception?.Flatten().InnerExceptions.AsEnumerable() + ?? (completed.IsCanceled ? [new TaskCanceledException(completed)] : []); + foreach (var failure in failures) { + if (failure is OperationCanceledException cancelled && expectedCancellation?.Invoke(cancelled) is true) + { + continue; + } + var entry = new KeyValuePair(name, failure); _failures.Enqueue(entry); _ = _firstFailure.TrySetResult(entry); } }, CancellationToken.None, - TaskContinuationOptions.ExecuteSynchronously | TaskContinuationOptions.OnlyOnFaulted, + TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); + _observers.Add(observer); } public async Task> WaitForFailureAsync(CancellationToken cancellationToken) => await _firstFailure.Task.WaitAsync(cancellationToken); + + public async Task WaitForAllAsync(CancellationToken cancellationToken) => + await Task.WhenAll(_observers).WaitAsync(cancellationToken); } diff --git a/tests/OpenCode.Sdk.Tests/Support/OwnedCleanup.cs b/tests/OpenCode.Sdk.Tests/Support/OwnedCleanup.cs new file mode 100644 index 00000000..4f06ddd8 --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Support/OwnedCleanup.cs @@ -0,0 +1,155 @@ +using System.Runtime.ExceptionServices; + +namespace OpenCode.Sdk.Tests.Support; + +internal sealed class OwnedCleanup(TimeSpan timeout) +{ + internal const string FailuresKey = "OwnedSessionCleanup.Failures"; + internal const string LateFailuresKey = "OwnedSessionCleanup.LateFailures"; + private readonly LateCleanupFailureReport _lateFailures = new(); + private readonly List _operations = []; + + public LateCleanupFailureReport? LateFailures => + _lateFailures.HasRegistrations ? _lateFailures : null; + + public void Own(string name, Func operation) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentNullException.ThrowIfNull(operation); + _operations.Add(new OwnedCleanupOperation(name, operation)); + } + + public void Own(string name, Task pending, Func? expectedCancellation = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentNullException.ThrowIfNull(pending); + _operations.Add(new OwnedCleanupOperation(name, pending, expectedCancellation)); + } + + public async Task CompleteAsync(Exception? primaryFailure) + { + _lateFailures.InheritDiagnostics(primaryFailure); + var failures = new List(); + foreach (var operation in _operations) + { + await CaptureFailureAsync(operation, failures); + } + + failures.RemoveAll(failure => ReferenceEquals(failure, primaryFailure)); + ThrowFailures(primaryFailure, failures); + } + + private async Task CaptureFailureAsync( + OwnedCleanupOperation operation, + List failures) + { + CancellationTokenSource? budget = new(); + try + { + var token = budget.Token; + // Invocation itself can block (including a synchronous cancellation callback). + // The deadline must not depend on that work or on its cooperative token. + var pending = await ObserveAsync(operation.Name, () => operation.StartAsync(token), + failures, operation.ExpectedCancellation); + if (pending.IsCompleted) + { + return; + } + + var cancellation = await ObserveAsync(operation.Name + " cancellation", budget.CancelAsync, failures); + DisposeBudgetAfterCompletion(budget, pending, cancellation); + budget = null; + } + finally + { + budget?.Dispose(); + } + } + + private static void DisposeBudgetAfterCompletion(CancellationTokenSource budget, Task pending, Task cancellation) + { + _ = Task.WhenAll(pending, cancellation).ContinueWith( + completed => + { + _ = completed.Exception; + budget.Dispose(); + }, + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + } + + private async Task ObserveAsync(string name, Func start, List failures, + Func? expectedCancellation = null) + { + var pending = Task.Run(start, CancellationToken.None); + try + { + await pending.WaitAsync(timeout); + } + catch (OperationCanceledException exception) when (expectedCancellation?.Invoke(exception) is true) + { + // An owned reader can identify its own cooperative teardown cancellation. + return pending; + } + catch (Exception exception) + { + failures.Add(exception); + if (!pending.IsCompleted) + { + _lateFailures.Observe(name, pending, expectedCancellation); + } + else if (pending.Exception is { } aggregate) + { + foreach (var failure in aggregate.Flatten().InnerExceptions) + { + if (!ReferenceEquals(failure, exception) + && (exception is not AggregateException caught + || !caught.Flatten().InnerExceptions.Contains(failure))) + { + failures.Add(failure); + } + } + } + } + + return pending; + } + + private void ThrowFailures(Exception? primaryFailure, List failures) + { + if (primaryFailure is not null) + { + if (failures.Count > 0) + { + var previous = primaryFailure.Data[FailuresKey] as AggregateException; + primaryFailure.Data[FailuresKey] = new AggregateException( + (previous?.InnerExceptions.AsEnumerable() ?? []).Concat(failures)); + } + + AttachLateFailures(primaryFailure); + ExceptionDispatchInfo.Capture(primaryFailure).Throw(); + } + + if (failures.Count is 1) + { + AttachLateFailures(failures[0]); + ExceptionDispatchInfo.Capture(failures[0]).Throw(); + } + + if (failures.Count > 1) + { + var aggregate = new AggregateException("Multiple failures occurred during owned cleanup.", failures); + AttachLateFailures(aggregate); + throw aggregate; + } + } + + private void AttachLateFailures(Exception exception) + { + if (_lateFailures.HasRegistrations) + { + exception.Data[LateFailuresKey] = _lateFailures.DiagnosticFailures; + } + } +} diff --git a/tests/OpenCode.Sdk.Tests/Support/OwnedCleanupOperation.cs b/tests/OpenCode.Sdk.Tests/Support/OwnedCleanupOperation.cs new file mode 100644 index 00000000..d4fda0fc --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Support/OwnedCleanupOperation.cs @@ -0,0 +1,26 @@ +namespace OpenCode.Sdk.Tests.Support; + +internal sealed class OwnedCleanupOperation +{ + private readonly Func? _operation; + private readonly Task? _pending; + + public OwnedCleanupOperation(string name, Func operation) + { + Name = name; + _operation = operation; + } + + public OwnedCleanupOperation(string name, Task pending, Func? expectedCancellation) + { + Name = name; + _pending = pending; + ExpectedCancellation = expectedCancellation; + } + + public string Name { get; } + + public Func? ExpectedCancellation { get; } + + public Task StartAsync(CancellationToken cancellationToken) => _pending ?? _operation!(cancellationToken); +} diff --git a/tests/OpenCode.Sdk.Tests/Support/OwnedCleanupTests.cs b/tests/OpenCode.Sdk.Tests/Support/OwnedCleanupTests.cs new file mode 100644 index 00000000..f3b362f9 --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Support/OwnedCleanupTests.cs @@ -0,0 +1,104 @@ +namespace OpenCode.Sdk.Tests.Support; + +public sealed class OwnedCleanupTests +{ + [Test] + public async Task CompleteAsync_Should_Retain_Every_Task_Fault_Without_Duplicating_The_Primary() + { + var primary = new InvalidOperationException("primary failed"); + var secondary = new InvalidOperationException("secondary failed"); + var cleanup = new OwnedCleanup(TimeSpan.FromSeconds(1)); + cleanup.Own("reader", Task.WhenAll(Task.FromException(primary), Task.FromException(secondary))); + + var thrown = await Assert.That(() => cleanup.CompleteAsync(primary)).Throws(); + + await Assert.That(thrown).IsSameReferenceAs(primary); + var failures = (AggregateException)primary.Data[OwnedCleanup.FailuresKey]!; + await Assert.That(failures.InnerExceptions.Single()).IsSameReferenceAs(secondary); + } + + [Test] + public async Task CompleteAsync_Should_Append_Nested_Late_Failures_To_The_Attached_Queue() + { + var first = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var second = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var inner = new OwnedCleanup(TimeSpan.FromMilliseconds(50)); + var outer = new OwnedCleanup(TimeSpan.FromMilliseconds(50)); + inner.Own("inner read", first.Task); + outer.Own("outer removal", second.Task); + var primary = new InvalidOperationException("primary failed"); + var firstFailure = new InvalidOperationException("inner late failure"); + var secondFailure = new InvalidOperationException("outer late failure"); + try + { + _ = await Assert.That(() => inner.CompleteAsync(primary)).Throws(); + var attached = primary.Data[OwnedCleanup.LateFailuresKey]; + _ = await Assert.That(() => outer.CompleteAsync(primary)).Throws(); + await Assert.That(primary.Data[OwnedCleanup.LateFailuresKey]).IsSameReferenceAs(attached); + first.SetException(firstFailure); + second.SetException(secondFailure); + var innerReport = inner.LateFailures; + var outerReport = outer.LateFailures; + await Assert.That(innerReport).IsNotNull(); + await Assert.That(outerReport).IsNotNull(); + using var observation = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + await innerReport.WaitForAllAsync(observation.Token); + await outerReport.WaitForAllAsync(observation.Token); + + var failures = (IReadOnlyCollection>)attached!; + await Assert.That(failures.Select(entry => entry.Value)) + .IsEquivalentTo(new Exception[] { firstFailure, secondFailure }); + } + finally + { + _ = first.TrySetResult(true); + _ = second.TrySetResult(true); + } + } + + [Test] + public async Task CompleteAsync_Should_Bound_An_Operation_And_Its_Stalled_Cancellation_Independently() + { + using var release = new ManualResetEventSlim(); + TaskCompletionSource? pending = null; + var primary = new InvalidOperationException("body failed"); + var late = new InvalidOperationException("late operation failed"); + var cleanup = new OwnedCleanup(TimeSpan.FromMilliseconds(50)); + var laterTokenCancelled = true; + cleanup.Own("stalled operation", async token => + { + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + pending = completion; + using var registration = token.Register(() => _ = release.Wait(TimeSpan.FromSeconds(10))); + _ = await completion.Task; + }); + cleanup.Own("later cleanup", token => + { + laterTokenCancelled = token.IsCancellationRequested; + return Task.CompletedTask; + }); + try + { + var thrown = await Assert.That(() => cleanup.CompleteAsync(primary)).Throws(); + + await Assert.That(thrown).IsSameReferenceAs(primary); + await Assert.That(laterTokenCancelled).IsFalse(); + var failures = (AggregateException)primary.Data[OwnedCleanup.FailuresKey]!; + await Assert.That(failures.InnerExceptions.OfType()).Count().IsEqualTo(2); + release.Set(); + await Assert.That(pending).IsNotNull(); + pending.SetException(late); + using var observation = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + var report = cleanup.LateFailures; + await Assert.That(report).IsNotNull(); + await report.WaitForAllAsync(observation.Token); + var attached = (IReadOnlyCollection>)primary.Data[OwnedCleanup.LateFailuresKey]!; + await Assert.That(attached.Single().Value).IsSameReferenceAs(late); + } + finally + { + release.Set(); + _ = pending?.TrySetResult(true); + } + } +} diff --git a/tests/OpenCode.Sdk.Tests/Support/OwnedEventReader.cs b/tests/OpenCode.Sdk.Tests/Support/OwnedEventReader.cs new file mode 100644 index 00000000..a6f67738 --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Support/OwnedEventReader.cs @@ -0,0 +1,83 @@ +namespace OpenCode.Sdk.Tests.Support; + +internal sealed class OwnedEventReader +{ + private readonly CancellationToken _callerToken; + private readonly CancellationTokenSource _window; + private readonly CancellationToken _eventToken; + private readonly OwnedCleanup _cleanup; + private readonly TimeSpan _observationWindow; + private bool _teardownCancellation; + private Task _reader = Task.CompletedTask; + private Task _cancellation = Task.CompletedTask; + + public OwnedEventReader(TimeSpan observationWindow, TimeSpan cleanupTimeout, CancellationToken callerToken) + { + _callerToken = callerToken; + _observationWindow = observationWindow; + _window = CancellationTokenSource.CreateLinkedTokenSource(callerToken); + _eventToken = _window.Token; + _window.CancelAfter(observationWindow); + _cleanup = new OwnedCleanup(cleanupTimeout); + } + + public CancellationToken Token => _eventToken; + + public LateCleanupFailureReport? LateFailures => _cleanup.LateFailures; + + public bool IsTeardownCancellation(OperationCanceledException exception) => + _teardownCancellation && !_callerToken.IsCancellationRequested + && exception.CancellationToken == Token && Token.IsCancellationRequested; + + public bool ObservationTimedOut(OperationCanceledException exception) => + !_callerToken.IsCancellationRequested && Token.IsCancellationRequested + && !IsTeardownCancellation(exception); + + public Task Start(Func> read) + { + ArgumentNullException.ThrowIfNull(read); + var pending = Task.Run(() => read(Token), CancellationToken.None); + _reader = pending; + return pending.WaitAsync(_observationWindow, _callerToken); + } + + public Task Start(Func read) + { + ArgumentNullException.ThrowIfNull(read); + var pending = Task.Run(() => read(Token), CancellationToken.None); + _reader = pending; + return pending.WaitAsync(_observationWindow, _callerToken); + } + + public void Own(Task reader) + { + ArgumentNullException.ThrowIfNull(reader); + _reader = reader; + } + + public async Task CompleteAsync(Exception? primaryFailure) + { + _teardownCancellation = !_window.IsCancellationRequested; + _cancellation = Task.Run(_window.CancelAsync, CancellationToken.None); + _cleanup.Own("event cancellation", _cancellation); + _cleanup.Own("event reader", _reader, IsTeardownCancellation); + try + { + await _cleanup.CompleteAsync(primaryFailure); + } + finally + { + // Retain the window while either callbacks or reader disposal still use it. + _ = Task.WhenAll(_cancellation, _reader).ContinueWith( + completed => + { + _ = completed.Exception; + _window.Dispose(); + }, + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + } + } + +} diff --git a/tests/OpenCode.Sdk.Tests/Support/OwnedEventReaderTests.cs b/tests/OpenCode.Sdk.Tests/Support/OwnedEventReaderTests.cs new file mode 100644 index 00000000..73d1d216 --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Support/OwnedEventReaderTests.cs @@ -0,0 +1,174 @@ +namespace OpenCode.Sdk.Tests.Support; + +public sealed class OwnedEventReaderTests +{ + [Test] + public async Task CompleteAsync_Should_Preserve_Primary_Reader_And_Later_Session_Failures() + { + var owner = new OwnedEventReader(TimeSpan.FromMinutes(1), TimeSpan.FromSeconds(1), CancellationToken.None); + var primary = new InvalidOperationException("prompt failed"); + var readerFailure = new InvalidOperationException("reader failed"); + var removalFailure = new InvalidOperationException("removal failed"); + _ = owner.Start(_ => Task.FromException(readerFailure)); + + var thrown = await Assert.That(() => owner.CompleteAsync(primary)).Throws(); + var outer = new OwnedSessionCleanup(_ => Task.CompletedTask, + _ => Task.FromException(removalFailure), TimeSpan.FromSeconds(1)); + _ = await Assert.That(() => outer.CompleteAsync(thrown)).Throws(); + + await Assert.That(thrown).IsSameReferenceAs(primary); + var failures = (AggregateException)primary.Data[OwnedCleanup.FailuresKey]!; + await Assert.That(failures.InnerExceptions).IsEquivalentTo(new Exception[] { readerFailure, removalFailure }); + } + + [Test] + public async Task CompleteAsync_Should_Observe_Reader_After_Cancellation_Callback_Fails() + { + var owner = new OwnedEventReader(TimeSpan.FromMinutes(1), TimeSpan.FromSeconds(1), CancellationToken.None); + var callbackFailure = new InvalidOperationException("cancellation callback failed"); + var readerFailure = new InvalidOperationException("reader also failed"); + var primary = new InvalidOperationException("prompt failed"); + using var registration = owner.Token.Register(() => throw callbackFailure); + _ = owner.Start(_ => Task.FromException(readerFailure)); + + var thrown = await Assert.That(() => owner.CompleteAsync(primary)).Throws(); + + await Assert.That(thrown).IsSameReferenceAs(primary); + var failures = ((AggregateException)primary.Data[OwnedCleanup.FailuresKey]!).Flatten().InnerExceptions; + await Assert.That(failures).Contains(callbackFailure); + await Assert.That(failures).Contains(readerFailure); + } + + [Test] + public async Task CompleteAsync_Should_Bound_Stalls_Reach_Removal_And_Attach_Late_Failures() + { + using var release = new ManualResetEventSlim(); + var pending = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var owner = new OwnedEventReader(TimeSpan.FromMinutes(1), TimeSpan.FromMilliseconds(50), CancellationToken.None); + var callbackFailure = new InvalidOperationException("late callback failed"); + var readerFailure = new InvalidOperationException("late reader failed"); + var primary = new InvalidOperationException("prompt failed"); + using var registration = owner.Token.Register(() => + { + _ = release.Wait(TimeSpan.FromSeconds(10)); + throw callbackFailure; + }); + owner.Own(pending.Task); + var removed = false; + var outer = new OwnedSessionCleanup(_ => Task.CompletedTask, token => + { + removed = !token.IsCancellationRequested; + return Task.CompletedTask; + }, TimeSpan.FromSeconds(1)); + try + { + var thrown = await Assert.That(() => owner.CompleteAsync(primary)).Throws(); + _ = await Assert.That(() => outer.CompleteAsync(thrown)).Throws(); + await Assert.That(removed).IsTrue(); + await Assert.That(thrown).IsSameReferenceAs(primary); + var failures = (AggregateException)primary.Data[OwnedCleanup.FailuresKey]!; + await Assert.That(failures.InnerExceptions.OfType()).Count().IsEqualTo(2); + var attached = (IReadOnlyCollection>)primary.Data[OwnedCleanup.LateFailuresKey]!; + pending.SetException(readerFailure); + using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + var report = owner.LateFailures; + await Assert.That(report).IsNotNull(); + var observed = await report.WaitForFailureAsync(deadline.Token); + await Assert.That(observed.Value).IsSameReferenceAs(readerFailure); + release.Set(); + await report.WaitForAllAsync(deadline.Token); + await Assert.That(attached.Select(entry => entry.Value)).IsEquivalentTo(new Exception[] { readerFailure, callbackFailure }); + await Assert.That(attached.Select(entry => entry.Key)).IsEquivalentTo(["event reader", "event cancellation"]); + } + finally + { + release.Set(); + _ = pending.TrySetResult(true); + } + } + + [Test] + public async Task CompleteAsync_Should_Accept_Only_Its_Own_Cooperative_Reader_Cancellation() + { + var owner = new OwnedEventReader(TimeSpan.FromMinutes(1), TimeSpan.FromSeconds(1), CancellationToken.None); + var pending = owner.Start(async token => + { + var cancellation = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var registration = token.Register(() => _ = cancellation.TrySetCanceled(token)); + _ = await cancellation.Task; + }); + + await owner.CompleteAsync(null); + + OperationCanceledException? cancelled = null; + try + { + await pending; + } + catch (OperationCanceledException exception) + { + cancelled = exception; + } + + await Assert.That(cancelled).IsNotNull(); + await Assert.That(owner.LateFailures).IsNull(); + } + + [Test] + public async Task CompleteAsync_Should_Preserve_Caller_Cancellation_Identity() + { + using var caller = new CancellationTokenSource(); + var owner = new OwnedEventReader(TimeSpan.FromMinutes(1), TimeSpan.FromSeconds(1), caller.Token); + await caller.CancelAsync(); + var primary = new OperationCanceledException(caller.Token); + _ = owner.Start(_ => Task.FromException(primary)); + + var thrown = await Assert.That(() => owner.CompleteAsync(primary)) + .Throws(); + + await Assert.That(thrown).IsSameReferenceAs(primary); + await Assert.That(thrown!.CancellationToken).IsEqualTo(caller.Token); + await Assert.That(primary.Data.Contains(OwnedCleanup.FailuresKey)).IsFalse(); + } + + [Test] + public async Task CompleteAsync_Should_Report_Unexpected_Reader_Cancellation() + { + var owner = new OwnedEventReader(TimeSpan.FromMinutes(1), TimeSpan.FromSeconds(1), CancellationToken.None); + var unexpected = new OperationCanceledException("unrelated cancellation"); + _ = owner.Start(_ => Task.FromException(unexpected)); + var primary = new InvalidOperationException("prompt failed"); + + var thrown = await Assert.That(() => owner.CompleteAsync(primary)) + .Throws(); + + await Assert.That(thrown).IsSameReferenceAs(primary); + var failures = (AggregateException)primary.Data[OwnedCleanup.FailuresKey]!; + await Assert.That(failures.InnerExceptions.Single()).IsSameReferenceAs(unexpected); + } + + [Test] + public async Task CompleteAsync_Should_Observe_Late_Cooperative_Cancellation_After_The_Reader_Deadline() + { + var pending = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var owner = new OwnedEventReader(TimeSpan.FromMinutes(1), TimeSpan.FromMilliseconds(50), CancellationToken.None); + owner.Own(pending.Task); + var token = owner.Token; + try + { + _ = await Assert.That(() => owner.CompleteAsync(null)).Throws(); + var report = owner.LateFailures; + await Assert.That(report).IsNotNull(); + + pending.SetCanceled(token); + using var observation = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + await report.WaitForAllAsync(observation.Token); + + await Assert.That(report.Failures).IsEmpty(); + } + finally + { + _ = pending.TrySetResult(true); + } + } +} diff --git a/tests/OpenCode.Sdk.Tests/Support/OwnedSessionCleanup.cs b/tests/OpenCode.Sdk.Tests/Support/OwnedSessionCleanup.cs index 26b46fcf..979ac5a0 100644 --- a/tests/OpenCode.Sdk.Tests/Support/OwnedSessionCleanup.cs +++ b/tests/OpenCode.Sdk.Tests/Support/OwnedSessionCleanup.cs @@ -1,21 +1,17 @@ -using System.Runtime.ExceptionServices; - namespace OpenCode.Sdk.Tests.Support; internal sealed class OwnedSessionCleanup { - internal const string FailuresKey = "OwnedSessionCleanup.Failures"; - internal const string LateFailuresKey = "OwnedSessionCleanup.LateFailures"; + internal const string FailuresKey = OwnedCleanup.FailuresKey; + internal const string LateFailuresKey = OwnedCleanup.LateFailuresKey; private readonly Func _interrupt; private readonly Func _remove; - private readonly LateCleanupFailureReport _lateFailures = new(); - private readonly List>> _resources = []; - private readonly TimeSpan _timeout; + private readonly OwnedCleanup _cleanup; private bool _turnCompleted; internal LateCleanupFailureReport? LateFailures => - _lateFailures.HasRegistrations ? _lateFailures : null; + _cleanup.LateFailures; public OwnedSessionCleanup(SessionClient session, TimeSpan timeout) : this( @@ -41,101 +37,24 @@ internal OwnedSessionCleanup( ArgumentNullException.ThrowIfNull(remove); _interrupt = interrupt; _remove = remove; - _timeout = timeout; + _cleanup = new OwnedCleanup(timeout); } public void MarkTurnCompleted() => _turnCompleted = true; public void MarkTurnStarted() => _turnCompleted = false; - public void Own(string name, Func resourceCleanup) - { - ArgumentException.ThrowIfNullOrWhiteSpace(name); - ArgumentNullException.ThrowIfNull(resourceCleanup); - _resources.Add(new KeyValuePair>(name, resourceCleanup)); - } + public void Own(string name, Func resourceCleanup) => + _cleanup.Own(name, resourceCleanup); public async Task CompleteAsync(Exception? primaryFailure) { - var failures = new List(); - foreach (var cleanup in _resources) - { - await CaptureFailureAsync(cleanup.Key, cleanup.Value, failures); - } - if (!_turnCompleted) { - await CaptureFailureAsync("session interrupt", _interrupt, failures); + _cleanup.Own("session interrupt", _interrupt); } - await CaptureFailureAsync("session removal", _remove, failures); - ThrowFailures(primaryFailure, failures); - } - - private async Task CaptureFailureAsync( - string name, - Func operation, - List failures) - { - using var budget = new CancellationTokenSource(_timeout); - Task? pending = null; - try - { - pending = operation(budget.Token); - await pending.WaitAsync(budget.Token); - } - catch (Exception exception) - { - failures.Add(exception); - if (pending is { IsCompleted: false }) - { - _lateFailures.Observe(name, pending); - } - else if (pending?.Exception is { } aggregate) - { - foreach (var failure in aggregate.Flatten().InnerExceptions) - { - if (!ReferenceEquals(failure, exception)) - { - failures.Add(failure); - } - } - } - } - } - - private void ThrowFailures(Exception? primaryFailure, List failures) - { - if (primaryFailure is not null) - { - if (failures.Count > 0) - { - primaryFailure.Data[FailuresKey] = new AggregateException(failures); - } - - AttachLateFailureReport(primaryFailure); - ExceptionDispatchInfo.Capture(primaryFailure).Throw(); - } - - if (failures.Count is 1) - { - AttachLateFailureReport(failures[0]); - ExceptionDispatchInfo.Capture(failures[0]).Throw(); - } - - if (failures.Count > 1) - { - var aggregate = new AggregateException("Multiple failures occurred while cleaning the session.", failures); - AttachLateFailureReport(aggregate); - throw aggregate; - } - } - - private void AttachLateFailureReport(Exception exception) - { - if (_lateFailures.HasRegistrations) - { - exception.Data[LateFailuresKey] = _lateFailures.DiagnosticFailures; - } + _cleanup.Own("session removal", _remove); + await _cleanup.CompleteAsync(primaryFailure); } } diff --git a/tests/OpenCode.Sdk.Tests/Support/PendingPermissionCleanup.cs b/tests/OpenCode.Sdk.Tests/Support/PendingPermissionCleanup.cs new file mode 100644 index 00000000..c8258983 --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Support/PendingPermissionCleanup.cs @@ -0,0 +1,32 @@ +using System.Globalization; +using OpenCode.Sdk.Models; + +namespace OpenCode.Sdk.Tests.Support; + +internal sealed class PendingPermissionCleanup(SessionClient session) +{ + public async Task RejectAsync(string? permissionId, CancellationToken cancellationToken) + { + if (permissionId is null) + { + return; + } + + var response = await session.PostPermissionReplyAsync( + permissionId, + new SessionPermissionReplyPostRequest { Reply = PermissionReply.Reject }, + OpenCodeRequestOptions.NoThrow, + cancellationToken); + if (response is { Status: 204, IsError: false } + || (response is { Status: 404, IsError: true, Error: PermissionNotFoundError missing } + && missing.RequestId == permissionId)) + { + return; + } + + throw new OpenCodeApiException( + "Pending permission rejection failed: status=" + response.Status.ToString(CultureInfo.InvariantCulture) + + "; error=" + (response.Error?.GetType().Name ?? "") + ".", + response.Status, response.Error, response.RawBody); + } +} diff --git a/tests/OpenCode.Sdk.Tests/Support/PendingPermissionCleanupTests.cs b/tests/OpenCode.Sdk.Tests/Support/PendingPermissionCleanupTests.cs new file mode 100644 index 00000000..6709b15e --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Support/PendingPermissionCleanupTests.cs @@ -0,0 +1,114 @@ +using OpenCode.Sdk.Models; + +namespace OpenCode.Sdk.Tests.Support; + +public sealed class PendingPermissionCleanupTests +{ + [Test] + public async Task RejectAsync_Should_Fail_Cleanup_When_The_Test_Body_Succeeded() + { + var pending = new PendingPermissionCleanup( + new ReplySessionClient(new SessionPermissionReplyPostResponse(500, null, "rejection failed"))); + var removed = false; + var cleanup = new OwnedSessionCleanup(_ => Task.CompletedTask, _ => + { + removed = true; + return Task.CompletedTask; + }, TimeSpan.FromSeconds(1)); + cleanup.Own("pending permission", token => pending.RejectAsync("per_owned", token)); + + var thrown = await Assert.That(() => cleanup.CompleteAsync(null)).Throws(); + + await Assert.That(thrown!.Status).IsEqualTo(500); + await Assert.That(removed).IsTrue(); + } + + [Test] + [Arguments(400)] + [Arguments(401)] + [Arguments(500)] + public async Task RejectAsync_Should_Report_Unexpected_Response_And_Continue_All_Cleanup(int status) + { + var response = new SessionPermissionReplyPostResponse(status, null, "rejection failed"); + var session = new ReplySessionClient(response); + var pending = new PendingPermissionCleanup(session); + var primary = new InvalidOperationException("primary failed"); + var removalFailure = new InvalidOperationException("session removal failed"); + var steps = new List(); + var cleanup = new OwnedSessionCleanup(_ => Task.CompletedTask, _ => + { + steps.Add("session"); + return Task.FromException(removalFailure); + }, TimeSpan.FromSeconds(1)); + cleanup.Own("pending permission", token => pending.RejectAsync("per_owned", token)); + cleanup.Own("saved discovery", _ => + { + steps.Add("discovery"); + return Task.CompletedTask; + }); + cleanup.Own("saved removal", _ => + { + steps.Add("saved"); + return Task.CompletedTask; + }); + + var thrown = await Assert.That(() => cleanup.CompleteAsync(primary)).Throws(); + + await Assert.That(thrown).IsSameReferenceAs(primary); + await Assert.That(steps).IsEquivalentTo(["discovery", "saved", "session"]); + var failures = (AggregateException)primary.Data[OwnedCleanup.FailuresKey]!; + await Assert.That(failures.InnerExceptions).Count().IsEqualTo(2); + var rejection = failures.InnerExceptions[0] as OpenCodeApiException; + await Assert.That(rejection).IsNotNull(); + await Assert.That(rejection!.Status).IsEqualTo(status); + await Assert.That(rejection.RawBody).IsEqualTo(response.RawBody); + await Assert.That(failures.InnerExceptions[1]).IsSameReferenceAs(removalFailure); + await Assert.That(session.Reply).IsEqualTo(PermissionReply.Reject); + await Assert.That(session.Options).IsSameReferenceAs(OpenCodeRequestOptions.NoThrow); + } + + [Test] + [Arguments(false)] + [Arguments(true)] + public async Task RejectAsync_Should_Accept_Success_Or_The_Owned_Already_Absent_Request(bool absent) + { + var response = absent + ? new SessionPermissionReplyPostResponse(404, + new PermissionNotFoundError { RequestId = "per_owned", Message = "not found" }, null) + : new SessionPermissionReplyPostResponse { Status = 204 }; + var pending = new PendingPermissionCleanup(new ReplySessionClient(response)); + + await pending.RejectAsync("per_owned", CancellationToken.None); + } + + [Test] + public async Task RejectAsync_Should_Reject_An_Unrelated_Not_Found_Envelope() + { + var error = new PermissionNotFoundError { RequestId = "per_other", Message = "not found" }; + var pending = new PendingPermissionCleanup( + new ReplySessionClient(new SessionPermissionReplyPostResponse(404, error, null))); + + var thrown = await Assert.That(() => pending.RejectAsync("per_owned", CancellationToken.None)) + .Throws(); + + await Assert.That(thrown!.Status).IsEqualTo(404); + await Assert.That(thrown.Error).IsSameReferenceAs(error); + await Assert.That(thrown.Message).Contains("PermissionNotFoundError"); + } + + private sealed class ReplySessionClient(SessionPermissionReplyPostResponse response) : SessionClient + { + public PermissionReply? Reply { get; private set; } + + public OpenCodeRequestOptions? Options { get; private set; } + + public override Task PostPermissionReplyAsync( + string requestId, SessionPermissionReplyPostRequest request, + OpenCodeRequestOptions? requestOptions = null, CancellationToken cancellationToken = default) + { + Reply = request.Reply; + Options = requestOptions; + return Task.FromResult(response); + } + } +} diff --git a/tests/OpenCode.Sdk.Tests/Support/ScriptedTurnEvents.cs b/tests/OpenCode.Sdk.Tests/Support/ScriptedTurnEvents.cs new file mode 100644 index 00000000..b6e7dc01 --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Support/ScriptedTurnEvents.cs @@ -0,0 +1,74 @@ +using OpenCode.Sdk.Models; + +namespace OpenCode.Sdk.Tests.Support; + +internal sealed class ScriptedTurnEvents(string reply, string sessionId, OwnedEventReader reader) +{ + private readonly EventDiagnosticSummary _observed = new(); + + public string DiagnosticSummary => _observed.ToString(); + + public async Task CompleteAsync( + IAsyncEnumerable events, TaskCompletionSource connected) + { + try + { + return await reader.Start(_ => ObserveAsync(events, connected)); + } + catch (Exception exception) + { + exception.Data[EventDiagnosticSummary.DataKey] = DiagnosticSummary; + throw; + } + } + + public async Task ObserveAsync( + IAsyncEnumerable events, TaskCompletionSource connected) + { + ArgumentNullException.ThrowIfNull(events); + ArgumentNullException.ThrowIfNull(connected); + var sawOwnedText = false; + try + { + await foreach (var @event in events.WithCancellation(reader.Token)) + { + _observed.Add(@event.Type); + if (@event is EventServerConnected) + { + _ = connected.TrySetResult(true); + } + + if (@event is SessionTextEnded textEnded + && textEnded.Data.SessionId == sessionId + && textEnded.Data.Text == reply) + { + sawOwnedText = true; + continue; + } + + if (sawOwnedText + && @event is SessionExecutionSucceeded succeeded + && succeeded.Data.SessionId == sessionId) + { + return succeeded; + } + } + } + catch (OperationCanceledException exception) + when (reader.ObservationTimedOut(exception)) + { + throw new TimeoutException( + $"The scripted turn did not complete within the event window. Events: {_observed}.", + exception); + } + catch (Exception exception) + { + exception.Data[EventDiagnosticSummary.DataKey] = DiagnosticSummary; + throw; + } + + throw new InvalidOperationException( + $"The event subscription ended before the scripted turn completed. Events: {_observed}."); + } + +} diff --git a/tests/OpenCode.Sdk.Tests/Support/ScriptedTurnEventsTests.cs b/tests/OpenCode.Sdk.Tests/Support/ScriptedTurnEventsTests.cs new file mode 100644 index 00000000..e354096d --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Support/ScriptedTurnEventsTests.cs @@ -0,0 +1,91 @@ +namespace OpenCode.Sdk.Tests.Support; + +public sealed class ScriptedTurnEventsTests +{ + [Test] + public async Task CompleteAsync_Should_Bound_A_Noncooperative_Reader_And_Reach_Later_Cleanup() + { + var scenario = new EventDiagnosticScenario { Stall = true }; + var reader = new OwnedEventReader(TimeSpan.FromMilliseconds(500), TimeSpan.FromMilliseconds(50), CancellationToken.None); + var events = new ScriptedTurnEvents("reply", "ses_owned", reader); + var connected = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var removed = false; + var sessionCleanup = new OwnedSessionCleanup(_ => Task.CompletedTask, token => + { + removed = !token.IsCancellationRequested; + return Task.CompletedTask; + }, TimeSpan.FromSeconds(1)); + try + { + var primary = await Assert.That(async () => + { + _ = await events.CompleteAsync(scenario.EventsAsync(), connected); + }).Throws(); + var observed = (string)primary!.Data[EventDiagnosticSummary.DataKey]!; + await Assert.That(observed.Length).IsLessThan(2000); + await Assert.That(observed).Contains("84 events omitted"); + await Assert.That(observed).Contains("event-0000-"); + await Assert.That(observed).Contains("event-0099-"); + await Assert.That(scenario.Pending).IsNotNull(); + await Assert.That(scenario.Pending.Task.IsCompleted).IsFalse(); + + var thrown = await Assert.That(() => reader.CompleteAsync(primary)).Throws(); + var propagated = await Assert.That(() => sessionCleanup.CompleteAsync(thrown)).Throws(); + await Assert.That(propagated).IsSameReferenceAs(primary); + await Assert.That(removed).IsTrue(); + await Assert.That(primary.Data[EventDiagnosticSummary.DataKey]).IsEqualTo(observed); + var lateFailure = new InvalidOperationException("late stalled reader failure"); + scenario.Pending.SetException(lateFailure); + var report = reader.LateFailures; + await Assert.That(report).IsNotNull(); + using var observation = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + await report.WaitForAllAsync(observation.Token); + var attached = (IReadOnlyCollection>)primary.Data[OwnedCleanup.LateFailuresKey]!; + await Assert.That(attached.Single().Value).IsSameReferenceAs(lateFailure); + } + finally + { + _ = scenario.Pending?.TrySetResult(true); + } + } + + [Test] + public async Task ObserveAsync_Should_Bound_First_And_Tail_Diagnostics_When_The_Stream_Ends() + { + var scenario = new EventDiagnosticScenario(); + var reader = new OwnedEventReader(TimeSpan.FromMinutes(1), TimeSpan.FromSeconds(1), CancellationToken.None); + var events = new ScriptedTurnEvents("reply", "ses_owned", reader); + var connected = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var thrown = await Assert.That(async () => + { + _ = await reader.Start(_ => events.ObserveAsync(scenario.EventsAsync(), connected)); + }).Throws(); + _ = await Assert.That(() => reader.CompleteAsync(thrown)).Throws(); + + await Assert.That(thrown!.Message.Length).IsLessThan(2000); + await Assert.That(thrown.Message).Contains("event-0000-"); + await Assert.That(thrown.Message).Contains("event-0099-"); + await Assert.That(thrown.Message).Contains("84 events omitted"); + await Assert.That(thrown.Message).DoesNotContain("event-0050-"); + } + + [Test] + public async Task ObserveAsync_Should_Retain_Observations_On_The_Original_Reader_Failure() + { + var failure = new InvalidOperationException("reader transport failed"); + var scenario = new EventDiagnosticScenario { Failure = failure }; + var reader = new OwnedEventReader(TimeSpan.FromMinutes(1), TimeSpan.FromSeconds(1), CancellationToken.None); + var events = new ScriptedTurnEvents("reply", "ses_owned", reader); + var connected = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var thrown = await Assert.That(async () => + { + _ = await reader.Start(_ => events.ObserveAsync(scenario.EventsAsync(), connected)); + }).Throws(); + _ = await Assert.That(() => reader.CompleteAsync(thrown)).Throws(); + + await Assert.That(thrown).IsSameReferenceAs(failure); + var diagnostic = (string)failure.Data[EventDiagnosticSummary.DataKey]!; + await Assert.That(diagnostic.Length).IsLessThan(2000); + await Assert.That(diagnostic).Contains("84 events omitted"); + } +} diff --git a/tests/OpenCode.Sdk.Tests/Support/SessionLogTranscript.cs b/tests/OpenCode.Sdk.Tests/Support/SessionLogTranscript.cs index 3bcee8a1..3d720c7d 100644 --- a/tests/OpenCode.Sdk.Tests/Support/SessionLogTranscript.cs +++ b/tests/OpenCode.Sdk.Tests/Support/SessionLogTranscript.cs @@ -8,6 +8,7 @@ internal sealed class SessionLogTranscript private readonly List _liveItems = []; private readonly List _replayItems = []; private readonly SessionClient? _session; + private readonly EventDiagnosticSummary _observed = new(); private CancellationToken _callerToken; private IAsyncEnumerator? _enumerator; private CancellationTokenSource? _window; @@ -43,9 +44,19 @@ public async Task> ReadToEndAsync( CancellationToken cancellationToken) { var items = new List(); - await foreach (var item in Session.GetLogAsync(request, cancellationToken)) + var observed = new EventDiagnosticSummary(); + try + { + await foreach (var item in Session.GetLogAsync(request, cancellationToken)) + { + items.Add(item); + observed.Add(item.Type); + } + } + catch (Exception exception) { - items.Add(item); + exception.Data[EventDiagnosticSummary.DataKey] = observed.ToString(); + throw; } return items; @@ -143,20 +154,28 @@ private async Task MoveNextAsync(string target) ?? throw new InvalidOperationException("The session log transcript is not attached."); try { - return await enumerator.MoveNextAsync(); + var moved = await enumerator.MoveNextAsync(); + if (moved) + { + _observed.Add(enumerator.Current.Type); + } + + return moved; } catch (OperationCanceledException exception) when (!_callerToken.IsCancellationRequested && _window!.IsCancellationRequested) { throw new TimeoutException( - $"The session log timed out before {target}. Items: {QuoteObservedItems()}.", + $"The session log timed out before {target}. Items: {_observed}.", exception); } + catch (Exception exception) + { + exception.Data[EventDiagnosticSummary.DataKey] = _observed.ToString(); + throw; + } } private InvalidOperationException EndedBefore(string target) => - new($"The session log ended before {target}. Items: {QuoteObservedItems()}."); - - private string QuoteObservedItems() => - string.Join(", ", _replayItems.Concat(_liveItems).Select(item => $"'{item.Type}'")); + new($"The session log ended before {target}. Items: {_observed}."); } diff --git a/tests/OpenCode.Sdk.Tests/Support/SessionLogTranscriptTests.cs b/tests/OpenCode.Sdk.Tests/Support/SessionLogTranscriptTests.cs index 36e7d7fa..2c06b043 100644 --- a/tests/OpenCode.Sdk.Tests/Support/SessionLogTranscriptTests.cs +++ b/tests/OpenCode.Sdk.Tests/Support/SessionLogTranscriptTests.cs @@ -4,6 +4,64 @@ namespace OpenCode.Sdk.Tests.Support; public sealed class SessionLogTranscriptTests { + [Test] + public async Task ReadToEndAsync_Should_Preserve_The_Failure_With_Bounded_Observations() + { + var failure = new InvalidOperationException("log enumeration failed"); + var transcript = new SessionLogTranscript(new EventDiagnosticScenario { Failure = failure }); + + var thrown = await Assert.That(async () => + { + _ = await transcript.ReadToEndAsync( + new SessionLogRequest { Follow = QueryBoolean.False }, CancellationToken.None); + }) + .Throws(); + + await Assert.That(thrown).IsSameReferenceAs(failure); + var diagnostic = (string)failure.Data[EventDiagnosticSummary.DataKey]!; + await Assert.That(diagnostic.Length).IsLessThan(2000); + await Assert.That(diagnostic).Contains("event-0000-"); + await Assert.That(diagnostic).Contains("event-0099-"); + await Assert.That(diagnostic).Contains("84 events omitted"); + await Assert.That(diagnostic).DoesNotContain("event-0050-"); + } + + [Test] + public async Task ReadToEndAsync_Should_Preserve_The_Complete_Successful_Transcript() + { + var transcript = new SessionLogTranscript(new EventDiagnosticScenario()); + + var items = await transcript.ReadToEndAsync( + new SessionLogRequest { Follow = QueryBoolean.False }, CancellationToken.None); + + await Assert.That(items).Count().IsEqualTo(100); + await Assert.That(items.All(item => item.Type.Length > 4000)).IsTrue(); + await Assert.That(items[50].Type).StartsWith("event-0050-"); + } + + [Test] + public async Task ReadTurnAsync_Should_Bound_Follow_Diagnostics_Without_Truncating_Observed_Items() + { + var transcript = new SessionLogTranscript(new EventDiagnosticScenario { IncludeMarker = true }); + try + { + await transcript.AttachAsync("1", CancellationToken.None); + var thrown = await Assert.That(() => transcript.ReadTurnAsync("ses_owned", "reply")) + .Throws(); + + await Assert.That(thrown!.Message.Length).IsLessThan(2000); + await Assert.That(thrown.Message).Contains("log.synced"); + await Assert.That(thrown.Message).Contains("event-0099-"); + await Assert.That(thrown.Message).Contains("85 events omitted"); + await Assert.That(transcript.LiveItems).Count().IsEqualTo(100); + await Assert.That(transcript.LiveItems[50].Type.Length).IsGreaterThan(4000); + } + finally + { + await transcript.DisposeAsync(CancellationToken.None); + } + } + [Test] public async Task Cleanup_Should_Bound_Stalled_Disposal_And_Retain_Its_Late_Failure() { @@ -35,7 +93,7 @@ public async Task Cleanup_Should_Bound_Stalled_Disposal_And_Retain_Its_Late_Fail var failures = primaryFailure.Data[OwnedSessionCleanup.FailuresKey] as AggregateException; await Assert.That(failures).IsNotNull(); await Assert.That(failures!.InnerExceptions).Count().IsEqualTo(2); - await Assert.That(failures.InnerExceptions[0]).IsTypeOf(); + await Assert.That(failures.InnerExceptions[0]).IsTypeOf(); await Assert.That(failures.InnerExceptions[1]).IsSameReferenceAs(removalFailure); var diagnosticFailures = primaryFailure.Data[OwnedSessionCleanup.LateFailuresKey] as IReadOnlyCollection>; diff --git a/tests/OpenCode.Sdk.Tests/Support/SimulatedSessionTurn.cs b/tests/OpenCode.Sdk.Tests/Support/SimulatedSessionTurn.cs index 11277ea6..4ea03d56 100644 --- a/tests/OpenCode.Sdk.Tests/Support/SimulatedSessionTurn.cs +++ b/tests/OpenCode.Sdk.Tests/Support/SimulatedSessionTurn.cs @@ -19,15 +19,11 @@ public async Task CompleteAsync( string reply, CancellationToken cancellationToken) { - using var eventWindow = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - using var cleanupSignal = new CancellationTokenSource(); - eventWindow.CancelAfter(EventWait); + var reader = new OwnedEventReader(EventWait, TimeSpan.FromSeconds(15), cancellationToken); var connected = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var observed = new List(); - var eventTask = Task.Run( - () => ObserveCompletionAsync( - reply, connected, observed, eventWindow.Token, cancellationToken, cleanupSignal.Token), - cancellationToken); + var events = new ScriptedTurnEvents(reply, sessionId, reader); + var eventTask = events.CompleteAsync(client.Events.SubscribeAsync(reader.Token), connected); + Exception? primaryFailure = null; try { @@ -56,70 +52,16 @@ await DriveAsync( return await eventTask; } - finally - { - await cleanupSignal.CancelAsync(); - await eventWindow.CancelAsync(); - try - { - _ = await eventTask; - } - catch (OperationCanceledException exception) when (!cancellationToken.IsCancellationRequested) - { - // The operation failed before the event window could finish. The primary failure - // is already propagating; record the reader's expected cleanup cancellation. - Console.WriteLine( - "simulated-session-turn: event reader cancelled during failure cleanup: " + - exception.GetType().Name); - } - } - } - - private async Task ObserveCompletionAsync( - string reply, - TaskCompletionSource connected, - List observed, - CancellationToken eventToken, - CancellationToken callerToken, - CancellationToken cleanupToken) - { - var sawOwnedText = false; - try + catch (Exception exception) { - await foreach (var @event in client.Events.SubscribeAsync(eventToken)) - { - observed.Add(@event.Type); - if (@event is EventServerConnected) - { - _ = connected.TrySetResult(true); - } - - if (@event is SessionTextEnded textEnded - && textEnded.Data.SessionId == sessionId - && textEnded.Data.Text == reply) - { - sawOwnedText = true; - continue; - } - - if (sawOwnedText - && @event is SessionExecutionSucceeded succeeded - && succeeded.Data.SessionId == sessionId) - { - return succeeded; - } - } + primaryFailure = exception; + exception.Data[EventDiagnosticSummary.DataKey] = events.DiagnosticSummary; + throw; } - catch (OperationCanceledException exception) - when (!callerToken.IsCancellationRequested && !cleanupToken.IsCancellationRequested) + finally { - throw new TimeoutException( - $"The scripted turn did not complete within the event window. Events: {Quote(observed)}.", - exception); + await reader.CompleteAsync(primaryFailure); } - - throw new InvalidOperationException( - $"The event subscription ended before the scripted turn completed. Events: {Quote(observed)}."); } private static void RequireSimulatedInvocation(DriveInvocation invocation) @@ -156,6 +98,4 @@ private static async Task DriveAsync(Func operation, string description) } } - private static string Quote(IEnumerable values) => - string.Join(", ", values.Select(value => $"'{value}'")); } From ee7c877d3b26d9ebb4c28d869a5c6dac18f15512 Mon Sep 17 00:00:00 2001 From: "denizirgin@gmail.com" Date: Sun, 6 Sep 2026 15:02:30 +0300 Subject: [PATCH 18/62] test: make owned cleanup deadline proofs deterministic --- .editorconfig | 47 ++++ .../Properties/AssemblyInfo.cs | 4 + .../SessionMessagePaginationLiveTests.cs | 51 +++-- .../Abstractions/IOwnedOperationDeadline.cs | 6 + .../Support/ControlledOperationDeadline.cs | 42 ++++ .../Support/EventDiagnosticScenario.cs | 3 + .../Support/LateCleanupFailureReport.cs | 40 ++-- .../Support/OperationDeadlineScenario.cs | 54 +++++ .../Support/OwnedCleanup.cs | 42 ++-- .../Support/OwnedCleanupDeadlineTests.cs | 202 ++++++++++++++++++ .../Support/OwnedCleanupOperation.cs | 3 +- .../Support/OwnedCleanupTests.cs | 99 ++++++--- .../Support/OwnedEventReader.cs | 25 ++- .../Support/OwnedEventReaderTests.cs | 73 +++++-- .../Support/OwnedOperationDeadline.cs | 16 ++ .../Support/OwnedOperationDeadlineTests.cs | 55 +++++ .../Support/OwnedSessionCleanup.cs | 6 +- .../Support/PendingPermissionCleanupTests.cs | 4 +- .../Support/ScriptedTurnEventsTests.cs | 37 +++- .../Support/SessionLogTranscriptTests.cs | 91 +++++--- 20 files changed, 754 insertions(+), 146 deletions(-) create mode 100644 tests/OpenCode.Sdk.Tests/Properties/AssemblyInfo.cs create mode 100644 tests/OpenCode.Sdk.Tests/Support/Abstractions/IOwnedOperationDeadline.cs create mode 100644 tests/OpenCode.Sdk.Tests/Support/ControlledOperationDeadline.cs create mode 100644 tests/OpenCode.Sdk.Tests/Support/OperationDeadlineScenario.cs create mode 100644 tests/OpenCode.Sdk.Tests/Support/OwnedCleanupDeadlineTests.cs create mode 100644 tests/OpenCode.Sdk.Tests/Support/OwnedOperationDeadline.cs create mode 100644 tests/OpenCode.Sdk.Tests/Support/OwnedOperationDeadlineTests.cs diff --git a/.editorconfig b/.editorconfig index a96fdda6..8d353d54 100644 --- a/.editorconfig +++ b/.editorconfig @@ -910,6 +910,53 @@ dotnet_diagnostic.VSTHRD003.severity = none [tests/OpenCode.Sdk.Tests/Support/LateCleanupFailureReport.cs] dotnet_diagnostic.VSTHRD003.severity = none +# Cleanup owners retain raw tasks until actual completion and separately bound observation. +# Awaiting those owned tasks directly preserves their original result/cancellation identity; +# callback invocation is isolated on the thread pool, with no foreign synchronization-context +# dependency. Winner: owned cleanup observation; VSTHRD003 remains enabled elsewhere. +[tests/OpenCode.Sdk.Tests/Support/OwnedCleanup.cs] +dotnet_diagnostic.VSTHRD003.severity = none + +[tests/OpenCode.Sdk.Tests/Support/OwnedEventReader.cs] +dotnet_diagnostic.VSTHRD003.severity = none + +# These per-scenario deadline helpers retain task handles and explicitly control entry, expiry +# and result delivery. Their caller releases signals and drains all owned work in finally; +# there is no foreign synchronization-context dependency. Winner: deterministic deadline control. +[tests/OpenCode.Sdk.Tests/Support/ControlledOperationDeadline.cs] +dotnet_diagnostic.VSTHRD003.severity = none + +[tests/OpenCode.Sdk.Tests/Support/OperationDeadlineScenario.cs] +dotnet_diagnostic.VSTHRD003.severity = none + +# These cleanup proofs await their own externally-completed barriers/tasks to establish the +# exact ordering under test. Finally releases and observes stalled work and deadline signals; +# no foreign synchronization context is involved. Winner: causal owned-work proofs. Each entry +# names one file; VSTHRD003 remains enabled outside these explicitly owned proof families. +[tests/OpenCode.Sdk.Tests/Support/OwnedCleanupTests.cs] +dotnet_diagnostic.VSTHRD003.severity = none + +[tests/OpenCode.Sdk.Tests/Support/OwnedCleanupDeadlineTests.cs] +dotnet_diagnostic.VSTHRD003.severity = none + +[tests/OpenCode.Sdk.Tests/Support/OwnedEventReaderTests.cs] +dotnet_diagnostic.VSTHRD003.severity = none + +[tests/OpenCode.Sdk.Tests/Support/OwnedOperationDeadlineTests.cs] +dotnet_diagnostic.VSTHRD003.severity = none + +[tests/OpenCode.Sdk.Tests/Support/ScriptedTurnEventsTests.cs] +dotnet_diagnostic.VSTHRD003.severity = none + +[tests/OpenCode.Sdk.Tests/Support/SessionLogTranscriptTests.cs] +dotnet_diagnostic.VSTHRD003.severity = none + +# The session-removal fault proof controls interrupt entry and expiry, then drains cancellation +# and removal in finally. Its task handles have no foreign synchronization-context dependency. +# Winner: the same causal cleanup proof embedded beside its actual live consumer. +[tests/OpenCode.Sdk.Tests/Sessions/SessionMessagePaginationLiveTests.cs] +dotnet_diagnostic.VSTHRD003.severity = none + # CliWrapServerAdapter parks on TaskCompletionSource handles the adapter itself completes — the # stdin lease that holds the child's ownership pipe open until disposal, and the first stdout # line that carries the readiness contract — plus the CliWrap execution task it started. That diff --git a/tests/OpenCode.Sdk.Tests/Properties/AssemblyInfo.cs b/tests/OpenCode.Sdk.Tests/Properties/AssemblyInfo.cs new file mode 100644 index 00000000..c2d4cc0e --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Properties/AssemblyInfo.cs @@ -0,0 +1,4 @@ +using System.Runtime.CompilerServices; + +// NSubstitute proxies the test infrastructure's internal deadline seam. +[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")] diff --git a/tests/OpenCode.Sdk.Tests/Sessions/SessionMessagePaginationLiveTests.cs b/tests/OpenCode.Sdk.Tests/Sessions/SessionMessagePaginationLiveTests.cs index 4a6672ba..e5102c9b 100644 --- a/tests/OpenCode.Sdk.Tests/Sessions/SessionMessagePaginationLiveTests.cs +++ b/tests/OpenCode.Sdk.Tests/Sessions/SessionMessagePaginationLiveTests.cs @@ -106,24 +106,51 @@ public async Task CleanupAsync_Should_Give_Removal_A_Fresh_Budget_And_Preserve_A var removalFailure = new InvalidOperationException("removal failure"); var removalTokenWasCancelled = true; + var scenario = new OperationDeadlineScenario(); + var deadline = scenario.Hold("session interrupt"); + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var cleanup = new OwnedSessionCleanup( - WaitForCancellationAsync, + token => + { + var interrupted = WaitForCancellationAsync(token); + _ = entered.TrySetResult(true); + return interrupted; + }, token => { removalTokenWasCancelled = token.IsCancellationRequested; return Task.FromException(removalFailure); }, - TimeSpan.FromMilliseconds(50)); - var thrown = await Assert.That(async () => await cleanup.CompleteAsync(primaryFailure)) - .Throws(); - - await Assert.That(thrown).IsSameReferenceAs(primaryFailure); - await Assert.That(removalTokenWasCancelled).IsFalse(); - var cleanupFailures = primaryFailure.Data[OwnedSessionCleanup.FailuresKey] as AggregateException; - await Assert.That(cleanupFailures).IsNotNull(); - await Assert.That(cleanupFailures!.InnerExceptions.Count).IsEqualTo(2); - await Assert.That(cleanupFailures.InnerExceptions[0]).IsTypeOf(); - await Assert.That(cleanupFailures.InnerExceptions[1]).IsSameReferenceAs(removalFailure); + TimeSpan.FromMilliseconds(50), scenario.Deadline); + var completing = cleanup.CompleteAsync(primaryFailure); + try + { + await entered.Task; + await deadline.Entered; + deadline.Expire(); + deadline.Deliver(); + var thrown = await Assert.That(() => completing).Throws(); + + await Assert.That(thrown).IsSameReferenceAs(primaryFailure); + await Assert.That(removalTokenWasCancelled).IsFalse(); + var cleanupFailures = (AggregateException)primaryFailure.Data[OwnedSessionCleanup.FailuresKey]!; + await Assert.That(cleanupFailures.InnerExceptions).Count().IsEqualTo(2); + await Assert.That(cleanupFailures.InnerExceptions[0]).IsTypeOf(); + await Assert.That(cleanupFailures.InnerExceptions[0].InnerException).IsSameReferenceAs(deadline.Failure); + await Assert.That(cleanupFailures.InnerExceptions[1]).IsSameReferenceAs(removalFailure); + var lateReport = cleanup.LateFailures; + await Assert.That(lateReport).IsNotNull(); + await lateReport.WaitForAllAsync(CancellationToken.None); + await Assert.That(lateReport.Failures.Single().Value).IsAssignableTo(); + } + finally + { + await scenario.DrainAsync(completing); + if (cleanup.LateFailures is { } report) + { + await report.WaitForAllAsync(CancellationToken.None); + } + } } private static async Task CreateOwnedSessionAsync( diff --git a/tests/OpenCode.Sdk.Tests/Support/Abstractions/IOwnedOperationDeadline.cs b/tests/OpenCode.Sdk.Tests/Support/Abstractions/IOwnedOperationDeadline.cs new file mode 100644 index 00000000..5ee62c8f --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Support/Abstractions/IOwnedOperationDeadline.cs @@ -0,0 +1,6 @@ +namespace OpenCode.Sdk.Tests.Support.Abstractions; + +internal interface IOwnedOperationDeadline +{ + public Task WaitAsync(string name, Task operation, TimeSpan timeout, CancellationToken cancellationToken = default); +} diff --git a/tests/OpenCode.Sdk.Tests/Support/ControlledOperationDeadline.cs b/tests/OpenCode.Sdk.Tests/Support/ControlledOperationDeadline.cs new file mode 100644 index 00000000..7aa7d64d --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Support/ControlledOperationDeadline.cs @@ -0,0 +1,42 @@ +namespace OpenCode.Sdk.Tests.Support; + +internal sealed class ControlledOperationDeadline +{ + private readonly TaskCompletionSource _entered = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _expired = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _won = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _delivery = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public Task Entered => _entered.Task; + + public Task Won => _won.Task; + + public TimeoutException Failure { get; } = new("The controlled deadline elapsed."); + + public void Expire() => _ = _expired.TrySetException(Failure); + + public void Deliver() => _ = _delivery.TrySetResult(true); + + public void Release() + { + Expire(); + Deliver(); + // Teardown can precede entry into a later stage; observe its unused failure signal too. + _ = _expired.Task.Exception; + } + + public async Task WaitAsync() + { + _ = _entered.TrySetResult(true); + try + { + await _expired.Task; + } + catch (TimeoutException) + { + _ = _won.TrySetResult(true); + await _delivery.Task; + throw; + } + } +} diff --git a/tests/OpenCode.Sdk.Tests/Support/EventDiagnosticScenario.cs b/tests/OpenCode.Sdk.Tests/Support/EventDiagnosticScenario.cs index 598b4d96..a3ffa6ab 100644 --- a/tests/OpenCode.Sdk.Tests/Support/EventDiagnosticScenario.cs +++ b/tests/OpenCode.Sdk.Tests/Support/EventDiagnosticScenario.cs @@ -14,6 +14,8 @@ internal sealed class EventDiagnosticScenario : SessionClient public TaskCompletionSource? Pending { get; private set; } + public TaskCompletionSource Stalled { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public override async IAsyncEnumerable GetLogAsync( SessionLogRequest? request = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) { @@ -46,6 +48,7 @@ public async IAsyncEnumerable EventsAsync() if (Stall) { + _ = Stalled.TrySetResult(true); _ = await completion.Task; } diff --git a/tests/OpenCode.Sdk.Tests/Support/LateCleanupFailureReport.cs b/tests/OpenCode.Sdk.Tests/Support/LateCleanupFailureReport.cs index ff724a36..3ee9755d 100644 --- a/tests/OpenCode.Sdk.Tests/Support/LateCleanupFailureReport.cs +++ b/tests/OpenCode.Sdk.Tests/Support/LateCleanupFailureReport.cs @@ -29,27 +29,31 @@ public void InheritDiagnostics(Exception? primaryFailure) public void Observe(string name, Task operation, Func? expectedCancellation = null) { _ = Interlocked.Increment(ref _registrations); - var observer = operation.ContinueWith( - completed => + _observers.Add(ObserveCompletionAsync(name, operation, expectedCancellation)); + } + + private async Task ObserveCompletionAsync(string name, Task operation, + Func? expectedCancellation) + { + try + { + await operation; + } + catch (Exception exception) + { + var failures = operation.Exception?.Flatten().InnerExceptions.AsEnumerable() ?? [exception]; + foreach (var failure in failures) { - var failures = completed.Exception?.Flatten().InnerExceptions.AsEnumerable() - ?? (completed.IsCanceled ? [new TaskCanceledException(completed)] : []); - foreach (var failure in failures) + if (failure is OperationCanceledException cancelled && expectedCancellation?.Invoke(cancelled) is true) { - if (failure is OperationCanceledException cancelled && expectedCancellation?.Invoke(cancelled) is true) - { - continue; - } - - var entry = new KeyValuePair(name, failure); - _failures.Enqueue(entry); - _ = _firstFailure.TrySetResult(entry); + continue; } - }, - CancellationToken.None, - TaskContinuationOptions.ExecuteSynchronously, - TaskScheduler.Default); - _observers.Add(observer); + + var entry = new KeyValuePair(name, failure); + _failures.Enqueue(entry); + _ = _firstFailure.TrySetResult(entry); + } + } } public async Task> WaitForFailureAsync(CancellationToken cancellationToken) => diff --git a/tests/OpenCode.Sdk.Tests/Support/OperationDeadlineScenario.cs b/tests/OpenCode.Sdk.Tests/Support/OperationDeadlineScenario.cs new file mode 100644 index 00000000..13f9495a --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Support/OperationDeadlineScenario.cs @@ -0,0 +1,54 @@ +using System.Collections.Concurrent; +using NSubstitute; +using OpenCode.Sdk.Tests.Support.Abstractions; + +namespace OpenCode.Sdk.Tests.Support; + +internal sealed class OperationDeadlineScenario +{ + private readonly Dictionary _deadlines = new(StringComparer.Ordinal); + private readonly ConcurrentQueue _operations = new(); + + public OperationDeadlineScenario() + { + Deadline = Substitute.For(); + Deadline.WaitAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(call => WaitAsync(call.ArgAt(0), call.ArgAt(1), call.ArgAt(3))); + } + + public IOwnedOperationDeadline Deadline { get; } + + public ControlledOperationDeadline Hold(string name) + { + var deadline = new ControlledOperationDeadline(); + _deadlines.Add(name, deadline); + return deadline; + } + + public async Task DrainAsync(Task completion) + { + foreach (var deadline in _deadlines.Values) + { + deadline.Release(); + } + + await ObserveAsync(completion); + await ObserveAsync(Task.WhenAll(_operations)); + } + + private async Task WaitAsync(string name, Task operation, CancellationToken cancellationToken) + { + _operations.Enqueue(operation); + if (_deadlines.TryGetValue(name, out var deadline)) + { + await deadline.WaitAsync(); + return; + } + + await ObserveAsync(operation).WaitAsync(cancellationToken); + } + + private static Task ObserveAsync(Task operation) => operation.ContinueWith( + static completed => { _ = completed.Exception; }, CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); +} diff --git a/tests/OpenCode.Sdk.Tests/Support/OwnedCleanup.cs b/tests/OpenCode.Sdk.Tests/Support/OwnedCleanup.cs index 4f06ddd8..ae4c68d2 100644 --- a/tests/OpenCode.Sdk.Tests/Support/OwnedCleanup.cs +++ b/tests/OpenCode.Sdk.Tests/Support/OwnedCleanup.cs @@ -1,14 +1,19 @@ using System.Runtime.ExceptionServices; +using OpenCode.Sdk.Tests.Support.Abstractions; namespace OpenCode.Sdk.Tests.Support; -internal sealed class OwnedCleanup(TimeSpan timeout) +internal sealed class OwnedCleanup(TimeSpan timeout, IOwnedOperationDeadline deadline) { internal const string FailuresKey = "OwnedSessionCleanup.Failures"; internal const string LateFailuresKey = "OwnedSessionCleanup.LateFailures"; private readonly LateCleanupFailureReport _lateFailures = new(); private readonly List _operations = []; + public OwnedCleanup(TimeSpan timeout) : this(timeout, new OwnedOperationDeadline()) + { + } + public LateCleanupFailureReport? LateFailures => _lateFailures.HasRegistrations ? _lateFailures : null; @@ -49,14 +54,15 @@ private async Task CaptureFailureAsync( var token = budget.Token; // Invocation itself can block (including a synchronous cancellation callback). // The deadline must not depend on that work or on its cooperative token. - var pending = await ObserveAsync(operation.Name, () => operation.StartAsync(token), - failures, operation.ExpectedCancellation); + var pending = operation.StartAsync(token); + await ObserveAsync(operation.Name, pending, failures, operation.ExpectedCancellation); if (pending.IsCompleted) { return; } - var cancellation = await ObserveAsync(operation.Name + " cancellation", budget.CancelAsync, failures); + var cancellation = Task.Run(budget.CancelAsync, CancellationToken.None); + await ObserveAsync(operation.Name + " cancellation", cancellation, failures); DisposeBudgetAfterCompletion(budget, pending, cancellation); budget = null; } @@ -79,27 +85,35 @@ private static void DisposeBudgetAfterCompletion(CancellationTokenSource budget, TaskScheduler.Default); } - private async Task ObserveAsync(string name, Func start, List failures, + private async Task ObserveAsync(string name, Task pending, List failures, Func? expectedCancellation = null) { - var pending = Task.Run(start, CancellationToken.None); try { - await pending.WaitAsync(timeout); + await deadline.WaitAsync(name, pending, timeout); + } + catch (TimeoutException exception) + { + failures.Add(new TimeoutException($"Owned operation '{name}' exceeded its cleanup deadline of {timeout}.", exception)); + // The task may already be terminal when the deadline result reaches us. + // Always observe it once, including cancellation with no Task.Exception. + _lateFailures.Observe(name, pending, expectedCancellation); + return; + } + + try + { + await pending; } catch (OperationCanceledException exception) when (expectedCancellation?.Invoke(exception) is true) { // An owned reader can identify its own cooperative teardown cancellation. - return pending; + return; } catch (Exception exception) { failures.Add(exception); - if (!pending.IsCompleted) - { - _lateFailures.Observe(name, pending, expectedCancellation); - } - else if (pending.Exception is { } aggregate) + if (pending.Exception is { } aggregate) { foreach (var failure in aggregate.Flatten().InnerExceptions) { @@ -112,8 +126,6 @@ private async Task ObserveAsync(string name, Func start, List failures) diff --git a/tests/OpenCode.Sdk.Tests/Support/OwnedCleanupDeadlineTests.cs b/tests/OpenCode.Sdk.Tests/Support/OwnedCleanupDeadlineTests.cs new file mode 100644 index 00000000..8762bd8b --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Support/OwnedCleanupDeadlineTests.cs @@ -0,0 +1,202 @@ +using NSubstitute; + +namespace OpenCode.Sdk.Tests.Support; + +public sealed class OwnedCleanupDeadlineTests +{ + [Test] + [Arguments(false, false)] + [Arguments(true, false)] + [Arguments(true, true)] + public async Task CompleteAsync_Should_Observe_Cancellation_After_The_Deadline_Wins_Before_Inspection( + bool ownedToken, bool cancelCaller) + { + using var caller = new CancellationTokenSource(); + using var unrelated = new CancellationTokenSource(); + var scenario = new OperationDeadlineScenario(); + var deadline = scenario.Hold("event reader"); + var owner = new OwnedEventReader(TimeSpan.FromMinutes(1), TimeSpan.FromMilliseconds(50), caller.Token, scenario.Deadline); + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var cancellation = new OperationCanceledException(ownedToken ? owner.Token : unrelated.Token); + var primary = new InvalidOperationException("body failed"); + var reading = Task.Run(async () => + { + _ = entered.TrySetResult(true); + await release.Task; + throw cancellation; + }, CancellationToken.None); + owner.Own(reading); + var completing = Task.CompletedTask; + try + { + await entered.Task; + completing = owner.CompleteAsync(primary); + await deadline.Entered; + deadline.Expire(); + await deadline.Won; + if (cancelCaller) + { + await caller.CancelAsync(); + } + + release.SetResult(true); + var readerFailure = await Assert.That(() => reading).Throws(); + await Assert.That(readerFailure).IsSameReferenceAs(cancellation); + await Assert.That(reading.IsCanceled).IsTrue(); + await Assert.That(completing.IsCompleted).IsFalse(); + deadline.Deliver(); + var thrown = await Assert.That(() => completing).Throws(); + await Assert.That(thrown).IsSameReferenceAs(primary); + var immediate = (AggregateException)primary.Data[OwnedCleanup.FailuresKey]!; + await Assert.That(immediate.InnerExceptions.Single().InnerException).IsSameReferenceAs(deadline.Failure); + var report = owner.LateFailures!; + await report.WaitForAllAsync(CancellationToken.None); + var attached = (IReadOnlyCollection>)primary.Data[OwnedCleanup.LateFailuresKey]!; + if (ownedToken && !cancelCaller) + { + await Assert.That(attached).IsEmpty(); + } + else + { + await Assert.That(attached).Count().IsEqualTo(1); + await Assert.That(attached.Single().Key).IsEqualTo("event reader"); + await Assert.That(attached.Single().Value).IsSameReferenceAs(cancellation); + await Assert.That(((OperationCanceledException)attached.Single().Value).CancellationToken) + .IsEqualTo(cancellation.CancellationToken); + } + } + finally + { + _ = release.TrySetResult(true); + await scenario.DrainAsync(Task.WhenAll(reading, completing)); + if (owner.LateFailures is { } report) + { + await report.WaitForAllAsync(CancellationToken.None); + } + } + } + + [Test] + [Arguments(false)] + [Arguments(true)] + public async Task CompleteAsync_Should_Observe_Terminal_Outcome_After_The_Deadline_Wins(bool fault) + { + var pending = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var scenario = new OperationDeadlineScenario(); + var deadline = scenario.Hold("owned read"); + var cleanup = new OwnedCleanup(TimeSpan.FromMilliseconds(50), scenario.Deadline); + var failure = new InvalidOperationException("late read failure"); + cleanup.Own("owned read", pending.Task); + var completing = cleanup.CompleteAsync(null); + try + { + await deadline.Entered; + deadline.Expire(); + await deadline.Won; + if (fault) + { + pending.SetException(failure); + } + else + { + pending.SetResult(true); + } + + await Assert.That(pending.Task.IsCompleted).IsTrue(); + deadline.Deliver(); + var thrown = await Assert.That(() => completing).Throws(); + await Assert.That(thrown!.Message).Contains("'owned read'"); + await Assert.That(thrown.InnerException).IsSameReferenceAs(deadline.Failure); + var lateReport = cleanup.LateFailures; + await Assert.That(lateReport).IsNotNull(); + await lateReport.WaitForAllAsync(CancellationToken.None); + var attached = (IReadOnlyCollection>)thrown.Data[OwnedCleanup.LateFailuresKey]!; + if (fault) + { + await Assert.That(attached.Single().Value).IsSameReferenceAs(failure); + } + else + { + await Assert.That(attached).IsEmpty(); + } + } + finally + { + _ = pending.TrySetResult(true); + await scenario.DrainAsync(completing); + if (cleanup.LateFailures is { } report) + { + await report.WaitForAllAsync(CancellationToken.None); + } + } + } + + [Test] + public async Task CompleteAsync_Should_Observe_An_Existing_Task_Without_Queuing_Invocation() + { + var scenario = new OperationDeadlineScenario(); + var pending = Task.CompletedTask; + var cleanup = new OwnedCleanup(TimeSpan.FromSeconds(1), scenario.Deadline); + cleanup.Own("already running", pending); + + await cleanup.CompleteAsync(null); + + await scenario.Deadline.Received(1).WaitAsync("already running", pending, TimeSpan.FromSeconds(1), CancellationToken.None); + } + + [Test] + public async Task CompleteAsync_Should_Preserve_An_Operation_Timeout_Identity() + { + var cause = new InvalidOperationException("transport cause"); + var timeout = new TimeoutException("operation timed out", cause); + var cleanup = new OwnedCleanup(TimeSpan.FromSeconds(1), new OperationDeadlineScenario().Deadline); + cleanup.Own("read", Task.FromException(timeout)); + + var thrown = await Assert.That(() => cleanup.CompleteAsync(null)).Throws(); + + await Assert.That(thrown).IsSameReferenceAs(timeout); + await Assert.That(thrown!.InnerException).IsSameReferenceAs(cause); + await Assert.That(cleanup.LateFailures).IsNull(); + } + + [Test] + public async Task CompleteAsync_Should_Bound_Synchronously_Blocked_Invocation() + { + using var release = new ManualResetEventSlim(); + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var scenario = new OperationDeadlineScenario(); + var deadline = scenario.Hold("blocking invocation"); + var cleanup = new OwnedCleanup(TimeSpan.FromMilliseconds(50), scenario.Deadline); + var late = new InvalidOperationException("invocation failed after release"); + cleanup.Own("blocking invocation", token => + { + _ = entered.TrySetResult(true); + release.Wait(CancellationToken.None); + throw late; + }); + var completing = cleanup.CompleteAsync(null); + try + { + await entered.Task; + await deadline.Entered; + deadline.Expire(); + deadline.Deliver(); + _ = await Assert.That(() => completing).Throws(); + release.Set(); + var lateReport = cleanup.LateFailures; + await Assert.That(lateReport).IsNotNull(); + await lateReport.WaitForAllAsync(CancellationToken.None); + await Assert.That(lateReport.Failures.Single().Value).IsSameReferenceAs(late); + } + finally + { + release.Set(); + await scenario.DrainAsync(completing); + if (cleanup.LateFailures is { } report) + { + await report.WaitForAllAsync(CancellationToken.None); + } + } + } +} diff --git a/tests/OpenCode.Sdk.Tests/Support/OwnedCleanupOperation.cs b/tests/OpenCode.Sdk.Tests/Support/OwnedCleanupOperation.cs index d4fda0fc..67176122 100644 --- a/tests/OpenCode.Sdk.Tests/Support/OwnedCleanupOperation.cs +++ b/tests/OpenCode.Sdk.Tests/Support/OwnedCleanupOperation.cs @@ -22,5 +22,6 @@ public OwnedCleanupOperation(string name, Task pending, Func? ExpectedCancellation { get; } - public Task StartAsync(CancellationToken cancellationToken) => _pending ?? _operation!(cancellationToken); + public Task StartAsync(CancellationToken cancellationToken) => + _pending ?? Task.Run(() => _operation!(cancellationToken), CancellationToken.None); } diff --git a/tests/OpenCode.Sdk.Tests/Support/OwnedCleanupTests.cs b/tests/OpenCode.Sdk.Tests/Support/OwnedCleanupTests.cs index f3b362f9..0d9ea280 100644 --- a/tests/OpenCode.Sdk.Tests/Support/OwnedCleanupTests.cs +++ b/tests/OpenCode.Sdk.Tests/Support/OwnedCleanupTests.cs @@ -7,7 +7,7 @@ public async Task CompleteAsync_Should_Retain_Every_Task_Fault_Without_Duplicati { var primary = new InvalidOperationException("primary failed"); var secondary = new InvalidOperationException("secondary failed"); - var cleanup = new OwnedCleanup(TimeSpan.FromSeconds(1)); + var cleanup = new OwnedCleanup(TimeSpan.FromSeconds(1), new OperationDeadlineScenario().Deadline); cleanup.Own("reader", Task.WhenAll(Task.FromException(primary), Task.FromException(secondary))); var thrown = await Assert.That(() => cleanup.CompleteAsync(primary)).Throws(); @@ -22,28 +22,41 @@ public async Task CompleteAsync_Should_Append_Nested_Late_Failures_To_The_Attach { var first = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var second = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var inner = new OwnedCleanup(TimeSpan.FromMilliseconds(50)); - var outer = new OwnedCleanup(TimeSpan.FromMilliseconds(50)); + var scenario = new OperationDeadlineScenario(); + var innerDeadline = scenario.Hold("inner read"); + var outerDeadline = scenario.Hold("outer removal"); + var inner = new OwnedCleanup(TimeSpan.FromMilliseconds(50), scenario.Deadline); + var outer = new OwnedCleanup(TimeSpan.FromMilliseconds(50), scenario.Deadline); inner.Own("inner read", first.Task); outer.Own("outer removal", second.Task); var primary = new InvalidOperationException("primary failed"); var firstFailure = new InvalidOperationException("inner late failure"); var secondFailure = new InvalidOperationException("outer late failure"); + var innerCompletion = inner.CompleteAsync(primary); + var outerCompletion = Task.CompletedTask; try { - _ = await Assert.That(() => inner.CompleteAsync(primary)).Throws(); + await innerDeadline.Entered; + innerDeadline.Expire(); + innerDeadline.Deliver(); + _ = await Assert.That(() => innerCompletion).Throws(); var attached = primary.Data[OwnedCleanup.LateFailuresKey]; - _ = await Assert.That(() => outer.CompleteAsync(primary)).Throws(); + outerCompletion = outer.CompleteAsync(primary); + await outerDeadline.Entered; + outerDeadline.Expire(); + outerDeadline.Deliver(); + _ = await Assert.That(() => outerCompletion).Throws(); await Assert.That(primary.Data[OwnedCleanup.LateFailuresKey]).IsSameReferenceAs(attached); + var immediate = (AggregateException)primary.Data[OwnedCleanup.FailuresKey]!; + await Assert.That(immediate.InnerExceptions).Count().IsEqualTo(2); first.SetException(firstFailure); second.SetException(secondFailure); - var innerReport = inner.LateFailures; - var outerReport = outer.LateFailures; - await Assert.That(innerReport).IsNotNull(); - await Assert.That(outerReport).IsNotNull(); - using var observation = new CancellationTokenSource(TimeSpan.FromSeconds(2)); - await innerReport.WaitForAllAsync(observation.Token); - await outerReport.WaitForAllAsync(observation.Token); + var innerLate = inner.LateFailures; + var outerLate = outer.LateFailures; + await Assert.That(innerLate).IsNotNull(); + await Assert.That(outerLate).IsNotNull(); + await innerLate.WaitForAllAsync(CancellationToken.None); + await outerLate.WaitForAllAsync(CancellationToken.None); var failures = (IReadOnlyCollection>)attached!; await Assert.That(failures.Select(entry => entry.Value)) @@ -53,6 +66,16 @@ await Assert.That(failures.Select(entry => entry.Value)) { _ = first.TrySetResult(true); _ = second.TrySetResult(true); + await scenario.DrainAsync(Task.WhenAll(innerCompletion, outerCompletion)); + if (inner.LateFailures is { } innerReport) + { + await innerReport.WaitForAllAsync(CancellationToken.None); + } + + if (outer.LateFailures is { } outerReport) + { + await outerReport.WaitForAllAsync(CancellationToken.None); + } } } @@ -60,45 +83,69 @@ await Assert.That(failures.Select(entry => entry.Value)) public async Task CompleteAsync_Should_Bound_An_Operation_And_Its_Stalled_Cancellation_Independently() { using var release = new ManualResetEventSlim(); - TaskCompletionSource? pending = null; + var pending = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var cancellationEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var primary = new InvalidOperationException("body failed"); var late = new InvalidOperationException("late operation failed"); - var cleanup = new OwnedCleanup(TimeSpan.FromMilliseconds(50)); + var scenario = new OperationDeadlineScenario(); + var operationDeadline = scenario.Hold("stalled operation"); + var cancellationDeadline = scenario.Hold("stalled operation cancellation"); + var cleanup = new OwnedCleanup(TimeSpan.FromMilliseconds(50), scenario.Deadline); var laterTokenCancelled = true; cleanup.Own("stalled operation", async token => { - var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - pending = completion; - using var registration = token.Register(() => _ = release.Wait(TimeSpan.FromSeconds(10))); - _ = await completion.Task; + using var registration = token.Register(() => + { + _ = cancellationEntered.TrySetResult(true); + release.Wait(CancellationToken.None); + }); + _ = entered.TrySetResult(true); + _ = await pending.Task; }); cleanup.Own("later cleanup", token => { laterTokenCancelled = token.IsCancellationRequested; return Task.CompletedTask; }); + var completing = cleanup.CompleteAsync(primary); try { - var thrown = await Assert.That(() => cleanup.CompleteAsync(primary)).Throws(); + await entered.Task; + await operationDeadline.Entered; + operationDeadline.Expire(); + operationDeadline.Deliver(); + await cancellationEntered.Task; + await cancellationDeadline.Entered; + cancellationDeadline.Expire(); + cancellationDeadline.Deliver(); + var thrown = await Assert.That(() => completing).Throws(); await Assert.That(thrown).IsSameReferenceAs(primary); await Assert.That(laterTokenCancelled).IsFalse(); var failures = (AggregateException)primary.Data[OwnedCleanup.FailuresKey]!; - await Assert.That(failures.InnerExceptions.OfType()).Count().IsEqualTo(2); + await Assert.That(failures.InnerExceptions).Count().IsEqualTo(2); + await Assert.That(failures.InnerExceptions[0].Message).Contains("'stalled operation'"); + await Assert.That(failures.InnerExceptions[0].InnerException).IsSameReferenceAs(operationDeadline.Failure); + await Assert.That(failures.InnerExceptions[1].Message).Contains("'stalled operation cancellation'"); + await Assert.That(failures.InnerExceptions[1].InnerException).IsSameReferenceAs(cancellationDeadline.Failure); release.Set(); - await Assert.That(pending).IsNotNull(); pending.SetException(late); - using var observation = new CancellationTokenSource(TimeSpan.FromSeconds(2)); - var report = cleanup.LateFailures; - await Assert.That(report).IsNotNull(); - await report.WaitForAllAsync(observation.Token); + var lateReport = cleanup.LateFailures; + await Assert.That(lateReport).IsNotNull(); + await lateReport.WaitForAllAsync(CancellationToken.None); var attached = (IReadOnlyCollection>)primary.Data[OwnedCleanup.LateFailuresKey]!; await Assert.That(attached.Single().Value).IsSameReferenceAs(late); } finally { release.Set(); - _ = pending?.TrySetResult(true); + _ = pending.TrySetResult(true); + await scenario.DrainAsync(completing); + if (cleanup.LateFailures is { } report) + { + await report.WaitForAllAsync(CancellationToken.None); + } } } } diff --git a/tests/OpenCode.Sdk.Tests/Support/OwnedEventReader.cs b/tests/OpenCode.Sdk.Tests/Support/OwnedEventReader.cs index a6f67738..477442c5 100644 --- a/tests/OpenCode.Sdk.Tests/Support/OwnedEventReader.cs +++ b/tests/OpenCode.Sdk.Tests/Support/OwnedEventReader.cs @@ -1,3 +1,5 @@ +using OpenCode.Sdk.Tests.Support.Abstractions; + namespace OpenCode.Sdk.Tests.Support; internal sealed class OwnedEventReader @@ -7,18 +9,21 @@ internal sealed class OwnedEventReader private readonly CancellationToken _eventToken; private readonly OwnedCleanup _cleanup; private readonly TimeSpan _observationWindow; + private readonly IOwnedOperationDeadline _deadline; private bool _teardownCancellation; private Task _reader = Task.CompletedTask; private Task _cancellation = Task.CompletedTask; - public OwnedEventReader(TimeSpan observationWindow, TimeSpan cleanupTimeout, CancellationToken callerToken) + public OwnedEventReader(TimeSpan observationWindow, TimeSpan cleanupTimeout, CancellationToken callerToken, + IOwnedOperationDeadline? deadline = null) { _callerToken = callerToken; _observationWindow = observationWindow; _window = CancellationTokenSource.CreateLinkedTokenSource(callerToken); _eventToken = _window.Token; _window.CancelAfter(observationWindow); - _cleanup = new OwnedCleanup(cleanupTimeout); + _deadline = deadline ?? new OwnedOperationDeadline(); + _cleanup = new OwnedCleanup(cleanupTimeout, _deadline); } public CancellationToken Token => _eventToken; @@ -38,7 +43,7 @@ public Task Start(Func> read) ArgumentNullException.ThrowIfNull(read); var pending = Task.Run(() => read(Token), CancellationToken.None); _reader = pending; - return pending.WaitAsync(_observationWindow, _callerToken); + return ObserveAsync(pending); } public Task Start(Func read) @@ -46,7 +51,19 @@ public Task Start(Func read) ArgumentNullException.ThrowIfNull(read); var pending = Task.Run(() => read(Token), CancellationToken.None); _reader = pending; - return pending.WaitAsync(_observationWindow, _callerToken); + return ObserveAsync(pending); + } + + private async Task ObserveAsync(Task pending) + { + await _deadline.WaitAsync("event observation", pending, _observationWindow, _callerToken); + return await pending; + } + + private async Task ObserveAsync(Task pending) + { + await _deadline.WaitAsync("event observation", pending, _observationWindow, _callerToken); + await pending; } public void Own(Task reader) diff --git a/tests/OpenCode.Sdk.Tests/Support/OwnedEventReaderTests.cs b/tests/OpenCode.Sdk.Tests/Support/OwnedEventReaderTests.cs index 73d1d216..a1048540 100644 --- a/tests/OpenCode.Sdk.Tests/Support/OwnedEventReaderTests.cs +++ b/tests/OpenCode.Sdk.Tests/Support/OwnedEventReaderTests.cs @@ -5,15 +5,15 @@ public sealed class OwnedEventReaderTests [Test] public async Task CompleteAsync_Should_Preserve_Primary_Reader_And_Later_Session_Failures() { - var owner = new OwnedEventReader(TimeSpan.FromMinutes(1), TimeSpan.FromSeconds(1), CancellationToken.None); + var owner = new OwnedEventReader(TimeSpan.FromMinutes(1), TimeSpan.FromSeconds(1), CancellationToken.None, new OperationDeadlineScenario().Deadline); var primary = new InvalidOperationException("prompt failed"); var readerFailure = new InvalidOperationException("reader failed"); var removalFailure = new InvalidOperationException("removal failed"); - _ = owner.Start(_ => Task.FromException(readerFailure)); + owner.Own(Task.FromException(readerFailure)); var thrown = await Assert.That(() => owner.CompleteAsync(primary)).Throws(); var outer = new OwnedSessionCleanup(_ => Task.CompletedTask, - _ => Task.FromException(removalFailure), TimeSpan.FromSeconds(1)); + _ => Task.FromException(removalFailure), TimeSpan.FromSeconds(1), new OperationDeadlineScenario().Deadline); _ = await Assert.That(() => outer.CompleteAsync(thrown)).Throws(); await Assert.That(thrown).IsSameReferenceAs(primary); @@ -24,12 +24,12 @@ public async Task CompleteAsync_Should_Preserve_Primary_Reader_And_Later_Session [Test] public async Task CompleteAsync_Should_Observe_Reader_After_Cancellation_Callback_Fails() { - var owner = new OwnedEventReader(TimeSpan.FromMinutes(1), TimeSpan.FromSeconds(1), CancellationToken.None); + var owner = new OwnedEventReader(TimeSpan.FromMinutes(1), TimeSpan.FromSeconds(1), CancellationToken.None, new OperationDeadlineScenario().Deadline); var callbackFailure = new InvalidOperationException("cancellation callback failed"); var readerFailure = new InvalidOperationException("reader also failed"); var primary = new InvalidOperationException("prompt failed"); using var registration = owner.Token.Register(() => throw callbackFailure); - _ = owner.Start(_ => Task.FromException(readerFailure)); + owner.Own(Task.FromException(readerFailure)); var thrown = await Assert.That(() => owner.CompleteAsync(primary)).Throws(); @@ -44,13 +44,18 @@ public async Task CompleteAsync_Should_Bound_Stalls_Reach_Removal_And_Attach_Lat { using var release = new ManualResetEventSlim(); var pending = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var owner = new OwnedEventReader(TimeSpan.FromMinutes(1), TimeSpan.FromMilliseconds(50), CancellationToken.None); + var cancellationEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var scenario = new OperationDeadlineScenario(); + var cancellationDeadline = scenario.Hold("event cancellation"); + var readerDeadline = scenario.Hold("event reader"); + var owner = new OwnedEventReader(TimeSpan.FromMinutes(1), TimeSpan.FromMilliseconds(50), CancellationToken.None, scenario.Deadline); var callbackFailure = new InvalidOperationException("late callback failed"); var readerFailure = new InvalidOperationException("late reader failed"); var primary = new InvalidOperationException("prompt failed"); using var registration = owner.Token.Register(() => { - _ = release.Wait(TimeSpan.FromSeconds(10)); + _ = cancellationEntered.TrySetResult(true); + release.Wait(); throw callbackFailure; }); owner.Own(pending.Task); @@ -59,24 +64,33 @@ public async Task CompleteAsync_Should_Bound_Stalls_Reach_Removal_And_Attach_Lat { removed = !token.IsCancellationRequested; return Task.CompletedTask; - }, TimeSpan.FromSeconds(1)); + }, TimeSpan.FromSeconds(1), new OperationDeadlineScenario().Deadline); + var completing = owner.CompleteAsync(primary); try { - var thrown = await Assert.That(() => owner.CompleteAsync(primary)).Throws(); + await cancellationEntered.Task; + await cancellationDeadline.Entered; + cancellationDeadline.Expire(); + cancellationDeadline.Deliver(); + await readerDeadline.Entered; + readerDeadline.Expire(); + readerDeadline.Deliver(); + var thrown = await Assert.That(() => completing).Throws(); _ = await Assert.That(() => outer.CompleteAsync(thrown)).Throws(); await Assert.That(removed).IsTrue(); await Assert.That(thrown).IsSameReferenceAs(primary); var failures = (AggregateException)primary.Data[OwnedCleanup.FailuresKey]!; await Assert.That(failures.InnerExceptions.OfType()).Count().IsEqualTo(2); + await Assert.That(failures.InnerExceptions[0].Message).Contains("'event cancellation'"); + await Assert.That(failures.InnerExceptions[1].Message).Contains("'event reader'"); var attached = (IReadOnlyCollection>)primary.Data[OwnedCleanup.LateFailuresKey]!; pending.SetException(readerFailure); - using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(2)); var report = owner.LateFailures; await Assert.That(report).IsNotNull(); - var observed = await report.WaitForFailureAsync(deadline.Token); + var observed = await report.WaitForFailureAsync(CancellationToken.None); await Assert.That(observed.Value).IsSameReferenceAs(readerFailure); release.Set(); - await report.WaitForAllAsync(deadline.Token); + await report.WaitForAllAsync(CancellationToken.None); await Assert.That(attached.Select(entry => entry.Value)).IsEquivalentTo(new Exception[] { readerFailure, callbackFailure }); await Assert.That(attached.Select(entry => entry.Key)).IsEquivalentTo(["event reader", "event cancellation"]); } @@ -84,13 +98,18 @@ public async Task CompleteAsync_Should_Bound_Stalls_Reach_Removal_And_Attach_Lat { release.Set(); _ = pending.TrySetResult(true); + await scenario.DrainAsync(completing); + if (owner.LateFailures is { } report) + { + await report.WaitForAllAsync(CancellationToken.None); + } } } [Test] public async Task CompleteAsync_Should_Accept_Only_Its_Own_Cooperative_Reader_Cancellation() { - var owner = new OwnedEventReader(TimeSpan.FromMinutes(1), TimeSpan.FromSeconds(1), CancellationToken.None); + var owner = new OwnedEventReader(TimeSpan.FromMinutes(1), TimeSpan.FromSeconds(1), CancellationToken.None, new OperationDeadlineScenario().Deadline); var pending = owner.Start(async token => { var cancellation = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); @@ -118,10 +137,10 @@ public async Task CompleteAsync_Should_Accept_Only_Its_Own_Cooperative_Reader_Ca public async Task CompleteAsync_Should_Preserve_Caller_Cancellation_Identity() { using var caller = new CancellationTokenSource(); - var owner = new OwnedEventReader(TimeSpan.FromMinutes(1), TimeSpan.FromSeconds(1), caller.Token); + var owner = new OwnedEventReader(TimeSpan.FromMinutes(1), TimeSpan.FromSeconds(1), caller.Token, new OperationDeadlineScenario().Deadline); await caller.CancelAsync(); var primary = new OperationCanceledException(caller.Token); - _ = owner.Start(_ => Task.FromException(primary)); + owner.Own(Task.FromException(primary)); var thrown = await Assert.That(() => owner.CompleteAsync(primary)) .Throws(); @@ -134,9 +153,9 @@ public async Task CompleteAsync_Should_Preserve_Caller_Cancellation_Identity() [Test] public async Task CompleteAsync_Should_Report_Unexpected_Reader_Cancellation() { - var owner = new OwnedEventReader(TimeSpan.FromMinutes(1), TimeSpan.FromSeconds(1), CancellationToken.None); + var owner = new OwnedEventReader(TimeSpan.FromMinutes(1), TimeSpan.FromSeconds(1), CancellationToken.None, new OperationDeadlineScenario().Deadline); var unexpected = new OperationCanceledException("unrelated cancellation"); - _ = owner.Start(_ => Task.FromException(unexpected)); + owner.Own(Task.FromException(unexpected)); var primary = new InvalidOperationException("prompt failed"); var thrown = await Assert.That(() => owner.CompleteAsync(primary)) @@ -151,24 +170,36 @@ public async Task CompleteAsync_Should_Report_Unexpected_Reader_Cancellation() public async Task CompleteAsync_Should_Observe_Late_Cooperative_Cancellation_After_The_Reader_Deadline() { var pending = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var owner = new OwnedEventReader(TimeSpan.FromMinutes(1), TimeSpan.FromMilliseconds(50), CancellationToken.None); + var scenario = new OperationDeadlineScenario(); + var deadline = scenario.Hold("event reader"); + var owner = new OwnedEventReader(TimeSpan.FromMinutes(1), TimeSpan.FromMilliseconds(50), CancellationToken.None, scenario.Deadline); owner.Own(pending.Task); var token = owner.Token; + var completing = owner.CompleteAsync(null); try { - _ = await Assert.That(() => owner.CompleteAsync(null)).Throws(); + await deadline.Entered; + await Assert.That(token.IsCancellationRequested).IsTrue(); + deadline.Expire(); + deadline.Deliver(); + var thrown = await Assert.That(() => completing).Throws(); + await Assert.That(thrown!.Message).Contains("'event reader'"); var report = owner.LateFailures; await Assert.That(report).IsNotNull(); pending.SetCanceled(token); - using var observation = new CancellationTokenSource(TimeSpan.FromSeconds(2)); - await report.WaitForAllAsync(observation.Token); + await report.WaitForAllAsync(CancellationToken.None); await Assert.That(report.Failures).IsEmpty(); } finally { _ = pending.TrySetResult(true); + await scenario.DrainAsync(completing); + if (owner.LateFailures is { } report) + { + await report.WaitForAllAsync(CancellationToken.None); + } } } } diff --git a/tests/OpenCode.Sdk.Tests/Support/OwnedOperationDeadline.cs b/tests/OpenCode.Sdk.Tests/Support/OwnedOperationDeadline.cs new file mode 100644 index 00000000..255d4094 --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Support/OwnedOperationDeadline.cs @@ -0,0 +1,16 @@ +using OpenCode.Sdk.Tests.Support.Abstractions; + +namespace OpenCode.Sdk.Tests.Support; + +internal sealed class OwnedOperationDeadline : IOwnedOperationDeadline +{ + public async Task WaitAsync(string name, Task operation, TimeSpan timeout, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(operation); + // Observe terminal state separately from the outcome: the operation can itself time out. + var completion = operation.ContinueWith( + static _ => true, CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); + _ = await completion.WaitAsync(timeout, cancellationToken); + } +} diff --git a/tests/OpenCode.Sdk.Tests/Support/OwnedOperationDeadlineTests.cs b/tests/OpenCode.Sdk.Tests/Support/OwnedOperationDeadlineTests.cs new file mode 100644 index 00000000..86eea0db --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Support/OwnedOperationDeadlineTests.cs @@ -0,0 +1,55 @@ +namespace OpenCode.Sdk.Tests.Support; + +public sealed class OwnedOperationDeadlineTests +{ + [Test] + public async Task WaitAsync_Should_Observe_Completed_Fault_Without_Confusing_It_With_The_Deadline() + { + var failure = new TimeoutException("operation timeout"); + var pending = Task.FromException(failure); + var deadline = new OwnedOperationDeadline(); + + await deadline.WaitAsync("read", pending, TimeSpan.Zero); + + await Assert.That(pending.Exception!.InnerException).IsSameReferenceAs(failure); + } + + [Test] + public async Task WaitAsync_Should_Bound_An_Unfinished_Operation_Without_Canceling_It() + { + var pending = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var deadline = new OwnedOperationDeadline(); + try + { + _ = await Assert.That(() => deadline.WaitAsync("read", pending.Task, TimeSpan.Zero)) + .Throws(); + await Assert.That(pending.Task.IsCompleted).IsFalse(); + } + finally + { + _ = pending.TrySetResult(true); + _ = await pending.Task; + } + } + + [Test] + public async Task WaitAsync_Should_Preserve_Caller_Cancellation() + { + using var caller = new CancellationTokenSource(); + await caller.CancelAsync(); + var pending = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var deadline = new OwnedOperationDeadline(); + try + { + var thrown = await Assert.That(() => deadline.WaitAsync("read", pending.Task, TimeSpan.FromSeconds(1), caller.Token)) + .Throws(); + await Assert.That(thrown!.CancellationToken).IsEqualTo(caller.Token); + await Assert.That(pending.Task.IsCompleted).IsFalse(); + } + finally + { + _ = pending.TrySetResult(true); + _ = await pending.Task; + } + } +} diff --git a/tests/OpenCode.Sdk.Tests/Support/OwnedSessionCleanup.cs b/tests/OpenCode.Sdk.Tests/Support/OwnedSessionCleanup.cs index 979ac5a0..f8d6fb44 100644 --- a/tests/OpenCode.Sdk.Tests/Support/OwnedSessionCleanup.cs +++ b/tests/OpenCode.Sdk.Tests/Support/OwnedSessionCleanup.cs @@ -1,3 +1,5 @@ +using OpenCode.Sdk.Tests.Support.Abstractions; + namespace OpenCode.Sdk.Tests.Support; internal sealed class OwnedSessionCleanup @@ -31,13 +33,13 @@ public OwnedSessionCleanup(SessionClient session, TimeSpan timeout) internal OwnedSessionCleanup( Func interrupt, Func remove, - TimeSpan timeout) + TimeSpan timeout, IOwnedOperationDeadline? deadline = null) { ArgumentNullException.ThrowIfNull(interrupt); ArgumentNullException.ThrowIfNull(remove); _interrupt = interrupt; _remove = remove; - _cleanup = new OwnedCleanup(timeout); + _cleanup = new OwnedCleanup(timeout, deadline ?? new OwnedOperationDeadline()); } public void MarkTurnCompleted() => _turnCompleted = true; diff --git a/tests/OpenCode.Sdk.Tests/Support/PendingPermissionCleanupTests.cs b/tests/OpenCode.Sdk.Tests/Support/PendingPermissionCleanupTests.cs index 6709b15e..a5acd11d 100644 --- a/tests/OpenCode.Sdk.Tests/Support/PendingPermissionCleanupTests.cs +++ b/tests/OpenCode.Sdk.Tests/Support/PendingPermissionCleanupTests.cs @@ -14,7 +14,7 @@ public async Task RejectAsync_Should_Fail_Cleanup_When_The_Test_Body_Succeeded() { removed = true; return Task.CompletedTask; - }, TimeSpan.FromSeconds(1)); + }, TimeSpan.FromSeconds(1), new OperationDeadlineScenario().Deadline); cleanup.Own("pending permission", token => pending.RejectAsync("per_owned", token)); var thrown = await Assert.That(() => cleanup.CompleteAsync(null)).Throws(); @@ -39,7 +39,7 @@ public async Task RejectAsync_Should_Report_Unexpected_Response_And_Continue_All { steps.Add("session"); return Task.FromException(removalFailure); - }, TimeSpan.FromSeconds(1)); + }, TimeSpan.FromSeconds(1), new OperationDeadlineScenario().Deadline); cleanup.Own("pending permission", token => pending.RejectAsync("per_owned", token)); cleanup.Own("saved discovery", _ => { diff --git a/tests/OpenCode.Sdk.Tests/Support/ScriptedTurnEventsTests.cs b/tests/OpenCode.Sdk.Tests/Support/ScriptedTurnEventsTests.cs index e354096d..8c9336cd 100644 --- a/tests/OpenCode.Sdk.Tests/Support/ScriptedTurnEventsTests.cs +++ b/tests/OpenCode.Sdk.Tests/Support/ScriptedTurnEventsTests.cs @@ -6,7 +6,10 @@ public sealed class ScriptedTurnEventsTests public async Task CompleteAsync_Should_Bound_A_Noncooperative_Reader_And_Reach_Later_Cleanup() { var scenario = new EventDiagnosticScenario { Stall = true }; - var reader = new OwnedEventReader(TimeSpan.FromMilliseconds(500), TimeSpan.FromMilliseconds(50), CancellationToken.None); + var deadlines = new OperationDeadlineScenario(); + var observationDeadline = deadlines.Hold("event observation"); + var cleanupDeadline = deadlines.Hold("event reader"); + var reader = new OwnedEventReader(TimeSpan.FromMinutes(1), TimeSpan.FromMilliseconds(50), CancellationToken.None, deadlines.Deadline); var events = new ScriptedTurnEvents("reply", "ses_owned", reader); var connected = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var removed = false; @@ -14,13 +17,16 @@ public async Task CompleteAsync_Should_Bound_A_Noncooperative_Reader_And_Reach_L { removed = !token.IsCancellationRequested; return Task.CompletedTask; - }, TimeSpan.FromSeconds(1)); + }, TimeSpan.FromSeconds(1), deadlines.Deadline); + Task observing = events.CompleteAsync(scenario.EventsAsync(), connected); + Task? completing = null; try { - var primary = await Assert.That(async () => - { - _ = await events.CompleteAsync(scenario.EventsAsync(), connected); - }).Throws(); + await scenario.Stalled.Task; + await observationDeadline.Entered; + observationDeadline.Expire(); + observationDeadline.Deliver(); + var primary = await Assert.That(() => observing).Throws(); var observed = (string)primary!.Data[EventDiagnosticSummary.DataKey]!; await Assert.That(observed.Length).IsLessThan(2000); await Assert.That(observed).Contains("84 events omitted"); @@ -29,7 +35,11 @@ public async Task CompleteAsync_Should_Bound_A_Noncooperative_Reader_And_Reach_L await Assert.That(scenario.Pending).IsNotNull(); await Assert.That(scenario.Pending.Task.IsCompleted).IsFalse(); - var thrown = await Assert.That(() => reader.CompleteAsync(primary)).Throws(); + completing = reader.CompleteAsync(primary); + await cleanupDeadline.Entered; + cleanupDeadline.Expire(); + cleanupDeadline.Deliver(); + var thrown = await Assert.That(() => completing).Throws(); var propagated = await Assert.That(() => sessionCleanup.CompleteAsync(thrown)).Throws(); await Assert.That(propagated).IsSameReferenceAs(primary); await Assert.That(removed).IsTrue(); @@ -38,14 +48,19 @@ public async Task CompleteAsync_Should_Bound_A_Noncooperative_Reader_And_Reach_L scenario.Pending.SetException(lateFailure); var report = reader.LateFailures; await Assert.That(report).IsNotNull(); - using var observation = new CancellationTokenSource(TimeSpan.FromSeconds(2)); - await report.WaitForAllAsync(observation.Token); + await report.WaitForAllAsync(CancellationToken.None); var attached = (IReadOnlyCollection>)primary.Data[OwnedCleanup.LateFailuresKey]!; await Assert.That(attached.Single().Value).IsSameReferenceAs(lateFailure); } finally { _ = scenario.Pending?.TrySetResult(true); + completing ??= reader.CompleteAsync(null); + await deadlines.DrainAsync(Task.WhenAll(observing, completing)); + if (reader.LateFailures is { } report) + { + await report.WaitForAllAsync(CancellationToken.None); + } } } @@ -53,7 +68,7 @@ public async Task CompleteAsync_Should_Bound_A_Noncooperative_Reader_And_Reach_L public async Task ObserveAsync_Should_Bound_First_And_Tail_Diagnostics_When_The_Stream_Ends() { var scenario = new EventDiagnosticScenario(); - var reader = new OwnedEventReader(TimeSpan.FromMinutes(1), TimeSpan.FromSeconds(1), CancellationToken.None); + var reader = new OwnedEventReader(TimeSpan.FromMinutes(1), TimeSpan.FromSeconds(1), CancellationToken.None, new OperationDeadlineScenario().Deadline); var events = new ScriptedTurnEvents("reply", "ses_owned", reader); var connected = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var thrown = await Assert.That(async () => @@ -74,7 +89,7 @@ public async Task ObserveAsync_Should_Retain_Observations_On_The_Original_Reader { var failure = new InvalidOperationException("reader transport failed"); var scenario = new EventDiagnosticScenario { Failure = failure }; - var reader = new OwnedEventReader(TimeSpan.FromMinutes(1), TimeSpan.FromSeconds(1), CancellationToken.None); + var reader = new OwnedEventReader(TimeSpan.FromMinutes(1), TimeSpan.FromSeconds(1), CancellationToken.None, new OperationDeadlineScenario().Deadline); var events = new ScriptedTurnEvents("reply", "ses_owned", reader); var connected = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var thrown = await Assert.That(async () => diff --git a/tests/OpenCode.Sdk.Tests/Support/SessionLogTranscriptTests.cs b/tests/OpenCode.Sdk.Tests/Support/SessionLogTranscriptTests.cs index 2c06b043..ac24dda9 100644 --- a/tests/OpenCode.Sdk.Tests/Support/SessionLogTranscriptTests.cs +++ b/tests/OpenCode.Sdk.Tests/Support/SessionLogTranscriptTests.cs @@ -73,6 +73,8 @@ public async Task Cleanup_Should_Bound_Stalled_Disposal_And_Retain_Its_Late_Fail var removalFailure = new InvalidOperationException("removal failure"); var lateDisposalFailure = new InvalidOperationException("late disposal failure"); var removalTokenWasCancelled = true; + var scenario = new OperationDeadlineScenario(); + var deadline = scenario.Hold("session log enumerator"); var cleanup = new OwnedSessionCleanup( _ => Task.CompletedTask, token => @@ -80,55 +82,76 @@ public async Task Cleanup_Should_Bound_Stalled_Disposal_And_Retain_Its_Late_Fail removalTokenWasCancelled = token.IsCancellationRequested; return Task.FromException(removalFailure); }, - TimeSpan.FromMilliseconds(50)); + TimeSpan.FromMilliseconds(50), scenario.Deadline); cleanup.MarkTurnCompleted(); cleanup.Own("session log enumerator", transcript.DisposeAsync); - var thrown = await Assert.That(async () => await cleanup.CompleteAsync(primaryFailure)) - .Throws(); + var completing = cleanup.CompleteAsync(primaryFailure); + try + { + await enumerator.Entered; + await deadline.Entered; + deadline.Expire(); + deadline.Deliver(); + var thrown = await Assert.That(() => completing) + .Throws(); - await Assert.That(thrown).IsSameReferenceAs(primaryFailure); - await Assert.That(enumerator.DisposeCalls).IsEqualTo(1); - await Assert.That(removalTokenWasCancelled).IsFalse(); - var failures = primaryFailure.Data[OwnedSessionCleanup.FailuresKey] as AggregateException; - await Assert.That(failures).IsNotNull(); - await Assert.That(failures!.InnerExceptions).Count().IsEqualTo(2); - await Assert.That(failures.InnerExceptions[0]).IsTypeOf(); - await Assert.That(failures.InnerExceptions[1]).IsSameReferenceAs(removalFailure); - var diagnosticFailures = primaryFailure.Data[OwnedSessionCleanup.LateFailuresKey] - as IReadOnlyCollection>; - await Assert.That(diagnosticFailures).IsNotNull(); - var late = cleanup.LateFailures; - await Assert.That(late).IsNotNull(); - - disposal.SetException(lateDisposalFailure); - using var observationBudget = new CancellationTokenSource(TimeSpan.FromSeconds(1)); - var observed = await late.WaitForFailureAsync(observationBudget.Token); - - await Assert.That(observed.Key).IsEqualTo("session log enumerator"); - await Assert.That(observed.Value).IsSameReferenceAs(lateDisposalFailure); - await Assert.That(late.Failures).Count().IsEqualTo(1); - await Assert.That(late.Failures[0].Value).IsSameReferenceAs(lateDisposalFailure); - await Assert.That(diagnosticFailures!).Count().IsEqualTo(1); - await Assert.That(diagnosticFailures!.Single().Key).IsEqualTo("session log enumerator"); - await Assert.That(diagnosticFailures.Single().Value).IsSameReferenceAs(lateDisposalFailure); - _ = await Assert.That(window.Cancel).Throws(); - Console.WriteLine( - "session-log-cleanup: immediate-failures=" + failures.InnerExceptions.Count.ToString( - System.Globalization.CultureInfo.InvariantCulture) + - " removal-token-cancelled=" + removalTokenWasCancelled.ToString() + - " late-operation=" + observed.Key); + await Assert.That(thrown).IsSameReferenceAs(primaryFailure); + await Assert.That(enumerator.DisposeCalls).IsEqualTo(1); + await Assert.That(removalTokenWasCancelled).IsFalse(); + var failures = primaryFailure.Data[OwnedSessionCleanup.FailuresKey] as AggregateException; + await Assert.That(failures).IsNotNull(); + await Assert.That(failures!.InnerExceptions).Count().IsEqualTo(2); + await Assert.That(failures.InnerExceptions[0]).IsTypeOf(); + await Assert.That(failures.InnerExceptions[1]).IsSameReferenceAs(removalFailure); + var diagnosticFailures = primaryFailure.Data[OwnedSessionCleanup.LateFailuresKey] + as IReadOnlyCollection>; + await Assert.That(diagnosticFailures).IsNotNull(); + var late = cleanup.LateFailures; + await Assert.That(late).IsNotNull(); + + disposal.SetException(lateDisposalFailure); + var observed = await late.WaitForFailureAsync(CancellationToken.None); + + await Assert.That(observed.Key).IsEqualTo("session log enumerator"); + await Assert.That(observed.Value).IsSameReferenceAs(lateDisposalFailure); + await Assert.That(late.Failures).Count().IsEqualTo(1); + await Assert.That(late.Failures[0].Value).IsSameReferenceAs(lateDisposalFailure); + await Assert.That(diagnosticFailures!).Count().IsEqualTo(1); + await Assert.That(diagnosticFailures!.Single().Key).IsEqualTo("session log enumerator"); + await Assert.That(diagnosticFailures.Single().Value).IsSameReferenceAs(lateDisposalFailure); + _ = await Assert.That(window.Cancel).Throws(); + Console.WriteLine( + "session-log-cleanup: immediate-failures=" + failures.InnerExceptions.Count.ToString( + System.Globalization.CultureInfo.InvariantCulture) + + " removal-token-cancelled=" + removalTokenWasCancelled.ToString() + + " late-operation=" + observed.Key); + } + finally + { + _ = disposal.TrySetResult(true); + await scenario.DrainAsync(completing); + if (cleanup.LateFailures is { } report) + { + await report.WaitForAllAsync(CancellationToken.None); + } + } } private sealed class ControlledAsyncEnumerator(Task disposal) : IAsyncEnumerator { public ISessionLogItem Current => throw new InvalidOperationException("No item is available."); + private readonly TaskCompletionSource _entered = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public Task Entered => _entered.Task; + public int DisposeCalls { get; private set; } public ValueTask DisposeAsync() { DisposeCalls++; + _ = _entered.TrySetResult(true); return new ValueTask(disposal); } From be2b3d7a91a791569fe5597a52ec7a3812863747 Mon Sep 17 00:00:00 2001 From: "denizirgin@gmail.com" Date: Sun, 6 Sep 2026 16:00:44 +0300 Subject: [PATCH 19/62] test: retain owned live failure diagnostics in CI artifacts --- .editorconfig | 17 +- .../Fixtures/Server/diagnostic-peer.js | 8 + .../Fixtures/Server/exited-diagnostic-peer.js | 2 + .../Server/invalid-diagnostic-peer.js | 6 + .../Fixtures/Server/silent-diagnostic-peer.js | 4 + ...PinnedOpenCodeServerFixtureFailureTests.cs | 217 ++++++++++++++++-- .../Ptys/PtySessionLiveTests.cs | 12 + .../ServerFailureArtifactsTests.cs | 44 ++++ .../OpenCode.Sdk.Tests/ServerLogTailTests.cs | 25 ++ .../Support/DiagnosticWriteBarrier.cs | 20 ++ .../Support/OperationDeadlineScenario.cs | 2 +- .../Support/OwnedCleanupDeadlineTests.cs | 1 + .../Support/OwnedCleanupTests.cs | 1 + .../Support/OwnedEventReader.cs | 3 +- .../Support/OwnedEventReaderTests.cs | 1 + .../Support/OwnedOperationDeadlineTests.cs | 1 + .../Support/OwnedSessionCleanup.cs | 3 +- .../Support/PendingPermissionCleanupTests.cs | 1 + .../Support/PtyDiagnosticServer.cs | 42 ++++ .../Support/PtyFailureDiagnostics.cs | 72 ++++++ .../Support/PtyFailureDiagnosticsTests.cs | 81 +++++++ .../Support/PtyLiveScenario.cs | 105 +++------ .../Support/PtyLiveTranscript.cs | 38 ++- .../Support/ScriptedTurnEventsTests.cs | 1 + .../Support/ServerDiagnosticScenario.cs | 104 +++++++++ .../Support/StartupDiagnosticMode.cs | 9 + .../TestResultsDirectoryTests.cs | 29 +++ tests/Shared/CliWrapServerAdapter.cs | 165 +++++++------ tests/Shared/DiagnosticFileWriter.cs | 12 + tests/Shared/FailureDiagnosticText.cs | 72 ++++++ .../Abstractions/IOwnedOperationDeadline.cs | 2 +- .../Ownership}/LateCleanupFailureReport.cs | 2 +- .../Ownership}/OwnedCleanup.cs | 4 +- .../Ownership}/OwnedCleanupOperation.cs | 2 +- .../Ownership}/OwnedOperationDeadline.cs | 4 +- tests/Shared/PinnedOpenCodeServerFixture.cs | 119 ++++++++-- tests/Shared/ServerFailureArtifacts.cs | 103 +++++++++ tests/Shared/ServerFailureCapture.cs | 60 +++++ .../Shared/ServerFixtureDiagnosticsOptions.cs | 13 ++ tests/Shared/ServerLogSnapshot.cs | 8 + tests/Shared/ServerLogTail.cs | 40 ++++ tests/Shared/ServerTestFailure.cs | 16 ++ tests/Shared/SimulatedDriveServerFixture.cs | 2 +- tests/Shared/TestResultsDirectory.cs | 32 +++ 44 files changed, 1300 insertions(+), 205 deletions(-) create mode 100644 tests/OpenCode.Sdk.Tests/Fixtures/Server/diagnostic-peer.js create mode 100644 tests/OpenCode.Sdk.Tests/Fixtures/Server/exited-diagnostic-peer.js create mode 100644 tests/OpenCode.Sdk.Tests/Fixtures/Server/invalid-diagnostic-peer.js create mode 100644 tests/OpenCode.Sdk.Tests/Fixtures/Server/silent-diagnostic-peer.js create mode 100644 tests/OpenCode.Sdk.Tests/ServerFailureArtifactsTests.cs create mode 100644 tests/OpenCode.Sdk.Tests/ServerLogTailTests.cs create mode 100644 tests/OpenCode.Sdk.Tests/Support/DiagnosticWriteBarrier.cs create mode 100644 tests/OpenCode.Sdk.Tests/Support/PtyDiagnosticServer.cs create mode 100644 tests/OpenCode.Sdk.Tests/Support/PtyFailureDiagnostics.cs create mode 100644 tests/OpenCode.Sdk.Tests/Support/PtyFailureDiagnosticsTests.cs create mode 100644 tests/OpenCode.Sdk.Tests/Support/ServerDiagnosticScenario.cs create mode 100644 tests/OpenCode.Sdk.Tests/Support/StartupDiagnosticMode.cs create mode 100644 tests/OpenCode.Sdk.Tests/TestResultsDirectoryTests.cs create mode 100644 tests/Shared/DiagnosticFileWriter.cs create mode 100644 tests/Shared/FailureDiagnosticText.cs rename tests/{OpenCode.Sdk.Tests/Support => Shared/Ownership}/Abstractions/IOwnedOperationDeadline.cs (74%) rename tests/{OpenCode.Sdk.Tests/Support => Shared/Ownership}/LateCleanupFailureReport.cs (98%) rename tests/{OpenCode.Sdk.Tests/Support => Shared/Ownership}/OwnedCleanup.cs (98%) rename tests/{OpenCode.Sdk.Tests/Support => Shared/Ownership}/OwnedCleanupOperation.cs (94%) rename tests/{OpenCode.Sdk.Tests/Support => Shared/Ownership}/OwnedOperationDeadline.cs (85%) create mode 100644 tests/Shared/ServerFailureArtifacts.cs create mode 100644 tests/Shared/ServerFailureCapture.cs create mode 100644 tests/Shared/ServerFixtureDiagnosticsOptions.cs create mode 100644 tests/Shared/ServerLogSnapshot.cs create mode 100644 tests/Shared/ServerLogTail.cs create mode 100644 tests/Shared/ServerTestFailure.cs create mode 100644 tests/Shared/TestResultsDirectory.cs diff --git a/.editorconfig b/.editorconfig index 8d353d54..4990f0f9 100644 --- a/.editorconfig +++ b/.editorconfig @@ -907,14 +907,14 @@ dotnet_diagnostic.VSTHRD003.severity = none # fault-observation continuations. The bridge lets a deterministic test await an otherwise-late # cleanup failure without sleeping. Winner: the bounded cleanup reporting contract; VSTHRD003 # remains enabled elsewhere. -[tests/OpenCode.Sdk.Tests/Support/LateCleanupFailureReport.cs] +[tests/Shared/Ownership/LateCleanupFailureReport.cs] dotnet_diagnostic.VSTHRD003.severity = none # Cleanup owners retain raw tasks until actual completion and separately bound observation. # Awaiting those owned tasks directly preserves their original result/cancellation identity; # callback invocation is isolated on the thread pool, with no foreign synchronization-context # dependency. Winner: owned cleanup observation; VSTHRD003 remains enabled elsewhere. -[tests/OpenCode.Sdk.Tests/Support/OwnedCleanup.cs] +[tests/Shared/Ownership/OwnedCleanup.cs] dotnet_diagnostic.VSTHRD003.severity = none [tests/OpenCode.Sdk.Tests/Support/OwnedEventReader.cs] @@ -957,6 +957,19 @@ dotnet_diagnostic.VSTHRD003.severity = none [tests/OpenCode.Sdk.Tests/Sessions/SessionMessagePaginationLiveTests.cs] dotnet_diagnostic.VSTHRD003.severity = none +# This fixture proof owns the disposal task and controls its deadline delivery. Its finally +# releases all signals and drains the raw operations. Winner: causal owned-process failure proof; +# VSTHRD003 remains enabled outside this exact file. +[tests/OpenCode.Sdk.Tests/PinnedOpenCodeServerFixtureFailureTests.cs] +dotnet_diagnostic.VSTHRD003.severity = none +# These diagnostic proofs control their own operation deadlines and retain supplied disposal +# tasks until finally releases and drains them. Winner: causal fixture/status ownership proofs; +# the rule remains enabled outside these exact files. +[tests/OpenCode.Sdk.Tests/Support/PtyFailureDiagnosticsTests.cs] +dotnet_diagnostic.VSTHRD003.severity = none + +[tests/OpenCode.Sdk.Tests/Support/ServerDiagnosticScenario.cs] +dotnet_diagnostic.VSTHRD003.severity = none # CliWrapServerAdapter parks on TaskCompletionSource handles the adapter itself completes — the # stdin lease that holds the child's ownership pipe open until disposal, and the first stdout # line that carries the readiness contract — plus the CliWrap execution task it started. That diff --git a/tests/OpenCode.Sdk.Tests/Fixtures/Server/diagnostic-peer.js b/tests/OpenCode.Sdk.Tests/Fixtures/Server/diagnostic-peer.js new file mode 100644 index 00000000..7d15bf2f --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Fixtures/Server/diagnostic-peer.js @@ -0,0 +1,8 @@ +console.log(JSON.stringify({ url: "http://127.0.0.1:1" })); +console.log("BODY-STDOUT"); +console.error("BODY-STDERR"); +process.stdin.resume(); +process.stdin.on("end", () => { + console.log("FINAL-STDOUT"); + console.error("FINAL-STDERR"); +}); diff --git a/tests/OpenCode.Sdk.Tests/Fixtures/Server/exited-diagnostic-peer.js b/tests/OpenCode.Sdk.Tests/Fixtures/Server/exited-diagnostic-peer.js new file mode 100644 index 00000000..ab0f975b --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Fixtures/Server/exited-diagnostic-peer.js @@ -0,0 +1,2 @@ +console.error("FINAL-STDERR"); +process.exit(7); diff --git a/tests/OpenCode.Sdk.Tests/Fixtures/Server/invalid-diagnostic-peer.js b/tests/OpenCode.Sdk.Tests/Fixtures/Server/invalid-diagnostic-peer.js new file mode 100644 index 00000000..2b35620e --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Fixtures/Server/invalid-diagnostic-peer.js @@ -0,0 +1,6 @@ +console.log("INVALID-READY"); +process.stdin.resume(); +process.stdin.on("end", () => { + console.log("FINAL-STDOUT"); + console.error("FINAL-STDERR"); +}); diff --git a/tests/OpenCode.Sdk.Tests/Fixtures/Server/silent-diagnostic-peer.js b/tests/OpenCode.Sdk.Tests/Fixtures/Server/silent-diagnostic-peer.js new file mode 100644 index 00000000..9525a7b9 --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Fixtures/Server/silent-diagnostic-peer.js @@ -0,0 +1,4 @@ +process.stdin.resume(); +process.stdin.on("end", () => { + console.error("FINAL-STDERR"); +}); diff --git a/tests/OpenCode.Sdk.Tests/PinnedOpenCodeServerFixtureFailureTests.cs b/tests/OpenCode.Sdk.Tests/PinnedOpenCodeServerFixtureFailureTests.cs index c97d4e31..0963fc5d 100644 --- a/tests/OpenCode.Sdk.Tests/PinnedOpenCodeServerFixtureFailureTests.cs +++ b/tests/OpenCode.Sdk.Tests/PinnedOpenCodeServerFixtureFailureTests.cs @@ -1,35 +1,222 @@ +using OpenCode.Sdk.Tests.Support; using OpenCode.Sdk.TestSupport; -using Testably.Abstractions; +using OpenCode.Sdk.TestSupport.Ownership; namespace OpenCode.Sdk.Tests; /// /// Pins the failure-path log-retention contract: a real -/// startup failure must retain stdout.log/stderr.log under the run root, not just the lower-level +/// failures retain bounded stdout/stderr and identity beneath the results root, beyond the lower-level /// adapter's own in-memory buffers. Drives the fixture itself (its internal command-override /// seam), not the shared per-session instance the other fixture tests share. /// [NotInParallel(ParallelConstraintKeys.ServerProcess)] public sealed class PinnedOpenCodeServerFixtureFailureTests { - private static readonly RealFileSystem FileSystem = new(); [Test] - public async Task InitializeAsync_Failure_Should_Retain_Logs_Under_The_Run_Root() + [Timeout(30_000)] + public async Task DisposeAsync_Should_Retain_A_Late_Capture_Failure_Without_Delaying_Other_Artifacts(CancellationToken cancellationToken) { - var pinnedCommand = new PinnedServerCommand(FileSystem); - var fixture = new PinnedOpenCodeServerFixture( - ["bun", "-e", "console.error('boom'); process.exit(7)"], - pinnedCommand.RepositoryRoot); + using var barrier = new DiagnosticWriteBarrier(); + var captureFailure = new IOException("Late stderr write failure."); + var scenario = new ServerDiagnosticScenario { CaptureBarrier = barrier, CaptureFailure = captureFailure }; + var deadline = scenario.Deadlines.Hold("pinned server stderr capture"); + await using var fixture = scenario.CreateFixture(); + var primary = new InvalidOperationException("Deliberate body failure before late capture."); + var completion = Task.CompletedTask; + try + { + await fixture.InitializeAsync(); + fixture.MarkFailure(primary, "late capture body", "pty-phase=initial READY"); + completion = fixture.DisposeAsync().AsTask(); + await barrier.Entered.WaitAsync(cancellationToken); + await deadline.Entered.WaitAsync(cancellationToken); + deadline.Release(); - _ = await Assert.That(fixture.InitializeAsync).Throws(); + var observed = await Assert.That(async () => await completion).Throws(); + await Assert.That(observed).IsSameReferenceAs(primary); + await Assert.That(await scenario.ReadLogAsync(fixture, "stdout.log")).Contains("FINAL-STDOUT"); + await Assert.That(await scenario.ReadMetadataAsync(fixture)).Contains("pinned server stderr capture"); + barrier.Release(); + await scenario.Deadlines.DrainAsync(completion); + await fixture.DrainDiagnosticsAsync(cancellationToken); + var late = primary.Data[OwnedCleanup.LateFailuresKey] + as System.Collections.Concurrent.ConcurrentQueue>; + await Assert.That(late!.Single().Value).IsSameReferenceAs(captureFailure); + } + finally + { + barrier.Release(); + await scenario.DisposeFixtureAsync(fixture, completion); + } + } - var runRootPath = fixture.RunRoot.Path; - await fixture.DisposeAsync(); + [Test] + [Timeout(30_000)] + public async Task DisposeAsync_Should_Report_An_Unconfirmed_Forced_Exit_Without_A_Body_Failure(CancellationToken cancellationToken) + { + var scenario = new ServerDiagnosticScenario(); + var grace = scenario.Deadlines.Hold("pinned server graceful exit"); + var forced = scenario.Deadlines.Hold("pinned server forced exit"); + await using var fixture = scenario.CreateFixture(); + var completion = Task.CompletedTask; + try + { + await fixture.InitializeAsync(); + completion = fixture.DisposeAsync().AsTask(); + await grace.Entered.WaitAsync(cancellationToken); + _ = await fixture.Adapter.WaitForErrorLineAsync("FINAL-STDERR", cancellationToken); + grace.Release(); + await forced.Entered.WaitAsync(cancellationToken); + forced.Release(); - var stdoutPath = FileSystem.Path.Combine(runRootPath, "logs", "stdout.log"); - var stderrPath = FileSystem.Path.Combine(runRootPath, "logs", "stderr.log"); - await Assert.That(FileSystem.File.Exists(stdoutPath)).IsTrue(); - await Assert.That(FileSystem.File.Exists(stderrPath)).IsTrue(); + var observed = await Assert.That(async () => await completion).Throws(); + await Assert.That(observed!.Message).Contains("pinned server forced exit"); + await Assert.That(observed.InnerException).IsSameReferenceAs(forced.Failure); + await fixture.DrainDiagnosticsAsync(cancellationToken); + await Assert.That(await scenario.ReadLogAsync(fixture, "stderr.log")).Contains("FINAL-STDERR"); + await Assert.That(await scenario.ReadMetadataAsync(fixture)).Contains("pinned server forced exit"); + } + finally + { + await scenario.DisposeFixtureAsync(fixture, completion); + } + } + + [Test] + [Arguments(StartupDiagnosticMode.InvalidReadiness)] + [Arguments(StartupDiagnosticMode.ReadinessTimeout)] + [Arguments(StartupDiagnosticMode.CallerCancellation)] + [Arguments(StartupDiagnosticMode.EarlyExit)] + [Timeout(30_000)] + public async Task StartAsync_Should_Preserve_Startup_Failure_When_Teardown_Also_Times_Out( + StartupDiagnosticMode mode, CancellationToken cancellationToken) + { + var scenario = new ServerDiagnosticScenario(); + var deadline = scenario.Deadlines.Hold("pinned server startup teardown"); + using var caller = new CancellationTokenSource(); + if (mode is StartupDiagnosticMode.CallerCancellation) + { + await caller.CancelAsync(); + } + + CliWrapServerAdapter? adapter = null; + var startup = scenario.StartAdapterAsync(mode, created => adapter = created, caller.Token); + try + { + await deadline.Entered.WaitAsync(cancellationToken); + var ownedAdapter = adapter ?? throw new InvalidOperationException("The startup adapter was not constructed."); + _ = await ownedAdapter.WaitForErrorLineAsync("FINAL-STDERR", cancellationToken); + deadline.Expire(); + await deadline.Won.WaitAsync(cancellationToken); + deadline.Deliver(); + + var failure = await Assert.That(async () => await startup).ThrowsException(); + if (mode is StartupDiagnosticMode.CallerCancellation) + { + await Assert.That(failure).IsTypeOf(); + await Assert.That(((OperationCanceledException)failure!).CancellationToken).IsEqualTo(caller.Token); + } + else + { + await Assert.That(failure).IsTypeOf(); + } + + var secondary = failure!.Data[OwnedCleanup.FailuresKey] as AggregateException; + await Assert.That(secondary!.InnerExceptions.OfType().Single().InnerException) + .IsSameReferenceAs(deadline.Failure); + await Assert.That(failure.Data["PinnedServer.StartupLogs"] as string).Contains("FINAL-STDERR"); + } + finally + { + await scenario.Deadlines.DrainAsync(startup); + if (adapter is not null) + { + await adapter.DisposeAsync(); + } + } + } + + [Test] + public async Task DisposeAsync_Should_Capture_Final_Output_And_Preserve_The_Body_Failure() + { + var scenario = new ServerDiagnosticScenario(); + var fixture = scenario.CreateFixture(); + var primary = new InvalidOperationException("Deliberate live body failure."); + try + { + await fixture.InitializeAsync(); + fixture.MarkFailure(primary, "controlled body", "pty-phase=initial READY; pty-id=controlled-pty"); + + var observed = await Assert.That(async () => await fixture.DisposeAsync()).Throws(); + + await Assert.That(observed).IsSameReferenceAs(primary); + await Assert.That(primary.Data[OwnedCleanup.FailuresKey]).IsNull(); + await Assert.That(await scenario.ReadLogAsync(fixture, "stdout.log")).Contains("FINAL-STDOUT"); + await Assert.That(await scenario.ReadLogAsync(fixture, "stderr.log")).Contains("FINAL-STDERR"); + var metadata = await scenario.ReadMetadataAsync(fixture); + await Assert.That(metadata).Contains("controlled body"); + await Assert.That(metadata).Contains("framework="); + await Assert.That(metadata).Contains("pty-phase=initial READY"); + } + finally + { + await scenario.DisposeFixtureAsync(fixture, Task.CompletedTask); + } + } + + [Test] + [Timeout(30_000)] + public async Task DisposeAsync_Should_Preserve_Teardown_And_Capture_Failures_With_Available_Output(CancellationToken cancellationToken) + { + var captureFailure = new IOException("Deliberate stderr capture failure."); + var scenario = new ServerDiagnosticScenario { CaptureFailure = captureFailure }; + var deadline = scenario.Deadlines.Hold("pinned server teardown"); + var fixture = scenario.CreateFixture(); + var primary = new InvalidOperationException("Deliberate live body failure."); + var completion = Task.CompletedTask; + try + { + await fixture.InitializeAsync(); + fixture.MarkFailure(primary, "controlled teardown", "pty-phase=initial READY"); + completion = fixture.DisposeAsync().AsTask(); + await deadline.Entered.WaitAsync(cancellationToken); + _ = await fixture.Adapter.WaitForErrorLineAsync("FINAL-STDERR", cancellationToken); + deadline.Expire(); + await deadline.Won.WaitAsync(cancellationToken); + deadline.Deliver(); + + var observed = await Assert.That(async () => await completion).Throws(); + await Assert.That(observed).IsSameReferenceAs(primary); + var failures = primary.Data[OwnedCleanup.FailuresKey] as AggregateException; + await Assert.That(failures!.InnerExceptions).Contains(captureFailure); + await Assert.That(failures.InnerExceptions.OfType().Any()).IsTrue(); + await Assert.That(await scenario.ReadLogAsync(fixture, "stdout.log")).Contains("FINAL-STDOUT"); + await Assert.That(await scenario.ReadMetadataAsync(fixture)).Contains("Deliberate live body failure"); + } + finally + { + await scenario.DisposeFixtureAsync(fixture, completion); + } + } + + [Test] + public async Task InitializeAsync_Should_Retain_Logs_Under_The_Results_Root_On_Failure() + { + var scenario = new ServerDiagnosticScenario(); + await using var fixture = scenario.CreateFixture("Server.exited-diagnostic-peer.js"); + try + { + var startup = await Assert.That(fixture.InitializeAsync).Throws(); + var disposal = await Assert.That(async () => await fixture.DisposeAsync()).Throws(); + await Assert.That(disposal).IsSameReferenceAs(startup); + await Assert.That(await scenario.ReadLogAsync(fixture, "stderr.log")).Contains("FINAL-STDERR"); + await Assert.That(await scenario.ReadMetadataAsync(fixture)).Contains("phase=server startup"); + } + finally + { + await scenario.DisposeFixtureAsync(fixture, Task.CompletedTask); + } } } diff --git a/tests/OpenCode.Sdk.Tests/Ptys/PtySessionLiveTests.cs b/tests/OpenCode.Sdk.Tests/Ptys/PtySessionLiveTests.cs index c0e5111c..3f042b55 100644 --- a/tests/OpenCode.Sdk.Tests/Ptys/PtySessionLiveTests.cs +++ b/tests/OpenCode.Sdk.Tests/Ptys/PtySessionLiveTests.cs @@ -62,6 +62,7 @@ public async Task Pty_Should_Create_Execute_Replay_Resume_And_Remove(Cancellatio private async Task AssertHttpLifecycleAsync(CancellationToken cancellationToken) { + _scenario.Diagnostics.Enter("create"); var created = await _scenario.Client.Ptys.CreatePtyAsync( new PtyCreateRequest { @@ -74,6 +75,8 @@ private async Task AssertHttpLifecycleAsync(CancellationToken c cancellationToken: cancellationToken); var ptyId = created.Pty.Id; + _scenario.Diagnostics.Created(ptyId, created.Pty.Pid, created.Status); + _scenario.Diagnostics.Enter("HTTP lifecycle"); var terminal = _scenario.Client.Ptys.GetPtyClient(ptyId); _scenario.Own(terminal); @@ -155,10 +158,13 @@ private async Task AssertInitialExecutionAsync( { var nonce = Guid.NewGuid().ToString("N"); var acknowledgement = "ACK:" + nonce; + _scenario.Diagnostics.Enter("initial connect"); var session = _scenario.Own(await terminal.ConnectAsync( new PtyConnectOptions { Location = _scenario.Location }, cancellationToken)); var transcript = new PtyLiveTranscript(); + _scenario.Diagnostics.Enter("initial READY", transcript); _ = await transcript.ReadThroughReplayAsync(session, [ReadyRecord], cancellationToken); + _scenario.Diagnostics.Enter("initial input acknowledgement", transcript); await session.WriteAsync("RUN " + nonce + "\r", cancellationToken); await transcript.ReadUntilRecordAsync(session, acknowledgement, cancellationToken); await session.DisposeAsync(); @@ -170,22 +176,27 @@ private async Task AssertReplayAndResumeAsync( string acknowledgementA, CancellationToken cancellationToken) { + _scenario.Diagnostics.Enter("replay connect"); var replay = _scenario.Own(await terminal.ConnectAsync( new PtyConnectOptions { Location = _scenario.Location }, cancellationToken)); var replayTranscript = new PtyLiveTranscript(); + _scenario.Diagnostics.Enter("replay records", replayTranscript); var replayCursor = await replayTranscript.ReadThroughReplayAsync(replay, [], cancellationToken); await Assert.That(replayTranscript.ContainsRecord(ReadyRecord)).IsTrue(); await Assert.That(replayTranscript.ContainsRecord(acknowledgementA)).IsTrue(); var nonceB = Guid.NewGuid().ToString("N"); var acknowledgementB = "ACK:" + nonceB; + _scenario.Diagnostics.Enter("replay input acknowledgement", replayTranscript); await replay.WriteAsync("RUN " + nonceB + "\r", cancellationToken); await replayTranscript.ReadUntilRecordAsync(replay, acknowledgementB, cancellationToken); await replay.DisposeAsync(); + _scenario.Diagnostics.Enter("resume connect"); var resumed = _scenario.Own(await terminal.ConnectAsync( new PtyConnectOptions { Location = _scenario.Location, Cursor = replayCursor }, cancellationToken)); var resumedTranscript = new PtyLiveTranscript(); + _scenario.Diagnostics.Enter("resume records", resumedTranscript); var resumeCursor = await resumedTranscript.ReadThroughReplayAsync(resumed, [], cancellationToken); await Assert.That(resumedTranscript.ContainsRecord(acknowledgementB)).IsTrue(); await Assert.That(resumedTranscript.ContainsRecord(ReadyRecord)).IsFalse(); @@ -207,6 +218,7 @@ private async Task AssertRemovalAsync( PtyReplayEvidence replay, CancellationToken cancellationToken) { + _scenario.Diagnostics.Enter("removal and socket close", replay.Transcript); var completion = replay.Transcript.ReadToCompletionAsync(replay.Session, cancellationToken); _scenario.Observe(completion); var removed = await terminal.RemovePtyAsync( diff --git a/tests/OpenCode.Sdk.Tests/ServerFailureArtifactsTests.cs b/tests/OpenCode.Sdk.Tests/ServerFailureArtifactsTests.cs new file mode 100644 index 00000000..7095254f --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/ServerFailureArtifactsTests.cs @@ -0,0 +1,44 @@ +using OpenCode.Sdk.TestSupport; +using Testably.Abstractions.Testing; + +namespace OpenCode.Sdk.Tests; + +public sealed class ServerFailureArtifactsTests +{ + [Test] + public async Task WriteMetadataAsync_Should_Bound_Details_Without_Losing_Primary_Identity() + { + var fileSystem = new MockFileSystem(); + var artifacts = new ServerFailureArtifacts(fileSystem, fileSystem.Path.GetFullPath("results")); + var path = artifacts.Mark(new InvalidOperationException("primary remains visible"), "bounded test", new string('x', 50_000)); + + await artifacts.WriteMetadataAsync("owned", "42", null); + + using var reader = fileSystem.File.OpenText(path); + var text = await reader.ReadToEndAsync(); + await Assert.That(text.Length).IsLessThanOrEqualTo(16_384); + await Assert.That(text).Contains("[truncated]"); + await Assert.That(text).Contains("primary remains visible"); + } + + [Test] + public async Task Mark_Should_Deduplicate_Only_The_Same_Invocation_And_Exception() + { + var fileSystem = new MockFileSystem(); + var artifacts = new ServerFailureArtifacts(fileSystem, fileSystem.Path.GetFullPath("results")); + var failure = new InvalidOperationException("shared failure"); + var first = artifacts.Mark(failure, "first test", "phase=create", "first"); + var duplicate = artifacts.Mark(failure, "first test", "phase=create", "first"); + var second = artifacts.Mark(failure, "second test", "phase=connect", "second"); + + await artifacts.WriteMetadataAsync("owned", "42", null); + + await Assert.That(first).IsEqualTo(duplicate); + await Assert.That(second).IsNotEqualTo(first); + await Assert.That(fileSystem.File.Exists(first)).IsTrue(); + await Assert.That(fileSystem.File.Exists(second)).IsTrue(); + await Assert.That(artifacts.Report(failure, "first")).IsTrue(); + await Assert.That(artifacts.Report(failure, "first")).IsFalse(); + await Assert.That(artifacts.Report(failure, "second")).IsTrue(); + } +} diff --git a/tests/OpenCode.Sdk.Tests/ServerLogTailTests.cs b/tests/OpenCode.Sdk.Tests/ServerLogTailTests.cs new file mode 100644 index 00000000..ac140ff0 --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/ServerLogTailTests.cs @@ -0,0 +1,25 @@ +using System.Globalization; +using OpenCode.Sdk.TestSupport; + +namespace OpenCode.Sdk.Tests; + +public sealed class ServerLogTailTests +{ + [Test] + public async Task Snapshot_Should_Bound_Long_Lines_And_Report_Discarded_Output() + { + var tail = new ServerLogTail(); + for (var index = 0; index < 501; index++) + { + tail.Append(index.ToString(CultureInfo.InvariantCulture) + new string('x', 5_000)); + } + + var snapshot = tail.Snapshot(); + + await Assert.That(snapshot.Count).IsEqualTo(501); + await Assert.That(snapshot[0]).Contains("discarded-lines=1 shortened-lines=501"); + await Assert.That(snapshot.Skip(1).All(static line => line.Length == 4_096)).IsTrue(); + await Assert.That(snapshot[500]).StartsWith("500"); + await Assert.That(snapshot[500]).EndsWith(" [truncated]"); + } +} diff --git a/tests/OpenCode.Sdk.Tests/Support/DiagnosticWriteBarrier.cs b/tests/OpenCode.Sdk.Tests/Support/DiagnosticWriteBarrier.cs new file mode 100644 index 00000000..d9772507 --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Support/DiagnosticWriteBarrier.cs @@ -0,0 +1,20 @@ +namespace OpenCode.Sdk.Tests.Support; + +/// Controls a synchronous filesystem callback executed by the owned capture worker. +internal sealed class DiagnosticWriteBarrier : IDisposable +{ + private readonly ManualResetEventSlim _release = new(); + private readonly TaskCompletionSource _entered = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public Task Entered => _entered.Task; + + public void Block() + { + _ = _entered.TrySetResult(true); + _release.Wait(); + } + + public void Release() => _release.Set(); + + public void Dispose() => _release.Dispose(); +} diff --git a/tests/OpenCode.Sdk.Tests/Support/OperationDeadlineScenario.cs b/tests/OpenCode.Sdk.Tests/Support/OperationDeadlineScenario.cs index 13f9495a..50eaa43a 100644 --- a/tests/OpenCode.Sdk.Tests/Support/OperationDeadlineScenario.cs +++ b/tests/OpenCode.Sdk.Tests/Support/OperationDeadlineScenario.cs @@ -1,6 +1,6 @@ using System.Collections.Concurrent; using NSubstitute; -using OpenCode.Sdk.Tests.Support.Abstractions; +using OpenCode.Sdk.TestSupport.Ownership.Abstractions; namespace OpenCode.Sdk.Tests.Support; diff --git a/tests/OpenCode.Sdk.Tests/Support/OwnedCleanupDeadlineTests.cs b/tests/OpenCode.Sdk.Tests/Support/OwnedCleanupDeadlineTests.cs index 8762bd8b..d134491f 100644 --- a/tests/OpenCode.Sdk.Tests/Support/OwnedCleanupDeadlineTests.cs +++ b/tests/OpenCode.Sdk.Tests/Support/OwnedCleanupDeadlineTests.cs @@ -1,4 +1,5 @@ using NSubstitute; +using OpenCode.Sdk.TestSupport.Ownership; namespace OpenCode.Sdk.Tests.Support; diff --git a/tests/OpenCode.Sdk.Tests/Support/OwnedCleanupTests.cs b/tests/OpenCode.Sdk.Tests/Support/OwnedCleanupTests.cs index 0d9ea280..ec9b979b 100644 --- a/tests/OpenCode.Sdk.Tests/Support/OwnedCleanupTests.cs +++ b/tests/OpenCode.Sdk.Tests/Support/OwnedCleanupTests.cs @@ -1,3 +1,4 @@ +using OpenCode.Sdk.TestSupport.Ownership; namespace OpenCode.Sdk.Tests.Support; public sealed class OwnedCleanupTests diff --git a/tests/OpenCode.Sdk.Tests/Support/OwnedEventReader.cs b/tests/OpenCode.Sdk.Tests/Support/OwnedEventReader.cs index 477442c5..f0c3f049 100644 --- a/tests/OpenCode.Sdk.Tests/Support/OwnedEventReader.cs +++ b/tests/OpenCode.Sdk.Tests/Support/OwnedEventReader.cs @@ -1,4 +1,5 @@ -using OpenCode.Sdk.Tests.Support.Abstractions; +using OpenCode.Sdk.TestSupport.Ownership; +using OpenCode.Sdk.TestSupport.Ownership.Abstractions; namespace OpenCode.Sdk.Tests.Support; diff --git a/tests/OpenCode.Sdk.Tests/Support/OwnedEventReaderTests.cs b/tests/OpenCode.Sdk.Tests/Support/OwnedEventReaderTests.cs index a1048540..de0cf137 100644 --- a/tests/OpenCode.Sdk.Tests/Support/OwnedEventReaderTests.cs +++ b/tests/OpenCode.Sdk.Tests/Support/OwnedEventReaderTests.cs @@ -1,3 +1,4 @@ +using OpenCode.Sdk.TestSupport.Ownership; namespace OpenCode.Sdk.Tests.Support; public sealed class OwnedEventReaderTests diff --git a/tests/OpenCode.Sdk.Tests/Support/OwnedOperationDeadlineTests.cs b/tests/OpenCode.Sdk.Tests/Support/OwnedOperationDeadlineTests.cs index 86eea0db..c2aecf75 100644 --- a/tests/OpenCode.Sdk.Tests/Support/OwnedOperationDeadlineTests.cs +++ b/tests/OpenCode.Sdk.Tests/Support/OwnedOperationDeadlineTests.cs @@ -1,3 +1,4 @@ +using OpenCode.Sdk.TestSupport.Ownership; namespace OpenCode.Sdk.Tests.Support; public sealed class OwnedOperationDeadlineTests diff --git a/tests/OpenCode.Sdk.Tests/Support/OwnedSessionCleanup.cs b/tests/OpenCode.Sdk.Tests/Support/OwnedSessionCleanup.cs index f8d6fb44..b6250f59 100644 --- a/tests/OpenCode.Sdk.Tests/Support/OwnedSessionCleanup.cs +++ b/tests/OpenCode.Sdk.Tests/Support/OwnedSessionCleanup.cs @@ -1,4 +1,5 @@ -using OpenCode.Sdk.Tests.Support.Abstractions; +using OpenCode.Sdk.TestSupport.Ownership; +using OpenCode.Sdk.TestSupport.Ownership.Abstractions; namespace OpenCode.Sdk.Tests.Support; diff --git a/tests/OpenCode.Sdk.Tests/Support/PendingPermissionCleanupTests.cs b/tests/OpenCode.Sdk.Tests/Support/PendingPermissionCleanupTests.cs index a5acd11d..dc1cc698 100644 --- a/tests/OpenCode.Sdk.Tests/Support/PendingPermissionCleanupTests.cs +++ b/tests/OpenCode.Sdk.Tests/Support/PendingPermissionCleanupTests.cs @@ -1,4 +1,5 @@ using OpenCode.Sdk.Models; +using OpenCode.Sdk.TestSupport.Ownership; namespace OpenCode.Sdk.Tests.Support; diff --git a/tests/OpenCode.Sdk.Tests/Support/PtyDiagnosticServer.cs b/tests/OpenCode.Sdk.Tests/Support/PtyDiagnosticServer.cs new file mode 100644 index 00000000..c86320d3 --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Support/PtyDiagnosticServer.cs @@ -0,0 +1,42 @@ +using System.Net; +using OpenCode.Sdk.TestSupport; + +namespace OpenCode.Sdk.Tests.Support; + +/// Serves a healthy external endpoint with either a completed or stalled typed PTY response. +internal sealed class PtyDiagnosticServer : IAsyncDisposable +{ + private readonly TaskCompletionSource _requested = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public PtyDiagnosticServer(bool stall) + { + var payload = new FixtureLoader().LoadJson("Serialization.known-pty.json"); + Server = LoopbackHttpServer.Start(path => + { + if (path == "/api/health") + { + return new LoopbackHttpResponse + { + StatusCode = HttpStatusCode.OK, + ContentType = "application/json", + Body = WireBodyData.HealthOk, + }; + } + + _ = _requested.TrySetResult(true); + return new LoopbackHttpResponse + { + StatusCode = HttpStatusCode.OK, + ContentType = "application/json", + Body = WireBodyData.LocationEnvelope(payload), + KeepOpen = stall, + }; + }); + } + + public LoopbackHttpServer Server { get; } + + public Task Requested => _requested.Task; + + public async ValueTask DisposeAsync() => await Server.DisposeAsync(); +} diff --git a/tests/OpenCode.Sdk.Tests/Support/PtyFailureDiagnostics.cs b/tests/OpenCode.Sdk.Tests/Support/PtyFailureDiagnostics.cs new file mode 100644 index 00000000..bd7c27e8 --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Support/PtyFailureDiagnostics.cs @@ -0,0 +1,72 @@ +using System.Globalization; +using OpenCode.Sdk.TestSupport; +using OpenCode.Sdk.TestSupport.Ownership; +using OpenCode.Sdk.TestSupport.Ownership.Abstractions; + +namespace OpenCode.Sdk.Tests.Support; + +internal sealed class PtyFailureDiagnostics(IOwnedOperationDeadline? deadline = null) +{ + private readonly FailureDiagnosticText _text = new(); + private string _phase = "initialize"; + private string? _id; + private long? _pid; + private int? _createStatus; + private PtyLiveTranscript? _transcript; + private string _status = "not available: no PTY identity"; + + public LateCleanupFailureReport? LateFailures { get; private set; } + + public void Created(string id, long pid, int status) + { + _id = id; + _pid = pid; + _createStatus = status; + } + + public void Enter(string phase, PtyLiveTranscript? transcript = null) + { + _phase = phase; + _transcript = transcript; + } + + public async Task CaptureStatusAsync(PinnedOpenCodeServerFixture server, LocationSelector? location, Exception primary) + { + if (_id is null) + { + return; + } + + var diagnostics = new OwnedCleanup(TimeSpan.FromSeconds(3), deadline ?? new OwnedOperationDeadline()); + _status = "lookup did not finish within its diagnostic budget"; + diagnostics.Own("PTY failure status lookup", async token => + { + // The independent client stays with this operation if its observation deadline wins. + using var client = server.CreateClient(location); + var response = await client.Ptys.GetPtyClient(_id).GetPtyAsync( + new PtyRequest { Location = location }, OpenCodeRequestOptions.NoThrow, token); + _status = "http=" + response.Status.ToString(CultureInfo.InvariantCulture) + + " status=" + (response.IsError ? "unavailable" : response.Pty.Status.ToString()) + + " pid=" + (response.IsError ? "unavailable" : response.Pty.Pid.ToString(CultureInfo.InvariantCulture)); + }); + try + { + await diagnostics.CompleteAsync(primary); + } + catch (Exception exception) when (ReferenceEquals(exception, primary)) + { + // The original failure now carries any immediate/late lookup errors; cleanup follows. + primary.Data["PtyFailureDiagnostics.Status"] = _status; + } + finally + { + LateFailures = diagnostics.LateFailures; + } + } + + public string Describe() => _text.Bound( + "pty-phase=" + _phase + "\npty-id=" + (_id ?? "not-created") + + "\npty-pid=" + (_pid?.ToString(CultureInfo.InvariantCulture) ?? "not-created") + + "\npty-create-http=" + (_createStatus?.ToString(CultureInfo.InvariantCulture) ?? "no-response") + + "\npty-status=" + _status + "\nlast-observation=" + (_transcript?.Describe() ?? "no-transcript")); +} diff --git a/tests/OpenCode.Sdk.Tests/Support/PtyFailureDiagnosticsTests.cs b/tests/OpenCode.Sdk.Tests/Support/PtyFailureDiagnosticsTests.cs new file mode 100644 index 00000000..358680f9 --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Support/PtyFailureDiagnosticsTests.cs @@ -0,0 +1,81 @@ +using OpenCode.Sdk.TestSupport; +using OpenCode.Sdk.TestSupport.Ownership; +using Testably.Abstractions; + +namespace OpenCode.Sdk.Tests.Support; + +[NotInParallel(ParallelConstraintKeys.ServerProcess)] +public sealed class PtyFailureDiagnosticsTests +{ + [Test] + [Arguments(false)] + [Arguments(true)] + [Timeout(30_000)] + public async Task CaptureStatusAsync_Should_Preserve_Caller_Failure_And_External_Ownership(bool stall, CancellationToken cancellationToken) + { + var fileSystem = new RealFileSystem(); + await using var peer = new PtyDiagnosticServer(stall); + var server = peer.Server; + await using var fixture = new PinnedOpenCodeServerFixture(new ExternalServerEndpoint(server.Endpoint, "test-password")); + var deadlines = new OperationDeadlineScenario(); + var held = stall ? deadlines.Hold("PTY failure status lookup") : null; + var diagnostics = new PtyFailureDiagnostics(deadlines.Deadline); + using var cancelled = new CancellationTokenSource(); + await cancelled.CancelAsync(); + var primary = new OperationCanceledException("Original body cancellation.", cancelled.Token); + var capture = Task.CompletedTask; + try + { + await fixture.InitializeAsync(); + using var client = fixture.CreateClient(); + diagnostics.Created("pty_100", 4242, 200); + diagnostics.Enter("initial READY"); + capture = diagnostics.CaptureStatusAsync(fixture, null, primary); + await peer.Requested.WaitAsync(cancellationToken); + if (held is not null) + { + await held.Entered.WaitAsync(cancellationToken); + held.Release(); + } + + await capture; + fixture.MarkFailure(primary, "external PTY failure", diagnostics.Describe()); + var observed = await Assert.That(async () => await fixture.DisposeAsync()).Throws(); + await Assert.That(observed).IsSameReferenceAs(primary); + await Assert.That(fileSystem.Directory.GetFiles(fixture.DiagnosticsDirectory, "stdout.log").Length).IsEqualTo(0); + await Assert.That(fileSystem.Directory.GetFiles(fixture.DiagnosticsDirectory, "stderr.log").Length).IsEqualTo(0); + var health = await client.GetHealthAsync(cancellationToken: cancellationToken); + await Assert.That(health.Health.Healthy).IsTrue(); + if (stall) + { + var lateReport = diagnostics.LateFailures ?? throw new InvalidOperationException("The status deadline did not retain its operation."); + await lateReport.WaitForAllAsync(cancellationToken); + await server.ClientDisconnected.WaitAsync(cancellationToken); + await Assert.That(primary.Data[OwnedCleanup.FailuresKey]).IsTypeOf(); + } + else + { + await Assert.That(diagnostics.Describe()).Contains("http=200 status=Running pid=4242"); + } + } + finally + { + server.ReleaseResponses(); + await deadlines.DrainAsync(capture); + try + { + using var cleanup = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + if (diagnostics.LateFailures is { } late) + { + await late.WaitForAllAsync(cleanup.Token); + } + + await deadlines.DrainAsync(fixture.DisposeAsync().AsTask()); + } + finally + { + fixture.RunRoot.Dispose(); + } + } + } +} diff --git a/tests/OpenCode.Sdk.Tests/Support/PtyLiveScenario.cs b/tests/OpenCode.Sdk.Tests/Support/PtyLiveScenario.cs index cb3c4425..64c2fdfb 100644 --- a/tests/OpenCode.Sdk.Tests/Support/PtyLiveScenario.cs +++ b/tests/OpenCode.Sdk.Tests/Support/PtyLiveScenario.cs @@ -1,6 +1,7 @@ using System.Globalization; using System.Runtime.ExceptionServices; using OpenCode.Sdk.TestSupport; +using OpenCode.Sdk.TestSupport.Ownership; namespace OpenCode.Sdk.Tests.Support; @@ -12,6 +13,8 @@ internal sealed class PtyLiveScenario { private static readonly TimeSpan CleanupTimeout = TimeSpan.FromSeconds(10); + public PtyFailureDiagnostics Diagnostics { get; } = new(); + private readonly List _pendingOperations = []; private readonly List _sessions = []; private readonly PinnedOpenCodeServerFixture _server; @@ -100,107 +103,55 @@ public async Task CleanupAsync(Exception? primaryFailure) primaryFailure ?? new InvalidOperationException("The PTY live scenario was cleaned twice.")).Throw(); } - var failures = new List(); - using var cleanup = new CancellationTokenSource(CleanupTimeout); - await RemoveTerminalAsync(failures, cleanup.Token); - await ObservePendingOperationsAsync(failures, cleanup.Token); - await DisposeSessionsAsync(failures); - DisposeOwners(failures); - if (primaryFailure is not null) { - if (failures.Count > 0) - { - primaryFailure.Data["PtyLiveScenario.CleanupFailures"] = new AggregateException(failures); - } - - ExceptionDispatchInfo.Capture(primaryFailure).Throw(); - } - - if (failures.Count is 1) - { - ExceptionDispatchInfo.Capture(failures[0]).Throw(); - } - - if (failures.Count > 1) - { - throw new AggregateException("Multiple failures occurred while cleaning the PTY live scenario.", failures); + await Diagnostics.CaptureStatusAsync(_server, _location, primaryFailure); } - } - private async Task RemoveTerminalAsync(List failures, CancellationToken cancellationToken) - { - if (_removed || _terminal is null) + var cleanup = new OwnedCleanup(CleanupTimeout); + if (!_removed && _terminal is not null) { - return; - } - - try - { - var removed = await _terminal.RemovePtyAsync( - new PtyRemoveRequest { Location = Location }, - OpenCodeRequestOptions.NoThrow, - cancellationToken); - if (removed.Status is not 204 and not 404) + cleanup.Own("PTY removal", async token => { - failures.Add(new InvalidOperationException( - "PTY cleanup answered HTTP " + removed.Status.ToString(CultureInfo.InvariantCulture) + ".")); - } - } - catch (Exception exception) - { - failures.Add(exception); + var response = await _terminal.RemovePtyAsync( + new PtyRemoveRequest { Location = Location }, OpenCodeRequestOptions.NoThrow, token); + if (response.Status is not 204 and not 404) + { + throw new InvalidOperationException("PTY cleanup answered HTTP " + + response.Status.ToString(CultureInfo.InvariantCulture) + "."); + } + }); } - } - private async Task ObservePendingOperationsAsync(List failures, CancellationToken cancellationToken) - { foreach (var operation in _pendingOperations) { - try - { - await operation.WaitAsync(cancellationToken); - } - catch (Exception exception) - { - failures.Add(exception); - } + cleanup.Own("PTY pending read", operation); } - } - private async Task DisposeSessionsAsync(List failures) - { foreach (var session in _sessions) { - try - { - await session.DisposeAsync(); - } - catch (Exception exception) - { - failures.Add(exception); - } + cleanup.Own("PTY socket disposal", async _ => await session.DisposeAsync()); } - } - private void DisposeOwners(List failures) - { - try + cleanup.Own("PTY client disposal", _ => { _client?.Dispose(); - } - catch (Exception exception) + return Task.CompletedTask; + }); + cleanup.Own("PTY workspace disposal", _ => { - failures.Add(exception); - } - + _workspace?.Dispose(); + return Task.CompletedTask; + }); try { - _workspace?.Dispose(); + await cleanup.CompleteAsync(primaryFailure); } catch (Exception exception) { - failures.Add(exception); + _server.MarkFailure(exception, "PtySessionLiveTests.Pty_Should_Create_Execute_Replay_Resume_And_Remove", + Diagnostics.Describe()); + throw; } } } diff --git a/tests/OpenCode.Sdk.Tests/Support/PtyLiveTranscript.cs b/tests/OpenCode.Sdk.Tests/Support/PtyLiveTranscript.cs index 8a590635..950cb768 100644 --- a/tests/OpenCode.Sdk.Tests/Support/PtyLiveTranscript.cs +++ b/tests/OpenCode.Sdk.Tests/Support/PtyLiveTranscript.cs @@ -19,6 +19,7 @@ internal sealed class PtyLiveTranscript private readonly StringBuilder _currentRecord = new(); private readonly List _records = []; private readonly StringBuilder _text = new(); + private readonly Lock _gate = new(); private TerminalControl _control; private long? _cursor; private bool _skipLineFeed; @@ -26,7 +27,10 @@ internal sealed class PtyLiveTranscript public bool ContainsRecord(string record) { ArgumentNullException.ThrowIfNull(record); - return _records.Contains(record, StringComparer.Ordinal); + lock (_gate) + { + return _records.Contains(record, StringComparer.Ordinal); + } } /// @@ -115,10 +119,17 @@ public async Task ReadToCompletionAsync(PtySession session, CancellationToken ca } } - public string Describe() => - "records=[" + string.Join(", ", _records.Select(static record => "'" + record + "'")) + - "] cursor=" + (_cursor?.ToString(CultureInfo.InvariantCulture) ?? "") + - " text=" + Excerpt(); + public string Describe() + { + lock (_gate) + { + return "cursor=" + (_cursor?.ToString(CultureInfo.InvariantCulture) ?? "") + + " record-count=" + _records.Count.ToString(CultureInfo.InvariantCulture) + + " last-records=[" + string.Join(", ", _records.Skip(Math.Max(0, _records.Count - 8)) + .Select(static record => "'" + (record.Length <= 128 ? record : record[..116] + " [truncated]") + "'")) + + "] text-characters=" + _text.Length.ToString(CultureInfo.InvariantCulture) + " text-tail=" + Excerpt(); + } + } private static string DescribeTargets(IReadOnlyList records) => "[" + string.Join(", ", records.Select(static record => "'" + record + "'")) + "]"; @@ -128,15 +139,18 @@ private bool Reached(long? cursor, IReadOnlyList records) => private void Observe(PtyFrame frame) { - if (frame is PtyCursorFrame cursor) + lock (_gate) { - _cursor = cursor.Cursor; - return; - } + if (frame is PtyCursorFrame cursor) + { + _cursor = cursor.Cursor; + return; + } - if (frame is PtyOutputFrame output) - { - Append(output.Text); + if (frame is PtyOutputFrame output) + { + Append(output.Text); + } } } diff --git a/tests/OpenCode.Sdk.Tests/Support/ScriptedTurnEventsTests.cs b/tests/OpenCode.Sdk.Tests/Support/ScriptedTurnEventsTests.cs index 8c9336cd..a0b298a8 100644 --- a/tests/OpenCode.Sdk.Tests/Support/ScriptedTurnEventsTests.cs +++ b/tests/OpenCode.Sdk.Tests/Support/ScriptedTurnEventsTests.cs @@ -1,3 +1,4 @@ +using OpenCode.Sdk.TestSupport.Ownership; namespace OpenCode.Sdk.Tests.Support; public sealed class ScriptedTurnEventsTests diff --git a/tests/OpenCode.Sdk.Tests/Support/ServerDiagnosticScenario.cs b/tests/OpenCode.Sdk.Tests/Support/ServerDiagnosticScenario.cs new file mode 100644 index 00000000..a0f1830e --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Support/ServerDiagnosticScenario.cs @@ -0,0 +1,104 @@ +using System.IO.Abstractions; +using NSubstitute; +using OpenCode.Sdk.TestSupport; +using Testably.Abstractions; + +namespace OpenCode.Sdk.Tests.Support; + +internal sealed class ServerDiagnosticScenario +{ + private readonly RealFileSystem _fileSystem = new(); + private readonly FixtureLoader _fixtures = new(); + private readonly IFileSystem _captureFileSystem; + + public ServerDiagnosticScenario() + { + _captureFileSystem = Substitute.For(); + _captureFileSystem.Path.Returns(_fileSystem.Path); + _captureFileSystem.Directory.Returns(_fileSystem.Directory); + _captureFileSystem.File.CreateText(Arg.Any()).Returns(call => + { + var path = call.ArgAt(0); + if (_fileSystem.Path.GetFileName(path) == "stderr.log") + { + CaptureBarrier?.Block(); + if (CaptureFailure is not null) + { + throw CaptureFailure; + } + } + + return _fileSystem.File.CreateText(path); + }); + } + + public Exception? CaptureFailure { get; set; } + + public DiagnosticWriteBarrier? CaptureBarrier { get; set; } + + public OperationDeadlineScenario Deadlines { get; } = new(); + + public async Task StartAdapterAsync(StartupDiagnosticMode mode, Action constructed, + CancellationToken cancellationToken) + { + var fixture = mode switch + { + StartupDiagnosticMode.InvalidReadiness => "invalid-diagnostic-peer.js", + StartupDiagnosticMode.EarlyExit => "exited-diagnostic-peer.js", + StartupDiagnosticMode.CallerCancellation or StartupDiagnosticMode.ReadinessTimeout => "silent-diagnostic-peer.js", + _ => throw new ArgumentOutOfRangeException(nameof(mode)), + }; + await using var adapter = await CliWrapServerAdapter.StartAsync( + ["bun", "-e", _fixtures.LoadText("Server." + fixture)], + new Dictionary(StringComparer.Ordinal), + new PinnedServerCommand(_fileSystem).RepositoryRoot, + mode is StartupDiagnosticMode.ReadinessTimeout ? TimeSpan.Zero : TimeSpan.FromSeconds(10), + onConstructed: constructed, deadline: Deadlines.Deadline, cancellationToken: cancellationToken); + } + + public PinnedOpenCodeServerFixture CreateFixture(string peer = "Server.diagnostic-peer.js") => new( + ["bun", "-e", _fixtures.LoadText(peer)], + new PinnedServerCommand(_fileSystem).RepositoryRoot, + new ServerFixtureDiagnosticsOptions + { + FileSystem = _captureFileSystem, + ResultsDirectory = new TestResultsDirectory(_fileSystem).Resolve(Environment.GetCommandLineArgs()), + Deadline = Deadlines.Deadline, + }); + + public async Task DisposeFixtureAsync(PinnedOpenCodeServerFixture fixture, Task completion) + { + try + { + await Deadlines.DrainAsync(completion); + await Deadlines.DrainAsync(fixture.DisposeAsync().AsTask()); + using var budget = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await fixture.DrainDiagnosticsAsync(budget.Token); + } + finally + { + fixture.RunRoot.Dispose(); + } + } + + public async Task ReadLogAsync(PinnedOpenCodeServerFixture fixture, string name) + { + using var reader = _fileSystem.File.OpenText(_fileSystem.Path.Combine(fixture.DiagnosticsDirectory, name)); + return await reader.ReadToEndAsync(); + } + + public async Task ReadMetadataAsync(PinnedOpenCodeServerFixture fixture) + { + var texts = new List(); + foreach (var path in _fileSystem.Directory.GetFiles(fixture.DiagnosticsDirectory, "*.log")) + { + if (_fileSystem.Path.GetFileName(path) is not "stdout.log" and not "stderr.log") + { + using var reader = _fileSystem.File.OpenText(path); + texts.Add(await reader.ReadToEndAsync()); + } + } + + return string.Join(Environment.NewLine, texts); + } +} diff --git a/tests/OpenCode.Sdk.Tests/Support/StartupDiagnosticMode.cs b/tests/OpenCode.Sdk.Tests/Support/StartupDiagnosticMode.cs new file mode 100644 index 00000000..1c5585cb --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Support/StartupDiagnosticMode.cs @@ -0,0 +1,9 @@ +namespace OpenCode.Sdk.Tests.Support; + +public enum StartupDiagnosticMode +{ + InvalidReadiness, + ReadinessTimeout, + CallerCancellation, + EarlyExit, +} diff --git a/tests/OpenCode.Sdk.Tests/TestResultsDirectoryTests.cs b/tests/OpenCode.Sdk.Tests/TestResultsDirectoryTests.cs new file mode 100644 index 00000000..68a13090 --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/TestResultsDirectoryTests.cs @@ -0,0 +1,29 @@ +using OpenCode.Sdk.TestSupport; +using Testably.Abstractions.Testing; + +namespace OpenCode.Sdk.Tests; + +public sealed class TestResultsDirectoryTests +{ + [Test] + public async Task Resolve_Should_Use_The_Forwarded_Results_Argument() + { + var fileSystem = new MockFileSystem(); + var resolver = new TestResultsDirectory(fileSystem); + + var path = resolver.Resolve(["test-host", "--results-directory", "retained-results"]); + + await Assert.That(path).IsEqualTo(fileSystem.Path.GetFullPath("retained-results")); + await Assert.That(resolver.Resolve(["--results-directory=retained-results"])).IsEqualTo(path); + } + + [Test] + public async Task Resolve_Should_Use_Test_Results_When_The_Runner_Has_No_Override() + { + var fileSystem = new MockFileSystem(); + var resolver = new TestResultsDirectory(fileSystem); + + await Assert.That(resolver.Resolve([])).IsEqualTo(fileSystem.Path.GetFullPath("test-results")); + _ = await Assert.That(() => resolver.Resolve(["--results-directory"])).Throws(); + } +} diff --git a/tests/Shared/CliWrapServerAdapter.cs b/tests/Shared/CliWrapServerAdapter.cs index c5387f93..276541d0 100644 --- a/tests/Shared/CliWrapServerAdapter.cs +++ b/tests/Shared/CliWrapServerAdapter.cs @@ -3,6 +3,8 @@ using System.Security.Cryptography; using CliWrap; using OpenCode.Sdk.Internal; +using OpenCode.Sdk.TestSupport.Ownership; +using OpenCode.Sdk.TestSupport.Ownership.Abstractions; namespace OpenCode.Sdk.TestSupport; @@ -25,21 +27,14 @@ internal sealed class CliWrapServerAdapter : IAsyncDisposable /// private static readonly TimeSpan ForcedExitTimeout = TimeSpan.FromSeconds(10); - /// - /// Bounded well above the 40-line exception tail (): the - /// failure-path log write wants enough context to diagnose without letting a chatty - /// PerTestSession-lifetime server grow these files unbounded. - /// - private const int RetainedLogLines = 500; - private readonly TaskCompletionSource _stdinLease = new(TaskCreationOptions.RunContinuationsAsynchronously); private readonly TaskCompletionSource _firstLine = new(TaskCreationOptions.RunContinuationsAsynchronously); private readonly CancellationTokenSource _forceKill = new(); private readonly Lock _logGate = new(); - private readonly Queue _stdout = new(); - private readonly Queue _stderr = new(); + private readonly ServerLogTail _stdout = new(); + private readonly ServerLogTail _stderr = new(); private TaskCompletionSource _stderrChanged = NewSignal(); private TimeSpan _gracefulShutdownTimeout = DefaultGracefulShutdownTimeout; private Task? _execution; @@ -47,6 +42,7 @@ internal sealed class CliWrapServerAdapter : IAsyncDisposable private string? _password; private int _processId; private int _disposed; + private IOwnedOperationDeadline _deadline = new OwnedOperationDeadline(); private CliWrapServerAdapter() { @@ -58,14 +54,15 @@ private CliWrapServerAdapter() public int ProcessId => _processId; + public LateCleanupFailureReport? LateFailures { get; private set; } + /// /// Starts the adapter and waits for readiness. runs /// synchronously the instant the adapter object exists - before the process is even spawned - /// so a caller can retain the reference regardless of whether this method later throws. That /// is what lets write out the captured stdout/stderr - /// on a startup failure: every failure path below still disposes the adapter itself before - /// throwing (the child is always ended), but the object - and its log buffers - survive for - /// the caller to inspect. + /// on a startup failure: teardown is bounded, and the adapter retains its buffers and any + /// pending execution until that work actually ends. /// public static async Task StartAsync( IReadOnlyList command, @@ -74,11 +71,13 @@ public static async Task StartAsync( TimeSpan readinessTimeout, TimeSpan? gracefulShutdownTimeout = null, Action? onConstructed = null, + IOwnedOperationDeadline? deadline = null, CancellationToken cancellationToken = default) { var adapter = new CliWrapServerAdapter { _password = GeneratePassword(), + _deadline = deadline ?? new OwnedOperationDeadline(), _gracefulShutdownTimeout = gracefulShutdownTimeout ?? DefaultGracefulShutdownTimeout, }; onConstructed?.Invoke(adapter); @@ -123,29 +122,25 @@ public static async Task StartAsync( } catch (OperationCanceledException exception) { - await adapter.DisposeAsync(); - if (cancellationToken.IsCancellationRequested) - { - throw new OperationCanceledException("The adapter start was canceled.", exception, cancellationToken); - } - - throw new InvalidOperationException( - $"The pinned server did not report readiness within {readinessTimeout.TotalSeconds.ToString("F0", CultureInfo.InvariantCulture)}s.{adapter.DescribeLogs()}"); + var failure = cancellationToken.IsCancellationRequested + ? (Exception)new OperationCanceledException("The adapter start was canceled.", exception, cancellationToken) + : new InvalidOperationException( + $"The pinned server did not report readiness within {readinessTimeout.TotalSeconds.ToString("F0", CultureInfo.InvariantCulture)}s."); + await adapter.ThrowAfterTeardownAsync(failure); + throw; } if (!adapter._firstLine.Task.IsCompleted) { - await adapter.DisposeAsync(); - throw new InvalidOperationException( - $"The pinned server exited before reporting readiness.{adapter.DescribeLogs()}"); + await adapter.ThrowAfterTeardownAsync(new InvalidOperationException( + "The pinned server exited before reporting readiness.")); } var line = await adapter._firstLine.Task; if (!ServerReadyLine.TryParse(line, out var endpoint)) { - await adapter.DisposeAsync(); - throw new InvalidOperationException( - $"The pinned server's first stdout line is not the readiness contract: '{line}'.{adapter.DescribeLogs()}"); + await adapter.ThrowAfterTeardownAsync(new InvalidOperationException( + $"The pinned server's first stdout line is not the readiness contract: '{line}'.")); } adapter._endpoint = endpoint; @@ -156,10 +151,8 @@ public string DescribeLogs() { lock (_logGate) { - var tail = _stderr.Skip(Math.Max(0, _stderr.Count - 40)); - return _stderr.Count == 0 - ? string.Empty - : string.Concat(" Recent stderr: ", string.Join(" | ", tail)); + var tail = _stderr.Describe(); + return tail.Length == 0 ? string.Empty : " Recent stderr: " + tail; } } @@ -168,7 +161,7 @@ public bool HasErrorLine(string fragment) ArgumentException.ThrowIfNullOrWhiteSpace(fragment); lock (_logGate) { - return _stderr.Any(line => line.Contains(fragment, StringComparison.Ordinal)); + return _stderr.Find(fragment) is not null; } } @@ -180,7 +173,7 @@ public async Task WaitForErrorLineAsync(string fragment, CancellationTok Task signal; lock (_logGate) { - var match = _stderr.FirstOrDefault(line => line.Contains(fragment, StringComparison.Ordinal)); + var match = _stderr.Find(fragment); if (match is not null) { return match; @@ -193,16 +186,23 @@ public async Task WaitForErrorLineAsync(string fragment, CancellationTok } } - public void WriteLogs(IFileSystem fileSystem, string directory) + public ServerLogSnapshot SnapshotLogs() { - _ = fileSystem.Directory.CreateDirectory(directory); lock (_logGate) { - fileSystem.File.WriteAllLines(fileSystem.Path.Combine(directory, "stdout.log"), _stdout); - fileSystem.File.WriteAllLines(fileSystem.Path.Combine(directory, "stderr.log"), _stderr); + return new ServerLogSnapshot { StandardOutput = _stdout.Snapshot(), StandardError = _stderr.Snapshot() }; } } + public async Task WriteLogsAsync(IFileSystem fileSystem, string directory) + { + var snapshot = SnapshotLogs(); + _ = fileSystem.Directory.CreateDirectory(directory); + var writer = new DiagnosticFileWriter(fileSystem); + await writer.WriteAsync(fileSystem.Path.Combine(directory, "stdout.log"), string.Join(Environment.NewLine, snapshot.StandardOutput)); + await writer.WriteAsync(fileSystem.Path.Combine(directory, "stderr.log"), string.Join(Environment.NewLine, snapshot.StandardError)); + } + public async ValueTask DisposeAsync() { // The onConstructed-captured reference (see StartAsync's doc comment) makes a second call @@ -217,64 +217,89 @@ public async ValueTask DisposeAsync() } _ = _stdinLease.TrySetResult(null); - if (_execution is not null) + var cancellation = Task.CompletedTask; + try { - // The grace is a bounded wait on the execution itself rather than a race against a - // timer: the child exiting on its own is the condition, and the bound is only what - // stops disposal waiting forever for it. - if (!await TryAwaitExitAsync(_execution, _gracefulShutdownTimeout)) + if (_execution is not null && !await TryAwaitExitAsync(_execution, _gracefulShutdownTimeout)) { - await _forceKill.CancelAsync(); + cancellation = Task.Run(_forceKill.CancelAsync, CancellationToken.None); + var forced = new OwnedCleanup(ForcedExitTimeout, _deadline); + forced.Own("pinned server forced cancellation", cancellation); + forced.Own("pinned server forced exit", _execution, _ => _forceKill.IsCancellationRequested); + try + { + await forced.CompleteAsync(null); + } + finally + { + LateFailures = forced.LateFailures; + } + } + } + finally + { + // A timed-out fixture still owns the execution and cancellation. Release the source + // only when neither can use it; the continuation also observes a late execution fault. + var pending = Task.WhenAll(_execution ?? Task.CompletedTask, cancellation); + if (pending.IsCompleted) + { + _ = pending.Exception; + _forceKill.Dispose(); + } + else + { + _ = pending.ContinueWith( + completed => + { + _ = completed.Exception; + _forceKill.Dispose(); + }, CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); } - - // Bounded the same way as the graceful wait above: the kill was issued, but CliWrap's - // own tree-kill confirming it is not guaranteed prompt, so this second wait gets its - // own bound rather than trusting it unconditionally - // (OpenCodeServer.EndOwnedChildAsync's double-layered escalation, mirrored here). - _ = await TryAwaitExitAsync(_execution, ForcedExitTimeout); } - - _forceKill.Dispose(); } /// /// Waits for the execution to finish inside a bound of its own. /// /// - /// True when the child exited inside the bound; false when the bound expired with it still - /// running, or when the execution reported the forced cancellation it was handed. The second - /// wait discards this because there is no third escalation to reach for: the tree kill has - /// already been issued, and a disposal must return to its caller regardless. + /// True when execution succeeds inside the bound; false when its observation deadline wins. + /// An execution failure remains distinct from a deadline failure. /// - private static async Task TryAwaitExitAsync(Task execution, TimeSpan bound) + private async Task TryAwaitExitAsync(Task execution, TimeSpan bound) { try { - await execution.WaitAsync(bound); - return true; + await _deadline.WaitAsync("pinned server graceful exit", execution, bound, CancellationToken.None); } catch (TimeoutException) { - // The bound expired with the child still running. return false; } - catch (OperationCanceledException) + + await execution; + return true; + } + + private async Task ThrowAfterTeardownAsync(Exception primary) + { + var cleanup = new OwnedCleanup(TimeSpan.FromSeconds(25), _deadline); + cleanup.Own("pinned server startup teardown", async _ => await DisposeAsync()); + try { - // The forced teardown reporting through the cancellation token it was given. - return false; + await cleanup.CompleteAsync(primary); + } + catch (Exception exception) when (ReferenceEquals(exception, primary)) + { + primary.Data["PinnedServer.StartupLogs"] = DescribeLogs(); + throw; } } - private void OnOutputLine(string line) { _ = _firstLine.TrySetResult(line); lock (_logGate) { - _stdout.Enqueue(line); - if (_stdout.Count > RetainedLogLines) - { - _ = _stdout.Dequeue(); - } + _stdout.Append(line); } } @@ -283,11 +308,7 @@ private void OnErrorLine(string line) TaskCompletionSource changed; lock (_logGate) { - _stderr.Enqueue(line); - if (_stderr.Count > RetainedLogLines) - { - _ = _stderr.Dequeue(); - } + _stderr.Append(line); changed = _stderrChanged; _stderrChanged = NewSignal(); diff --git a/tests/Shared/DiagnosticFileWriter.cs b/tests/Shared/DiagnosticFileWriter.cs new file mode 100644 index 00000000..0cf99fcd --- /dev/null +++ b/tests/Shared/DiagnosticFileWriter.cs @@ -0,0 +1,12 @@ +using System.IO.Abstractions; + +namespace OpenCode.Sdk.TestSupport; + +internal sealed class DiagnosticFileWriter(IFileSystem fileSystem) +{ + public async Task WriteAsync(string path, string text) + { + using var writer = fileSystem.File.CreateText(path); + await writer.WriteAsync(text); + } +} diff --git a/tests/Shared/FailureDiagnosticText.cs b/tests/Shared/FailureDiagnosticText.cs new file mode 100644 index 00000000..53596321 --- /dev/null +++ b/tests/Shared/FailureDiagnosticText.cs @@ -0,0 +1,72 @@ +using System.Collections.Concurrent; +using System.Text; +using OpenCode.Sdk.TestSupport.Ownership; + +namespace OpenCode.Sdk.TestSupport; + +/// Bounds each failure summary without serializing arbitrary exception data. +internal sealed class FailureDiagnosticText(int maximumCharacters = 16_384) +{ + internal const int MaximumCharacters = 16_384; + private const string Truncated = "\n[truncated]"; + + public string Bound(string text) => text.Length <= maximumCharacters + ? text + : text[..(maximumCharacters - Truncated.Length)] + Truncated; + + public string Describe(Exception exception) + { + var text = new StringBuilder(); + var pending = new Queue(); + var seen = new HashSet(); + pending.Enqueue(exception); + while (pending.Count > 0 && text.Length < MaximumCharacters && seen.Count < 64) + { + var current = pending.Dequeue(); + if (!seen.Add(current)) + { + continue; + } + + _ = text.AppendLine(current.GetType().FullName).AppendLine(new FailureDiagnosticText(1_024).Bound(current.Message)); + if (current.InnerException is { } inner) + { + pending.Enqueue(inner); + } + + if (current is AggregateException aggregate) + { + foreach (var failure in aggregate.InnerExceptions.Take(64)) + { + pending.Enqueue(failure); + } + + if (aggregate.InnerExceptions.Count > 64) + { + _ = text.Append(Truncated); + } + } + + if (current.Data[OwnedCleanup.FailuresKey] is AggregateException cleanup) + { + pending.Enqueue(cleanup); + } + + if (current.Data[OwnedCleanup.LateFailuresKey] is ConcurrentQueue> late) + { + foreach (var entry in late.Take(64)) + { + _ = text.AppendLine(Bound(entry.Key)); + pending.Enqueue(entry.Value); + } + } + } + + if (pending.Count > 0) + { + _ = text.Append(Truncated); + } + + return Bound(text.ToString()); + } +} diff --git a/tests/OpenCode.Sdk.Tests/Support/Abstractions/IOwnedOperationDeadline.cs b/tests/Shared/Ownership/Abstractions/IOwnedOperationDeadline.cs similarity index 74% rename from tests/OpenCode.Sdk.Tests/Support/Abstractions/IOwnedOperationDeadline.cs rename to tests/Shared/Ownership/Abstractions/IOwnedOperationDeadline.cs index 5ee62c8f..34cfc8ce 100644 --- a/tests/OpenCode.Sdk.Tests/Support/Abstractions/IOwnedOperationDeadline.cs +++ b/tests/Shared/Ownership/Abstractions/IOwnedOperationDeadline.cs @@ -1,4 +1,4 @@ -namespace OpenCode.Sdk.Tests.Support.Abstractions; +namespace OpenCode.Sdk.TestSupport.Ownership.Abstractions; internal interface IOwnedOperationDeadline { diff --git a/tests/OpenCode.Sdk.Tests/Support/LateCleanupFailureReport.cs b/tests/Shared/Ownership/LateCleanupFailureReport.cs similarity index 98% rename from tests/OpenCode.Sdk.Tests/Support/LateCleanupFailureReport.cs rename to tests/Shared/Ownership/LateCleanupFailureReport.cs index 3ee9755d..41593f19 100644 --- a/tests/OpenCode.Sdk.Tests/Support/LateCleanupFailureReport.cs +++ b/tests/Shared/Ownership/LateCleanupFailureReport.cs @@ -1,6 +1,6 @@ using System.Collections.Concurrent; -namespace OpenCode.Sdk.Tests.Support; +namespace OpenCode.Sdk.TestSupport.Ownership; internal sealed class LateCleanupFailureReport { diff --git a/tests/OpenCode.Sdk.Tests/Support/OwnedCleanup.cs b/tests/Shared/Ownership/OwnedCleanup.cs similarity index 98% rename from tests/OpenCode.Sdk.Tests/Support/OwnedCleanup.cs rename to tests/Shared/Ownership/OwnedCleanup.cs index ae4c68d2..29eae149 100644 --- a/tests/OpenCode.Sdk.Tests/Support/OwnedCleanup.cs +++ b/tests/Shared/Ownership/OwnedCleanup.cs @@ -1,7 +1,7 @@ using System.Runtime.ExceptionServices; -using OpenCode.Sdk.Tests.Support.Abstractions; +using OpenCode.Sdk.TestSupport.Ownership.Abstractions; -namespace OpenCode.Sdk.Tests.Support; +namespace OpenCode.Sdk.TestSupport.Ownership; internal sealed class OwnedCleanup(TimeSpan timeout, IOwnedOperationDeadline deadline) { diff --git a/tests/OpenCode.Sdk.Tests/Support/OwnedCleanupOperation.cs b/tests/Shared/Ownership/OwnedCleanupOperation.cs similarity index 94% rename from tests/OpenCode.Sdk.Tests/Support/OwnedCleanupOperation.cs rename to tests/Shared/Ownership/OwnedCleanupOperation.cs index 67176122..d61b27c2 100644 --- a/tests/OpenCode.Sdk.Tests/Support/OwnedCleanupOperation.cs +++ b/tests/Shared/Ownership/OwnedCleanupOperation.cs @@ -1,4 +1,4 @@ -namespace OpenCode.Sdk.Tests.Support; +namespace OpenCode.Sdk.TestSupport.Ownership; internal sealed class OwnedCleanupOperation { diff --git a/tests/OpenCode.Sdk.Tests/Support/OwnedOperationDeadline.cs b/tests/Shared/Ownership/OwnedOperationDeadline.cs similarity index 85% rename from tests/OpenCode.Sdk.Tests/Support/OwnedOperationDeadline.cs rename to tests/Shared/Ownership/OwnedOperationDeadline.cs index 255d4094..3d63f21d 100644 --- a/tests/OpenCode.Sdk.Tests/Support/OwnedOperationDeadline.cs +++ b/tests/Shared/Ownership/OwnedOperationDeadline.cs @@ -1,6 +1,6 @@ -using OpenCode.Sdk.Tests.Support.Abstractions; +using OpenCode.Sdk.TestSupport.Ownership.Abstractions; -namespace OpenCode.Sdk.Tests.Support; +namespace OpenCode.Sdk.TestSupport.Ownership; internal sealed class OwnedOperationDeadline : IOwnedOperationDeadline { diff --git a/tests/Shared/PinnedOpenCodeServerFixture.cs b/tests/Shared/PinnedOpenCodeServerFixture.cs index 96fa85b3..08dc2820 100644 --- a/tests/Shared/PinnedOpenCodeServerFixture.cs +++ b/tests/Shared/PinnedOpenCodeServerFixture.cs @@ -1,5 +1,7 @@ using System.Globalization; +using System.Runtime.ExceptionServices; using System.Text.Json; +using OpenCode.Sdk.TestSupport.Ownership; using Testably.Abstractions; using TUnit.Core.Interfaces; @@ -18,7 +20,7 @@ namespace OpenCode.Sdk.TestSupport; /// Environment variables this fixture and its neighbors respond to, one line each: /// /// OPENCODE_SDK_TESTS_KEEP_LOGS=1 - retain the spawned server's stdout/stderr (and -/// the run root) under the OS temp root instead of deleting them on dispose. +/// the run root); bounded log files are written beneath the runner's results directory. /// OPENCODE_SDK_TESTS_ENDPOINT - an operator-supplied server to attach to instead of /// spawning one (paired with OPENCODE_SDK_TESTS_PASSWORD; see below). /// OPENCODE_SDK_TESTS_PASSWORD - the Basic-auth password for @@ -34,7 +36,7 @@ namespace OpenCode.Sdk.TestSupport; /// starting , so stays unset and every member below /// reads from the external pair. /// -public sealed class PinnedOpenCodeServerFixture : IAsyncInitializer, IAsyncDisposable +public sealed class PinnedOpenCodeServerFixture : IAsyncInitializer, IAsyncDisposable, ITestEndEventReceiver { private static readonly TimeSpan ReadinessTimeout = TimeSpan.FromMinutes(3); @@ -48,6 +50,11 @@ public sealed class PinnedOpenCodeServerFixture : IAsyncInitializer, IAsyncDispo private TestRunRoot? _runRoot; private ExternalServerEndpoint? _external; private bool _retainLogs; + private bool _externalMode; + private ServerFailureArtifacts? _artifacts; + private readonly ServerFixtureDiagnosticsOptions? _diagnostics; + private int _disposed; + private readonly List _lateReports = []; public PinnedOpenCodeServerFixture() { @@ -59,8 +66,9 @@ public PinnedOpenCodeServerFixture() /// forced through this fixture itself - pinning the failure-path log-retention contract rather /// than only exercising it through the lower-level adapter. /// - internal PinnedOpenCodeServerFixture(IReadOnlyList command, string workingDirectory) + internal PinnedOpenCodeServerFixture(IReadOnlyList command, string workingDirectory, ServerFixtureDiagnosticsOptions? diagnostics = null) { + _diagnostics = diagnostics; _commandOverride = command; _workingDirectoryOverride = workingDirectory; } @@ -79,6 +87,8 @@ internal PinnedOpenCodeServerFixture(ExternalServerEndpoint external) public Uri Endpoint => _external?.Endpoint ?? Adapter.Endpoint; + public int Order => 0; + internal bool IsExternal { get @@ -117,6 +127,7 @@ public async Task InitializeAsync() var external = _externalOverride ?? ExternalServerEndpoint.FromEnvironment(); if (external is not null) { + _externalMode = true; await AttachToExternalAsync(external).ConfigureAwait(false); return; } @@ -158,11 +169,13 @@ public async Task InitializeAsync() // disposes its own local on every failure path before throwing, but never hands it // back through its return value), which made the WriteLogs call below unreachable // dead code on exactly the failure it exists for. - onConstructed: created => _adapter = created); + onConstructed: created => _adapter = created, + deadline: _diagnostics?.Deadline); } - catch + catch (Exception exception) { _retainLogs = true; + MarkFailure(exception, "fixture initialization", "phase=server startup"); throw; } } @@ -177,30 +190,104 @@ public OpenCodeClient CreateClient(LocationSelector? location = null) => public TestWorkspace CreateWorkspace() => new(_fileSystem, RunRoot.Path); + internal string DiagnosticsDirectory => Artifacts.Directory; + + internal async Task DrainDiagnosticsAsync(CancellationToken cancellationToken) + { + foreach (var report in _lateReports) + { + await report.WaitForAllAsync(cancellationToken); + } + + if (_adapter?.LateFailures is { } adapterReport) + { + await adapterReport.WaitForAllAsync(cancellationToken); + } + } + + private ServerFailureArtifacts Artifacts => _artifacts ??= new ServerFailureArtifacts( + _diagnostics?.FileSystem ?? _fileSystem, + _diagnostics?.ResultsDirectory ?? new TestResultsDirectory(_fileSystem).Resolve(Environment.GetCommandLineArgs())); + + internal void MarkFailure(Exception exception, string test, string details) + { + ArgumentNullException.ThrowIfNull(exception); + _ = Artifacts.Mark(exception, test, details, TestContext.Current?.Id); + } + + public ValueTask OnTestEnd(TestContext context) + { + ArgumentNullException.ThrowIfNull(context); + if (context.Execution.Result?.Exception is { } failure) + { + var path = Artifacts.Mark(failure, + context.Metadata.TestDetails.ClassType.FullName + "." + context.Metadata.TestName, + "phase=test-body; receiver observes the body result, before later teardown outcomes", context.Id); + if (Artifacts.Report(failure, context.Id)) + { + context.Output.AttachArtifact(path, "Owned server failure diagnostics"); + } + } + + return default; + } + public async ValueTask DisposeAsync() { - var keep = _retainLogs || - string.Equals( - Environment.GetEnvironmentVariable("OPENCODE_SDK_TESTS_KEEP_LOGS"), - "1", - StringComparison.Ordinal); + if (Interlocked.Exchange(ref _disposed, 1) is 1) + { + return; + } + + var keep = _retainLogs || string.Equals( + Environment.GetEnvironmentVariable("OPENCODE_SDK_TESTS_KEEP_LOGS"), "1", StringComparison.Ordinal); + var recorded = _artifacts?.Failures; + var primary = recorded is { Count: > 0 } ? recorded[0].Exception : null; + var teardown = new OwnedCleanup(TimeSpan.FromSeconds(25), + _diagnostics?.Deadline ?? new OwnedOperationDeadline()); if (_adapter is not null) { - if (keep && _runRoot is not null) + teardown.Own("pinned server teardown", async _ => await _adapter.DisposeAsync()); + } + + Exception? failure = null; + try + { + await teardown.CompleteAsync(primary); + } + catch (Exception exception) + { + failure = exception; + } + + if (teardown.LateFailures is { } late) + { + _lateReports.Add(late); + } + + if (failure is not null || keep) + { + if (failure is not null && !Artifacts.Failures.Any(item => ReferenceEquals(item.Exception, failure))) { - _adapter.WriteLogs(_fileSystem, _fileSystem.Path.Combine(_runRoot.Path, "logs")); - Console.WriteLine($"Pinned server logs retained under: {_runRoot.Path}"); + _ = Artifacts.Mark(failure, "fixture disposal", "phase=owned-server-teardown"); } - await _adapter.DisposeAsync(); + var capture = new ServerFailureCapture(Artifacts, _diagnostics?.FileSystem ?? _fileSystem, + _diagnostics?.Deadline ?? new OwnedOperationDeadline()); + failure = await capture.CaptureAsync(_adapter, _externalMode, failure); + _lateReports.AddRange(capture.LateReports); + Console.WriteLine("Pinned server diagnostics: " + Artifacts.Directory); } - if (!keep) + if (failure is null && !keep) { _runRoot?.Dispose(); } + if (failure is not null && !(_artifacts?.IsReported(failure) ?? false)) + { + ExceptionDispatchInfo.Capture(failure).Throw(); + } } - private Dictionary BuildEnvironment(string runRoot) => ServerIsolation.Environment(_fileSystem, runRoot); diff --git a/tests/Shared/ServerFailureArtifacts.cs b/tests/Shared/ServerFailureArtifacts.cs new file mode 100644 index 00000000..c6e9a1cc --- /dev/null +++ b/tests/Shared/ServerFailureArtifacts.cs @@ -0,0 +1,103 @@ +using System.Globalization; +using System.IO.Abstractions; +using System.Reflection; +using System.Runtime.Versioning; + +namespace OpenCode.Sdk.TestSupport; + +/// Writes bounded diagnostic files beneath the runner's uploaded results directory. +internal sealed class ServerFailureArtifacts +{ + private readonly IFileSystem _fileSystem; + private readonly FailureDiagnosticText _text = new(); + private readonly List _failures = []; + private readonly Lock _gate = new(); + private readonly string _assembly = typeof(ServerFailureArtifacts).Assembly.GetName().Name ?? "tests"; + + public ServerFailureArtifacts(IFileSystem fileSystem, string resultsDirectory) + { + _fileSystem = fileSystem; + Framework = typeof(ServerFailureArtifacts).Assembly.GetCustomAttribute()?.FrameworkName + ?? "unknown-framework"; + Directory = fileSystem.Path.Combine(resultsDirectory, "server-diagnostics", + _assembly, Guid.NewGuid().ToString("N")); + } + + public string Framework { get; } + + public string Directory { get; } + + public IReadOnlyList Failures + { + get + { + lock (_gate) + { + return [.. _failures]; + } + } + } + + public string Mark(Exception exception, string test, string details, string? invocation = null) + { + lock (_gate) + { + var identity = invocation ?? test; + var failure = _failures.Find(item => item.Invocation == identity && ReferenceEquals(item.Exception, exception)); + if (failure is null) + { + failure = new ServerTestFailure + { + // The fixture directory is unique; a local ordinal avoids a redundant UUID + // in each filename and preserves room for the runner's results path on net472. + Id = (_failures.Count + 1).ToString(CultureInfo.InvariantCulture), + Exception = exception, + Test = new FailureDiagnosticText(512).Bound(test), + Invocation = identity, + Details = new FailureDiagnosticText(4_096).Bound(details), + }; + _failures.Add(failure); + } + + return _fileSystem.Path.Combine(Directory, failure.Id + ".log"); + } + } + + public bool Report(Exception exception, string invocation) + { + lock (_gate) + { + var failure = _failures.Single(item => item.Invocation == invocation && ReferenceEquals(item.Exception, exception)); + var firstReport = !failure.Reported; + failure.Reported = true; + return firstReport; + } + } + + public bool IsReported(Exception exception) + { + lock (_gate) + { + return _failures.Any(item => item.Reported && ReferenceEquals(item.Exception, exception)); + } + } + + public async Task WriteMetadataAsync(string mode, string process, Exception? teardownFailure) + { + _ = _fileSystem.Directory.CreateDirectory(Directory); + foreach (var failure in Failures) + { + var text = "test=" + failure.Test + "\ninvocation=" + new FailureDiagnosticText(256).Bound(failure.Invocation) + + "\nassembly=" + _assembly + "\nframework=" + Framework + "\nmode=" + mode + + "\nserver-process=" + process + "\n" + failure.Details + "\nprimary:\n" + + new FailureDiagnosticText(8_192).Describe(failure.Exception); + if (teardownFailure is not null && !ReferenceEquals(teardownFailure, failure.Exception)) + { + text += "\nteardown:\n" + new FailureDiagnosticText(2_048).Describe(teardownFailure); + } + + await new DiagnosticFileWriter(_fileSystem).WriteAsync( + _fileSystem.Path.Combine(Directory, failure.Id + ".log"), _text.Bound(text)); + } + } +} diff --git a/tests/Shared/ServerFailureCapture.cs b/tests/Shared/ServerFailureCapture.cs new file mode 100644 index 00000000..51c4fd9f --- /dev/null +++ b/tests/Shared/ServerFailureCapture.cs @@ -0,0 +1,60 @@ +using System.Globalization; +using System.IO.Abstractions; +using OpenCode.Sdk.TestSupport.Ownership; +using OpenCode.Sdk.TestSupport.Ownership.Abstractions; + +namespace OpenCode.Sdk.TestSupport; + +/// Captures output and then failure metadata under independent finite deadlines. +internal sealed class ServerFailureCapture( + ServerFailureArtifacts artifacts, IFileSystem fileSystem, IOwnedOperationDeadline deadline) +{ + private readonly List _lateReports = []; + + public IReadOnlyList LateReports => _lateReports; + + public async Task CaptureAsync(CliWrapServerAdapter? adapter, bool external, Exception? failure) + { + var capture = new OwnedCleanup(TimeSpan.FromSeconds(5), deadline); + if (adapter is not null) + { + var snapshot = adapter.SnapshotLogs(); + capture.Own("pinned server stdout capture", _ => WriteLogAsync("stdout.log", snapshot.StandardOutput)); + capture.Own("pinned server stderr capture", _ => WriteLogAsync("stderr.log", snapshot.StandardError)); + } + + failure = await CaptureFailureAsync(capture, failure); + var metadata = new OwnedCleanup(TimeSpan.FromSeconds(5), deadline); + metadata.Own("pinned server failure metadata", _ => + artifacts.WriteMetadataAsync(external ? "external" : "owned", + adapter?.ProcessId.ToString(CultureInfo.InvariantCulture) ?? "not-owned", failure)); + return await CaptureFailureAsync(metadata, failure); + } + + private async Task WriteLogAsync(string name, IReadOnlyList lines) + { + _ = fileSystem.Directory.CreateDirectory(artifacts.Directory); + await new DiagnosticFileWriter(fileSystem).WriteAsync( + fileSystem.Path.Combine(artifacts.Directory, name), string.Join(Environment.NewLine, lines)); + } + + private async Task CaptureFailureAsync(OwnedCleanup cleanup, Exception? primary) + { + try + { + await cleanup.CompleteAsync(primary); + return null; + } + catch (Exception exception) + { + return exception; + } + finally + { + if (cleanup.LateFailures is { } late) + { + _lateReports.Add(late); + } + } + } +} diff --git a/tests/Shared/ServerFixtureDiagnosticsOptions.cs b/tests/Shared/ServerFixtureDiagnosticsOptions.cs new file mode 100644 index 00000000..53229bf8 --- /dev/null +++ b/tests/Shared/ServerFixtureDiagnosticsOptions.cs @@ -0,0 +1,13 @@ +using System.IO.Abstractions; +using OpenCode.Sdk.TestSupport.Ownership.Abstractions; + +namespace OpenCode.Sdk.TestSupport; + +internal sealed record ServerFixtureDiagnosticsOptions +{ + public required IFileSystem FileSystem { get; init; } + + public required string ResultsDirectory { get; init; } + + public IOwnedOperationDeadline? Deadline { get; init; } +} diff --git a/tests/Shared/ServerLogSnapshot.cs b/tests/Shared/ServerLogSnapshot.cs new file mode 100644 index 00000000..40b06555 --- /dev/null +++ b/tests/Shared/ServerLogSnapshot.cs @@ -0,0 +1,8 @@ +namespace OpenCode.Sdk.TestSupport; + +internal sealed record ServerLogSnapshot +{ + public required IReadOnlyList StandardOutput { get; init; } + + public required IReadOnlyList StandardError { get; init; } +} diff --git a/tests/Shared/ServerLogTail.cs b/tests/Shared/ServerLogTail.cs new file mode 100644 index 00000000..07be8e0f --- /dev/null +++ b/tests/Shared/ServerLogTail.cs @@ -0,0 +1,40 @@ +using System.Globalization; + +namespace OpenCode.Sdk.TestSupport; + +/// Retains the last 500 lines, each capped at 4,096 characters, for one server stream. +internal sealed class ServerLogTail +{ + internal const int MaximumLines = 500; + internal const int MaximumLineCharacters = 4_096; + private const string Truncated = " [truncated]"; + private readonly Queue _lines = new(); + private long _discardedLines; + private long _shortenedLines; + + public void Append(string line) + { + if (line.Length > MaximumLineCharacters) + { + line = line[..(MaximumLineCharacters - Truncated.Length)] + Truncated; + _shortenedLines++; + } + + _lines.Enqueue(line); + if (_lines.Count > MaximumLines) + { + _ = _lines.Dequeue(); + _discardedLines++; + } + } + + public string? Find(string fragment) => + _lines.FirstOrDefault(line => line.Contains(fragment, StringComparison.Ordinal)); + + public string Describe() => string.Join(" | ", _lines.Skip(Math.Max(0, _lines.Count - 40))); + + public IReadOnlyList Snapshot() => + ["tail: maximum-lines=500 maximum-line-characters=4096 discarded-lines=" + + _discardedLines.ToString(CultureInfo.InvariantCulture) + " shortened-lines=" + + _shortenedLines.ToString(CultureInfo.InvariantCulture), .. _lines]; +} diff --git a/tests/Shared/ServerTestFailure.cs b/tests/Shared/ServerTestFailure.cs new file mode 100644 index 00000000..076270b3 --- /dev/null +++ b/tests/Shared/ServerTestFailure.cs @@ -0,0 +1,16 @@ +namespace OpenCode.Sdk.TestSupport; + +internal sealed record ServerTestFailure +{ + public required Exception Exception { get; init; } + + public required string Test { get; init; } + + public required string Invocation { get; init; } + + public required string Details { get; init; } + + public required string Id { get; init; } + + public bool Reported { get; set; } +} diff --git a/tests/Shared/SimulatedDriveServerFixture.cs b/tests/Shared/SimulatedDriveServerFixture.cs index a3b8379b..24c6de01 100644 --- a/tests/Shared/SimulatedDriveServerFixture.cs +++ b/tests/Shared/SimulatedDriveServerFixture.cs @@ -172,7 +172,7 @@ public async ValueTask DisposeAsync() { try { - _adapter.WriteLogs(_fileSystem, _fileSystem.Path.Combine(_runRoot.Path, "logs")); + await _adapter.WriteLogsAsync(_fileSystem, _fileSystem.Path.Combine(_runRoot.Path, "logs")); Console.WriteLine($"Simulated server logs retained under: {_runRoot.Path}"); } catch (Exception exception) diff --git a/tests/Shared/TestResultsDirectory.cs b/tests/Shared/TestResultsDirectory.cs new file mode 100644 index 00000000..75635ac4 --- /dev/null +++ b/tests/Shared/TestResultsDirectory.cs @@ -0,0 +1,32 @@ +using System.IO.Abstractions; + +namespace OpenCode.Sdk.TestSupport; + +/// Resolves the results argument received by the test executable itself. +internal sealed class TestResultsDirectory(IFileSystem fileSystem) +{ + public string Resolve(IReadOnlyList arguments) + { + ArgumentNullException.ThrowIfNull(arguments); + for (var index = 0; index < arguments.Count; index++) + { + if (arguments[index] == "--results-directory") + { + if (index + 1 == arguments.Count || string.IsNullOrWhiteSpace(arguments[index + 1])) + { + throw new ArgumentException("The test results directory requires a value.", nameof(arguments)); + } + + return fileSystem.Path.GetFullPath(arguments[index + 1]); + } + + const string prefix = "--results-directory="; + if (arguments[index].StartsWith(prefix, StringComparison.Ordinal)) + { + return fileSystem.Path.GetFullPath(arguments[index][prefix.Length..]); + } + } + + return fileSystem.Path.GetFullPath("test-results"); + } +} From 402d27a7547507cf8e84cfb4b41940ecd0725592 Mon Sep 17 00:00:00 2001 From: "denizirgin@gmail.com" Date: Sun, 6 Sep 2026 16:33:45 +0300 Subject: [PATCH 20/62] fix: correct live diagnostic failure attribution --- ...PinnedOpenCodeServerFixtureFailureTests.cs | 68 +++++++++++++++++++ .../ServerFailureArtifactsTests.cs | 4 +- .../Support/OwnedCleanupTests.cs | 3 + .../Support/ServerDiagnosticScenario.cs | 3 + tests/Shared/Ownership/OwnedCleanup.cs | 7 ++ tests/Shared/PinnedOpenCodeServerFixture.cs | 8 ++- tests/Shared/ServerFailureArtifacts.cs | 18 +++-- tests/Shared/ServerFailureCapture.cs | 11 ++- .../Shared/ServerFixtureDiagnosticsOptions.cs | 2 + 9 files changed, 111 insertions(+), 13 deletions(-) diff --git a/tests/OpenCode.Sdk.Tests/PinnedOpenCodeServerFixtureFailureTests.cs b/tests/OpenCode.Sdk.Tests/PinnedOpenCodeServerFixtureFailureTests.cs index 0963fc5d..7fe7c68b 100644 --- a/tests/OpenCode.Sdk.Tests/PinnedOpenCodeServerFixtureFailureTests.cs +++ b/tests/OpenCode.Sdk.Tests/PinnedOpenCodeServerFixtureFailureTests.cs @@ -13,6 +13,74 @@ namespace OpenCode.Sdk.Tests; [NotInParallel(ParallelConstraintKeys.ServerProcess)] public sealed class PinnedOpenCodeServerFixtureFailureTests { + [Test] + [Arguments(false)] + [Arguments(true)] + public async Task DisposeAsync_Should_Not_Label_Another_Body_Failure_As_Teardown(bool captureFails) + { + var captureFailure = new IOException("Shared fixture stderr capture failed."); + var scenario = new ServerDiagnosticScenario { CaptureFailure = captureFails ? captureFailure : null }; + var fixture = scenario.CreateFixture(); + var first = new InvalidOperationException("First distinct body failure."); + var second = new IOException("Second distinct body failure."); + try + { + await fixture.InitializeAsync(); + fixture.MarkFailure(first, "first consumer", "phase=first body"); + fixture.MarkFailure(second, "second consumer", "phase=second body"); + + var observed = await Assert.That(async () => await fixture.DisposeAsync()).Throws(); + + await Assert.That(observed).IsSameReferenceAs(first); + var metadata = await scenario.ReadLogAsync(fixture, "2.log"); + await Assert.That(metadata).Contains("test=second consumer"); + await Assert.That(metadata).Contains(second.Message); + await Assert.That(metadata).DoesNotContain(first.Message); + await Assert.That(metadata).DoesNotContain("teardown:"); + if (captureFails) + { + await Assert.That(metadata).Contains("lifecycle:"); + await Assert.That(metadata).Contains(captureFailure.Message); + } + else + { + await Assert.That(metadata).DoesNotContain("lifecycle:"); + } + + await Assert.That(await scenario.ReadLogAsync(fixture, "stdout.log")).Contains("FINAL-STDOUT"); + } + finally + { + await scenario.DisposeFixtureAsync(fixture, Task.CompletedTask); + } + } + + [Test] + public async Task DisposeAsync_Should_Create_Identity_Metadata_For_A_Capture_Only_Failure() + { + var failure = new IOException("Only stderr capture failed."); + var scenario = new ServerDiagnosticScenario { CaptureFailure = failure, RetainLogs = true }; + var fixture = scenario.CreateFixture(); + try + { + await fixture.InitializeAsync(); + + var observed = await Assert.That(async () => await fixture.DisposeAsync()).Throws(); + + await Assert.That(observed).IsSameReferenceAs(failure); + await Assert.That(await scenario.ReadLogAsync(fixture, "stdout.log")).Contains("FINAL-STDOUT"); + var metadata = await scenario.ReadMetadataAsync(fixture); + await Assert.That(metadata).Contains("test=fixture disposal"); + await Assert.That(metadata).Contains("framework="); + await Assert.That(metadata).Contains("mode=owned"); + await Assert.That(metadata).Contains("phase=server diagnostic capture"); + await Assert.That(metadata).Contains(failure.Message); + } + finally + { + await scenario.DisposeFixtureAsync(fixture, Task.CompletedTask); + } + } [Test] [Timeout(30_000)] diff --git a/tests/OpenCode.Sdk.Tests/ServerFailureArtifactsTests.cs b/tests/OpenCode.Sdk.Tests/ServerFailureArtifactsTests.cs index 7095254f..c8e78c65 100644 --- a/tests/OpenCode.Sdk.Tests/ServerFailureArtifactsTests.cs +++ b/tests/OpenCode.Sdk.Tests/ServerFailureArtifactsTests.cs @@ -12,7 +12,7 @@ public async Task WriteMetadataAsync_Should_Bound_Details_Without_Losing_Primary var artifacts = new ServerFailureArtifacts(fileSystem, fileSystem.Path.GetFullPath("results")); var path = artifacts.Mark(new InvalidOperationException("primary remains visible"), "bounded test", new string('x', 50_000)); - await artifacts.WriteMetadataAsync("owned", "42", null); + await artifacts.WriteMetadataAsync("owned", "42", []); using var reader = fileSystem.File.OpenText(path); var text = await reader.ReadToEndAsync(); @@ -31,7 +31,7 @@ public async Task Mark_Should_Deduplicate_Only_The_Same_Invocation_And_Exception var duplicate = artifacts.Mark(failure, "first test", "phase=create", "first"); var second = artifacts.Mark(failure, "second test", "phase=connect", "second"); - await artifacts.WriteMetadataAsync("owned", "42", null); + await artifacts.WriteMetadataAsync("owned", "42", []); await Assert.That(first).IsEqualTo(duplicate); await Assert.That(second).IsNotEqualTo(first); diff --git a/tests/OpenCode.Sdk.Tests/Support/OwnedCleanupTests.cs b/tests/OpenCode.Sdk.Tests/Support/OwnedCleanupTests.cs index ec9b979b..e15be47b 100644 --- a/tests/OpenCode.Sdk.Tests/Support/OwnedCleanupTests.cs +++ b/tests/OpenCode.Sdk.Tests/Support/OwnedCleanupTests.cs @@ -16,6 +16,9 @@ public async Task CompleteAsync_Should_Retain_Every_Task_Fault_Without_Duplicati await Assert.That(thrown).IsSameReferenceAs(primary); var failures = (AggregateException)primary.Data[OwnedCleanup.FailuresKey]!; await Assert.That(failures.InnerExceptions.Single()).IsSameReferenceAs(secondary); + await Assert.That(cleanup.OperationFailures).Count().IsEqualTo(2); + await Assert.That(cleanup.OperationFailures[0]).IsSameReferenceAs(primary); + await Assert.That(cleanup.OperationFailures[1]).IsSameReferenceAs(secondary); } [Test] diff --git a/tests/OpenCode.Sdk.Tests/Support/ServerDiagnosticScenario.cs b/tests/OpenCode.Sdk.Tests/Support/ServerDiagnosticScenario.cs index a0f1830e..2c9d7282 100644 --- a/tests/OpenCode.Sdk.Tests/Support/ServerDiagnosticScenario.cs +++ b/tests/OpenCode.Sdk.Tests/Support/ServerDiagnosticScenario.cs @@ -36,6 +36,8 @@ public ServerDiagnosticScenario() public DiagnosticWriteBarrier? CaptureBarrier { get; set; } + public bool RetainLogs { get; init; } + public OperationDeadlineScenario Deadlines { get; } = new(); public async Task StartAdapterAsync(StartupDiagnosticMode mode, Action constructed, @@ -64,6 +66,7 @@ public async Task StartAdapterAsync(StartupDiagnosticMode mode, Action _lateFailures.HasRegistrations ? _lateFailures : null; + /// + /// A copied snapshot of immediate operation failures from the completed observation pass, + /// before primary deduplication. Late failures remain in their existing owned report. + /// + public IReadOnlyList OperationFailures { get; private set; } = []; + public void Own(string name, Func operation) { ArgumentException.ThrowIfNullOrWhiteSpace(name); @@ -40,6 +46,7 @@ public async Task CompleteAsync(Exception? primaryFailure) await CaptureFailureAsync(operation, failures); } + OperationFailures = [.. failures]; failures.RemoveAll(failure => ReferenceEquals(failure, primaryFailure)); ThrowFailures(primaryFailure, failures); } diff --git a/tests/Shared/PinnedOpenCodeServerFixture.cs b/tests/Shared/PinnedOpenCodeServerFixture.cs index 08dc2820..1463502e 100644 --- a/tests/Shared/PinnedOpenCodeServerFixture.cs +++ b/tests/Shared/PinnedOpenCodeServerFixture.cs @@ -239,8 +239,7 @@ public async ValueTask DisposeAsync() return; } - var keep = _retainLogs || string.Equals( - Environment.GetEnvironmentVariable("OPENCODE_SDK_TESTS_KEEP_LOGS"), "1", StringComparison.Ordinal); + var keep = ShouldRetainLogs; var recorded = _artifacts?.Failures; var primary = recorded is { Count: > 0 } ? recorded[0].Exception : null; var teardown = new OwnedCleanup(TimeSpan.FromSeconds(25), @@ -274,7 +273,7 @@ public async ValueTask DisposeAsync() var capture = new ServerFailureCapture(Artifacts, _diagnostics?.FileSystem ?? _fileSystem, _diagnostics?.Deadline ?? new OwnedOperationDeadline()); - failure = await capture.CaptureAsync(_adapter, _externalMode, failure); + failure = await capture.CaptureAsync(_adapter, _externalMode, failure, teardown.OperationFailures); _lateReports.AddRange(capture.LateReports); Console.WriteLine("Pinned server diagnostics: " + Artifacts.Directory); } @@ -288,6 +287,9 @@ public async ValueTask DisposeAsync() ExceptionDispatchInfo.Capture(failure).Throw(); } } + private bool ShouldRetainLogs => _retainLogs || _diagnostics?.RetainLogs is true || string.Equals( + Environment.GetEnvironmentVariable("OPENCODE_SDK_TESTS_KEEP_LOGS"), "1", StringComparison.Ordinal); + private Dictionary BuildEnvironment(string runRoot) => ServerIsolation.Environment(_fileSystem, runRoot); diff --git a/tests/Shared/ServerFailureArtifacts.cs b/tests/Shared/ServerFailureArtifacts.cs index c6e9a1cc..07b6d371 100644 --- a/tests/Shared/ServerFailureArtifacts.cs +++ b/tests/Shared/ServerFailureArtifacts.cs @@ -2,6 +2,7 @@ using System.IO.Abstractions; using System.Reflection; using System.Runtime.Versioning; +using System.Text; namespace OpenCode.Sdk.TestSupport; @@ -82,22 +83,27 @@ public bool IsReported(Exception exception) } } - public async Task WriteMetadataAsync(string mode, string process, Exception? teardownFailure) + public async Task WriteMetadataAsync(string mode, string process, IReadOnlyList lifecycleFailures) { _ = _fileSystem.Directory.CreateDirectory(Directory); foreach (var failure in Failures) { - var text = "test=" + failure.Test + "\ninvocation=" + new FailureDiagnosticText(256).Bound(failure.Invocation) + + var text = new StringBuilder("test=" + failure.Test + "\ninvocation=" + new FailureDiagnosticText(256).Bound(failure.Invocation) + "\nassembly=" + _assembly + "\nframework=" + Framework + "\nmode=" + mode + "\nserver-process=" + process + "\n" + failure.Details + "\nprimary:\n" + - new FailureDiagnosticText(8_192).Describe(failure.Exception); - if (teardownFailure is not null && !ReferenceEquals(teardownFailure, failure.Exception)) + new FailureDiagnosticText(8_192).Describe(failure.Exception)); + foreach (var lifecycleFailure in lifecycleFailures.Take(64).Where(item => !ReferenceEquals(item, failure.Exception))) { - text += "\nteardown:\n" + new FailureDiagnosticText(2_048).Describe(teardownFailure); + _ = text.Append("\nlifecycle:\n").Append(new FailureDiagnosticText(2_048).Describe(lifecycleFailure)); + } + + if (lifecycleFailures.Count > 64) + { + _ = text.Append("\n[truncated]"); } await new DiagnosticFileWriter(_fileSystem).WriteAsync( - _fileSystem.Path.Combine(Directory, failure.Id + ".log"), _text.Bound(text)); + _fileSystem.Path.Combine(Directory, failure.Id + ".log"), _text.Bound(text.ToString())); } } } diff --git a/tests/Shared/ServerFailureCapture.cs b/tests/Shared/ServerFailureCapture.cs index 51c4fd9f..b9111e26 100644 --- a/tests/Shared/ServerFailureCapture.cs +++ b/tests/Shared/ServerFailureCapture.cs @@ -13,7 +13,8 @@ internal sealed class ServerFailureCapture( public IReadOnlyList LateReports => _lateReports; - public async Task CaptureAsync(CliWrapServerAdapter? adapter, bool external, Exception? failure) + public async Task CaptureAsync(CliWrapServerAdapter? adapter, bool external, Exception? failure, + IReadOnlyList teardownFailures) { var capture = new OwnedCleanup(TimeSpan.FromSeconds(5), deadline); if (adapter is not null) @@ -24,10 +25,16 @@ internal sealed class ServerFailureCapture( } failure = await CaptureFailureAsync(capture, failure); + if (failure is not null && artifacts.Failures.Count is 0) + { + _ = artifacts.Mark(failure, "fixture disposal", "phase=server diagnostic capture"); + } + + var lifecycleFailures = teardownFailures.Concat(capture.OperationFailures).ToArray(); var metadata = new OwnedCleanup(TimeSpan.FromSeconds(5), deadline); metadata.Own("pinned server failure metadata", _ => artifacts.WriteMetadataAsync(external ? "external" : "owned", - adapter?.ProcessId.ToString(CultureInfo.InvariantCulture) ?? "not-owned", failure)); + adapter?.ProcessId.ToString(CultureInfo.InvariantCulture) ?? "not-owned", lifecycleFailures)); return await CaptureFailureAsync(metadata, failure); } diff --git a/tests/Shared/ServerFixtureDiagnosticsOptions.cs b/tests/Shared/ServerFixtureDiagnosticsOptions.cs index 53229bf8..c2a563b4 100644 --- a/tests/Shared/ServerFixtureDiagnosticsOptions.cs +++ b/tests/Shared/ServerFixtureDiagnosticsOptions.cs @@ -10,4 +10,6 @@ internal sealed record ServerFixtureDiagnosticsOptions public required string ResultsDirectory { get; init; } public IOwnedOperationDeadline? Deadline { get; init; } + + public bool RetainLogs { get; init; } } From bc7a8b50d1407e29263cf55ffbc8d6833592556d Mon Sep 17 00:00:00 2001 From: "denizirgin@gmail.com" Date: Sun, 6 Sep 2026 18:20:09 +0300 Subject: [PATCH 21/62] test: request PTY readiness after attachment --- tests/OpenCode.Sdk.Tests/Fixtures/Ptys/terminal-peer.js | 6 +++--- tests/OpenCode.Sdk.Tests/Ptys/PtySessionLiveTests.cs | 2 ++ 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/OpenCode.Sdk.Tests/Fixtures/Ptys/terminal-peer.js b/tests/OpenCode.Sdk.Tests/Fixtures/Ptys/terminal-peer.js index 75ad8e2f..ea50af86 100644 --- a/tests/OpenCode.Sdk.Tests/Fixtures/Ptys/terminal-peer.js +++ b/tests/OpenCode.Sdk.Tests/Fixtures/Ptys/terminal-peer.js @@ -7,11 +7,11 @@ const input = readline.createInterface({ }); input.on("line", (line) => { + // Readiness is requested after attachment, so the PTY host already owns the output listener. + // A leading delimiter keeps the response distinct from echoed input and terminal controls. + if (line === "READY?") process.stdout.write("\nREADY\n"); const match = /^RUN ([0-9a-f]+)$/.exec(line); if (match) process.stdout.write(`\nACK:${match[1]}\n`); }); -// A leading delimiter keeps the record distinct from terminal-mode controls emitted by a PTY -// host before the child produces its first byte. -process.stdout.write("\nREADY\n"); await new Promise((resolve) => input.once("close", resolve)); diff --git a/tests/OpenCode.Sdk.Tests/Ptys/PtySessionLiveTests.cs b/tests/OpenCode.Sdk.Tests/Ptys/PtySessionLiveTests.cs index 3f042b55..37abc0a8 100644 --- a/tests/OpenCode.Sdk.Tests/Ptys/PtySessionLiveTests.cs +++ b/tests/OpenCode.Sdk.Tests/Ptys/PtySessionLiveTests.cs @@ -162,6 +162,8 @@ private async Task AssertInitialExecutionAsync( var session = _scenario.Own(await terminal.ConnectAsync( new PtyConnectOptions { Location = _scenario.Location }, cancellationToken)); var transcript = new PtyLiveTranscript(); + _scenario.Diagnostics.Enter("readiness request", transcript); + await session.WriteAsync("READY?\r", cancellationToken); _scenario.Diagnostics.Enter("initial READY", transcript); _ = await transcript.ReadThroughReplayAsync(session, [ReadyRecord], cancellationToken); _scenario.Diagnostics.Enter("initial input acknowledgement", transcript); From cec1df1fb32aa55704b5ea4c24d0555a7b31bf01 Mon Sep 17 00:00:00 2001 From: "denizirgin@gmail.com" Date: Sun, 6 Sep 2026 19:03:17 +0300 Subject: [PATCH 22/62] test: prove session move live behavior --- .../Sessions/SessionClientLiveTests.cs | 156 ++++++++++++++++++ .../Support/SessionMoveEvents.cs | 66 ++++++++ 2 files changed, 222 insertions(+) create mode 100644 tests/OpenCode.Sdk.Tests/Sessions/SessionClientLiveTests.cs create mode 100644 tests/OpenCode.Sdk.Tests/Support/SessionMoveEvents.cs diff --git a/tests/OpenCode.Sdk.Tests/Sessions/SessionClientLiveTests.cs b/tests/OpenCode.Sdk.Tests/Sessions/SessionClientLiveTests.cs new file mode 100644 index 00000000..3c32dfda --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Sessions/SessionClientLiveTests.cs @@ -0,0 +1,156 @@ +using System.Globalization; +using OpenCode.Sdk.Models; +using OpenCode.Sdk.Tests.Support; +using OpenCode.Sdk.TestSupport; + +namespace OpenCode.Sdk.Tests.Sessions; + +[ClassDataSource(Shared = SharedType.PerTestSession)] +[NotInParallel(ParallelConstraintKeys.ServerProcess)] +public sealed class SessionClientLiveTests(SimulatedDriveServerFixture server) +{ + private const string ModelId = "sim-model"; + private const string ProviderId = "sim"; + private static readonly TimeSpan CleanupTimeout = TimeSpan.FromSeconds(15); + private static readonly TimeSpan EventWait = TimeSpan.FromSeconds(120); + + [Test] + [Timeout(180_000)] + public async Task PostMoveAsync_Should_Apply_The_Move_Before_The_Destination_Read( + CancellationToken cancellationToken) + { + using var source = server.CreateWorkspace(); + using var destination = server.CreateWorkspace(); + using var sourceClient = server.CreateClient(new LocationSelector { Directory = source.Path }); + using var destinationClient = server.CreateClient(new LocationSelector { Directory = destination.Path }); + var created = await sourceClient.Sessions.CreateSessionAsync(new SessionCreateRequest + { + Title = "session-move-live", + Location = new LocationRef { Directory = source.Path }, + Model = new ModelRef { Id = ModelId, ProviderId = ProviderId }, + }, cancellationToken: cancellationToken); + var sessionId = created.Session.Id; + var sourceSession = sourceClient.Sessions.GetSessionClient(sessionId); + var cleanup = CreateCleanup(sourceSession, sessionId); + var reader = new OwnedEventReader(EventWait, CleanupTimeout, cancellationToken); + var connected = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var events = new SessionMoveEvents(sessionId, destination.Path, reader); + var movedTask = events.CompleteAsync(sourceClient.Events.SubscribeAsync(reader.Token), connected); + Exception? primaryFailure = null; + + try + { + var attached = await Task.WhenAny(connected.Task, movedTask); + if (attached == movedTask) + { + _ = await movedTask; + throw new InvalidOperationException("The event subscription ended before server.connected."); + } + + var admitted = await sourceSession.PostMoveAsync( + new SessionMovePostRequest { Directory = destination.Path }, + cancellationToken: cancellationToken); + await Assert.That(admitted.Status).IsEqualTo(204); + await Assert.That(admitted.IsError).IsFalse(); + + var moved = await movedTask; + await Assert.That(moved.Data.SessionId).IsEqualTo(sessionId); + await Assert.That(moved.Data.Location.Directory).IsEqualTo(destination.Path); + await Assert.That(moved.Data.ProjectId).IsNotEmpty(); + + var destinationSession = destinationClient.Sessions.GetSessionClient(sessionId); + var applied = await destinationSession.GetSessionAsync(cancellationToken: cancellationToken); + await Assert.That(applied.Status).IsEqualTo(200); + await Assert.That(applied.Session.Id).IsEqualTo(sessionId); + await Assert.That(applied.Session.Location.Directory).IsEqualTo(destination.Path); + await Assert.That(applied.Session.ProjectId).IsEqualTo(moved.Data.ProjectId); + + Console.WriteLine( + "session-move-live: admission=" + Number(admitted.Status) + + " applied-event=session.moved destination-project=" + moved.Data.ProjectId); + } + catch (Exception exception) + { + primaryFailure = exception; + if (!exception.Data.Contains(EventDiagnosticSummary.DataKey)) + { + exception.Data[EventDiagnosticSummary.DataKey] = events.DiagnosticSummary; + } + } + finally + { + await CompleteCleanupAsync(reader, cleanup, primaryFailure); + } + } + + [Test] + [Timeout(180_000)] + public async Task PostMoveAsync_Should_Return_The_Typed_404_For_A_Missing_Session( + CancellationToken cancellationToken) + { + using var destination = server.CreateWorkspace(); + using var client = server.CreateClient(new LocationSelector { Directory = destination.Path }); + var sessionId = "ses_sdk_missing_" + Guid.NewGuid().ToString("N"); + var response = await client.Sessions.GetSessionClient(sessionId).PostMoveAsync( + new SessionMovePostRequest { Directory = destination.Path }, + OpenCodeRequestOptions.NoThrow, + cancellationToken); + + await Assert.That(response.Status).IsEqualTo(404); + await Assert.That(response.Error).IsTypeOf(); + var error = response.Error as SessionNotFoundError; + await Assert.That(error?.Tag).IsEqualTo("SessionNotFoundError"); + await Assert.That(error?.SessionId).IsEqualTo(sessionId); + + Console.WriteLine( + "session-move-missing-live: status=" + Number(response.Status) + " error=" + error?.Tag); + } + + private static OwnedSessionCleanup CreateCleanup(SessionClient session, string sessionId) + { + var cleanup = new OwnedSessionCleanup( + _ => Task.CompletedTask, + token => RemoveOwnedSessionAsync(session, sessionId, token), + CleanupTimeout); + cleanup.MarkTurnCompleted(); + return cleanup; + } + + private static async Task RemoveOwnedSessionAsync( + SessionClient session, + string sessionId, + CancellationToken cancellationToken) + { + var response = await session.RemoveSessionAsync(OpenCodeRequestOptions.NoThrow, cancellationToken); + if (response.Status == 204 + || (response is { Status: 404, Error: SessionNotFoundError missing } + && missing.SessionId == sessionId)) + { + return; + } + + throw new InvalidOperationException( + "Owned session cleanup returned status " + Number(response.Status) + + ", error " + response.Error?.GetType().Name + ", body " + response.RawBody + "."); + } + + private static async Task CompleteCleanupAsync( + OwnedEventReader reader, + OwnedSessionCleanup cleanup, + Exception? primaryFailure) + { + try + { + await reader.CompleteAsync(primaryFailure); + } + catch (Exception exception) + { + primaryFailure = exception; + } + + await cleanup.CompleteAsync(primaryFailure); + } + + private static string Number(int value) => + value.ToString(CultureInfo.InvariantCulture); +} diff --git a/tests/OpenCode.Sdk.Tests/Support/SessionMoveEvents.cs b/tests/OpenCode.Sdk.Tests/Support/SessionMoveEvents.cs new file mode 100644 index 00000000..dc9a4d4d --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Support/SessionMoveEvents.cs @@ -0,0 +1,66 @@ +using OpenCode.Sdk.Models; + +namespace OpenCode.Sdk.Tests.Support; + +internal sealed class SessionMoveEvents(string sessionId, string destination, OwnedEventReader reader) +{ + private readonly EventDiagnosticSummary _observed = new(); + + public string DiagnosticSummary => _observed.ToString(); + + public async Task CompleteAsync( + IAsyncEnumerable events, + TaskCompletionSource connected) + { + try + { + return await reader.Start(_ => ObserveAsync(events, connected)); + } + catch (Exception exception) + { + exception.Data[EventDiagnosticSummary.DataKey] = DiagnosticSummary; + throw; + } + } + + public async Task ObserveAsync( + IAsyncEnumerable events, + TaskCompletionSource connected) + { + ArgumentNullException.ThrowIfNull(events); + ArgumentNullException.ThrowIfNull(connected); + try + { + await foreach (var @event in events.WithCancellation(reader.Token)) + { + _observed.Add(@event.Type); + if (@event is EventServerConnected) + { + _ = connected.TrySetResult(true); + } + + if (@event is SessionMoved moved + && moved.Data.SessionId == sessionId + && moved.Data.Location.Directory == destination) + { + return moved; + } + } + } + catch (OperationCanceledException exception) + when (reader.ObservationTimedOut(exception)) + { + throw new TimeoutException( + $"The session move did not complete within the event window. Events: {_observed}.", + exception); + } + catch (Exception exception) + { + exception.Data[EventDiagnosticSummary.DataKey] = DiagnosticSummary; + throw; + } + + throw new InvalidOperationException( + $"The event subscription ended before the session move completed. Events: {_observed}."); + } +} From 4346a94c58d3a726a3f954a90a656362345c411c Mon Sep 17 00:00:00 2001 From: "denizirgin@gmail.com" Date: Sun, 6 Sep 2026 19:33:22 +0300 Subject: [PATCH 23/62] test: prove session transfer and stats live --- .../Sessions/SessionsClientLiveTests.cs | 269 ++++++++++++++++++ 1 file changed, 269 insertions(+) create mode 100644 tests/OpenCode.Sdk.Tests/Sessions/SessionsClientLiveTests.cs diff --git a/tests/OpenCode.Sdk.Tests/Sessions/SessionsClientLiveTests.cs b/tests/OpenCode.Sdk.Tests/Sessions/SessionsClientLiveTests.cs new file mode 100644 index 00000000..03fc2dcc --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Sessions/SessionsClientLiveTests.cs @@ -0,0 +1,269 @@ +using System.Globalization; +using System.Text.Json; +using OpenCode.Sdk.Models; +using OpenCode.Sdk.Tests.Support; +using OpenCode.Sdk.TestSupport; + +namespace OpenCode.Sdk.Tests.Sessions; + +[ClassDataSource(Shared = SharedType.PerTestSession)] +[NotInParallel(ParallelConstraintKeys.ServerProcess)] +public sealed class SessionsClientLiveTests(SimulatedDriveServerFixture server) +{ + private const string ModelId = "sim-model"; + private const string ProviderId = "sim"; + private const string TransferPrompt = "task-two raw transfer prompt"; + private const string TransferReply = "Task two raw transfer reply."; + private const string StatsPrompt = "task-two statistics prompt"; + private const string StatsReply = "Task two statistics reply."; + private static readonly TimeSpan CleanupTimeout = TimeSpan.FromSeconds(15); + + [Test] + [Timeout(180_000)] + public async Task PostImportAsync_Should_Preserve_The_Raw_Exported_Session( + CancellationToken cancellationToken) + { + using var source = server.CreateWorkspace(); + using var destination = server.CreateWorkspace(); + using var sourceClient = server.CreateClient(new LocationSelector { Directory = source.Path }); + using var destinationClient = server.CreateClient(new LocationSelector { Directory = destination.Path }); + var created = await CreateOwnedSessionAsync( + sourceClient, source.Path, "session-transfer-live", cancellationToken); + var session = sourceClient.Sessions.GetSessionClient(created.Id); + var cleanup = CreateCleanup(session, created.Id); + Exception? primaryFailure = null; + + try + { + var turn = new SimulatedSessionTurn(server, sourceClient, session, created.Id); + _ = await turn.CompleteAsync(TransferPrompt, TransferReply, cancellationToken); + cleanup.MarkTurnCompleted(); + var exported = await session.GetExportAsync( + new SessionExportRequest { Sanitize = QueryBoolean.False }, + cancellationToken: cancellationToken); + var exportedEvidence = await AssertRawExportAsync(exported, created.Id); + var importRequest = CreateImportRequest(exported.Export, destination.Path); + + await AssertDuplicateImportConflictAsync( + destinationClient, importRequest, created.Id, cancellationToken); + + var removed = await session.RemoveSessionAsync(cancellationToken: cancellationToken); + await Assert.That(removed.Status).IsEqualTo(204); + await Assert.That(removed.IsError).IsFalse(); + + var imported = await destinationClient.Sessions.PostImportAsync( + importRequest, cancellationToken: cancellationToken); + await Assert.That(imported.Status).IsEqualTo(200); + await Assert.That(imported.IsError).IsFalse(); + await Assert.That(imported.Import.Id).IsEqualTo(created.Id); + await Assert.That(imported.Import.Location.Directory).IsEqualTo(destination.Path); + + var importedMessages = await destinationClient.Sessions.GetSessionClient(created.Id).ListMessagesAsync( + new MessageListRequest { Order = ListOrder.Ascending }, + cancellationToken: cancellationToken); + await Assert.That(importedMessages.Status).IsEqualTo(200); + await Assert.That(importedMessages.IsError).IsFalse(); + var importedEvidence = ProjectMessages(importedMessages.Messages); + await Assert.That(importedEvidence.SequenceEqual(exportedEvidence)).IsTrue(); + + Console.WriteLine( + "session-transfer-live: export=" + Number(exported.Status) + + " duplicate=409 import=" + Number(imported.Status) + + " messages=" + Number(importedEvidence.Count) + " sanitize=false"); + } + catch (Exception exception) + { + primaryFailure = exception; + } + finally + { + await cleanup.CompleteAsync(primaryFailure); + } + } + + [Test] + [Timeout(180_000)] + public async Task GetStatsAsync_Should_Report_Positive_Owned_Session_Activity( + CancellationToken cancellationToken) + { + using var workspace = server.CreateWorkspace(); + using var client = server.CreateClient(new LocationSelector { Directory = workspace.Path }); + var created = await CreateOwnedSessionAsync(client, workspace.Path, "session-stats-live", cancellationToken); + var session = client.Sessions.GetSessionClient(created.Id); + var cleanup = CreateCleanup(session, created.Id); + Exception? primaryFailure = null; + + try + { + var turn = new SimulatedSessionTurn(server, client, session, created.Id); + _ = await turn.CompleteAsync(StatsPrompt, StatsReply, cancellationToken); + cleanup.MarkTurnCompleted(); + var messages = await session.ListMessagesAsync( + new MessageListRequest { Order = ListOrder.Ascending }, + cancellationToken: cancellationToken); + var observed = ProjectMessages(messages.Messages); + await Assert.That(observed.Count).IsGreaterThan(0); + var from = observed.Min(message => message.Created) - 1; + var to = observed.Max(message => message.Created) + 1; + + var response = await client.Sessions.GetStatsAsync(new SessionStatsRequest + { + From = Number(from), + To = Number(to), + Project = created.ProjectId, + }, cancellationToken: cancellationToken); + + await Assert.That(response.Status).IsEqualTo(200); + await Assert.That(response.IsError).IsFalse(); + await Assert.That(response.Stats.Range.From).IsEqualTo(from); + await Assert.That(response.Stats.Range.To).IsEqualTo(to); + await Assert.That(response.Stats.Sessions).IsGreaterThan(0); + await Assert.That(response.Stats.Prompts).IsGreaterThan(0); + await Assert.That(response.Stats.Steps).IsGreaterThan(0); + await Assert.That(response.Stats.Models.Any(model => + model.Model is { Id: ModelId, ProviderId: ProviderId })).IsTrue(); + + Console.WriteLine( + "session-stats-live: sessions=" + Number(response.Stats.Sessions) + + " prompts=" + Number(response.Stats.Prompts) + + " steps=" + Number(response.Stats.Steps) + " model=sim/sim-model"); + } + catch (Exception exception) + { + primaryFailure = exception; + } + finally + { + await cleanup.CompleteAsync(primaryFailure); + } + } + + private static async Task CreateOwnedSessionAsync( + OpenCodeClient client, + string directory, + string title, + CancellationToken cancellationToken) + { + var response = await client.Sessions.CreateSessionAsync(new SessionCreateRequest + { + Title = title, + Location = new LocationRef { Directory = directory }, + Model = new ModelRef { Id = ModelId, ProviderId = ProviderId }, + }, cancellationToken: cancellationToken); + return response.Session; + } + + private static OwnedSessionCleanup CreateCleanup(SessionClient session, string sessionId) + { + var cleanup = new OwnedSessionCleanup( + async token => _ = await session.PostInterruptAsync(cancellationToken: token), + token => RemoveOwnedSessionAsync(session, sessionId, token), + CleanupTimeout); + cleanup.MarkTurnStarted(); + return cleanup; + } + + private static async Task> AssertRawExportAsync( + SessionExportResponse response, + string sessionId) + { + await Assert.That(response.Status).IsEqualTo(200); + await Assert.That(response.IsError).IsFalse(); + await Assert.That(response.Export.Info.Id).IsEqualTo(sessionId); + await Assert.That(response.Export.Messages.Count).IsGreaterThan(0); + await Assert.That(response.Export.Messages.OfType().Any()).IsTrue(); + await Assert.That(response.Export.Messages.OfType().Any()).IsTrue(); + var evidence = ProjectMessages(response.Export.Messages); + await Assert.That(evidence.Any(message => + message is { Type: "user", Text: TransferPrompt })).IsTrue(); + await Assert.That(evidence.Any(message => + message is { Type: "assistant", Text: TransferReply })).IsTrue(); + return evidence; + } + + private static async Task AssertDuplicateImportConflictAsync( + OpenCodeClient destinationClient, + SessionImportPostRequest request, + string sessionId, + CancellationToken cancellationToken) + { + var response = await destinationClient.Sessions.PostImportAsync( + request, OpenCodeRequestOptions.NoThrow, cancellationToken); + + await Assert.That(response.Status).IsEqualTo(409); + await Assert.That(response.Error).IsTypeOf(); + var conflict = response.Error as ConflictError; + await Assert.That(conflict?.Tag).IsEqualTo("ConflictError"); + await Assert.That(conflict?.Resource).IsEqualTo(sessionId); + } + + private static SessionImportPostRequest CreateImportRequest( + SessionTransferData exported, + string destination) => + new() + { + Info = exported.Info, + Messages = exported.Messages, + Location = new LocationRef { Directory = destination }, + }; + + private static List<(string Type, string Id, string Text, double Created)> ProjectMessages( + IEnumerable messages) + { + var serializer = new GeneratedJsonSerializer(); + var evidence = new List<(string Type, string Id, string Text, double Created)>(); + foreach (var message in messages) + { + using var document = JsonDocument.Parse(serializer.Serialize(message)); + var root = document.RootElement; + evidence.Add(( + root.GetProperty("type").GetString()!, + root.GetProperty("id").GetString()!, + ReadText(root), + root.GetProperty("time").GetProperty("created").GetDouble())); + } + + return evidence; + } + + private static string ReadText(JsonElement message) + { + if (message.TryGetProperty("text", out var text)) + { + return text.GetString()!; + } + + if (!message.TryGetProperty("content", out var content)) + { + return string.Empty; + } + + return string.Concat(content.EnumerateArray() + .Where(item => item.GetProperty("type").GetString() == "text") + .Select(item => item.GetProperty("text").GetString())); + } + + private static async Task RemoveOwnedSessionAsync( + SessionClient session, + string sessionId, + CancellationToken cancellationToken) + { + var response = await session.RemoveSessionAsync(OpenCodeRequestOptions.NoThrow, cancellationToken); + if (response.Status == 204 + || (response is { Status: 404, Error: SessionNotFoundError missing } + && missing.SessionId == sessionId)) + { + return; + } + + throw new InvalidOperationException( + "Owned session cleanup returned status " + Number(response.Status) + + ", error " + response.Error?.GetType().Name + ", body " + response.RawBody + "."); + } + + private static string Number(int value) => value.ToString(CultureInfo.InvariantCulture); + + private static string Number(long value) => value.ToString(CultureInfo.InvariantCulture); + + private static string Number(double value) => value.ToString("R", CultureInfo.InvariantCulture); +} From 54a73ff2bb4d4ab3079d44da7c6fc9f9b08c31d2 Mon Sep 17 00:00:00 2001 From: "denizirgin@gmail.com" Date: Sun, 6 Sep 2026 20:01:58 +0300 Subject: [PATCH 24/62] test: cover catalog clients against simulated server --- .../Agents/AgentsClientLiveTests.cs | 74 +++++++++++++++++++ .../Commands/CommandsClientLiveTests.cs | 33 +++++++++ .../LanguageModelsClientLiveTests.cs | 43 +++++++++++ .../Providers/ProvidersClientLiveTests.cs | 72 ++++++++++++++++++ .../References/ReferencesClientLiveTests.cs | 39 ++++++++++ tests/Shared/SimulationConfigSeed.cs | 69 ++++++++++++++++- 6 files changed, 326 insertions(+), 4 deletions(-) create mode 100644 tests/OpenCode.Sdk.Tests/Agents/AgentsClientLiveTests.cs create mode 100644 tests/OpenCode.Sdk.Tests/Commands/CommandsClientLiveTests.cs create mode 100644 tests/OpenCode.Sdk.Tests/LanguageModels/LanguageModelsClientLiveTests.cs create mode 100644 tests/OpenCode.Sdk.Tests/Providers/ProvidersClientLiveTests.cs create mode 100644 tests/OpenCode.Sdk.Tests/References/ReferencesClientLiveTests.cs diff --git a/tests/OpenCode.Sdk.Tests/Agents/AgentsClientLiveTests.cs b/tests/OpenCode.Sdk.Tests/Agents/AgentsClientLiveTests.cs new file mode 100644 index 00000000..13035bcf --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Agents/AgentsClientLiveTests.cs @@ -0,0 +1,74 @@ +using System.Globalization; +using OpenCode.Sdk.Models; +using OpenCode.Sdk.TestSupport; + +namespace OpenCode.Sdk.Tests; + +[ClassDataSource(Shared = SharedType.PerTestSession)] +[NotInParallel(ParallelConstraintKeys.ServerProcess)] +public sealed class AgentsClientLiveTests(SimulatedDriveServerFixture server) +{ + private const string BuildAgentId = "build"; + + [Test] + [Timeout(60_000)] + public async Task ListAgentsAsync_And_GetAgentAsync_Should_Report_The_Build_Agent( + CancellationToken cancellationToken) + { + using var workspace = server.CreateWorkspace(); + using var client = server.CreateClient(new LocationSelector { Directory = workspace.Path }); + var settled = await client.Plugins.AwaitPluginActivationAsync(cancellationToken: cancellationToken); + await Assert.That(settled.Status).IsEqualTo(204); + + var listed = await client.Agents.ListAgentsAsync(cancellationToken: cancellationToken); + + await Assert.That(listed.Status).IsEqualTo(200); + await Assert.That(listed.IsError).IsFalse(); + await Assert.That(listed.Location.Directory).IsEqualTo(workspace.Path); + var build = listed.Agents.Single(agent => agent.Id == BuildAgentId); + await AssertBuildAgentAsync(build); + + var found = await client.Agents.GetAgentAsync(BuildAgentId, cancellationToken: cancellationToken); + await Assert.That(found.Status).IsEqualTo(200); + await Assert.That(found.IsError).IsFalse(); + await Assert.That(found.Location.Directory).IsEqualTo(workspace.Path); + await AssertBuildAgentAsync(found.Agent); + + Console.WriteLine( + "agents-live: list=" + Number(listed.Status) + " get=" + Number(found.Status) + " id=" + build.Id); + } + + [Test] + [Timeout(60_000)] + public async Task GetAgentAsync_Should_Return_The_Typed_404_For_A_Missing_Agent( + CancellationToken cancellationToken) + { + using var workspace = server.CreateWorkspace(); + using var client = server.CreateClient(new LocationSelector { Directory = workspace.Path }); + _ = await client.Plugins.AwaitPluginActivationAsync(cancellationToken: cancellationToken); + var agentId = "sdk-live-missing-agent-" + Guid.NewGuid().ToString("N"); + + var response = await client.Agents.GetAgentAsync( + agentId, requestOptions: OpenCodeRequestOptions.NoThrow, cancellationToken: cancellationToken); + + await Assert.That(response.Status).IsEqualTo(404); + await Assert.That(response.IsError).IsTrue(); + await Assert.That(response.Error).IsTypeOf(); + var missing = response.Error as AgentNotFoundError; + await Assert.That(missing?.Tag).IsEqualTo("AgentNotFoundError"); + await Assert.That(missing?.AgentId).IsEqualTo(agentId); + await Assert.That(missing?.Message).Contains(agentId); + } + + private static async Task AssertBuildAgentAsync(AgentInfo agent) + { + await Assert.That(agent.Id).IsEqualTo(BuildAgentId); + await Assert.That(agent.Name).IsEqualTo("Build"); + await Assert.That(agent.Mode).IsEqualTo(AgentInfoMode.Primary); + await Assert.That(agent.Hidden).IsFalse(); + await Assert.That(agent.Permissions.Any(rule => + rule is { Action: "question", Resource: "*", Effect: PermissionEffect.Allow })).IsTrue(); + } + + private static string Number(int value) => value.ToString(CultureInfo.InvariantCulture); +} diff --git a/tests/OpenCode.Sdk.Tests/Commands/CommandsClientLiveTests.cs b/tests/OpenCode.Sdk.Tests/Commands/CommandsClientLiveTests.cs new file mode 100644 index 00000000..2a71d5cd --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Commands/CommandsClientLiveTests.cs @@ -0,0 +1,33 @@ +using System.Globalization; +using OpenCode.Sdk.TestSupport; + +namespace OpenCode.Sdk.Tests; + +[ClassDataSource(Shared = SharedType.PerTestSession)] +[NotInParallel(ParallelConstraintKeys.ServerProcess)] +public sealed class CommandsClientLiveTests(SimulatedDriveServerFixture server) +{ + [Test] + [Timeout(60_000)] + public async Task ListCommandsAsync_Should_Report_The_Typed_Seeded_Command( + CancellationToken cancellationToken) + { + using var workspace = server.CreateWorkspace(); + using var client = server.CreateClient(new LocationSelector { Directory = workspace.Path }); + var settled = await client.Plugins.AwaitPluginActivationAsync(cancellationToken: cancellationToken); + await Assert.That(settled.Status).IsEqualTo(204); + + var response = await client.Commands.ListCommandsAsync(cancellationToken: cancellationToken); + + await Assert.That(response.Status).IsEqualTo(200); + await Assert.That(response.IsError).IsFalse(); + await Assert.That(response.Location.Directory).IsEqualTo(workspace.Path); + var command = response.Commands.Single(item => item.Name == SimulationConfigSeed.CommandName); + await Assert.That(command.Description).IsEqualTo(SimulationConfigSeed.CommandDescription); + + Console.WriteLine( + "commands-live: status=" + Number(response.Status) + " name=" + command.Name); + } + + private static string Number(int value) => value.ToString(CultureInfo.InvariantCulture); +} diff --git a/tests/OpenCode.Sdk.Tests/LanguageModels/LanguageModelsClientLiveTests.cs b/tests/OpenCode.Sdk.Tests/LanguageModels/LanguageModelsClientLiveTests.cs new file mode 100644 index 00000000..113e8d74 --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/LanguageModels/LanguageModelsClientLiveTests.cs @@ -0,0 +1,43 @@ +using System.Globalization; +using OpenCode.Sdk.TestSupport; + +namespace OpenCode.Sdk.Tests; + +[ClassDataSource(Shared = SharedType.PerTestSession)] +[NotInParallel(ParallelConstraintKeys.ServerProcess)] +public sealed class LanguageModelsClientLiveTests(SimulatedDriveServerFixture server) +{ + [Test] + [Timeout(60_000)] + public async Task ListModelsAsync_And_GetDefaultAsync_Should_Report_The_Selected_Sim_Model( + CancellationToken cancellationToken) + { + using var workspace = server.CreateWorkspace(); + using var client = server.CreateClient(new LocationSelector { Directory = workspace.Path }); + var settled = await client.Plugins.AwaitPluginActivationAsync(cancellationToken: cancellationToken); + await Assert.That(settled.Status).IsEqualTo(204); + + var listed = await client.LanguageModels.ListModelsAsync(cancellationToken: cancellationToken); + + await Assert.That(listed.Status).IsEqualTo(200); + await Assert.That(listed.IsError).IsFalse(); + await Assert.That(listed.Location.Directory).IsEqualTo(workspace.Path); + var model = listed.Models.Single(item => + item.ProviderId == SimulationConfigSeed.ProviderId && item.ModelId == SimulationConfigSeed.ModelId); + await Assert.That(model.Name).IsEqualTo(SimulationConfigSeed.ModelName); + + var selected = await client.LanguageModels.GetDefaultAsync(cancellationToken: cancellationToken); + await Assert.That(selected.Status).IsEqualTo(200); + await Assert.That(selected.IsError).IsFalse(); + await Assert.That(selected.Location.Directory).IsEqualTo(workspace.Path); + await Assert.That(selected.Default).IsNotNull(); + await Assert.That(selected.Default?.ProviderId).IsEqualTo(SimulationConfigSeed.ProviderId); + await Assert.That(selected.Default?.ModelId).IsEqualTo(SimulationConfigSeed.ModelId); + + Console.WriteLine( + "models-live: list=" + Number(listed.Status) + " default=" + Number(selected.Status) + + " model=" + model.ProviderId + "/" + model.ModelId); + } + + private static string Number(int value) => value.ToString(CultureInfo.InvariantCulture); +} diff --git a/tests/OpenCode.Sdk.Tests/Providers/ProvidersClientLiveTests.cs b/tests/OpenCode.Sdk.Tests/Providers/ProvidersClientLiveTests.cs new file mode 100644 index 00000000..8c564eaf --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Providers/ProvidersClientLiveTests.cs @@ -0,0 +1,72 @@ +using System.Globalization; +using OpenCode.Sdk.Models; +using OpenCode.Sdk.TestSupport; + +namespace OpenCode.Sdk.Tests; + +[ClassDataSource(Shared = SharedType.PerTestSession)] +[NotInParallel(ParallelConstraintKeys.ServerProcess)] +public sealed class ProvidersClientLiveTests(SimulatedDriveServerFixture server) +{ + [Test] + [Timeout(60_000)] + public async Task ListProvidersAsync_And_GetProviderAsync_Should_Report_The_Sim_Provider( + CancellationToken cancellationToken) + { + using var workspace = server.CreateWorkspace(); + using var client = server.CreateClient(new LocationSelector { Directory = workspace.Path }); + var settled = await client.Plugins.AwaitPluginActivationAsync(cancellationToken: cancellationToken); + await Assert.That(settled.Status).IsEqualTo(204); + + var listed = await client.Providers.ListProvidersAsync(cancellationToken: cancellationToken); + + await Assert.That(listed.Status).IsEqualTo(200); + await Assert.That(listed.IsError).IsFalse(); + await Assert.That(listed.Location.Directory).IsEqualTo(workspace.Path); + var provider = listed.Providers.Single(item => item.Id == SimulationConfigSeed.ProviderId); + await AssertSimProviderAsync(provider); + + var found = await client.Providers.GetProviderAsync( + SimulationConfigSeed.ProviderId, cancellationToken: cancellationToken); + await Assert.That(found.Status).IsEqualTo(200); + await Assert.That(found.IsError).IsFalse(); + await Assert.That(found.Location.Directory).IsEqualTo(workspace.Path); + await AssertSimProviderAsync(found.Provider); + + Console.WriteLine( + "providers-live: list=" + Number(listed.Status) + " get=" + Number(found.Status) + + " id=" + provider.Id); + } + + [Test] + [Timeout(60_000)] + public async Task GetProviderAsync_Should_Return_The_Typed_404_For_A_Missing_Provider( + CancellationToken cancellationToken) + { + using var workspace = server.CreateWorkspace(); + using var client = server.CreateClient(new LocationSelector { Directory = workspace.Path }); + _ = await client.Plugins.AwaitPluginActivationAsync(cancellationToken: cancellationToken); + var providerId = "sdk-live-missing-provider-" + Guid.NewGuid().ToString("N"); + + var response = await client.Providers.GetProviderAsync( + providerId, requestOptions: OpenCodeRequestOptions.NoThrow, cancellationToken: cancellationToken); + + await Assert.That(response.Status).IsEqualTo(404); + await Assert.That(response.IsError).IsTrue(); + await Assert.That(response.Error).IsTypeOf(); + var missing = response.Error as ProviderNotFoundError; + await Assert.That(missing?.Tag).IsEqualTo("ProviderNotFoundError"); + await Assert.That(missing?.ProviderId).IsEqualTo(providerId); + await Assert.That(missing?.Message).Contains(providerId); + } + + private static async Task AssertSimProviderAsync(ProviderInfo provider) + { + await Assert.That(provider.Id).IsEqualTo(SimulationConfigSeed.ProviderId); + await Assert.That(provider.Name).IsEqualTo(SimulationConfigSeed.ProviderName); + await Assert.That(provider.Activation).IsEqualTo(ProviderInfoActivation.Enabled); + await Assert.That(provider.Package).IsEqualTo(SimulationConfigSeed.ProviderPackage); + } + + private static string Number(int value) => value.ToString(CultureInfo.InvariantCulture); +} diff --git a/tests/OpenCode.Sdk.Tests/References/ReferencesClientLiveTests.cs b/tests/OpenCode.Sdk.Tests/References/ReferencesClientLiveTests.cs new file mode 100644 index 00000000..ad5c78da --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/References/ReferencesClientLiveTests.cs @@ -0,0 +1,39 @@ +using System.Globalization; +using OpenCode.Sdk.Models; +using OpenCode.Sdk.TestSupport; + +namespace OpenCode.Sdk.Tests; + +[ClassDataSource(Shared = SharedType.PerTestSession)] +[NotInParallel(ParallelConstraintKeys.ServerProcess)] +public sealed class ReferencesClientLiveTests(SimulatedDriveServerFixture server) +{ + [Test] + [Timeout(60_000)] + public async Task ListReferencesAsync_Should_Report_The_Typed_Local_Reference( + CancellationToken cancellationToken) + { + using var workspace = server.CreateWorkspace(); + using var client = server.CreateClient(new LocationSelector { Directory = workspace.Path }); + var settled = await client.Plugins.AwaitPluginActivationAsync(cancellationToken: cancellationToken); + await Assert.That(settled.Status).IsEqualTo(204); + + var response = await client.References.ListReferencesAsync(cancellationToken: cancellationToken); + + await Assert.That(response.Status).IsEqualTo(200); + await Assert.That(response.IsError).IsFalse(); + await Assert.That(response.Location.Directory).IsEqualTo(workspace.Path); + var reference = response.References.Single(item => item.Name == SimulationConfigSeed.ReferenceName); + await Assert.That(reference.Path).IsEqualTo(workspace.Path); + await Assert.That(reference.Description).IsEqualTo(SimulationConfigSeed.ReferenceDescription); + await Assert.That(reference.Source).IsTypeOf(); + var local = reference.Source as ReferenceLocalSource; + await Assert.That(local?.Path).IsEqualTo(workspace.Path); + + Console.WriteLine( + "references-live: status=" + Number(response.Status) + " name=" + reference.Name + + " path=" + reference.Path); + } + + private static string Number(int value) => value.ToString(CultureInfo.InvariantCulture); +} diff --git a/tests/Shared/SimulationConfigSeed.cs b/tests/Shared/SimulationConfigSeed.cs index 28ca832d..76c342fb 100644 --- a/tests/Shared/SimulationConfigSeed.cs +++ b/tests/Shared/SimulationConfigSeed.cs @@ -14,8 +14,19 @@ namespace OpenCode.Sdk.TestSupport; /// internal sealed partial class SimulationConfigSeed { + internal const string CommandDescription = "The deterministic SDK live command."; + internal const string CommandName = "sdk-live-command"; + internal const string CommandTemplate = "Report the live catalog for $ARGUMENTS"; + internal const string ModelId = "sim-model"; + internal const string ModelName = "Simulated Model"; internal const string PermissionProbeAction = "sdk.live.permission"; internal const string PermissionProbeAgentId = "permission-probe"; + internal const string ProviderId = "sim"; + internal const string ProviderName = "Simulated"; + internal const string ProviderPackage = "@opencode-ai/ai/providers/openai-compatible"; + internal const string ReferenceDescription = "The deterministic SDK live reference."; + internal const string ReferenceName = "sdk-live-reference"; + internal const string ReferencePath = "."; internal static string Json { get; } = JsonSerializer.Serialize( new Configuration @@ -35,12 +46,25 @@ internal sealed partial class SimulationConfigSeed ], }, }, + Commands = new Dictionary(StringComparer.Ordinal) + { + [CommandName] = new CommandConfiguration + { + Template = CommandTemplate, + Description = CommandDescription, + }, + }, + Model = new ModelSelectionConfiguration + { + ProviderIdentifier = ProviderId, + ModelIdentifier = ModelId, + }, Providers = new Dictionary(StringComparer.Ordinal) { - ["sim"] = new ProviderConfiguration + [ProviderId] = new ProviderConfiguration { - Name = "Simulated", - Package = "@opencode-ai/ai/providers/openai-compatible", + Name = ProviderName, + Package = ProviderPackage, Settings = new ProviderSettings { BaseUrl = "https://api.openai.com/v1", @@ -48,10 +72,18 @@ internal sealed partial class SimulationConfigSeed }, Models = new Dictionary(StringComparer.Ordinal) { - ["sim-model"] = new ModelConfiguration { Name = "Simulated Model" }, + [ModelId] = new ModelConfiguration { Name = ModelName }, }, }, }, + References = new Dictionary(StringComparer.Ordinal) + { + [ReferenceName] = new ReferenceLocalConfiguration + { + Path = ReferencePath, + Description = ReferenceDescription, + }, + }, }, SerializationContext.Default.Configuration); @@ -59,7 +91,13 @@ private sealed record Configuration { public required IReadOnlyDictionary Agents { get; init; } + public required IReadOnlyDictionary Commands { get; init; } + + public required ModelSelectionConfiguration Model { get; init; } + public required IReadOnlyDictionary Providers { get; init; } + + public required IReadOnlyDictionary References { get; init; } } private sealed record AgentConfiguration @@ -67,6 +105,22 @@ private sealed record AgentConfiguration public required IReadOnlyList Permissions { get; init; } } + private sealed record CommandConfiguration + { + public required string Template { get; init; } + + public required string Description { get; init; } + } + + private sealed record ModelSelectionConfiguration + { + [JsonPropertyName("providerID")] + public required string ProviderIdentifier { get; init; } + + [JsonPropertyName("model")] + public required string ModelIdentifier { get; init; } + } + private sealed record ProviderConfiguration { public required string Name { get; init; } @@ -91,6 +145,13 @@ private sealed record ModelConfiguration public required string Name { get; init; } } + private sealed record ReferenceLocalConfiguration + { + public required string Path { get; init; } + + public required string Description { get; init; } + } + [JsonSourceGenerationOptions( GenerationMode = JsonSourceGenerationMode.Metadata, PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] From c7c25912803672c0a4f8c5cc9df1b82f4788d495 Mon Sep 17 00:00:00 2001 From: "denizirgin@gmail.com" Date: Sun, 6 Sep 2026 21:59:49 +0300 Subject: [PATCH 25/62] feat: refresh location-scoped worktree operations --- CHANGELOG.md | 6 + CONTEXT.md | 9 + README.md | 2 +- external/opencode | 2 +- spec/SNAPSHOT.md | 4 +- spec/openapi.json | 315 +++++++++++++---- spec/receipt.json | 25 +- spec/source-watch.json | 8 +- src/OpenCode.Sdk/.generated-manifest.json | 4 +- .../FileSystem/FileSystemClient.cs | 2 +- src/OpenCode.Sdk/FileSystem/FsListRequest.cs | 2 +- .../WorktreeCreateResponseAdapter.cs | 2 +- .../WorktreeListResponseAdapter.cs | 4 +- .../WorktreeRefreshPostResponseAdapter.cs | 2 +- .../WorktreeRemoveResponseAdapter.cs | 2 +- .../Models/SessionCompactionEndedData.cs | 15 + .../SessionMessageCompactionCompleted.cs | 14 + .../Models/WorktreeCreateRequest.cs | 12 +- .../Models/WorktreeRemoveRequest.cs | 6 + src/OpenCode.Sdk/OpenCodeRoutes.cs | 80 +++-- .../Worktrees/ProjectWorktreesClient.cs | 94 ----- .../Worktrees/WorktreeCreateResponse.cs | 2 +- .../Worktrees/WorktreeListRequest.cs | 13 + .../Worktrees/WorktreeListResponse.cs | 2 +- .../Worktrees/WorktreeRefreshPostRequest.cs | 13 + .../Worktrees/WorktreeRefreshPostResponse.cs | 2 +- .../Worktrees/WorktreeRemoveResponse.cs | 2 +- src/OpenCode.Sdk/Worktrees/WorktreesClient.cs | 67 +++- .../OpenCode.Sdk.Tests/OpenCodeRoutesTests.cs | 21 +- .../OpenCode.Sdk.Tests/OwnedTransportTests.cs | 4 +- .../Snapshots/PublicApi.verified.txt | 61 ++-- .../Support/WireBodyData.cs | 4 + .../ProjectWorktreesClientContractTests.cs | 229 ------------- .../Worktrees/WorktreesClientContractTests.cs | 323 +++++++++++++++++- .../Binding/OperationPlanBinderTests.cs | 1 - tools/curation.json | 6 +- 36 files changed, 833 insertions(+), 527 deletions(-) delete mode 100644 src/OpenCode.Sdk/Worktrees/ProjectWorktreesClient.cs create mode 100644 src/OpenCode.Sdk/Worktrees/WorktreeListRequest.cs create mode 100644 src/OpenCode.Sdk/Worktrees/WorktreeRefreshPostRequest.cs delete mode 100644 tests/OpenCode.Sdk.Tests/Worktrees/ProjectWorktreesClientContractTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index d62772f0..4902aeff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,12 @@ Each released version links straight to its GitHub Release tag. ### ✨ New features +- **Refreshed to upstream `b2cecc6`.** Worktree operations now resolve their project through an + optional location query. Call create, list, remove, and refresh directly on `WorktreesClient`; + the project-bound `ProjectWorktreesClient` and its factory have been removed. Create accepts + the upstream's optional string strategy and inputs; remove retains its required JSON body. + The list operation also exposes its declared `WorktreeError` failure arm. Compaction completion + payloads now expose the optional model and provider state supplied by the upstream schema. - **`OpenCode.Sdk` — the typed client.** **135 of the 140 operations** in the pinned OpenAPI snapshot are callable across **28 client families**: sessions, PTYs, persistent PTYs, shells, events, MCP servers, integrations, projects, worktrees, workspaces, providers, language models, diff --git a/CONTEXT.md b/CONTEXT.md index 91e8ac88..0a9e9dce 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -104,6 +104,15 @@ optional Workspace. Resolving one yields the Project it belongs to. The repository root a Location resolves to, carrying its own id and canonical path. Sessions, saved Permissions, and worktrees are scoped to it. +**Worktree**: +A managed local directory in a Project's worktree inventory. The API resolves that Project +through a requested Location; create, list, remove, and refresh are location-scoped operations. + +**Worktree strategy**: +A server-registered strategy used to discover and create Worktrees. Creation may select one by +its string identifier; removal uses the recorded strategy. The SDK does not define a closed +strategy list or choose server defaults. + **Workspace**: A separately provisioned place a Location can point into, created and destroyed through the API; the optional second member of a Location. diff --git a/README.md b/README.md index fbc6bac1..db41d2fe 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ thing missing is the first published package. **Versioning**: the SDK builds against an accepted OpenAPI snapshot, never a live branch. The exact upstream commit and the refresh procedure live in [`spec/SNAPSHOT.md`](spec/SNAPSHOT.md); today's -pin is `48f24669` on upstream's `v2` branch. +pin is `b2cecc63` on upstream's `v2` branch. ## 🚀 Platform Compatibility & Quality Status diff --git a/external/opencode b/external/opencode index 48f24669..b2cecc63 160000 --- a/external/opencode +++ b/external/opencode @@ -1 +1 @@ -Subproject commit 48f246695e97a0014f57399f7603df0b1b2b85d4 +Subproject commit b2cecc6350d377c382e1ec32ee66ec63ad68f715 diff --git a/spec/SNAPSHOT.md b/spec/SNAPSHOT.md index ea1cfea6..6b550cce 100644 --- a/spec/SNAPSHOT.md +++ b/spec/SNAPSHOT.md @@ -1,6 +1,6 @@ # OpenAPI Snapshot -Date: 2026-09-03 +Date: 2026-09-06 `openapi.json` is the accepted snapshot of the upstream opencode OpenAPI 3.1 document — the v2 protocol surface (ADR-0005). The SDK is built against this snapshot, never against a live @@ -14,7 +14,7 @@ their hash-pinned manifests. |---|---| | Upstream file | `packages/protocol/openapi.json` | | Upstream branch | `v2` (active successor line; no release tags yet) | -| Commit | `48f246695e97a0014f57399f7603df0b1b2b85d4` | +| Commit | `b2cecc6350d377c382e1ec32ee66ec63ad68f715` | | Upstream product channel | `opencode2` — npm `@opencode-ai/cli@next` (pre-release) | Platform evidence for the v2 line: internal research, 2026-08-16, "The opencode v2 platform: diff --git a/spec/openapi.json b/spec/openapi.json index ba844676..b8761470 100644 --- a/spec/openapi.json +++ b/spec/openapi.json @@ -8890,7 +8890,8 @@ { "type": "null" } - ] + ], + "description": "An absolute path or a path relative to the requested location. Defaults to the location directory." }, "required": false } @@ -8941,7 +8942,7 @@ } } }, - "description": "List direct children of one directory relative to the requested location.", + "description": "List direct children using an absolute path or a path relative to the requested location, including parents and siblings outside its directory. Entry paths remain relative to the requested location; listing does not switch locations.", "summary": "List directory" } }, @@ -11996,18 +11997,50 @@ "summary": "List references" } }, - "/api/worktree/{projectID}": { + "/api/worktree": { "get": { "tags": ["worktree"], "operationId": "v2.worktree.list", "parameters": [ { - "name": "projectID", - "in": "path", + "name": "location", + "in": "query", "schema": { - "type": "string" + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] }, - "required": true + "required": false, + "style": "deepObject", + "explode": true } ], "security": [], @@ -12023,11 +12056,18 @@ } }, "400": { - "description": "InvalidRequestError", + "description": "WorktreeError | InvalidRequestError", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/InvalidRequestErrorEncoded" + "anyOf": [ + { + "$ref": "#/components/schemas/WorktreeErrorEncoded" + }, + { + "$ref": "#/components/schemas/InvalidRequestErrorEncoded" + } + ] } } } @@ -12043,7 +12083,7 @@ } } }, - "description": "List known local worktrees for a project.", + "description": "Discover worktrees through the requested location's strategies and return its project's inventory.", "summary": "List worktrees" }, "post": { @@ -12051,12 +12091,44 @@ "operationId": "v2.worktree.create", "parameters": [ { - "name": "projectID", - "in": "path", + "name": "location", + "in": "query", "schema": { - "type": "string" + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] }, - "required": true + "required": false, + "style": "deepObject", + "explode": true } ], "security": [], @@ -12099,32 +12171,13 @@ } } }, - "description": "Create a worktree for a project and run its configured setup script.", + "description": "Create a local worktree using the location's registered strategy and directory defaults, then run the project's setup script.", "summary": "Create worktree", "requestBody": { "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "strategy": { - "type": "string" - }, - "from": { - "type": "string" - }, - "branch": { - "type": "string" - }, - "directory": { - "type": "string" - }, - "name": { - "type": "string" - } - }, - "required": ["strategy", "directory"], - "additionalProperties": false + "$ref": "#/components/schemas/Worktree.CreateInput" } } }, @@ -12136,12 +12189,44 @@ "operationId": "v2.worktree.remove", "parameters": [ { - "name": "projectID", - "in": "path", + "name": "location", + "in": "query", "schema": { - "type": "string" + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] }, - "required": true + "required": false, + "style": "deepObject", + "explode": true } ], "security": [], @@ -12177,23 +12262,13 @@ } } }, - "description": "Remove a managed worktree from a project.", + "description": "Remove a managed worktree from the requested location's project using its recorded strategy.", "summary": "Remove worktree", "requestBody": { "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "directory": { - "type": "string" - }, - "force": { - "type": "boolean" - } - }, - "required": ["directory", "force"], - "additionalProperties": false + "$ref": "#/components/schemas/Worktree.RemoveInput" } } }, @@ -12201,18 +12276,50 @@ } } }, - "/api/worktree/{projectID}/refresh": { + "/api/worktree/refresh": { "post": { "tags": ["worktree"], "operationId": "v2.worktree.refresh", "parameters": [ { - "name": "projectID", - "in": "path", + "name": "location", + "in": "query", "schema": { - "type": "string" + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] }, - "required": true + "required": false, + "style": "deepObject", + "explode": true } ], "security": [], @@ -12248,7 +12355,7 @@ } } }, - "description": "Reconcile stored worktrees with the project repositories.", + "description": "Discover worktrees from the requested location and reconcile the shared project inventory.", "summary": "Refresh worktrees" } }, @@ -13777,8 +13884,12 @@ } ] }, - "subtask": { + "subagent": { "type": "boolean" + }, + "subtask": { + "type": "boolean", + "description": "Deprecated alias for subagent." } }, "required": ["template"], @@ -14141,6 +14252,9 @@ ] } }, + "worktree": { + "$ref": "#/components/schemas/Config.Worktree" + }, "warming": { "anyOf": [ { @@ -14458,6 +14572,16 @@ }, "additionalProperties": false }, + "Config.Worktree": { + "type": "object", + "properties": { + "directory": { + "type": "string" + } + }, + "required": ["directory"], + "additionalProperties": false + }, "ConfigWebSearch.InfoEncoded": { "type": "object", "properties": { @@ -18333,7 +18457,7 @@ "type": "string" }, "state": { - "$ref": "#/components/schemas/Session.Message.ProviderState_14" + "$ref": "#/components/schemas/Session.Message.ProviderState_15" }, "time": { "type": "object", @@ -18397,7 +18521,7 @@ "type": "string" }, "state": { - "$ref": "#/components/schemas/Session.Message.ProviderState_13" + "$ref": "#/components/schemas/Session.Message.ProviderState_14" } }, "required": ["type", "text"], @@ -18478,10 +18602,10 @@ "type": "boolean" }, "providerState": { - "$ref": "#/components/schemas/Session.Message.ProviderState_15" + "$ref": "#/components/schemas/Session.Message.ProviderState_16" }, "providerResultState": { - "$ref": "#/components/schemas/Session.Message.ProviderState_16" + "$ref": "#/components/schemas/Session.Message.ProviderState_17" }, "state": { "anyOf": [ @@ -18577,6 +18701,12 @@ "type": "string", "enum": ["auto", "manual"] }, + "model": { + "$ref": "#/components/schemas/Model.Ref" + }, + "providerState": { + "$ref": "#/components/schemas/Session.Message.ProviderState_5" + }, "summary": { "type": "string" }, @@ -18816,6 +18946,9 @@ "Session.Message.ProviderState_16": { "type": "object" }, + "Session.Message.ProviderState_17": { + "type": "object" + }, "Session.Message.ProviderState_2": { "type": "object" }, @@ -20538,6 +20671,27 @@ "additionalProperties": false, "description": "Reports whether this request destroyed an existing workspace." }, + "Worktree.CreateInput": { + "type": "object", + "properties": { + "strategy": { + "type": "string" + }, + "from": { + "type": "string" + }, + "branch": { + "type": "string" + }, + "directory": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "additionalProperties": false + }, "Worktree.Directory": { "type": "object", "properties": { @@ -20567,6 +20721,19 @@ "$ref": "#/components/schemas/Worktree.Directory" } }, + "Worktree.RemoveInput": { + "type": "object", + "properties": { + "directory": { + "type": "string" + }, + "force": { + "type": "boolean" + } + }, + "required": ["directory", "force"], + "additionalProperties": false + }, "WorktreeErrorEncoded": { "type": "object", "properties": { @@ -21763,6 +21930,12 @@ "type": "string", "enum": ["auto", "manual"] }, + "model": { + "$ref": "#/components/schemas/Model.Ref" + }, + "providerState": { + "$ref": "#/components/schemas/Session.Message.ProviderState_5" + }, "text": { "type": "string" }, @@ -22960,7 +23133,7 @@ "type": "string" }, "state": { - "$ref": "#/components/schemas/Session.Message.ProviderState_9" + "$ref": "#/components/schemas/Session.Message.ProviderState_10" } }, "required": ["sessionID", "assistantMessageID", "ordinal", "text"], @@ -23024,7 +23197,7 @@ "minimum": 0 }, "state": { - "$ref": "#/components/schemas/Session.Message.ProviderState_8" + "$ref": "#/components/schemas/Session.Message.ProviderState_9" } }, "required": ["sessionID", "assistantMessageID", "ordinal"], @@ -23576,7 +23749,7 @@ "type": "string" }, "providerState": { - "$ref": "#/components/schemas/Session.Message.ProviderState_5" + "$ref": "#/components/schemas/Session.Message.ProviderState_6" }, "cost": { "$ref": "#/components/schemas/Money.USD" @@ -23665,7 +23838,7 @@ "type": "string" }, "providerState": { - "$ref": "#/components/schemas/Session.Message.ProviderState_6" + "$ref": "#/components/schemas/Session.Message.ProviderState_7" }, "cost": { "$ref": "#/components/schemas/Money.USD" @@ -23978,7 +24151,7 @@ "type": "string" }, "state": { - "$ref": "#/components/schemas/Session.Message.ProviderState_7" + "$ref": "#/components/schemas/Session.Message.ProviderState_8" } }, "required": ["sessionID", "assistantMessageID", "ordinal", "text"], @@ -24108,7 +24281,7 @@ "type": "boolean" }, "state": { - "$ref": "#/components/schemas/Session.Message.ProviderState_10" + "$ref": "#/components/schemas/Session.Message.ProviderState_11" } }, "required": ["sessionID", "assistantMessageID", "id", "input", "executed"], @@ -24192,7 +24365,7 @@ "type": "boolean" }, "resultState": { - "$ref": "#/components/schemas/Session.Message.ProviderState_12" + "$ref": "#/components/schemas/Session.Message.ProviderState_13" } }, "required": ["sessionID", "assistantMessageID", "id", "error", "executed"], @@ -24489,7 +24662,7 @@ "type": "boolean" }, "resultState": { - "$ref": "#/components/schemas/Session.Message.ProviderState_11" + "$ref": "#/components/schemas/Session.Message.ProviderState_12" } }, "required": ["sessionID", "assistantMessageID", "id", "content", "executed"], @@ -25253,7 +25426,7 @@ }, { "name": "worktree", - "description": "Project worktree management routes." + "description": "Location-scoped worktree management routes." }, { "name": "workspace", diff --git a/spec/receipt.json b/spec/receipt.json index 3511510b..1e2179f8 100644 --- a/spec/receipt.json +++ b/spec/receipt.json @@ -1,8 +1,8 @@ { "schemaVersion": 1, - "upstreamCommit": "48f246695e97a0014f57399f7603df0b1b2b85d4", - "rawDocumentSha256": "72904ec26d51c93161c0dc7d6420ff90a7e3e810f6c0ec1a5fadea3611aa4606", - "generatedBaselineSha256": "72904ec26d51c93161c0dc7d6420ff90a7e3e810f6c0ec1a5fadea3611aa4606", + "upstreamCommit": "b2cecc6350d377c382e1ec32ee66ec63ad68f715", + "rawDocumentSha256": "359c859bc9edd76059d271d7e5f4e2485d84974cb87b9c3d6d90fb77916bd077", + "generatedBaselineSha256": "359c859bc9edd76059d271d7e5f4e2485d84974cb87b9c3d6d90fb77916bd077", "patches": [ { "manifest": "001-restore-sse-payloads.json", @@ -19,18 +19,13 @@ ] } ], - "normalizedDocumentSha256": "28e5f973eedf2b0d38a13f9b59babed9b3196ea01244fa4bd52e0bf249728663", + "normalizedDocumentSha256": "ea918cde7be4037bee1e7315d9555c2278aed754feaae944983aa530a993f2ce", "normalizedDocumentPath": null, "operationSetDigest": "038fa8d3b8cf12a9a3db9320f5945e5c7aaa5d07158cebc6a0527e4c56a0a2f8", "operationCount": 140, - "addedOperations": [ - "v2.plugin.awaitActivation", - "v2.plugin.check", - "v2.plugin.update", - "v2.rpc.call" - ], + "addedOperations": [], "removedOperations": [], - "componentCount": 348, + "componentCount": 352, "contentSchemaCount": 2, "watchedSources": [ { @@ -65,7 +60,7 @@ }, { "path": "packages/server/src/routes.ts", - "sha256": "1f2cb97373ad5c154cc34b3882faf57fa23446aed6678637d6037a2fca728f9c", + "sha256": "4a0f61b114eb087e422dc593a58634bef2b75947dafb48f1941cdfaaa613df95", "anchorMatched": true }, { @@ -110,12 +105,12 @@ }, { "path": "packages/core/package.json", - "sha256": "82df23aad60157df407b113bb4a6a8ae06e7ff9e875bcb472bed2f505ed815b8", + "sha256": "0e44b780c50ee09630574b1bbc49777420e0811fd534057d6cf6bef0fe947813", "anchorMatched": true }, { "path": "packages/server/src/process.ts", - "sha256": "ec105dccd27afbfe55724ca8201dd7cb4ea0dd410055f7ffe0ef90568a1b1144", + "sha256": "2e7b36c11d53e947a4fa15ce0271b03664031125ffac77036975881c6a9de6ec", "anchorMatched": true }, { @@ -130,7 +125,7 @@ }, { "path": "packages/tui/src/component/terminal-pane.tsx", - "sha256": "7b613272b8c01b067d985de1e660303f7d10ed3ea0ecdbfc9e7a63fd41fd4e36", + "sha256": "973e352937fe66c218e7ca1c955cdd335eaedf81dc1f57f1a97ba0777e4578ae", "anchorMatched": true } ] diff --git a/spec/source-watch.json b/spec/source-watch.json index 42353f12..365b4f93 100644 --- a/spec/source-watch.json +++ b/spec/source-watch.json @@ -57,7 +57,7 @@ }, { "path": "packages/server/src/routes.ts", - "sha256": "1f2cb97373ad5c154cc34b3882faf57fa23446aed6678637d6037a2fca728f9c", + "sha256": "4a0f61b114eb087e422dc593a58634bef2b75947dafb48f1941cdfaaa613df95", "behavior": "The PTY ticket service and the persistent-PTY service are wired into the server layer graph.", "anchor": { "type": "contains", @@ -138,7 +138,7 @@ }, { "path": "packages/core/package.json", - "sha256": "82df23aad60157df407b113bb4a6a8ae06e7ff9e875bcb472bed2f505ed815b8", + "sha256": "0e44b780c50ee09630574b1bbc49777420e0811fd534057d6cf6bef0fe947813", "behavior": "The opencode-pty daemon dependency is pinned at 0.1.13, which fixes the daemon protocol the persistent-PTY door speaks.", "anchor": { "type": "contains", @@ -147,7 +147,7 @@ }, { "path": "packages/server/src/process.ts", - "sha256": "ec105dccd27afbfe55724ca8201dd7cb4ea0dd410055f7ffe0ef90568a1b1144", + "sha256": "2e7b36c11d53e947a4fa15ce0271b03664031125ffac77036975881c6a9de6ec", "behavior": "A ticketed PTY connect is admitted before the server reports ready.", "anchor": { "type": "contains", @@ -174,7 +174,7 @@ }, { "path": "packages/tui/src/component/terminal-pane.tsx", - "sha256": "7b613272b8c01b067d985de1e660303f7d10ed3ea0ecdbfc9e7a63fd41fd4e36", + "sha256": "973e352937fe66c218e7ca1c955cdd335eaedf81dc1f57f1a97ba0777e4578ae", "behavior": "Behavioral oracle: the TUI resumes a terminal from the snapshot tail cursor.", "anchor": { "type": "contains", diff --git a/src/OpenCode.Sdk/.generated-manifest.json b/src/OpenCode.Sdk/.generated-manifest.json index ec4613b5..6e6715f7 100644 --- a/src/OpenCode.Sdk/.generated-manifest.json +++ b/src/OpenCode.Sdk/.generated-manifest.json @@ -1101,9 +1101,10 @@ "Workspaces/WorkspaceCreateResponse.cs", "Workspaces/WorkspaceDestroyDeleteResponse.cs", "Workspaces/WorkspacesClient.cs", - "Worktrees/ProjectWorktreesClient.cs", "Worktrees/WorktreeCreateResponse.cs", + "Worktrees/WorktreeListRequest.cs", "Worktrees/WorktreeListResponse.cs", + "Worktrees/WorktreeRefreshPostRequest.cs", "Worktrees/WorktreeRefreshPostResponse.cs", "Worktrees/WorktreeRemoveResponse.cs", "Worktrees/WorktreesClient.cs" @@ -1122,6 +1123,7 @@ "Session.Message.ProviderState_14": "Session.Message.ProviderState", "Session.Message.ProviderState_15": "Session.Message.ProviderState", "Session.Message.ProviderState_16": "Session.Message.ProviderState", + "Session.Message.ProviderState_17": "Session.Message.ProviderState", "Session.Message.ProviderState_2": "Session.Message.ProviderState", "Session.Message.ProviderState_3": "Session.Message.ProviderState", "Session.Message.ProviderState_4": "Session.Message.ProviderState", diff --git a/src/OpenCode.Sdk/FileSystem/FileSystemClient.cs b/src/OpenCode.Sdk/FileSystem/FileSystemClient.cs index 7a2a2e3f..a0d96832 100644 --- a/src/OpenCode.Sdk/FileSystem/FileSystemClient.cs +++ b/src/OpenCode.Sdk/FileSystem/FileSystemClient.cs @@ -41,7 +41,7 @@ public virtual Task FindEntriesAsync(FsFindRequest request, Open } /// - /// List directory. List direct children of one directory relative to the requested location. + /// List directory. List direct children using an absolute path or a path relative to the requested location, including parents and siblings outside its directory. Entry paths remain relative to the requested location; listing does not switch locations. /// /// The request shaping the query. /// The per-call options. diff --git a/src/OpenCode.Sdk/FileSystem/FsListRequest.cs b/src/OpenCode.Sdk/FileSystem/FsListRequest.cs index a0be4f23..b292a07b 100644 --- a/src/OpenCode.Sdk/FileSystem/FsListRequest.cs +++ b/src/OpenCode.Sdk/FileSystem/FsListRequest.cs @@ -11,7 +11,7 @@ public sealed record FsListRequest /// public LocationSelector? Location { get; init; } /// - /// Gets the 'path' query value; the server default applies when unset. + /// An absolute path or a path relative to the requested location. Defaults to the location directory. /// public string? Path { get; init; } } diff --git a/src/OpenCode.Sdk/Internal/ResponseAdapters/WorktreeCreateResponseAdapter.cs b/src/OpenCode.Sdk/Internal/ResponseAdapters/WorktreeCreateResponseAdapter.cs index 78b77979..636427f5 100644 --- a/src/OpenCode.Sdk/Internal/ResponseAdapters/WorktreeCreateResponseAdapter.cs +++ b/src/OpenCode.Sdk/Internal/ResponseAdapters/WorktreeCreateResponseAdapter.cs @@ -4,7 +4,7 @@ namespace OpenCode.Sdk.Internal.ResponseAdapters; /// -/// Adapts the 'POST /api/worktree/{projectID}' responses onto 'WorktreeCreateResponse'. +/// Adapts the 'POST /api/worktree' responses onto 'WorktreeCreateResponse'. /// internal sealed class WorktreeCreateResponseAdapter : ResponseAdapter { diff --git a/src/OpenCode.Sdk/Internal/ResponseAdapters/WorktreeListResponseAdapter.cs b/src/OpenCode.Sdk/Internal/ResponseAdapters/WorktreeListResponseAdapter.cs index 48195ef3..59999823 100644 --- a/src/OpenCode.Sdk/Internal/ResponseAdapters/WorktreeListResponseAdapter.cs +++ b/src/OpenCode.Sdk/Internal/ResponseAdapters/WorktreeListResponseAdapter.cs @@ -4,11 +4,11 @@ namespace OpenCode.Sdk.Internal.ResponseAdapters; /// -/// Adapts the 'GET /api/worktree/{projectID}' responses onto 'WorktreeListResponse'. +/// Adapts the 'GET /api/worktree' responses onto 'WorktreeListResponse'. /// internal sealed class WorktreeListResponseAdapter : ResponseAdapter { - private static readonly string[] Status400Tags = ["InvalidRequestError"]; + private static readonly string[] Status400Tags = ["InvalidRequestError", "WorktreeError"]; private static readonly string[] Status401Tags = ["UnauthorizedError"]; private WorktreeListResponseAdapter() { diff --git a/src/OpenCode.Sdk/Internal/ResponseAdapters/WorktreeRefreshPostResponseAdapter.cs b/src/OpenCode.Sdk/Internal/ResponseAdapters/WorktreeRefreshPostResponseAdapter.cs index f69afacb..c18ea5ee 100644 --- a/src/OpenCode.Sdk/Internal/ResponseAdapters/WorktreeRefreshPostResponseAdapter.cs +++ b/src/OpenCode.Sdk/Internal/ResponseAdapters/WorktreeRefreshPostResponseAdapter.cs @@ -2,7 +2,7 @@ // Do not edit by hand — change tools/curation.json or the emitters, then regenerate. namespace OpenCode.Sdk.Internal.ResponseAdapters; /// -/// Adapts the 'POST /api/worktree/{projectID}/refresh' responses onto 'WorktreeRefreshPostResponse'. +/// Adapts the 'POST /api/worktree/refresh' responses onto 'WorktreeRefreshPostResponse'. /// internal sealed class WorktreeRefreshPostResponseAdapter : ResponseAdapter { diff --git a/src/OpenCode.Sdk/Internal/ResponseAdapters/WorktreeRemoveResponseAdapter.cs b/src/OpenCode.Sdk/Internal/ResponseAdapters/WorktreeRemoveResponseAdapter.cs index 347131c4..7551f4ac 100644 --- a/src/OpenCode.Sdk/Internal/ResponseAdapters/WorktreeRemoveResponseAdapter.cs +++ b/src/OpenCode.Sdk/Internal/ResponseAdapters/WorktreeRemoveResponseAdapter.cs @@ -2,7 +2,7 @@ // Do not edit by hand — change tools/curation.json or the emitters, then regenerate. namespace OpenCode.Sdk.Internal.ResponseAdapters; /// -/// Adapts the 'DELETE /api/worktree/{projectID}' responses onto 'WorktreeRemoveResponse'. +/// Adapts the 'DELETE /api/worktree' responses onto 'WorktreeRemoveResponse'. /// internal sealed class WorktreeRemoveResponseAdapter : ResponseAdapter { diff --git a/src/OpenCode.Sdk/Models/SessionCompactionEndedData.cs b/src/OpenCode.Sdk/Models/SessionCompactionEndedData.cs index a1e620e3..3c0c33ab 100644 --- a/src/OpenCode.Sdk/Models/SessionCompactionEndedData.cs +++ b/src/OpenCode.Sdk/Models/SessionCompactionEndedData.cs @@ -1,5 +1,6 @@ // Generated by OpenCode.Sdk.Tools from spec/openapi.json. // Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Text.Json; using System.Text.Json.Serialization; namespace OpenCode.Sdk.Models; @@ -20,6 +21,20 @@ public sealed record SessionCompactionEndedData [JsonPropertyName("reason")] public required SessionCompactionEndedDataReason Reason { get; init; } + /// + /// Gets the model value. + /// + [JsonPropertyName("model")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ModelRef? Model { get; init; } + + /// + /// Gets the provider state value. + /// + [JsonPropertyName("providerState")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IReadOnlyDictionary? ProviderState { get; init; } + /// /// Gets the text value. /// diff --git a/src/OpenCode.Sdk/Models/SessionMessageCompactionCompleted.cs b/src/OpenCode.Sdk/Models/SessionMessageCompactionCompleted.cs index 529432cf..563f6c8a 100644 --- a/src/OpenCode.Sdk/Models/SessionMessageCompactionCompleted.cs +++ b/src/OpenCode.Sdk/Models/SessionMessageCompactionCompleted.cs @@ -46,6 +46,20 @@ public sealed record SessionMessageCompactionCompleted : ISessionMessageCompacti [JsonPropertyName("reason")] public required SessionMessageCompactionCompletedReason Reason { get; init; } + /// + /// Gets the model value. + /// + [JsonPropertyName("model")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ModelRef? Model { get; init; } + + /// + /// Gets the provider state value. + /// + [JsonPropertyName("providerState")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IReadOnlyDictionary? ProviderState { get; init; } + /// /// Gets the summary value. /// diff --git a/src/OpenCode.Sdk/Models/WorktreeCreateRequest.cs b/src/OpenCode.Sdk/Models/WorktreeCreateRequest.cs index c76ef73a..5993e120 100644 --- a/src/OpenCode.Sdk/Models/WorktreeCreateRequest.cs +++ b/src/OpenCode.Sdk/Models/WorktreeCreateRequest.cs @@ -12,7 +12,8 @@ public sealed record WorktreeCreateRequest /// Gets the strategy value. /// [JsonPropertyName("strategy")] - public required string Strategy { get; init; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Strategy { get; init; } /// /// Gets the from value. @@ -32,7 +33,8 @@ public sealed record WorktreeCreateRequest /// Gets the directory value. /// [JsonPropertyName("directory")] - public required string Directory { get; init; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Directory { get; init; } /// /// Gets the name value. @@ -40,4 +42,10 @@ public sealed record WorktreeCreateRequest [JsonPropertyName("name")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? Name { get; init; } + + /// + /// Gets the 'location' query value; the server default applies when unset. + /// + [JsonIgnore] + public LocationSelector? Location { get; init; } } diff --git a/src/OpenCode.Sdk/Models/WorktreeRemoveRequest.cs b/src/OpenCode.Sdk/Models/WorktreeRemoveRequest.cs index c8bc179e..4d7c16ee 100644 --- a/src/OpenCode.Sdk/Models/WorktreeRemoveRequest.cs +++ b/src/OpenCode.Sdk/Models/WorktreeRemoveRequest.cs @@ -19,4 +19,10 @@ public sealed record WorktreeRemoveRequest /// [JsonPropertyName("force")] public required bool Force { get; init; } + + /// + /// Gets the 'location' query value; the server default applies when unset. + /// + [JsonIgnore] + public LocationSelector? Location { get; init; } } diff --git a/src/OpenCode.Sdk/OpenCodeRoutes.cs b/src/OpenCode.Sdk/OpenCodeRoutes.cs index b78470ac..6197525c 100644 --- a/src/OpenCode.Sdk/OpenCodeRoutes.cs +++ b/src/OpenCode.Sdk/OpenCodeRoutes.cs @@ -3133,83 +3133,91 @@ public static string DestroyWorkspace(string workspaceId) public static class Worktrees { /// - /// The 'POST /api/worktree/{projectID}' route template. + /// The 'POST /api/worktree' route template. /// - public const string CreateWorktreeTemplate = "/api/worktree/{projectID}"; + public const string CreateWorktreeTemplate = "/api/worktree"; /// - /// Builds the '/api/worktree/{projectID}' route. + /// Builds the '/api/worktree' route. /// - /// The 'projectID' route value. + /// The request shaping the query. /// The escaped route. - public static string CreateWorktree(string projectId) + public static string CreateWorktree(WorktreeCreateRequest? request = null) { - ArgumentException.ThrowIfNullOrWhiteSpace(projectId); - if (projectId is "." or "..") + var path = "/api/worktree"; + if (request is null) { - throw new ArgumentException("Route values must not be dot segments.", nameof(projectId)); + return path; } - return "/api/worktree/" + RouteValuePolicy.Escape(projectId, nameof(projectId)); + var query = new QueryStringBuilder(); + query.AddLocation("location", request.Location); + return path + query.Value; } /// - /// The 'GET /api/worktree/{projectID}' route template. + /// The 'GET /api/worktree' route template. /// - public const string ListWorktreesTemplate = "/api/worktree/{projectID}"; + public const string ListWorktreesTemplate = "/api/worktree"; /// - /// Builds the '/api/worktree/{projectID}' route. + /// Builds the '/api/worktree' route. /// - /// The 'projectID' route value. + /// The request shaping the query. /// The escaped route. - public static string ListWorktrees(string projectId) + public static string ListWorktrees(WorktreeListRequest? request = null) { - ArgumentException.ThrowIfNullOrWhiteSpace(projectId); - if (projectId is "." or "..") + var path = "/api/worktree"; + if (request is null) { - throw new ArgumentException("Route values must not be dot segments.", nameof(projectId)); + return path; } - return "/api/worktree/" + RouteValuePolicy.Escape(projectId, nameof(projectId)); + var query = new QueryStringBuilder(); + query.AddLocation("location", request.Location); + return path + query.Value; } /// - /// The 'POST /api/worktree/{projectID}/refresh' route template. + /// The 'POST /api/worktree/refresh' route template. /// - public const string RefreshWorktreesTemplate = "/api/worktree/{projectID}/refresh"; + public const string RefreshWorktreesTemplate = "/api/worktree/refresh"; /// - /// Builds the '/api/worktree/{projectID}/refresh' route. + /// Builds the '/api/worktree/refresh' route. /// - /// The 'projectID' route value. + /// The request shaping the query. /// The escaped route. - public static string RefreshWorktrees(string projectId) + public static string RefreshWorktrees(WorktreeRefreshPostRequest? request = null) { - ArgumentException.ThrowIfNullOrWhiteSpace(projectId); - if (projectId is "." or "..") + var path = "/api/worktree/refresh"; + if (request is null) { - throw new ArgumentException("Route values must not be dot segments.", nameof(projectId)); + return path; } - return "/api/worktree/" + RouteValuePolicy.Escape(projectId, nameof(projectId)) + "/refresh"; + var query = new QueryStringBuilder(); + query.AddLocation("location", request.Location); + return path + query.Value; } /// - /// The 'DELETE /api/worktree/{projectID}' route template. + /// The 'DELETE /api/worktree' route template. /// - public const string RemoveWorktreeTemplate = "/api/worktree/{projectID}"; + public const string RemoveWorktreeTemplate = "/api/worktree"; /// - /// Builds the '/api/worktree/{projectID}' route. + /// Builds the '/api/worktree' route. /// - /// The 'projectID' route value. + /// The request shaping the query. /// The escaped route. - public static string RemoveWorktree(string projectId) + public static string RemoveWorktree(WorktreeRemoveRequest? request = null) { - ArgumentException.ThrowIfNullOrWhiteSpace(projectId); - if (projectId is "." or "..") + var path = "/api/worktree"; + if (request is null) { - throw new ArgumentException("Route values must not be dot segments.", nameof(projectId)); + return path; } - return "/api/worktree/" + RouteValuePolicy.Escape(projectId, nameof(projectId)); + var query = new QueryStringBuilder(); + query.AddLocation("location", request.Location); + return path + query.Value; } } diff --git a/src/OpenCode.Sdk/Worktrees/ProjectWorktreesClient.cs b/src/OpenCode.Sdk/Worktrees/ProjectWorktreesClient.cs deleted file mode 100644 index 31375495..00000000 --- a/src/OpenCode.Sdk/Worktrees/ProjectWorktreesClient.cs +++ /dev/null @@ -1,94 +0,0 @@ -// Generated by OpenCode.Sdk.Tools from spec/openapi.json. -// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. -using OpenCode.Sdk.Internal; -using OpenCode.Sdk.Internal.ResponseAdapters; -using OpenCode.Sdk.Internal.Serialization; -using OpenCode.Sdk.Models; - -namespace OpenCode.Sdk; -/// -/// A bound 'ProjectWorktreesClient' handle; it holds an immutable identifier and the shared pipeline. -/// -public class ProjectWorktreesClient -{ - private readonly Pipeline? _pipeline; - private readonly string? _projectId; - internal ProjectWorktreesClient(Pipeline pipeline, string projectId) - { - ArgumentNullException.ThrowIfNull(pipeline); - ArgumentException.ThrowIfNullOrWhiteSpace(projectId); - if (projectId is "." or "..") - { - throw new ArgumentException("Route values must not be dot segments.", nameof(projectId)); - } - - _pipeline = pipeline; - _projectId = projectId; - } - - /// - /// Initializes a mocking instance; members invoked without an override throw an instructive failure. - /// - protected ProjectWorktreesClient() - { - } - - private Pipeline Pipeline => _pipeline ?? throw MockSeam.CreateError("ProjectWorktreesClient", "Pipeline"); - private string ProjectId => _projectId ?? throw MockSeam.CreateError("ProjectWorktreesClient", "ProjectId"); - - /// - /// Create worktree. Create a worktree for a project and run its configured setup script. - /// - /// The request body. - /// The per-call options. - /// The cancellation token. - /// The 'WorktreeCreateResponse' envelope. - /// The API returned an error status (declared: 400, 401) and NoThrow was not selected. - /// The server could not be reached or returned a malformed success body. - public virtual Task CreateWorktreeAsync(WorktreeCreateRequest request, OpenCodeRequestOptions? requestOptions = null, CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(request); - return Pipeline.ExecuteAsync(HttpMethod.Post, OpenCodeRoutes.Worktrees.CreateWorktree(ProjectId), request, OpenCodeJsonContext.Default.WorktreeCreateRequest, WorktreeCreateResponseAdapter.Instance, requestOptions, cancellationToken); - } - - /// - /// List worktrees. List known local worktrees for a project. - /// - /// The per-call options. - /// The cancellation token. - /// The 'WorktreeListResponse' envelope. - /// The API returned an error status (declared: 400, 401) and NoThrow was not selected. - /// The server could not be reached or returned a malformed success body. - public virtual Task ListWorktreesAsync(OpenCodeRequestOptions? requestOptions = null, CancellationToken cancellationToken = default) - { - return Pipeline.ExecuteAsync(HttpMethod.Get, OpenCodeRoutes.Worktrees.ListWorktrees(ProjectId), WorktreeListResponseAdapter.Instance, requestOptions, cancellationToken); - } - - /// - /// Refresh worktrees. Reconcile stored worktrees with the project repositories. - /// - /// The per-call options. - /// The cancellation token. - /// The 'WorktreeRefreshPostResponse' envelope. - /// The API returned an error status (declared: 400, 401) and NoThrow was not selected. - /// The server could not be reached or returned a malformed success body. - public virtual Task RefreshWorktreesAsync(OpenCodeRequestOptions? requestOptions = null, CancellationToken cancellationToken = default) - { - return Pipeline.ExecuteAsync(HttpMethod.Post, OpenCodeRoutes.Worktrees.RefreshWorktrees(ProjectId), WorktreeRefreshPostResponseAdapter.Instance, requestOptions, cancellationToken); - } - - /// - /// Remove worktree. Remove a managed worktree from a project. - /// - /// The request body. - /// The per-call options. - /// The cancellation token. - /// The 'WorktreeRemoveResponse' envelope. - /// The API returned an error status (declared: 400, 401) and NoThrow was not selected. - /// The server could not be reached or returned a malformed success body. - public virtual Task RemoveWorktreeAsync(WorktreeRemoveRequest request, OpenCodeRequestOptions? requestOptions = null, CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(request); - return Pipeline.ExecuteAsync(HttpMethod.Delete, OpenCodeRoutes.Worktrees.RemoveWorktree(ProjectId), request, OpenCodeJsonContext.Default.WorktreeRemoveRequest, WorktreeRemoveResponseAdapter.Instance, requestOptions, cancellationToken); - } -} diff --git a/src/OpenCode.Sdk/Worktrees/WorktreeCreateResponse.cs b/src/OpenCode.Sdk/Worktrees/WorktreeCreateResponse.cs index ad1b6050..c933c66e 100644 --- a/src/OpenCode.Sdk/Worktrees/WorktreeCreateResponse.cs +++ b/src/OpenCode.Sdk/Worktrees/WorktreeCreateResponse.cs @@ -6,7 +6,7 @@ namespace OpenCode.Sdk; /// -/// Represents the response of the 'POST /api/worktree/{projectID}' operation. +/// Represents the response of the 'POST /api/worktree' operation. /// public sealed record WorktreeCreateResponse : OpenCodeResponse { diff --git a/src/OpenCode.Sdk/Worktrees/WorktreeListRequest.cs b/src/OpenCode.Sdk/Worktrees/WorktreeListRequest.cs new file mode 100644 index 00000000..326ad9d9 --- /dev/null +++ b/src/OpenCode.Sdk/Worktrees/WorktreeListRequest.cs @@ -0,0 +1,13 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +namespace OpenCode.Sdk; +/// +/// Shapes the 'GET /api/worktree' query. +/// +public sealed record WorktreeListRequest +{ + /// + /// Gets the 'location' query value; the server default applies when unset. + /// + public LocationSelector? Location { get; init; } +} diff --git a/src/OpenCode.Sdk/Worktrees/WorktreeListResponse.cs b/src/OpenCode.Sdk/Worktrees/WorktreeListResponse.cs index 15479b30..8689ad67 100644 --- a/src/OpenCode.Sdk/Worktrees/WorktreeListResponse.cs +++ b/src/OpenCode.Sdk/Worktrees/WorktreeListResponse.cs @@ -6,7 +6,7 @@ namespace OpenCode.Sdk; /// -/// Represents the response of the 'GET /api/worktree/{projectID}' operation. +/// Represents the response of the 'GET /api/worktree' operation. /// public sealed record WorktreeListResponse : OpenCodeResponse { diff --git a/src/OpenCode.Sdk/Worktrees/WorktreeRefreshPostRequest.cs b/src/OpenCode.Sdk/Worktrees/WorktreeRefreshPostRequest.cs new file mode 100644 index 00000000..4b0f4bea --- /dev/null +++ b/src/OpenCode.Sdk/Worktrees/WorktreeRefreshPostRequest.cs @@ -0,0 +1,13 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +namespace OpenCode.Sdk; +/// +/// Shapes the 'POST /api/worktree/refresh' query. +/// +public sealed record WorktreeRefreshPostRequest +{ + /// + /// Gets the 'location' query value; the server default applies when unset. + /// + public LocationSelector? Location { get; init; } +} diff --git a/src/OpenCode.Sdk/Worktrees/WorktreeRefreshPostResponse.cs b/src/OpenCode.Sdk/Worktrees/WorktreeRefreshPostResponse.cs index ceb8f64e..3e5dc23d 100644 --- a/src/OpenCode.Sdk/Worktrees/WorktreeRefreshPostResponse.cs +++ b/src/OpenCode.Sdk/Worktrees/WorktreeRefreshPostResponse.cs @@ -5,7 +5,7 @@ namespace OpenCode.Sdk; /// -/// Represents the response of the 'POST /api/worktree/{projectID}/refresh' operation. +/// Represents the response of the 'POST /api/worktree/refresh' operation. /// public sealed record WorktreeRefreshPostResponse : OpenCodeResponse { diff --git a/src/OpenCode.Sdk/Worktrees/WorktreeRemoveResponse.cs b/src/OpenCode.Sdk/Worktrees/WorktreeRemoveResponse.cs index 3ae9f143..f1dafc19 100644 --- a/src/OpenCode.Sdk/Worktrees/WorktreeRemoveResponse.cs +++ b/src/OpenCode.Sdk/Worktrees/WorktreeRemoveResponse.cs @@ -5,7 +5,7 @@ namespace OpenCode.Sdk; /// -/// Represents the response of the 'DELETE /api/worktree/{projectID}' operation. +/// Represents the response of the 'DELETE /api/worktree' operation. /// public sealed record WorktreeRemoveResponse : OpenCodeResponse { diff --git a/src/OpenCode.Sdk/Worktrees/WorktreesClient.cs b/src/OpenCode.Sdk/Worktrees/WorktreesClient.cs index 223a93e4..e07cbf4b 100644 --- a/src/OpenCode.Sdk/Worktrees/WorktreesClient.cs +++ b/src/OpenCode.Sdk/Worktrees/WorktreesClient.cs @@ -1,6 +1,9 @@ // Generated by OpenCode.Sdk.Tools from spec/openapi.json. // Do not edit by hand — change tools/curation.json or the emitters, then regenerate. using OpenCode.Sdk.Internal; +using OpenCode.Sdk.Internal.ResponseAdapters; +using OpenCode.Sdk.Internal.Serialization; +using OpenCode.Sdk.Models; namespace OpenCode.Sdk; /// @@ -8,6 +11,7 @@ namespace OpenCode.Sdk; /// public class WorktreesClient { + private static readonly WorktreeCreateRequest EmptyWorktreeCreateRequest = new(); private readonly Pipeline? _pipeline; internal WorktreesClient(Pipeline pipeline) { @@ -22,21 +26,62 @@ protected WorktreesClient() { } + private Pipeline Pipeline => _pipeline ?? throw MockSeam.CreateError("WorktreesClient", "Pipeline"); + /// - /// Gets a bound 'ProjectWorktreesClient'; the handle never caches server state. + /// Create worktree. Create a local worktree using the location's registered strategy and directory defaults, then run the project's setup script. /// - /// The 'projectID' route value. - /// The bound 'ProjectWorktreesClient'. - public virtual ProjectWorktreesClient GetProjectWorktreesClient(string projectId) + /// The request body; an empty body is sent when omitted. + /// The per-call options. + /// The cancellation token. + /// The 'WorktreeCreateResponse' envelope. + /// The API returned an error status (declared: 400, 401) and NoThrow was not selected. + /// The server could not be reached or returned a malformed success body. + public virtual Task CreateWorktreeAsync(WorktreeCreateRequest? request = null, OpenCodeRequestOptions? requestOptions = null, CancellationToken cancellationToken = default) { - ArgumentException.ThrowIfNullOrWhiteSpace(projectId); - if (projectId is "." or "..") - { - throw new ArgumentException("Route values must not be dot segments.", nameof(projectId)); - } + return Pipeline.ExecuteAsync(HttpMethod.Post, OpenCodeRoutes.Worktrees.CreateWorktree(request), request ?? EmptyWorktreeCreateRequest, OpenCodeJsonContext.Default.WorktreeCreateRequest, WorktreeCreateResponseAdapter.Instance, requestOptions, cancellationToken); + } - return new ProjectWorktreesClient(Pipeline, projectId); + /// + /// List worktrees. Discover worktrees through the requested location's strategies and return its project's inventory. + /// + /// The request shaping the query. + /// The per-call options. + /// The cancellation token. + /// The 'WorktreeListResponse' envelope. + /// The API returned an error status (declared: 400, 401) and NoThrow was not selected. + /// The server could not be reached or returned a malformed success body. + public virtual Task ListWorktreesAsync(WorktreeListRequest? request = null, OpenCodeRequestOptions? requestOptions = null, CancellationToken cancellationToken = default) + { + return Pipeline.ExecuteAsync(HttpMethod.Get, OpenCodeRoutes.Worktrees.ListWorktrees(request), WorktreeListResponseAdapter.Instance, requestOptions, cancellationToken); } - private Pipeline Pipeline => _pipeline ?? throw MockSeam.CreateError("WorktreesClient", "Pipeline"); + /// + /// Refresh worktrees. Discover worktrees from the requested location and reconcile the shared project inventory. + /// + /// The request shaping the query. + /// The per-call options. + /// The cancellation token. + /// The 'WorktreeRefreshPostResponse' envelope. + /// The API returned an error status (declared: 400, 401) and NoThrow was not selected. + /// The server could not be reached or returned a malformed success body. + public virtual Task RefreshWorktreesAsync(WorktreeRefreshPostRequest? request = null, OpenCodeRequestOptions? requestOptions = null, CancellationToken cancellationToken = default) + { + return Pipeline.ExecuteAsync(HttpMethod.Post, OpenCodeRoutes.Worktrees.RefreshWorktrees(request), WorktreeRefreshPostResponseAdapter.Instance, requestOptions, cancellationToken); + } + + /// + /// Remove worktree. Remove a managed worktree from the requested location's project using its recorded strategy. + /// + /// The request body. + /// The per-call options. + /// The cancellation token. + /// The 'WorktreeRemoveResponse' envelope. + /// The API returned an error status (declared: 400, 401) and NoThrow was not selected. + /// The server could not be reached or returned a malformed success body. + public virtual Task RemoveWorktreeAsync(WorktreeRemoveRequest request, OpenCodeRequestOptions? requestOptions = null, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + return Pipeline.ExecuteAsync(HttpMethod.Delete, OpenCodeRoutes.Worktrees.RemoveWorktree(request), request, OpenCodeJsonContext.Default.WorktreeRemoveRequest, WorktreeRemoveResponseAdapter.Instance, requestOptions, cancellationToken); + } } diff --git a/tests/OpenCode.Sdk.Tests/OpenCodeRoutesTests.cs b/tests/OpenCode.Sdk.Tests/OpenCodeRoutesTests.cs index 518405bb..c30425bb 100644 --- a/tests/OpenCode.Sdk.Tests/OpenCodeRoutesTests.cs +++ b/tests/OpenCode.Sdk.Tests/OpenCodeRoutesTests.cs @@ -166,22 +166,11 @@ public async Task GetOutput_Should_Return_The_Bare_Path_When_Nothing_Is_Set() } [Test] - public async Task Worktree_Routes_Should_Escape_The_Project_Segment() + public async Task Worktree_Routes_Should_Use_Location_Scoped_Paths() { - await Assert.That(OpenCodeRoutes.Worktrees.CreateWorktree("a b")).IsEqualTo("/api/worktree/a%20b"); - await Assert.That(OpenCodeRoutes.Worktrees.ListWorktrees("a b")).IsEqualTo("/api/worktree/a%20b"); - await Assert.That(OpenCodeRoutes.Worktrees.RefreshWorktrees("a b")).IsEqualTo("/api/worktree/a%20b/refresh"); - await Assert.That(OpenCodeRoutes.Worktrees.RemoveWorktree("a b")).IsEqualTo("/api/worktree/a%20b"); - } - - [Test] - [Arguments(".")] - [Arguments("..")] - [Arguments(" ")] - public async Task RefreshWorktrees_Should_Refuse_An_Unsafe_Project_Segment(string projectId) - { - var exception = Assert.Throws(() => _ = OpenCodeRoutes.Worktrees.RefreshWorktrees(projectId)); - - await Assert.That(exception.ParamName).IsEqualTo("projectId"); + await Assert.That(OpenCodeRoutes.Worktrees.CreateWorktree()).IsEqualTo("/api/worktree"); + await Assert.That(OpenCodeRoutes.Worktrees.ListWorktrees()).IsEqualTo("/api/worktree"); + await Assert.That(OpenCodeRoutes.Worktrees.RefreshWorktrees()).IsEqualTo("/api/worktree/refresh"); + await Assert.That(OpenCodeRoutes.Worktrees.RemoveWorktree()).IsEqualTo("/api/worktree"); } } diff --git a/tests/OpenCode.Sdk.Tests/OwnedTransportTests.cs b/tests/OpenCode.Sdk.Tests/OwnedTransportTests.cs index b4566ee8..1efb66fd 100644 --- a/tests/OpenCode.Sdk.Tests/OwnedTransportTests.cs +++ b/tests/OpenCode.Sdk.Tests/OwnedTransportTests.cs @@ -35,7 +35,7 @@ public async Task ExecuteAsync_Should_Send_A_Delete_Body_Through_The_Real_Handle }); using var client = new OpenCodeClient(new OpenCodeClientOptions { Endpoint = server.Endpoint }); - var response = await client.Worktrees.GetProjectWorktreesClient("prj_1").RemoveWorktreeAsync(new WorktreeRemoveRequest + var response = await client.Worktrees.RemoveWorktreeAsync(new WorktreeRemoveRequest { Directory = "/repo/feature", Force = true, @@ -44,7 +44,7 @@ public async Task ExecuteAsync_Should_Send_A_Delete_Body_Through_The_Real_Handle await Assert.That(response.Status).IsEqualTo(204); var request = server.Requests.Single(); await Assert.That(request.Method).IsEqualTo("DELETE"); - await Assert.That(request.Path).IsEqualTo("/api/worktree/prj_1"); + await Assert.That(request.Path).IsEqualTo("/api/worktree"); await Assert.That(request.Body).IsEqualTo("{\"directory\":\"/repo/feature\",\"force\":true}"); } diff --git a/tests/OpenCode.Sdk.Tests/Snapshots/PublicApi.verified.txt b/tests/OpenCode.Sdk.Tests/Snapshots/PublicApi.verified.txt index 25183cf0..ae2a8baf 100644 --- a/tests/OpenCode.Sdk.Tests/Snapshots/PublicApi.verified.txt +++ b/tests/OpenCode.Sdk.Tests/Snapshots/PublicApi.verified.txt @@ -863,14 +863,14 @@ } public static class Worktrees { - public const string CreateWorktreeTemplate = "/api/worktree/{projectID}"; - public const string ListWorktreesTemplate = "/api/worktree/{projectID}"; - public const string RefreshWorktreesTemplate = "/api/worktree/{projectID}/refresh"; - public const string RemoveWorktreeTemplate = "/api/worktree/{projectID}"; - public static string CreateWorktree(string projectId) { } - public static string ListWorktrees(string projectId) { } - public static string RefreshWorktrees(string projectId) { } - public static string RemoveWorktree(string projectId) { } + public const string CreateWorktreeTemplate = "/api/worktree"; + public const string ListWorktreesTemplate = "/api/worktree"; + public const string RefreshWorktreesTemplate = "/api/worktree/refresh"; + public const string RemoveWorktreeTemplate = "/api/worktree"; + public static string CreateWorktree(OpenCode.Sdk.Models.WorktreeCreateRequest? request = null) { } + public static string ListWorktrees(OpenCode.Sdk.WorktreeListRequest? request = null) { } + public static string RefreshWorktrees(OpenCode.Sdk.WorktreeRefreshPostRequest? request = null) { } + public static string RemoveWorktree(OpenCode.Sdk.Models.WorktreeRemoveRequest? request = null) { } } } public class OpenCodeServer : System.IAsyncDisposable @@ -1183,14 +1183,6 @@ public required OpenCode.Sdk.Models.Project Update { get; init; } protected override bool PrintMembers(System.Text.StringBuilder builder) { } } - public class ProjectWorktreesClient - { - protected ProjectWorktreesClient() { } - public virtual System.Threading.Tasks.Task CreateWorktreeAsync(OpenCode.Sdk.Models.WorktreeCreateRequest request, OpenCode.Sdk.OpenCodeRequestOptions? requestOptions = null, System.Threading.CancellationToken cancellationToken = default) { } - public virtual System.Threading.Tasks.Task ListWorktreesAsync(OpenCode.Sdk.OpenCodeRequestOptions? requestOptions = null, System.Threading.CancellationToken cancellationToken = default) { } - public virtual System.Threading.Tasks.Task RefreshWorktreesAsync(OpenCode.Sdk.OpenCodeRequestOptions? requestOptions = null, System.Threading.CancellationToken cancellationToken = default) { } - public virtual System.Threading.Tasks.Task RemoveWorktreeAsync(OpenCode.Sdk.Models.WorktreeRemoveRequest request, OpenCode.Sdk.OpenCodeRequestOptions? requestOptions = null, System.Threading.CancellationToken cancellationToken = default) { } - } public class ProjectsClient { protected ProjectsClient() { } @@ -1935,12 +1927,22 @@ public required OpenCode.Sdk.Models.WorktreeInfo Worktree { get; init; } protected override bool PrintMembers(System.Text.StringBuilder builder) { } } + public sealed class WorktreeListRequest : System.IEquatable + { + public WorktreeListRequest() { } + public OpenCode.Sdk.LocationSelector? Location { get; init; } + } public sealed class WorktreeListResponse : OpenCode.Sdk.OpenCodeResponse, System.IEquatable { public WorktreeListResponse() { } public required System.Collections.Generic.IReadOnlyList Worktrees { get; init; } protected override bool PrintMembers(System.Text.StringBuilder builder) { } } + public sealed class WorktreeRefreshPostRequest : System.IEquatable + { + public WorktreeRefreshPostRequest() { } + public OpenCode.Sdk.LocationSelector? Location { get; init; } + } public sealed class WorktreeRefreshPostResponse : OpenCode.Sdk.OpenCodeResponse, System.IEquatable { public WorktreeRefreshPostResponse() { } @@ -1952,7 +1954,10 @@ public class WorktreesClient { protected WorktreesClient() { } - public virtual OpenCode.Sdk.ProjectWorktreesClient GetProjectWorktreesClient(string projectId) { } + public virtual System.Threading.Tasks.Task CreateWorktreeAsync(OpenCode.Sdk.Models.WorktreeCreateRequest? request = null, OpenCode.Sdk.OpenCodeRequestOptions? requestOptions = null, System.Threading.CancellationToken cancellationToken = default) { } + public virtual System.Threading.Tasks.Task ListWorktreesAsync(OpenCode.Sdk.WorktreeListRequest? request = null, OpenCode.Sdk.OpenCodeRequestOptions? requestOptions = null, System.Threading.CancellationToken cancellationToken = default) { } + public virtual System.Threading.Tasks.Task RefreshWorktreesAsync(OpenCode.Sdk.WorktreeRefreshPostRequest? request = null, OpenCode.Sdk.OpenCodeRequestOptions? requestOptions = null, System.Threading.CancellationToken cancellationToken = default) { } + public virtual System.Threading.Tasks.Task RemoveWorktreeAsync(OpenCode.Sdk.Models.WorktreeRemoveRequest request, OpenCode.Sdk.OpenCodeRequestOptions? requestOptions = null, System.Threading.CancellationToken cancellationToken = default) { } } } namespace OpenCode.Sdk.Models @@ -5071,6 +5076,12 @@ namespace OpenCode.Sdk.Models public sealed class SessionCompactionEndedData : System.IEquatable { public SessionCompactionEndedData() { } + [System.Text.Json.Serialization.JsonIgnore(Condition=System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] + [System.Text.Json.Serialization.JsonPropertyName("model")] + public OpenCode.Sdk.Models.ModelRef? Model { get; init; } + [System.Text.Json.Serialization.JsonIgnore(Condition=System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] + [System.Text.Json.Serialization.JsonPropertyName("providerState")] + public System.Collections.Generic.IReadOnlyDictionary? ProviderState { get; init; } [System.Text.Json.Serialization.JsonPropertyName("reason")] public required OpenCode.Sdk.Models.SessionCompactionEndedDataReason Reason { get; init; } [System.Text.Json.Serialization.JsonPropertyName("recent")] @@ -6268,6 +6279,12 @@ namespace OpenCode.Sdk.Models [System.Text.Json.Serialization.JsonIgnore(Condition=System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] [System.Text.Json.Serialization.JsonPropertyName("metadata")] public System.Collections.Generic.IReadOnlyDictionary? Metadata { get; init; } + [System.Text.Json.Serialization.JsonIgnore(Condition=System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] + [System.Text.Json.Serialization.JsonPropertyName("model")] + public OpenCode.Sdk.Models.ModelRef? Model { get; init; } + [System.Text.Json.Serialization.JsonIgnore(Condition=System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] + [System.Text.Json.Serialization.JsonPropertyName("providerState")] + public System.Collections.Generic.IReadOnlyDictionary? ProviderState { get; init; } [System.Text.Json.Serialization.JsonPropertyName("reason")] public required OpenCode.Sdk.Models.SessionMessageCompactionCompletedReason Reason { get; init; } [System.Text.Json.Serialization.JsonPropertyName("recent")] @@ -9257,16 +9274,20 @@ namespace OpenCode.Sdk.Models [System.Text.Json.Serialization.JsonIgnore(Condition=System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] [System.Text.Json.Serialization.JsonPropertyName("branch")] public string? Branch { get; init; } + [System.Text.Json.Serialization.JsonIgnore(Condition=System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] [System.Text.Json.Serialization.JsonPropertyName("directory")] - public required string Directory { get; init; } + public string? Directory { get; init; } [System.Text.Json.Serialization.JsonIgnore(Condition=System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] [System.Text.Json.Serialization.JsonPropertyName("from")] public string? From { get; init; } + [System.Text.Json.Serialization.JsonIgnore] + public OpenCode.Sdk.LocationSelector? Location { get; init; } [System.Text.Json.Serialization.JsonIgnore(Condition=System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] [System.Text.Json.Serialization.JsonPropertyName("name")] public string? Name { get; init; } + [System.Text.Json.Serialization.JsonIgnore(Condition=System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] [System.Text.Json.Serialization.JsonPropertyName("strategy")] - public required string Strategy { get; init; } + public string? Strategy { get; init; } } public sealed class WorktreeDirectory : System.IEquatable { @@ -9307,6 +9328,8 @@ namespace OpenCode.Sdk.Models public required string Directory { get; init; } [System.Text.Json.Serialization.JsonPropertyName("force")] public required bool Force { get; init; } + [System.Text.Json.Serialization.JsonIgnore] + public OpenCode.Sdk.LocationSelector? Location { get; init; } } public sealed class WorktreeResolved : OpenCode.Sdk.Models.IEvent, System.IEquatable { diff --git a/tests/OpenCode.Sdk.Tests/Support/WireBodyData.cs b/tests/OpenCode.Sdk.Tests/Support/WireBodyData.cs index 74e2acc7..0f40afd7 100644 --- a/tests/OpenCode.Sdk.Tests/Support/WireBodyData.cs +++ b/tests/OpenCode.Sdk.Tests/Support/WireBodyData.cs @@ -43,6 +43,10 @@ public static byte[] HealthWithMalformedUtf8UnknownField() public const string WorktreeError = "{\"name\":\"WorktreeError\",\"data\":{\"message\":\"the worktree has uncommitted changes\",\"forceRequired\":true}}"; + public const string Worktree = "{\"directory\":\"/repo/feature\"}"; + + public const string Worktrees = "[{\"directory\":\"/repo\",\"strategy\":\"branch\"},{\"directory\":\"/repo-2\"}]"; + public const string PermissionNotFoundError = "{\"_tag\":\"PermissionNotFoundError\",\"requestID\":\"req_9\",\"message\":\"gone\"}"; diff --git a/tests/OpenCode.Sdk.Tests/Worktrees/ProjectWorktreesClientContractTests.cs b/tests/OpenCode.Sdk.Tests/Worktrees/ProjectWorktreesClientContractTests.cs deleted file mode 100644 index 08b8a783..00000000 --- a/tests/OpenCode.Sdk.Tests/Worktrees/ProjectWorktreesClientContractTests.cs +++ /dev/null @@ -1,229 +0,0 @@ -using System.Net; -using OpenCode.Sdk.Models; -using OpenCode.Sdk.Tests.Support; - -namespace OpenCode.Sdk.Tests; - -public sealed class ProjectWorktreesClientContractTests -{ - [Test] - public async Task CreateWorktreeAsync_Should_Return_The_Typed_Worktree() - { - using var scenario = ContractScenario.Responding(HttpStatusCode.OK, "{\"directory\":\"/repo/feature\"}"); - - var response = await scenario.Client.Worktrees.GetProjectWorktreesClient("prj_1").CreateWorktreeAsync(new WorktreeCreateRequest - { - Strategy = "branch", - Directory = "/repo/feature", - Branch = "feature/x", - }); - - await Assert.That(response.Worktree.Directory).IsEqualTo("/repo/feature"); - var request = scenario.Requests.Single(); - await Assert.That(request.Method.Method).IsEqualTo("POST"); - await Assert.That(request.RequestUri).IsEqualTo(new Uri("http://localhost:4096/api/worktree/prj_1")); - await Assert.That(request.Body) - .IsEqualTo("{\"strategy\":\"branch\",\"branch\":\"feature/x\",\"directory\":\"/repo/feature\"}"); - } - - [Test] - public async Task CreateWorktreeAsync_Should_Throw_The_Declared_400_Worktree_Error() - { - using var scenario = ContractScenario.Responding(HttpStatusCode.BadRequest, WireBodyData.WorktreeError); - - var exception = await Assert - .That(async () => _ = await scenario.Client.Worktrees.GetProjectWorktreesClient("prj_1").CreateWorktreeAsync(CreateRequest())) - .Throws(); - - await Assert.That(exception!.Status).IsEqualTo(400); - await Assert.That(exception.Message).Contains("'WorktreeError'"); - await Assert.That(exception.Error).IsTypeOf(); - var error = (WorktreeError)exception.Error!; - await Assert.That(error.Data.Message).IsEqualTo("the worktree has uncommitted changes"); - await Assert.That(error.Data.ForceRequired).IsTrue(); - } - - [Test] - public async Task CreateWorktreeAsync_Should_Throw_The_Other_Declared_400_Error() - { - using var scenario = ContractScenario.Responding(HttpStatusCode.BadRequest, WireBodyData.InvalidRequestError); - - var exception = await Assert - .That(async () => _ = await scenario.Client.Worktrees.GetProjectWorktreesClient("prj_1").CreateWorktreeAsync(CreateRequest())) - .Throws(); - - await Assert.That(exception!.Status).IsEqualTo(400); - await Assert.That(exception.Error).IsTypeOf(); - } - - [Test] - public async Task CreateWorktreeAsync_Should_Return_The_401_Error_On_The_NoThrow_Spine() - { - using var scenario = ContractScenario.Responding(HttpStatusCode.Unauthorized, WireBodyData.UnauthorizedError); - - var response = await scenario.Client.Worktrees.GetProjectWorktreesClient("prj_1") - .CreateWorktreeAsync(CreateRequest(), OpenCodeRequestOptions.NoThrow); - - await Assert.That(response.IsError).IsTrue(); - await Assert.That(response.Status).IsEqualTo(401); - await Assert.That(response.Error).IsTypeOf(); - } - - /// - /// The pinned document declares a required body on this DELETE, so the wire must carry it: - /// a 204 that arrived without the body would be the server answering a different request. - /// - [Test] - public async Task RemoveWorktreeAsync_Should_Send_The_Json_Body_On_The_Delete_Route() - { - using var scenario = ContractScenario.Responding(HttpStatusCode.NoContent, string.Empty); - - var response = await scenario.Client.Worktrees.GetProjectWorktreesClient("prj_1").RemoveWorktreeAsync(new WorktreeRemoveRequest - { - Directory = "/repo/feature", - Force = true, - }); - - await Assert.That(response.Status).IsEqualTo(204); - await Assert.That(response.IsError).IsFalse(); - var request = scenario.Requests.Single(); - await Assert.That(request.Method.Method).IsEqualTo("DELETE"); - await Assert.That(request.RequestUri).IsEqualTo(new Uri("http://localhost:4096/api/worktree/prj_1")); - await Assert.That(request.Body).IsEqualTo("{\"directory\":\"/repo/feature\",\"force\":true}"); - } - - [Test] - public async Task RemoveWorktreeAsync_Should_Throw_The_Declared_400_Worktree_Error() - { - using var scenario = ContractScenario.Responding(HttpStatusCode.BadRequest, WireBodyData.WorktreeError); - - var exception = await Assert - .That(async () => _ = await scenario.Client.Worktrees.GetProjectWorktreesClient("prj_1").RemoveWorktreeAsync(RemoveRequest())) - .Throws(); - - await Assert.That(exception!.Status).IsEqualTo(400); - await Assert.That(exception.Message).Contains("'WorktreeError'"); - await Assert.That(exception.Error).IsTypeOf(); - await Assert.That(((WorktreeError)exception.Error!).Data.ForceRequired).IsTrue(); - } - - [Test] - public async Task RemoveWorktreeAsync_Should_Return_The_401_Error_On_The_NoThrow_Spine() - { - using var scenario = ContractScenario.Responding(HttpStatusCode.Unauthorized, WireBodyData.UnauthorizedError); - - var response = await scenario.Client.Worktrees.GetProjectWorktreesClient("prj_1") - .RemoveWorktreeAsync(RemoveRequest(), OpenCodeRequestOptions.NoThrow); - - await Assert.That(response.IsError).IsTrue(); - await Assert.That(response.Status).IsEqualTo(401); - await Assert.That(response.Error).IsTypeOf(); - } - - [Test] - public async Task RefreshWorktreesAsync_Should_Post_On_The_Refresh_Route() - { - using var scenario = ContractScenario.Responding(HttpStatusCode.NoContent, string.Empty); - - var response = await scenario.Client.Worktrees.GetProjectWorktreesClient("prj_1").RefreshWorktreesAsync(); - - await Assert.That(response.Status).IsEqualTo(204); - await Assert.That(response.IsError).IsFalse(); - var request = scenario.Requests.Single(); - await Assert.That(request.Method.Method).IsEqualTo("POST"); - await Assert.That(request.RequestUri).IsEqualTo(new Uri("http://localhost:4096/api/worktree/prj_1/refresh")); - } - - [Test] - public async Task RefreshWorktreesAsync_Should_Throw_The_Declared_400_Worktree_Error() - { - using var scenario = ContractScenario.Responding(HttpStatusCode.BadRequest, WireBodyData.WorktreeError); - - var exception = await Assert - .That(async () => _ = await scenario.Client.Worktrees.GetProjectWorktreesClient("prj_1").RefreshWorktreesAsync()) - .Throws(); - - await Assert.That(exception!.Status).IsEqualTo(400); - await Assert.That(exception.Error).IsTypeOf(); - } - - [Test] - public async Task RefreshWorktreesAsync_Should_Return_The_401_Error_On_The_NoThrow_Spine() - { - using var scenario = ContractScenario.Responding(HttpStatusCode.Unauthorized, WireBodyData.UnauthorizedError); - - var response = await scenario.Client.Worktrees.GetProjectWorktreesClient("prj_1") - .RefreshWorktreesAsync(OpenCodeRequestOptions.NoThrow); - - await Assert.That(response.IsError).IsTrue(); - await Assert.That(response.Status).IsEqualTo(401); - await Assert.That(response.Error).IsTypeOf(); - } - - [Test] - public async Task ListWorktreesAsync_Should_Return_The_Typed_Worktrees() - { - const string worktrees = "[{\"directory\":\"/repo\",\"strategy\":\"branch\"},{\"directory\":\"/repo-2\"}]"; - using var scenario = ContractScenario.Responding(HttpStatusCode.OK, worktrees); - - var response = await scenario.Client.Worktrees.GetProjectWorktreesClient("prj_1").ListWorktreesAsync(); - - await Assert.That(response.Worktrees.Count).IsEqualTo(2); - await Assert.That(response.Worktrees[0].Directory).IsEqualTo("/repo"); - await Assert.That(response.Worktrees[0].Strategy).IsEqualTo("branch"); - await Assert.That(response.Worktrees[1].Directory).IsEqualTo("/repo-2"); - await Assert.That(response.Worktrees[1].Strategy).IsNull(); - await Assert.That(scenario.Requests.Single().RequestUri) - .IsEqualTo(new Uri("http://localhost:4096/api/worktree/prj_1")); - } - - [Test] - public async Task ListWorktreesAsync_Should_Return_An_Empty_List() - { - using var scenario = ContractScenario.Responding(HttpStatusCode.OK, "[]"); - - var response = await scenario.Client.Worktrees.GetProjectWorktreesClient("prj_1").ListWorktreesAsync(); - - await Assert.That(response.Worktrees).IsEmpty(); - } - - [Test] - public async Task ListWorktreesAsync_Should_Throw_The_Declared_400_Error() - { - using var scenario = ContractScenario.Responding(HttpStatusCode.BadRequest, WireBodyData.InvalidRequestError); - - var exception = await Assert - .That(async () => _ = await scenario.Client.Worktrees.GetProjectWorktreesClient("prj_1").ListWorktreesAsync()) - .Throws(); - - await Assert.That(exception!.Status).IsEqualTo(400); - await Assert.That(exception.Error).IsTypeOf(); - } - - [Test] - public async Task ListWorktreesAsync_Should_Return_The_401_Error_On_The_NoThrow_Spine() - { - using var scenario = ContractScenario.Responding(HttpStatusCode.Unauthorized, WireBodyData.UnauthorizedError); - - var response = await scenario.Client.Worktrees.GetProjectWorktreesClient("prj_1") - .ListWorktreesAsync(OpenCodeRequestOptions.NoThrow); - - await Assert.That(response.IsError).IsTrue(); - await Assert.That(response.Status).IsEqualTo(401); - await Assert.That(response.Error).IsTypeOf(); - } - - private static WorktreeCreateRequest CreateRequest() => - new() - { - Strategy = "branch", - Directory = "/repo/feature", - }; - - private static WorktreeRemoveRequest RemoveRequest() => - new() - { - Directory = "/repo/feature", - Force = false, - }; -} diff --git a/tests/OpenCode.Sdk.Tests/Worktrees/WorktreesClientContractTests.cs b/tests/OpenCode.Sdk.Tests/Worktrees/WorktreesClientContractTests.cs index 07e0d83b..372b1618 100644 --- a/tests/OpenCode.Sdk.Tests/Worktrees/WorktreesClientContractTests.cs +++ b/tests/OpenCode.Sdk.Tests/Worktrees/WorktreesClientContractTests.cs @@ -1,3 +1,5 @@ +using System.Net; +using OpenCode.Sdk.Models; using OpenCode.Sdk.Tests.Support; namespace OpenCode.Sdk.Tests; @@ -5,15 +7,322 @@ namespace OpenCode.Sdk.Tests; public sealed class WorktreesClientContractTests { [Test] - [Arguments(" ")] - [Arguments(".")] - [Arguments("..")] - public async Task GetProjectWorktreesClient_Should_Refuse_An_Id_The_Route_Would_Refuse(string projectId) + public async Task CreateWorktreeAsync_Should_Send_An_Empty_Required_Body_When_Options_Are_Omitted() { - using var scenario = ContractScenario.Responding(); + using var scenario = ContractScenario.Responding(HttpStatusCode.OK, WireBodyData.Worktree); - var exception = Assert.Throws(() => _ = scenario.Client.Worktrees.GetProjectWorktreesClient(projectId)); + var response = await scenario.Client.Worktrees.CreateWorktreeAsync(); - await Assert.That(exception.ParamName).IsEqualTo("projectId"); + await Assert.That(response.Status).IsEqualTo(200); + var request = scenario.Requests.Single(); + await Assert.That(request.Method).IsEqualTo(HttpMethod.Post); + await Assert.That(request.RequestUri).IsEqualTo(new Uri("http://localhost:4096/api/worktree")); + await Assert.That(request.Body).IsEqualTo("{}"); } + + [Test] + public async Task CreateWorktreeAsync_Should_Send_Optional_Inputs_Beside_The_Location_Query() + { + using var scenario = ContractScenario.Responding(HttpStatusCode.OK, WireBodyData.Worktree); + + _ = await scenario.Client.Worktrees.CreateWorktreeAsync(new WorktreeCreateRequest + { + Strategy = "custom-strategy", + From = "main", + Branch = "feature/x", + Directory = "/repo/feature", + Name = "feature", + Location = new LocationSelector { Directory = "/a b", Workspace = "wrk_1" }, + }); + + var request = scenario.Requests.Single(); + await Assert.That(request.RequestUri!.AbsoluteUri).IsEqualTo( + "http://localhost:4096/api/worktree?location[directory]=%2Fa%20b&location[workspace]=wrk_1"); + await Assert.That(request.Body).IsEqualTo( + "{\"strategy\":\"custom-strategy\",\"from\":\"main\",\"branch\":\"feature/x\",\"directory\":\"/repo/feature\",\"name\":\"feature\"}"); + } + + [Test] + public async Task CreateWorktreeAsync_Should_Return_The_Typed_Worktree() + { + using var scenario = ContractScenario.Responding(HttpStatusCode.OK, WireBodyData.Worktree); + + var response = await scenario.Client.Worktrees.CreateWorktreeAsync(new WorktreeCreateRequest + { + Strategy = "branch", + Directory = "/repo/feature", + Branch = "feature/x", + }); + + await Assert.That(response.Worktree.Directory).IsEqualTo("/repo/feature"); + var request = scenario.Requests.Single(); + await Assert.That(request.Method.Method).IsEqualTo("POST"); + await Assert.That(request.RequestUri).IsEqualTo(new Uri("http://localhost:4096/api/worktree")); + await Assert.That(request.Body) + .IsEqualTo("{\"strategy\":\"branch\",\"branch\":\"feature/x\",\"directory\":\"/repo/feature\"}"); + } + + [Test] + public async Task CreateWorktreeAsync_Should_Throw_The_Declared_400_Worktree_Error() + { + using var scenario = ContractScenario.Responding(HttpStatusCode.BadRequest, WireBodyData.WorktreeError); + + var exception = await Assert + .That(async () => _ = await scenario.Client.Worktrees.CreateWorktreeAsync(CreateRequest())) + .Throws(); + + await Assert.That(exception!.Status).IsEqualTo(400); + await Assert.That(exception.Message).Contains("'WorktreeError'"); + await Assert.That(exception.Error).IsTypeOf(); + var error = (WorktreeError)exception.Error!; + await Assert.That(error.Data.Message).IsEqualTo("the worktree has uncommitted changes"); + await Assert.That(error.Data.ForceRequired).IsTrue(); + } + + [Test] + public async Task CreateWorktreeAsync_Should_Throw_The_Other_Declared_400_Error() + { + using var scenario = ContractScenario.Responding(HttpStatusCode.BadRequest, WireBodyData.InvalidRequestError); + + var exception = await Assert + .That(async () => _ = await scenario.Client.Worktrees.CreateWorktreeAsync(CreateRequest())) + .Throws(); + + await Assert.That(exception!.Status).IsEqualTo(400); + await Assert.That(exception.Error).IsTypeOf(); + } + + [Test] + public async Task CreateWorktreeAsync_Should_Return_The_401_Error_On_The_NoThrow_Spine() + { + using var scenario = ContractScenario.Responding(HttpStatusCode.Unauthorized, WireBodyData.UnauthorizedError); + + var response = await scenario.Client.Worktrees + .CreateWorktreeAsync(CreateRequest(), OpenCodeRequestOptions.NoThrow); + + await Assert.That(response.IsError).IsTrue(); + await Assert.That(response.Status).IsEqualTo(401); + await Assert.That(response.Error).IsTypeOf(); + } + + /// + /// The pinned document declares a required body on this DELETE, so the wire must carry it: + /// a 204 that arrived without the body would be the server answering a different request. + /// + [Test] + public async Task RemoveWorktreeAsync_Should_Send_The_Json_Body_On_The_Delete_Route() + { + using var scenario = ContractScenario.Responding(HttpStatusCode.NoContent, string.Empty); + + var response = await scenario.Client.Worktrees.RemoveWorktreeAsync(new WorktreeRemoveRequest + { + Directory = "/repo/feature", + Force = true, + }); + + await Assert.That(response.Status).IsEqualTo(204); + await Assert.That(response.IsError).IsFalse(); + var request = scenario.Requests.Single(); + await Assert.That(request.Method.Method).IsEqualTo("DELETE"); + await Assert.That(request.RequestUri).IsEqualTo(new Uri("http://localhost:4096/api/worktree")); + await Assert.That(request.Body).IsEqualTo("{\"directory\":\"/repo/feature\",\"force\":true}"); + } + + [Test] + public async Task RemoveWorktreeAsync_Should_Throw_The_Declared_400_Worktree_Error() + { + using var scenario = ContractScenario.Responding(HttpStatusCode.BadRequest, WireBodyData.WorktreeError); + + var exception = await Assert + .That(async () => _ = await scenario.Client.Worktrees.RemoveWorktreeAsync(RemoveRequest())) + .Throws(); + + await Assert.That(exception!.Status).IsEqualTo(400); + await Assert.That(exception.Message).Contains("'WorktreeError'"); + await Assert.That(exception.Error).IsTypeOf(); + await Assert.That(((WorktreeError)exception.Error!).Data.ForceRequired).IsTrue(); + } + + [Test] + public async Task RemoveWorktreeAsync_Should_Keep_False_In_The_Body_Beside_The_Location_Query() + { + using var scenario = ContractScenario.Responding(HttpStatusCode.NoContent, string.Empty); + + _ = await scenario.Client.Worktrees.RemoveWorktreeAsync(new WorktreeRemoveRequest + { + Directory = "/repo/feature", + Force = false, + Location = new LocationSelector { Directory = "/a b", Workspace = "wrk_1" }, + }); + + var request = scenario.Requests.Single(); + await Assert.That(request.Method).IsEqualTo(HttpMethod.Delete); + await Assert.That(request.RequestUri!.AbsoluteUri).IsEqualTo( + "http://localhost:4096/api/worktree?location[directory]=%2Fa%20b&location[workspace]=wrk_1"); + await Assert.That(request.Body).IsEqualTo("{\"directory\":\"/repo/feature\",\"force\":false}"); + } + + [Test] + public async Task RemoveWorktreeAsync_Should_Return_The_401_Error_On_The_NoThrow_Spine() + { + using var scenario = ContractScenario.Responding(HttpStatusCode.Unauthorized, WireBodyData.UnauthorizedError); + + var response = await scenario.Client.Worktrees + .RemoveWorktreeAsync(RemoveRequest(), OpenCodeRequestOptions.NoThrow); + + await Assert.That(response.IsError).IsTrue(); + await Assert.That(response.Status).IsEqualTo(401); + await Assert.That(response.Error).IsTypeOf(); + } + + [Test] + public async Task RefreshWorktreesAsync_Should_Post_On_The_Refresh_Route() + { + using var scenario = ContractScenario.Responding(HttpStatusCode.NoContent, string.Empty); + + var response = await scenario.Client.Worktrees.RefreshWorktreesAsync(); + + await Assert.That(response.Status).IsEqualTo(204); + await Assert.That(response.IsError).IsFalse(); + var request = scenario.Requests.Single(); + await Assert.That(request.Method.Method).IsEqualTo("POST"); + await Assert.That(request.RequestUri).IsEqualTo(new Uri("http://localhost:4096/api/worktree/refresh")); + } + + [Test] + public async Task RefreshWorktreesAsync_Should_Throw_The_Declared_400_Worktree_Error() + { + using var scenario = ContractScenario.Responding(HttpStatusCode.BadRequest, WireBodyData.WorktreeError); + + var exception = await Assert + .That(async () => _ = await scenario.Client.Worktrees.RefreshWorktreesAsync()) + .Throws(); + + await Assert.That(exception!.Status).IsEqualTo(400); + await Assert.That(exception.Error).IsTypeOf(); + } + + [Test] + public async Task RefreshWorktreesAsync_Should_Compose_The_Location_Query() + { + using var scenario = ContractScenario.Responding(HttpStatusCode.NoContent, string.Empty); + + _ = await scenario.Client.Worktrees.RefreshWorktreesAsync(new WorktreeRefreshPostRequest + { + Location = new LocationSelector { Directory = "/a b", Workspace = "wrk_1" }, + }); + + var request = scenario.Requests.Single(); + await Assert.That(request.Method).IsEqualTo(HttpMethod.Post); + await Assert.That(request.RequestUri!.AbsoluteUri).IsEqualTo( + "http://localhost:4096/api/worktree/refresh?location[directory]=%2Fa%20b&location[workspace]=wrk_1"); + } + + [Test] + public async Task RefreshWorktreesAsync_Should_Return_The_401_Error_On_The_NoThrow_Spine() + { + using var scenario = ContractScenario.Responding(HttpStatusCode.Unauthorized, WireBodyData.UnauthorizedError); + + var response = await scenario.Client.Worktrees + .RefreshWorktreesAsync(requestOptions: OpenCodeRequestOptions.NoThrow); + + await Assert.That(response.IsError).IsTrue(); + await Assert.That(response.Status).IsEqualTo(401); + await Assert.That(response.Error).IsTypeOf(); + } + + [Test] + public async Task ListWorktreesAsync_Should_Return_The_Typed_Worktrees() + { + using var scenario = ContractScenario.Responding(HttpStatusCode.OK, WireBodyData.Worktrees); + + var response = await scenario.Client.Worktrees.ListWorktreesAsync(); + + await Assert.That(response.Worktrees.Count).IsEqualTo(2); + await Assert.That(response.Worktrees[0].Directory).IsEqualTo("/repo"); + await Assert.That(response.Worktrees[0].Strategy).IsEqualTo("branch"); + await Assert.That(response.Worktrees[1].Directory).IsEqualTo("/repo-2"); + await Assert.That(response.Worktrees[1].Strategy).IsNull(); + await Assert.That(scenario.Requests.Single().RequestUri) + .IsEqualTo(new Uri("http://localhost:4096/api/worktree")); + } + + [Test] + public async Task ListWorktreesAsync_Should_Return_An_Empty_List() + { + using var scenario = ContractScenario.Responding(HttpStatusCode.OK, "[]"); + + var response = await scenario.Client.Worktrees.ListWorktreesAsync(); + + await Assert.That(response.Worktrees).IsEmpty(); + } + + [Test] + public async Task ListWorktreesAsync_Should_Compose_The_Location_Query() + { + using var scenario = ContractScenario.Responding(HttpStatusCode.OK, WireBodyData.Worktrees); + + _ = await scenario.Client.Worktrees.ListWorktreesAsync(new WorktreeListRequest + { + Location = new LocationSelector { Directory = "/a b", Workspace = "wrk_1" }, + }); + + var request = scenario.Requests.Single(); + await Assert.That(request.Method).IsEqualTo(HttpMethod.Get); + await Assert.That(request.RequestUri!.AbsoluteUri).IsEqualTo( + "http://localhost:4096/api/worktree?location[directory]=%2Fa%20b&location[workspace]=wrk_1"); + } + + [Test] + public async Task ListWorktreesAsync_Should_Return_The_Declared_Worktree_Error_On_The_NoThrow_Spine() + { + using var scenario = ContractScenario.Responding(HttpStatusCode.BadRequest, WireBodyData.WorktreeError); + + var response = await scenario.Client.Worktrees.ListWorktreesAsync(requestOptions: OpenCodeRequestOptions.NoThrow); + + await Assert.That(response.Status).IsEqualTo(400); + await Assert.That(response.IsError).IsTrue(); + await Assert.That(response.Error).IsTypeOf(); + await Assert.That(((WorktreeError)response.Error!).Data.ForceRequired).IsTrue(); + } + + [Test] + public async Task ListWorktreesAsync_Should_Throw_The_Declared_400_Error() + { + using var scenario = ContractScenario.Responding(HttpStatusCode.BadRequest, WireBodyData.InvalidRequestError); + + var exception = await Assert + .That(async () => _ = await scenario.Client.Worktrees.ListWorktreesAsync()) + .Throws(); + + await Assert.That(exception!.Status).IsEqualTo(400); + await Assert.That(exception.Error).IsTypeOf(); + } + + [Test] + public async Task ListWorktreesAsync_Should_Return_The_401_Error_On_The_NoThrow_Spine() + { + using var scenario = ContractScenario.Responding(HttpStatusCode.Unauthorized, WireBodyData.UnauthorizedError); + + var response = await scenario.Client.Worktrees + .ListWorktreesAsync(requestOptions: OpenCodeRequestOptions.NoThrow); + + await Assert.That(response.IsError).IsTrue(); + await Assert.That(response.Status).IsEqualTo(401); + await Assert.That(response.Error).IsTypeOf(); + } + + private static WorktreeCreateRequest CreateRequest() => + new() + { + Strategy = "branch", + Directory = "/repo/feature", + }; + + private static WorktreeRemoveRequest RemoveRequest() => + new() + { + Directory = "/repo/feature", + Force = false, + }; } diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationPlanBinderTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationPlanBinderTests.cs index a7360a60..f4eaf2b0 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationPlanBinderTests.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationPlanBinderTests.cs @@ -29,7 +29,6 @@ public sealed class OperationPlanBinderTests "PersistentPtyRawClient", "PersistentPtysRawClient", "PluginsClient", - "ProjectWorktreesClient", "ProjectsClient", "ProvidersClient", "PtyRawClient", diff --git a/tools/curation.json b/tools/curation.json index 0fab0aca..74c73912 100644 --- a/tools/curation.json +++ b/tools/curation.json @@ -152,9 +152,7 @@ "worktree": { "placement": "client", "clientName": "Worktrees", - "handleName": "ProjectWorktreesClient", - "handleParameter": "projectID", - "reason": "The family's full pinned surface (list, create, remove, refresh) all chain on the same projectID, so ADR-0019's full-surface rule gives it a handle, and the whole surface is selected. The handle is keyed by a project id, not a worktree id, so it is named for what it actually holds: one project's worktree registry." + "reason": "The family's full pinned surface (list, create, remove, refresh) resolves the project through an optional location query and carries no required path id, so ADR-0019 yields a flat client. The whole surface is selected and retains the upstream worktree terminology." }, "form": { "placement": "client", @@ -328,7 +326,7 @@ { "operationId": "v2.worktree.refresh", "methodName": "RefreshWorktreesAsync", - "reason": "The operation's own verb is refresh; the mechanical POST prefix is transport detail, not part of the reviewed surface, exactly like v2.credential.update's own 'update' verb. The subject is the project's worktrees in the plural, matching v2.worktree.list's reviewed subject in the same client family." + "reason": "The operation's own verb is refresh; the mechanical POST prefix is transport detail, not part of the reviewed surface. The plural subject matches ListWorktreesAsync in the same location-scoped client family." }, { "operationId": "v2.rpc.call", From 99d5bf11086d19bed2479d08f67aa5fb72bcd12a Mon Sep 17 00:00:00 2001 From: "denizirgin@gmail.com" Date: Sun, 6 Sep 2026 22:11:31 +0300 Subject: [PATCH 26/62] test: cover skills and filesystem against simulated server --- .../FileSystem/FileSystemClientLiveTests.cs | 62 +++++++++++++++++++ .../Fixtures/Skills/sdk-live-skill.md | 5 ++ .../OpenCode.Sdk.Tests.csproj | 1 + .../Skills/SkillsClientLiveTests.cs | 42 +++++++++++++ tests/Shared/TestWorkspace.cs | 13 ++++ 5 files changed, 123 insertions(+) create mode 100644 tests/OpenCode.Sdk.Tests/FileSystem/FileSystemClientLiveTests.cs create mode 100644 tests/OpenCode.Sdk.Tests/Fixtures/Skills/sdk-live-skill.md create mode 100644 tests/OpenCode.Sdk.Tests/Skills/SkillsClientLiveTests.cs diff --git a/tests/OpenCode.Sdk.Tests/FileSystem/FileSystemClientLiveTests.cs b/tests/OpenCode.Sdk.Tests/FileSystem/FileSystemClientLiveTests.cs new file mode 100644 index 00000000..f42b5c8e --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/FileSystem/FileSystemClientLiveTests.cs @@ -0,0 +1,62 @@ +using System.Globalization; +using OpenCode.Sdk.Models; +using OpenCode.Sdk.TestSupport; + +namespace OpenCode.Sdk.Tests; + +[ClassDataSource(Shared = SharedType.PerTestSession)] +[NotInParallel(ParallelConstraintKeys.ServerProcess)] +public sealed class FileSystemClientLiveTests(SimulatedDriveServerFixture server) +{ + private const string ChildDirectory = "sdk-live-child"; + private const string ChildFile = "sdk-live-child/sdk-live-find-target.txt"; + private const string RootFile = "sdk-live-root-file.txt"; + + [Test] + [Timeout(60_000)] + public async Task FindEntriesAsync_And_ListEntriesAsync_Should_Report_The_Workspace_Entries( + CancellationToken cancellationToken) + { + using var workspace = server.CreateWorkspace(); + _ = workspace.WriteTextFile(RootFile, "root entry"); + _ = workspace.WriteTextFile(ChildFile, "find target"); + using var client = server.CreateClient(new LocationSelector { Directory = workspace.Path }); + + var found = await client.FileSystem.FindEntriesAsync( + new FsFindRequest + { + Query = "sdk-live-find-target", + Type = FsFindRequestType.File, + Limit = "10", + }, + cancellationToken: cancellationToken); + + await Assert.That(found.Status).IsEqualTo(200); + await Assert.That(found.IsError).IsFalse(); + await Assert.That(found.Location.Directory).IsEqualTo(workspace.Path); + var foundFile = found.Entries.Single(item => NormalizeSeparators(item.Path) == ChildFile); + await Assert.That(foundFile.Type).IsEqualTo(FileSystemEntryType.File); + + var listed = await client.FileSystem.ListEntriesAsync( + new FsListRequest { Path = "." }, cancellationToken: cancellationToken); + + await Assert.That(listed.Status).IsEqualTo(200); + await Assert.That(listed.IsError).IsFalse(); + await Assert.That(listed.Location.Directory).IsEqualTo(workspace.Path); + var rootFile = listed.Entries.Single(item => NormalizeSeparators(item.Path) == RootFile); + await Assert.That(rootFile.Type).IsEqualTo(FileSystemEntryType.File); + var childDirectory = listed.Entries.Single( + item => NormalizeSeparators(item.Path) == ChildDirectory + "/"); + await Assert.That(childDirectory.Type).IsEqualTo(FileSystemEntryType.Directory); + + Console.WriteLine( + "filesystem-live: find-status=" + Number(found.Status) + + " find-path=" + foundFile.Path + + " list-status=" + Number(listed.Status) + + " directory=" + childDirectory.Path); + } + + private static string NormalizeSeparators(string value) => value.Replace('\\', '/'); + + private static string Number(int value) => value.ToString(CultureInfo.InvariantCulture); +} diff --git a/tests/OpenCode.Sdk.Tests/Fixtures/Skills/sdk-live-skill.md b/tests/OpenCode.Sdk.Tests/Fixtures/Skills/sdk-live-skill.md new file mode 100644 index 00000000..46135c56 --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Fixtures/Skills/sdk-live-skill.md @@ -0,0 +1,5 @@ +--- +name: SDK live skill +description: Exercises local skill discovery for SDK live verification. +--- +Use this skill to verify local SDK skill discovery. diff --git a/tests/OpenCode.Sdk.Tests/OpenCode.Sdk.Tests.csproj b/tests/OpenCode.Sdk.Tests/OpenCode.Sdk.Tests.csproj index 7d22a167..e0ac86e8 100644 --- a/tests/OpenCode.Sdk.Tests/OpenCode.Sdk.Tests.csproj +++ b/tests/OpenCode.Sdk.Tests/OpenCode.Sdk.Tests.csproj @@ -47,6 +47,7 @@ + diff --git a/tests/OpenCode.Sdk.Tests/Skills/SkillsClientLiveTests.cs b/tests/OpenCode.Sdk.Tests/Skills/SkillsClientLiveTests.cs new file mode 100644 index 00000000..a07e52b5 --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Skills/SkillsClientLiveTests.cs @@ -0,0 +1,42 @@ +using System.Globalization; +using OpenCode.Sdk.TestSupport; + +namespace OpenCode.Sdk.Tests; + +[ClassDataSource(Shared = SharedType.PerTestSession)] +[NotInParallel(ParallelConstraintKeys.ServerProcess)] +public sealed class SkillsClientLiveTests(SimulatedDriveServerFixture server) +{ + private const string SkillDescription = "Exercises local skill discovery for SDK live verification."; + private const string SkillId = "sdk-live-skill"; + private const string SkillName = "SDK live skill"; + private const string SkillContent = "Use this skill to verify local SDK skill discovery."; + + [Test] + [Timeout(60_000)] + public async Task ListSkillsAsync_Should_Report_The_Workspace_Skill(CancellationToken cancellationToken) + { + using var workspace = server.CreateWorkspace(); + var fixture = new FixtureLoader().LoadText("Skills.sdk-live-skill.md"); + var skillPath = workspace.WriteTextFile(".opencode/skills/sdk-live-skill/SKILL.md", fixture); + using var client = server.CreateClient(new LocationSelector { Directory = workspace.Path }); + var settled = await client.Plugins.AwaitPluginActivationAsync(cancellationToken: cancellationToken); + await Assert.That(settled.Status).IsEqualTo(204); + + var response = await client.Skills.ListSkillsAsync(cancellationToken: cancellationToken); + + await Assert.That(response.Status).IsEqualTo(200); + await Assert.That(response.IsError).IsFalse(); + await Assert.That(response.Location.Directory).IsEqualTo(workspace.Path); + var skill = response.Skills.Single(item => item.Id == SkillId); + await Assert.That(skill.Name).IsEqualTo(SkillName); + await Assert.That(skill.Description).IsEqualTo(SkillDescription); + await Assert.That(skill.Location).IsEqualTo(skillPath); + await Assert.That(skill.Content).IsEqualTo(SkillContent); + + Console.WriteLine( + "skills-live: status=" + Number(response.Status) + " id=" + skill.Id + " location=" + skill.Location); + } + + private static string Number(int value) => value.ToString(CultureInfo.InvariantCulture); +} diff --git a/tests/Shared/TestWorkspace.cs b/tests/Shared/TestWorkspace.cs index ae33df66..94b31117 100644 --- a/tests/Shared/TestWorkspace.cs +++ b/tests/Shared/TestWorkspace.cs @@ -18,6 +18,19 @@ public TestWorkspace(IFileSystem fileSystem, string runRoot) public string Path { get; } + /// Writes one text file below the workspace, creating its parent directories. + public string WriteTextFile(string relativePath, string content) + { + ArgumentException.ThrowIfNullOrWhiteSpace(relativePath); + ArgumentNullException.ThrowIfNull(content); + + var filePath = _fileSystem.Path.GetFullPath(_fileSystem.Path.Combine(Path, relativePath)); + var directory = _fileSystem.Path.GetDirectoryName(filePath)!; + _ = _fileSystem.Directory.CreateDirectory(directory); + _fileSystem.File.WriteAllText(filePath, content); + return filePath; + } + public void Dispose() { // Discarded deliberately: this workspace lives under the run root, so a tree a straggling From 98c3d59cace5d52c87cfa344169b4e09b0944ba4 Mon Sep 17 00:00:00 2001 From: "denizirgin@gmail.com" Date: Sun, 6 Sep 2026 22:25:21 +0300 Subject: [PATCH 27/62] fix: contain test workspace file writes --- .../OpenCode.Sdk.Tests/TestWorkspaceTests.cs | 57 +++++++++++++++++++ tests/Shared/TestWorkspace.cs | 17 +++++- 2 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 tests/OpenCode.Sdk.Tests/TestWorkspaceTests.cs diff --git a/tests/OpenCode.Sdk.Tests/TestWorkspaceTests.cs b/tests/OpenCode.Sdk.Tests/TestWorkspaceTests.cs new file mode 100644 index 00000000..8a1b4aa5 --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/TestWorkspaceTests.cs @@ -0,0 +1,57 @@ +using OpenCode.Sdk.TestSupport; +using Testably.Abstractions.Testing; + +namespace OpenCode.Sdk.Tests; + +public sealed class TestWorkspaceTests +{ + [Test] + public async Task WriteTextFile_Should_Reject_A_Rooted_Path_Without_Writing_Outside_The_Workspace() + { + var fileSystem = new MockFileSystem(); + var runRoot = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), "test-workspace-rooted"); + using var workspace = new TestWorkspace(fileSystem, runRoot); + var outside = fileSystem.Path.GetFullPath( + fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), "test-workspace-rooted-outside.txt")); + + var exception = await Assert + .That(() => workspace.WriteTextFile(outside, "outside")) + .Throws(); + + await Assert.That(exception!.ParamName).IsEqualTo("relativePath"); + await Assert.That(fileSystem.File.Exists(outside)).IsFalse(); + } + + [Test] + public async Task WriteTextFile_Should_Reject_Traversal_Without_Writing_Outside_The_Workspace() + { + var fileSystem = new MockFileSystem(); + var runRoot = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), "test-workspace-traversal"); + using var workspace = new TestWorkspace(fileSystem, runRoot); + var outside = fileSystem.Path.GetFullPath( + fileSystem.Path.Combine(workspace.Path, "..", "test-workspace-traversal-outside.txt")); + + var exception = await Assert + .That(() => workspace.WriteTextFile("../test-workspace-traversal-outside.txt", "outside")) + .Throws(); + + await Assert.That(exception!.ParamName).IsEqualTo("relativePath"); + await Assert.That(fileSystem.File.Exists(outside)).IsFalse(); + } + + [Test] + public async Task WriteTextFile_Should_Write_A_Valid_Nested_Path() + { + var fileSystem = new MockFileSystem(); + var runRoot = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), "test-workspace-valid"); + using var workspace = new TestWorkspace(fileSystem, runRoot); + + var written = workspace.WriteTextFile("nested/owned.txt", "owned content"); + + var expected = fileSystem.Path.GetFullPath(fileSystem.Path.Combine(workspace.Path, "nested", "owned.txt")); + await Assert.That(written).IsEqualTo(expected); + await Assert.That(fileSystem.File.Exists(expected)).IsTrue(); + using var reader = fileSystem.File.OpenText(expected); + await Assert.That(await reader.ReadToEndAsync()).IsEqualTo("owned content"); + } +} diff --git a/tests/Shared/TestWorkspace.cs b/tests/Shared/TestWorkspace.cs index 94b31117..f3dbdea7 100644 --- a/tests/Shared/TestWorkspace.cs +++ b/tests/Shared/TestWorkspace.cs @@ -12,7 +12,8 @@ public TestWorkspace(IFileSystem fileSystem, string runRoot) ArgumentNullException.ThrowIfNull(fileSystem); _fileSystem = fileSystem; - Path = fileSystem.Path.Combine(runRoot, "workspaces", Guid.NewGuid().ToString("N")); + Path = fileSystem.Path.GetFullPath( + fileSystem.Path.Combine(runRoot, "workspaces", Guid.NewGuid().ToString("N"))); _ = fileSystem.Directory.CreateDirectory(Path); } @@ -24,7 +25,21 @@ public string WriteTextFile(string relativePath, string content) ArgumentException.ThrowIfNullOrWhiteSpace(relativePath); ArgumentNullException.ThrowIfNull(content); + if (_fileSystem.Path.IsPathRooted(relativePath)) + { + throw new ArgumentException("The path must remain below the workspace.", nameof(relativePath)); + } + var filePath = _fileSystem.Path.GetFullPath(_fileSystem.Path.Combine(Path, relativePath)); + var workspacePrefix = Path + _fileSystem.Path.DirectorySeparatorChar; + var comparison = _fileSystem.Path.DirectorySeparatorChar == '\\' + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + if (!filePath.StartsWith(workspacePrefix, comparison)) + { + throw new ArgumentException("The path must remain below the workspace.", nameof(relativePath)); + } + var directory = _fileSystem.Path.GetDirectoryName(filePath)!; _ = _fileSystem.Directory.CreateDirectory(directory); _fileSystem.File.WriteAllText(filePath, content); From edbb8c87c6f592a42694a6121d52060fed09ea5a Mon Sep 17 00:00:00 2001 From: "denizirgin@gmail.com" Date: Sun, 6 Sep 2026 22:54:04 +0300 Subject: [PATCH 28/62] test: cover server and project inventory live --- .../Projects/ProjectsClientLiveTests.cs | 47 +++++++++++++++++++ .../Server/ServerClientLiveTests.cs | 44 +++++++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 tests/OpenCode.Sdk.Tests/Projects/ProjectsClientLiveTests.cs create mode 100644 tests/OpenCode.Sdk.Tests/Server/ServerClientLiveTests.cs diff --git a/tests/OpenCode.Sdk.Tests/Projects/ProjectsClientLiveTests.cs b/tests/OpenCode.Sdk.Tests/Projects/ProjectsClientLiveTests.cs new file mode 100644 index 00000000..fc6a5f25 --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Projects/ProjectsClientLiveTests.cs @@ -0,0 +1,47 @@ +using System.Globalization; +using OpenCode.Sdk.TestSupport; + +namespace OpenCode.Sdk.Tests; + +[ClassDataSource(Shared = SharedType.PerTestSession)] +[NotInParallel(ParallelConstraintKeys.ServerProcess)] +public sealed class ProjectsClientLiveTests(SimulatedDriveServerFixture server) +{ + [Test] + [Timeout(60_000)] + public async Task GetCurrentAsync_And_ListProjectsAsync_Should_Report_The_Workspace_Project( + CancellationToken cancellationToken) + { + using var workspace = server.CreateWorkspace(); + using var client = server.CreateClient(); + + var current = await client.Projects.GetCurrentAsync( + new ProjectCurrentRequest + { + Location = new LocationSelector { Directory = workspace.Path }, + }, + cancellationToken: cancellationToken); + + await Assert.That(current.Status).IsEqualTo(200); + await Assert.That(current.IsError).IsFalse(); + await Assert.That(string.IsNullOrWhiteSpace(current.Current.Id)).IsFalse(); + await Assert.That(current.Current.Directory).IsEqualTo(workspace.Path); + await Assert.That(current.Current.Canonical).IsEqualTo(workspace.Path); + + var listed = await client.Projects.ListProjectsAsync(cancellationToken: cancellationToken); + + await Assert.That(listed.Status).IsEqualTo(200); + await Assert.That(listed.IsError).IsFalse(); + var project = listed.Projects.Single(item => item.Id == current.Current.Id); + await Assert.That(project.Canonical).IsEqualTo(current.Current.Canonical); + await Assert.That(project.Canonical).IsEqualTo(workspace.Path); + + Console.WriteLine( + "projects-live: current=" + Number(current.Status) + + " list=" + Number(listed.Status) + + " id=" + project.Id + + " directory=" + current.Current.Directory); + } + + private static string Number(int value) => value.ToString(CultureInfo.InvariantCulture); +} diff --git a/tests/OpenCode.Sdk.Tests/Server/ServerClientLiveTests.cs b/tests/OpenCode.Sdk.Tests/Server/ServerClientLiveTests.cs new file mode 100644 index 00000000..690eeb50 --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Server/ServerClientLiveTests.cs @@ -0,0 +1,44 @@ +using System.Globalization; +using OpenCode.Sdk.TestSupport; + +namespace OpenCode.Sdk.Tests; + +[ClassDataSource(Shared = SharedType.PerTestSession)] +[NotInParallel(ParallelConstraintKeys.ServerProcess)] +public sealed class ServerClientLiveTests(SimulatedDriveServerFixture server) +{ + [Test] + [Timeout(60_000)] + public async Task GetServerAsync_Should_Report_The_Reachable_Fixture_Endpoint( + CancellationToken cancellationToken) + { + using var client = server.CreateClient(); + + var response = await client.Server.GetServerAsync(cancellationToken: cancellationToken); + + await Assert.That(response.Status).IsEqualTo(200); + await Assert.That(response.IsError).IsFalse(); + await Assert.That(response.Urls).IsNotEmpty(); + foreach (var value in response.Urls) + { + var isAbsolute = Uri.TryCreate(value, UriKind.Absolute, out var url); + await Assert.That(isAbsolute).IsTrue(); + await Assert.That(url).IsNotNull(); + await Assert.That( + string.Equals(url.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) || + string.Equals(url.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)).IsTrue(); + await Assert.That(url.Port).IsGreaterThan(0); + await Assert.That(url.IsDefaultPort).IsFalse(); + } + + await Assert.That(response.Urls.Any(value => + Uri.TryCreate(value, UriKind.Absolute, out var url) && url == server.Endpoint)).IsTrue(); + + Console.WriteLine( + "server-live: status=" + Number(response.Status) + + " endpoint=" + server.Endpoint + + " urls=" + Number(response.Urls.Count)); + } + + private static string Number(int value) => value.ToString(CultureInfo.InvariantCulture); +} From 4cfd197a8027e9a76907aec6aefb390c7c58f52e Mon Sep 17 00:00:00 2001 From: "denizirgin@gmail.com" Date: Sun, 6 Sep 2026 23:18:54 +0300 Subject: [PATCH 29/62] test: cover debug location eviction live --- .../Debug/DebugClientLiveTests.cs | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 tests/OpenCode.Sdk.Tests/Debug/DebugClientLiveTests.cs diff --git a/tests/OpenCode.Sdk.Tests/Debug/DebugClientLiveTests.cs b/tests/OpenCode.Sdk.Tests/Debug/DebugClientLiveTests.cs new file mode 100644 index 00000000..36508f40 --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Debug/DebugClientLiveTests.cs @@ -0,0 +1,84 @@ +using System.Globalization; +using OpenCode.Sdk.Models; +using OpenCode.Sdk.TestSupport; + +namespace OpenCode.Sdk.Tests; + +[ClassDataSource(Shared = SharedType.PerTestSession)] +[NotInParallel(ParallelConstraintKeys.ServerProcess)] +public sealed class DebugClientLiveTests(SimulatedDriveServerFixture server) +{ + [Test] + [Timeout(60_000)] + public async Task EvictLocationAsync_Should_Remove_And_Allow_Reopening_The_Owned_Location( + CancellationToken cancellationToken) + { + using var workspace = server.CreateWorkspace(); + using var locationClient = server.CreateClient(new LocationSelector { Directory = workspace.Path }); + using var rootClient = server.CreateClient(); + + var warmed = await locationClient.GetLocationAsync(cancellationToken: cancellationToken); + + await Assert.That(warmed.Status).IsEqualTo(200); + await Assert.That(warmed.IsError).IsFalse(); + await Assert.That(warmed.ResolvedLocation.Directory).IsEqualTo(workspace.Path); + var ownedLocation = new LocationRef + { + Directory = warmed.ResolvedLocation.Directory, + WorkspaceId = warmed.ResolvedLocation.WorkspaceId, + }; + + var beforeEviction = await rootClient.Debug.ListLocationsAsync(cancellationToken: cancellationToken); + + await Assert.That(beforeEviction.Status).IsEqualTo(200); + await Assert.That(beforeEviction.IsError).IsFalse(); + await Assert.That(Contains(beforeEviction.Locations, ownedLocation)).IsTrue(); + + var eviction = await rootClient.Debug.EvictLocationAsync( + new DebugLocationEvictDeleteRequest + { + Location = new LocationSelector + { + Directory = ownedLocation.Directory, + Workspace = ownedLocation.WorkspaceId, + }, + }, + cancellationToken: cancellationToken); + + await Assert.That(eviction.Status).IsEqualTo(204); + await Assert.That(eviction.IsError).IsFalse(); + + var afterEviction = await rootClient.Debug.ListLocationsAsync(cancellationToken: cancellationToken); + + await Assert.That(afterEviction.Status).IsEqualTo(200); + await Assert.That(afterEviction.IsError).IsFalse(); + await Assert.That(Contains(afterEviction.Locations, ownedLocation)).IsFalse(); + + var reopened = await locationClient.GetLocationAsync(cancellationToken: cancellationToken); + + await Assert.That(reopened.Status).IsEqualTo(200); + await Assert.That(reopened.IsError).IsFalse(); + await Assert.That(reopened.ResolvedLocation.Directory).IsEqualTo(ownedLocation.Directory); + await Assert.That(reopened.ResolvedLocation.WorkspaceId).IsEqualTo(ownedLocation.WorkspaceId); + + var afterReopening = await rootClient.Debug.ListLocationsAsync(cancellationToken: cancellationToken); + + await Assert.That(afterReopening.Status).IsEqualTo(200); + await Assert.That(afterReopening.IsError).IsFalse(); + await Assert.That(Contains(afterReopening.Locations, ownedLocation)).IsTrue(); + + Console.WriteLine( + "debug-location-live: warm=" + Number(warmed.Status) + + " evict=" + Number(eviction.Status) + + " reopen=" + Number(reopened.Status) + + " directory=" + ownedLocation.Directory + + " workspace=" + (ownedLocation.WorkspaceId ?? "")); + } + + private static bool Contains(IReadOnlyList locations, LocationRef expected) => + locations.Any(location => + string.Equals(location.Directory, expected.Directory, StringComparison.Ordinal) && + string.Equals(location.WorkspaceId, expected.WorkspaceId, StringComparison.Ordinal)); + + private static string Number(int value) => value.ToString(CultureInfo.InvariantCulture); +} From 4e5faaf009cfef095e1ca7f30f22bbcd1ecd55df Mon Sep 17 00:00:00 2001 From: "denizirgin@gmail.com" Date: Sun, 6 Sep 2026 23:54:57 +0300 Subject: [PATCH 30/62] test: prove session inbox live behavior --- .../Sessions/SessionInboxLiveTests.cs | 461 ++++++++++++++++++ .../Support/OwnedDriveInvocation.cs | 40 ++ 2 files changed, 501 insertions(+) create mode 100644 tests/OpenCode.Sdk.Tests/Sessions/SessionInboxLiveTests.cs create mode 100644 tests/OpenCode.Sdk.Tests/Support/OwnedDriveInvocation.cs diff --git a/tests/OpenCode.Sdk.Tests/Sessions/SessionInboxLiveTests.cs b/tests/OpenCode.Sdk.Tests/Sessions/SessionInboxLiveTests.cs new file mode 100644 index 00000000..432d94b4 --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Sessions/SessionInboxLiveTests.cs @@ -0,0 +1,461 @@ +using System.Globalization; +using OpenCode.Sdk.Models; +using OpenCode.Sdk.Tests.Support; +using OpenCode.Sdk.TestSupport; +using OpenCode.Sdk.TestSupport.Ownership; + +namespace OpenCode.Sdk.Tests.Sessions; + +[ClassDataSource(Shared = SharedType.PerTestSession)] +[NotInParallel(ParallelConstraintKeys.ServerProcess)] +public sealed class SessionInboxLiveTests(SimulatedDriveServerFixture server) +{ + private const string ChatCompletionsUrl = "https://api.openai.com/v1/chat/completions"; + private const string ModelId = "sim-model"; + private const string ProviderId = "sim"; + private static readonly TimeSpan CleanupTimeout = TimeSpan.FromSeconds(15); + private static readonly TimeSpan RequestWait = TimeSpan.FromSeconds(60); + + [Test] + [Timeout(180_000)] + public async Task DeleteInboxCancelAsync_Should_Remove_A_Queued_Prompt_While_PostWaitAsync_Completes_The_Held_Turn( + CancellationToken cancellationToken) + { + using var workspace = server.CreateWorkspace(); + using var client = server.CreateClient(new LocationSelector { Directory = workspace.Path }); + var sessionId = await CreateOwnedSessionAsync(client, workspace.Path, "session-inbox-cancel", cancellationToken); + var session = client.Sessions.GetSessionClient(sessionId); + var cleanup = CreateCleanup(session, sessionId); + var suffix = Guid.NewGuid().ToString("N"); + var promptA = "task-seven cancel active prompt " + suffix; + var replyA = "Task seven cancel active reply " + suffix + "."; + var inboxId = "msg_task7_cancel_" + suffix; + var promptB = "task-seven queued cancellation prompt " + suffix; + OwnedDriveInvocation? invocationA = null; + Exception? primaryFailure = null; + + try + { + _ = await session.PostPromptAsync( + new SessionPromptPostRequest { Text = promptA }, + cancellationToken: cancellationToken); + invocationA = new OwnedDriveInvocation( + server.Controller, + await server.Controller.WaitForRequestAsync(RequestWait)); + + await RequireSimulatedInvocationAsync(invocationA.Invocation); + + var active = await client.Sessions.GetActiveAsync(cancellationToken: cancellationToken); + await Assert.That(active.Status).IsEqualTo(200); + await Assert.That(active.IsError).IsFalse(); + await Assert.That(active.Active.ContainsKey(sessionId)).IsTrue(); + await Assert.That(active.Active[sessionId].Type).IsEqualTo("running"); + + var admittedB = await session.PostPromptAsync( + new SessionPromptPostRequest + { + Id = inboxId, + Text = promptB, + Delivery = SessionInboxDelivery.Queue, + Resume = false, + }, + cancellationToken: cancellationToken); + await AssertPromptAsync(admittedB, sessionId, inboxId, promptB, SessionInboxDelivery.Queue); + var pending = await session.ListInboxAsync(cancellationToken: cancellationToken); + await AssertPendingPromptAsync(pending, sessionId, inboxId, promptB, SessionInboxDelivery.Queue); + + var wait = session.PostWaitAsync(cancellationToken: cancellationToken); + + var cancelled = await session.DeleteInboxCancelAsync(inboxId, cancellationToken: cancellationToken); + await AssertNoContentAsync(cancelled); + var conflict = await session.DeleteInboxCancelAsync( + inboxId, + OpenCodeRequestOptions.NoThrow, + cancellationToken); + await AssertConflictAsync(conflict, inboxId); + var afterCancellation = await session.ListInboxAsync(cancellationToken: cancellationToken); + await AssertInboxAbsentAsync(afterCancellation, inboxId); + await Assert.That(await server.Controller.PendingCountAsync()).IsEqualTo(1); + + await invocationA.ChunkTextAsync(replyA); + await invocationA.FinishAsync(); + var waited = await wait; + await AssertNoContentAsync(waited); + + var log = await ReadFiniteLogAsync(session, cancellationToken); + await AssertCancellationLogAsync(log, sessionId, inboxId, promptB, replyA); + var finalInbox = await session.ListInboxAsync(cancellationToken: cancellationToken); + await AssertInboxAbsentAsync(finalInbox, inboxId); + + Console.WriteLine( + "session-inbox-cancel-live: active=running enqueue=queue cancel=204 conflict=409 wait=" + + Number(waited.Status)); + } + catch (Exception exception) + { + primaryFailure = exception; + } + finally + { + await CompleteCleanupAsync(cleanup, primaryFailure, invocationA); + } + } + + [Test] + [Timeout(180_000)] + public async Task PostInboxSteerAsync_Should_Change_And_Deliver_A_Queued_Prompt_After_The_Active_Step( + CancellationToken cancellationToken) + { + using var workspace = server.CreateWorkspace(); + using var client = server.CreateClient(new LocationSelector { Directory = workspace.Path }); + var sessionId = await CreateOwnedSessionAsync(client, workspace.Path, "session-inbox-steer", cancellationToken); + var session = client.Sessions.GetSessionClient(sessionId); + var cleanup = CreateCleanup(session, sessionId); + var suffix = Guid.NewGuid().ToString("N"); + var promptA = "task-seven steer active prompt " + suffix; + var replyA = "Task seven steer first reply " + suffix + "."; + var inboxId = "msg_task7_steer_" + suffix; + var promptB = "task-seven queued steer prompt " + suffix; + var replyB = "Task seven steer second reply " + suffix + "."; + OwnedDriveInvocation? invocationA = null; + OwnedDriveInvocation? invocationB = null; + Exception? primaryFailure = null; + + try + { + _ = await session.PostPromptAsync( + new SessionPromptPostRequest { Text = promptA }, + cancellationToken: cancellationToken); + invocationA = new OwnedDriveInvocation( + server.Controller, + await server.Controller.WaitForRequestAsync(RequestWait)); + + await RequireSimulatedInvocationAsync(invocationA.Invocation); + + var admittedB = await session.PostPromptAsync( + new SessionPromptPostRequest + { + Id = inboxId, + Text = promptB, + Delivery = SessionInboxDelivery.Queue, + Resume = false, + }, + cancellationToken: cancellationToken); + await AssertPromptAsync(admittedB, sessionId, inboxId, promptB, SessionInboxDelivery.Queue); + var queued = await session.ListInboxAsync(cancellationToken: cancellationToken); + await AssertPendingPromptAsync(queued, sessionId, inboxId, promptB, SessionInboxDelivery.Queue); + + var steered = await session.PostInboxSteerAsync(inboxId, cancellationToken: cancellationToken); + await AssertNoContentAsync(steered); + var changed = await session.ListInboxAsync(cancellationToken: cancellationToken); + await AssertPendingPromptAsync(changed, sessionId, inboxId, promptB, SessionInboxDelivery.Steer); + var conflict = await session.PostInboxSteerAsync( + inboxId, + OpenCodeRequestOptions.NoThrow, + cancellationToken); + await AssertConflictAsync(conflict, inboxId); + await Assert.That(await server.Controller.PendingCountAsync()).IsEqualTo(1); + + await invocationA.ChunkTextAsync(replyA); + await invocationA.FinishAsync(); + invocationB = new OwnedDriveInvocation( + server.Controller, + await server.Controller.WaitForRequestAsync(RequestWait)); + + await RequireSimulatedInvocationAsync(invocationB.Invocation); + await invocationB.ChunkTextAsync(replyB); + await invocationB.FinishAsync(); + var waited = await session.PostWaitAsync(cancellationToken: cancellationToken); + await AssertNoContentAsync(waited); + + var log = await ReadFiniteLogAsync(session, cancellationToken); + await AssertSteerLogAsync(log, sessionId, inboxId, promptB, replyA, replyB); + var finalInbox = await session.ListInboxAsync(cancellationToken: cancellationToken); + await AssertInboxAbsentAsync(finalInbox, inboxId); + + Console.WriteLine( + "session-inbox-steer-live: enqueue=queue change=steer delivered=true conflict=409 wait=" + + Number(waited.Status)); + } + catch (Exception exception) + { + primaryFailure = exception; + } + finally + { + await CompleteCleanupAsync(cleanup, primaryFailure, invocationA, invocationB); + } + } + + private static async Task CreateOwnedSessionAsync( + OpenCodeClient client, + string directory, + string title, + CancellationToken cancellationToken) + { + var response = await client.Sessions.CreateSessionAsync( + new SessionCreateRequest + { + Title = title, + Location = new LocationRef { Directory = directory }, + Model = new ModelRef { Id = ModelId, ProviderId = ProviderId }, + }, + cancellationToken: cancellationToken); + return response.Session.Id; + } + + private static OwnedCleanup CreateCleanup(SessionClient session, string sessionId) + { + var cleanup = new OwnedCleanup(CleanupTimeout); + cleanup.Own("session interrupt", token => CleanupInterruptAsync(session, sessionId, token)); + cleanup.Own("session wait", token => CleanupWaitAsync(session, sessionId, token)); + cleanup.Own("session removal", token => CleanupRemovalAsync(session, sessionId, token)); + return cleanup; + } + + private static async Task CleanupInterruptAsync( + SessionClient session, + string sessionId, + CancellationToken cancellationToken) + { + var response = await session.PostInterruptAsync( + requestOptions: OpenCodeRequestOptions.NoThrow, + cancellationToken: cancellationToken); + RequireCleanupResponse(response, sessionId, "interrupt", 200); + } + + private static async Task CleanupWaitAsync( + SessionClient session, + string sessionId, + CancellationToken cancellationToken) + { + var response = await session.PostWaitAsync(OpenCodeRequestOptions.NoThrow, cancellationToken); + RequireCleanupResponse(response, sessionId, "wait", 204); + } + + private static async Task CleanupRemovalAsync( + SessionClient session, + string sessionId, + CancellationToken cancellationToken) + { + var response = await session.RemoveSessionAsync(OpenCodeRequestOptions.NoThrow, cancellationToken); + RequireCleanupResponse(response, sessionId, "removal", 204); + } + + private static void RequireCleanupResponse( + OpenCodeResponse response, + string sessionId, + string operation, + int successStatus) + { + if ((response.Status == successStatus && !response.IsError) + || (response is { Status: 404, Error: SessionNotFoundError missing } + && missing.SessionId == sessionId)) + { + return; + } + + throw new InvalidOperationException( + "Owned session " + operation + " returned status " + Number(response.Status) + + ", error " + response.Error?.GetType().Name + ", body " + response.RawBody + "."); + } + + private static async Task CompleteCleanupAsync( + OwnedCleanup cleanup, + Exception? primaryFailure, + params OwnedDriveInvocation?[] invocations) + { + Exception cleanupFailure; + try + { + await cleanup.CompleteAsync(primaryFailure); + return; + } + catch (Exception exception) + { + cleanupFailure = exception; + if (cleanup.OperationFailures.Count == 0) + { + throw; + } + } + + var driveCleanup = new OwnedCleanup(CleanupTimeout); + foreach (var invocation in invocations.Where(invocation => invocation is { IsFinished: false })) + { + driveCleanup.Own("drive invocation " + invocation!.Invocation.Id, invocation.DisconnectAsync); + } + + await driveCleanup.CompleteAsync(cleanupFailure); + } + + private static async Task> ReadFiniteLogAsync( + SessionClient session, + CancellationToken cancellationToken) + { + var transcript = new SessionLogTranscript(session); + return await transcript.ReadToEndAsync( + new SessionLogRequest { Follow = QueryBoolean.False }, + cancellationToken); + } + + private static async Task AssertPromptAsync( + SessionPromptPostResponse response, + string sessionId, + string inboxId, + string text, + SessionInboxDelivery delivery) + { + await Assert.That(response.Status).IsEqualTo(200); + await Assert.That(response.IsError).IsFalse(); + await Assert.That(response.Prompt.Id).IsEqualTo(inboxId); + await Assert.That(response.Prompt.SessionId).IsEqualTo(sessionId); + await Assert.That(response.Prompt.Payload.Text).IsEqualTo(text); + await Assert.That(response.Prompt.Delivery).IsEqualTo(delivery); + } + + private static async Task AssertPendingPromptAsync( + SessionInboxListResponse response, + string sessionId, + string inboxId, + string text, + SessionInboxDelivery delivery) + { + await Assert.That(response.Status).IsEqualTo(200); + await Assert.That(response.IsError).IsFalse(); + var rows = response.Inbox.OfType() + .Where(item => item.Id == inboxId) + .ToList(); + await Assert.That(rows).Count().IsEqualTo(1); + await Assert.That(rows[0].SessionId).IsEqualTo(sessionId); + await Assert.That(rows[0].Payload.Text).IsEqualTo(text); + await Assert.That(rows[0].Delivery).IsEqualTo(delivery); + } + + private static async Task AssertInboxAbsentAsync(SessionInboxListResponse response, string inboxId) + { + await Assert.That(response.Status).IsEqualTo(200); + await Assert.That(response.IsError).IsFalse(); + await Assert.That(response.Inbox.OfType().Any(item => item.Id == inboxId)).IsFalse(); + } + + private static async Task AssertConflictAsync(OpenCodeResponse response, string inboxId) + { + await Assert.That(response.Status).IsEqualTo(409); + await Assert.That(response.Error).IsTypeOf(); + var conflict = response.Error as ConflictError; + await Assert.That(conflict?.Tag).IsEqualTo("ConflictError"); + await Assert.That(conflict?.Resource).IsEqualTo(inboxId); + await Assert.That(conflict?.Message).Contains(inboxId); + } + + private static async Task AssertNoContentAsync(OpenCodeResponse response) + { + await Assert.That(response.Status).IsEqualTo(204); + await Assert.That(response.IsError).IsFalse(); + } + + private static async Task AssertCancellationLogAsync( + IReadOnlyList items, + string sessionId, + string inboxId, + string prompt, + string reply) + { + var enqueued = FindIndex(items, 0, item => + item is SessionInboxEnqueued { Data: var data } + && data.SessionId == sessionId + && data.InboxId == inboxId + && data.Item is SessionInboxItemUser user + && user.Delivery == SessionInboxDelivery.Queue + && user.Payload.Text == prompt); + var cancelled = FindIndex(items, enqueued + 1, item => + item is SessionInboxCancelled { Data: var data } + && data.SessionId == sessionId + && data.InboxId == inboxId); + var textEnded = FindIndex(items, cancelled + 1, item => + item is SessionTextEnded { Data: var data } + && data.SessionId == sessionId + && data.Text == reply); + var succeeded = FindIndex(items, textEnded + 1, item => + item is SessionExecutionSucceeded { Data.SessionId: var observed } + && observed == sessionId); + + await Assert.That(enqueued).IsGreaterThanOrEqualTo(0); + await Assert.That(cancelled).IsGreaterThan(enqueued); + await Assert.That(textEnded).IsGreaterThan(cancelled); + await Assert.That(succeeded).IsGreaterThan(textEnded); + await Assert.That(items.OfType().Any(item => + item.Data.SessionId == sessionId && item.Data.InboxId == inboxId)).IsFalse(); + } + + private static async Task AssertSteerLogAsync( + IReadOnlyList items, + string sessionId, + string inboxId, + string prompt, + string replyA, + string replyB) + { + var enqueued = FindIndex(items, 0, item => + item is SessionInboxEnqueued { Data: var data } + && data.SessionId == sessionId + && data.InboxId == inboxId + && data.Item is SessionInboxItemUser user + && user.Delivery == SessionInboxDelivery.Queue + && user.Payload.Text == prompt); + var changed = FindIndex(items, enqueued + 1, item => + item is SessionInboxDeliveryChanged { Data: var data } + && data.SessionId == sessionId + && data.InboxId == inboxId + && data.Delivery == SessionInboxDelivery.Steer); + var delivered = FindIndex(items, changed + 1, item => + item is SessionInboxDelivered { Data: var data } + && data.SessionId == sessionId + && data.InboxId == inboxId); + var textAEnded = FindIndex(items, 0, item => + item is SessionTextEnded { Data: var data } + && data.SessionId == sessionId + && data.Text == replyA); + var textBEnded = FindIndex(items, delivered + 1, item => + item is SessionTextEnded { Data: var data } + && data.SessionId == sessionId + && data.Text == replyB); + var succeeded = FindIndex(items, textBEnded + 1, item => + item is SessionExecutionSucceeded { Data.SessionId: var observed } + && observed == sessionId); + + await Assert.That(enqueued).IsGreaterThanOrEqualTo(0); + await Assert.That(changed).IsGreaterThan(enqueued); + await Assert.That(delivered).IsGreaterThan(changed); + await Assert.That(textAEnded).IsGreaterThanOrEqualTo(0); + await Assert.That(textBEnded).IsGreaterThan(textAEnded); + await Assert.That(textBEnded).IsGreaterThan(delivered); + await Assert.That(succeeded).IsGreaterThan(textBEnded); + } + + private static int FindIndex( + IReadOnlyList items, + int start, + Func predicate) + { + for (var index = Math.Max(0, start); index < items.Count; index++) + { + if (predicate(items[index])) + { + return index; + } + } + + return -1; + } + + private static async Task RequireSimulatedInvocationAsync(DriveInvocation invocation) + { + await Assert.That(invocation.Model).IsEqualTo(ModelId); + await Assert.That(invocation.Url).IsEqualTo(ChatCompletionsUrl); + } + + private static string Number(int value) => + value.ToString(CultureInfo.InvariantCulture); +} diff --git a/tests/OpenCode.Sdk.Tests/Support/OwnedDriveInvocation.cs b/tests/OpenCode.Sdk.Tests/Support/OwnedDriveInvocation.cs new file mode 100644 index 00000000..3c0cfd4c --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Support/OwnedDriveInvocation.cs @@ -0,0 +1,40 @@ +using OpenCode.Sdk.TestSupport; + +namespace OpenCode.Sdk.Tests.Support; + +internal sealed class OwnedDriveInvocation +{ + private readonly DriveController _controller; + + public OwnedDriveInvocation(DriveController controller, DriveInvocation invocation) + { + ArgumentNullException.ThrowIfNull(controller); + ArgumentNullException.ThrowIfNull(invocation); + _controller = controller; + Invocation = invocation; + } + + public DriveInvocation Invocation { get; } + + public bool IsFinished { get; private set; } + + public Task ChunkTextAsync(string text) => + _controller.ChunkTextAsync(Invocation.Id, text); + + public async Task FinishAsync() + { + await _controller.FinishAsync(Invocation.Id); + IsFinished = true; + } + + public async Task DisconnectAsync(CancellationToken _) + { + if (IsFinished) + { + return; + } + + await _controller.DisconnectAsync(Invocation.Id); + IsFinished = true; + } +} From 8de8de1cb2ce7b43151b1e163986d4ea7b78aa5e Mon Sep 17 00:00:00 2001 From: "denizirgin@gmail.com" Date: Mon, 7 Sep 2026 00:35:32 +0300 Subject: [PATCH 31/62] test: repair session inventory live proof --- .../Sessions/SessionInboxLiveTests.cs | 107 +---------- .../Sessions/SessionsClientLiveTests.cs | 46 +---- .../Support/OwnedDriveInvocation.cs | 30 ++- .../Support/OwnedSessionInboxCleanup.cs | 172 ++++++++++++++++++ .../Support/OwnedSessionInboxCleanupTests.cs | 131 +++++++++++++ .../Support/SessionMessageEvidence.cs | 3 + .../SessionMessageEvidenceProjector.cs | 44 +++++ 7 files changed, 388 insertions(+), 145 deletions(-) create mode 100644 tests/OpenCode.Sdk.Tests/Support/OwnedSessionInboxCleanup.cs create mode 100644 tests/OpenCode.Sdk.Tests/Support/OwnedSessionInboxCleanupTests.cs create mode 100644 tests/OpenCode.Sdk.Tests/Support/SessionMessageEvidence.cs create mode 100644 tests/OpenCode.Sdk.Tests/Support/SessionMessageEvidenceProjector.cs diff --git a/tests/OpenCode.Sdk.Tests/Sessions/SessionInboxLiveTests.cs b/tests/OpenCode.Sdk.Tests/Sessions/SessionInboxLiveTests.cs index 432d94b4..256d0a45 100644 --- a/tests/OpenCode.Sdk.Tests/Sessions/SessionInboxLiveTests.cs +++ b/tests/OpenCode.Sdk.Tests/Sessions/SessionInboxLiveTests.cs @@ -2,7 +2,6 @@ using OpenCode.Sdk.Models; using OpenCode.Sdk.Tests.Support; using OpenCode.Sdk.TestSupport; -using OpenCode.Sdk.TestSupport.Ownership; namespace OpenCode.Sdk.Tests.Sessions; @@ -25,13 +24,12 @@ public async Task DeleteInboxCancelAsync_Should_Remove_A_Queued_Prompt_While_Pos using var client = server.CreateClient(new LocationSelector { Directory = workspace.Path }); var sessionId = await CreateOwnedSessionAsync(client, workspace.Path, "session-inbox-cancel", cancellationToken); var session = client.Sessions.GetSessionClient(sessionId); - var cleanup = CreateCleanup(session, sessionId); + var cleanup = new OwnedSessionInboxCleanup(session, sessionId, server.Controller, CleanupTimeout); var suffix = Guid.NewGuid().ToString("N"); var promptA = "task-seven cancel active prompt " + suffix; var replyA = "Task seven cancel active reply " + suffix + "."; var inboxId = "msg_task7_cancel_" + suffix; var promptB = "task-seven queued cancellation prompt " + suffix; - OwnedDriveInvocation? invocationA = null; Exception? primaryFailure = null; try @@ -39,8 +37,7 @@ public async Task DeleteInboxCancelAsync_Should_Remove_A_Queued_Prompt_While_Pos _ = await session.PostPromptAsync( new SessionPromptPostRequest { Text = promptA }, cancellationToken: cancellationToken); - invocationA = new OwnedDriveInvocation( - server.Controller, + var invocationA = cleanup.RetainInvocation( await server.Controller.WaitForRequestAsync(RequestWait)); await RequireSimulatedInvocationAsync(invocationA.Invocation); @@ -65,6 +62,7 @@ public async Task DeleteInboxCancelAsync_Should_Remove_A_Queued_Prompt_While_Pos await AssertPendingPromptAsync(pending, sessionId, inboxId, promptB, SessionInboxDelivery.Queue); var wait = session.PostWaitAsync(cancellationToken: cancellationToken); + cleanup.RetainWait(wait); var cancelled = await session.DeleteInboxCancelAsync(inboxId, cancellationToken: cancellationToken); await AssertNoContentAsync(cancelled); @@ -97,7 +95,7 @@ public async Task DeleteInboxCancelAsync_Should_Remove_A_Queued_Prompt_While_Pos } finally { - await CompleteCleanupAsync(cleanup, primaryFailure, invocationA); + await cleanup.CompleteAsync(primaryFailure); } } @@ -110,15 +108,13 @@ public async Task PostInboxSteerAsync_Should_Change_And_Deliver_A_Queued_Prompt_ using var client = server.CreateClient(new LocationSelector { Directory = workspace.Path }); var sessionId = await CreateOwnedSessionAsync(client, workspace.Path, "session-inbox-steer", cancellationToken); var session = client.Sessions.GetSessionClient(sessionId); - var cleanup = CreateCleanup(session, sessionId); + var cleanup = new OwnedSessionInboxCleanup(session, sessionId, server.Controller, CleanupTimeout); var suffix = Guid.NewGuid().ToString("N"); var promptA = "task-seven steer active prompt " + suffix; var replyA = "Task seven steer first reply " + suffix + "."; var inboxId = "msg_task7_steer_" + suffix; var promptB = "task-seven queued steer prompt " + suffix; var replyB = "Task seven steer second reply " + suffix + "."; - OwnedDriveInvocation? invocationA = null; - OwnedDriveInvocation? invocationB = null; Exception? primaryFailure = null; try @@ -126,8 +122,7 @@ public async Task PostInboxSteerAsync_Should_Change_And_Deliver_A_Queued_Prompt_ _ = await session.PostPromptAsync( new SessionPromptPostRequest { Text = promptA }, cancellationToken: cancellationToken); - invocationA = new OwnedDriveInvocation( - server.Controller, + var invocationA = cleanup.RetainInvocation( await server.Controller.WaitForRequestAsync(RequestWait)); await RequireSimulatedInvocationAsync(invocationA.Invocation); @@ -158,8 +153,7 @@ public async Task PostInboxSteerAsync_Should_Change_And_Deliver_A_Queued_Prompt_ await invocationA.ChunkTextAsync(replyA); await invocationA.FinishAsync(); - invocationB = new OwnedDriveInvocation( - server.Controller, + var invocationB = cleanup.RetainInvocation( await server.Controller.WaitForRequestAsync(RequestWait)); await RequireSimulatedInvocationAsync(invocationB.Invocation); @@ -183,7 +177,7 @@ public async Task PostInboxSteerAsync_Should_Change_And_Deliver_A_Queued_Prompt_ } finally { - await CompleteCleanupAsync(cleanup, primaryFailure, invocationA, invocationB); + await cleanup.CompleteAsync(primaryFailure); } } @@ -204,91 +198,6 @@ private static async Task CreateOwnedSessionAsync( return response.Session.Id; } - private static OwnedCleanup CreateCleanup(SessionClient session, string sessionId) - { - var cleanup = new OwnedCleanup(CleanupTimeout); - cleanup.Own("session interrupt", token => CleanupInterruptAsync(session, sessionId, token)); - cleanup.Own("session wait", token => CleanupWaitAsync(session, sessionId, token)); - cleanup.Own("session removal", token => CleanupRemovalAsync(session, sessionId, token)); - return cleanup; - } - - private static async Task CleanupInterruptAsync( - SessionClient session, - string sessionId, - CancellationToken cancellationToken) - { - var response = await session.PostInterruptAsync( - requestOptions: OpenCodeRequestOptions.NoThrow, - cancellationToken: cancellationToken); - RequireCleanupResponse(response, sessionId, "interrupt", 200); - } - - private static async Task CleanupWaitAsync( - SessionClient session, - string sessionId, - CancellationToken cancellationToken) - { - var response = await session.PostWaitAsync(OpenCodeRequestOptions.NoThrow, cancellationToken); - RequireCleanupResponse(response, sessionId, "wait", 204); - } - - private static async Task CleanupRemovalAsync( - SessionClient session, - string sessionId, - CancellationToken cancellationToken) - { - var response = await session.RemoveSessionAsync(OpenCodeRequestOptions.NoThrow, cancellationToken); - RequireCleanupResponse(response, sessionId, "removal", 204); - } - - private static void RequireCleanupResponse( - OpenCodeResponse response, - string sessionId, - string operation, - int successStatus) - { - if ((response.Status == successStatus && !response.IsError) - || (response is { Status: 404, Error: SessionNotFoundError missing } - && missing.SessionId == sessionId)) - { - return; - } - - throw new InvalidOperationException( - "Owned session " + operation + " returned status " + Number(response.Status) + - ", error " + response.Error?.GetType().Name + ", body " + response.RawBody + "."); - } - - private static async Task CompleteCleanupAsync( - OwnedCleanup cleanup, - Exception? primaryFailure, - params OwnedDriveInvocation?[] invocations) - { - Exception cleanupFailure; - try - { - await cleanup.CompleteAsync(primaryFailure); - return; - } - catch (Exception exception) - { - cleanupFailure = exception; - if (cleanup.OperationFailures.Count == 0) - { - throw; - } - } - - var driveCleanup = new OwnedCleanup(CleanupTimeout); - foreach (var invocation in invocations.Where(invocation => invocation is { IsFinished: false })) - { - driveCleanup.Own("drive invocation " + invocation!.Invocation.Id, invocation.DisconnectAsync); - } - - await driveCleanup.CompleteAsync(cleanupFailure); - } - private static async Task> ReadFiniteLogAsync( SessionClient session, CancellationToken cancellationToken) diff --git a/tests/OpenCode.Sdk.Tests/Sessions/SessionsClientLiveTests.cs b/tests/OpenCode.Sdk.Tests/Sessions/SessionsClientLiveTests.cs index 03fc2dcc..0c4f935e 100644 --- a/tests/OpenCode.Sdk.Tests/Sessions/SessionsClientLiveTests.cs +++ b/tests/OpenCode.Sdk.Tests/Sessions/SessionsClientLiveTests.cs @@ -1,5 +1,4 @@ using System.Globalization; -using System.Text.Json; using OpenCode.Sdk.Models; using OpenCode.Sdk.Tests.Support; using OpenCode.Sdk.TestSupport; @@ -17,6 +16,7 @@ public sealed class SessionsClientLiveTests(SimulatedDriveServerFixture server) private const string StatsPrompt = "task-two statistics prompt"; private const string StatsReply = "Task two statistics reply."; private static readonly TimeSpan CleanupTimeout = TimeSpan.FromSeconds(15); + private readonly SessionMessageEvidenceProjector _messageEvidence = new(); [Test] [Timeout(180_000)] @@ -63,7 +63,7 @@ await AssertDuplicateImportConflictAsync( cancellationToken: cancellationToken); await Assert.That(importedMessages.Status).IsEqualTo(200); await Assert.That(importedMessages.IsError).IsFalse(); - var importedEvidence = ProjectMessages(importedMessages.Messages); + var importedEvidence = _messageEvidence.Project(importedMessages.Messages); await Assert.That(importedEvidence.SequenceEqual(exportedEvidence)).IsTrue(); Console.WriteLine( @@ -101,7 +101,7 @@ public async Task GetStatsAsync_Should_Report_Positive_Owned_Session_Activity( var messages = await session.ListMessagesAsync( new MessageListRequest { Order = ListOrder.Ascending }, cancellationToken: cancellationToken); - var observed = ProjectMessages(messages.Messages); + var observed = _messageEvidence.Project(messages.Messages); await Assert.That(observed.Count).IsGreaterThan(0); var from = observed.Min(message => message.Created) - 1; var to = observed.Max(message => message.Created) + 1; @@ -163,7 +163,7 @@ private static OwnedSessionCleanup CreateCleanup(SessionClient session, string s return cleanup; } - private static async Task> AssertRawExportAsync( + private async Task> AssertRawExportAsync( SessionExportResponse response, string sessionId) { @@ -173,7 +173,7 @@ private static OwnedSessionCleanup CreateCleanup(SessionClient session, string s await Assert.That(response.Export.Messages.Count).IsGreaterThan(0); await Assert.That(response.Export.Messages.OfType().Any()).IsTrue(); await Assert.That(response.Export.Messages.OfType().Any()).IsTrue(); - var evidence = ProjectMessages(response.Export.Messages); + var evidence = _messageEvidence.Project(response.Export.Messages); await Assert.That(evidence.Any(message => message is { Type: "user", Text: TransferPrompt })).IsTrue(); await Assert.That(evidence.Any(message => @@ -207,42 +207,6 @@ private static SessionImportPostRequest CreateImportRequest( Location = new LocationRef { Directory = destination }, }; - private static List<(string Type, string Id, string Text, double Created)> ProjectMessages( - IEnumerable messages) - { - var serializer = new GeneratedJsonSerializer(); - var evidence = new List<(string Type, string Id, string Text, double Created)>(); - foreach (var message in messages) - { - using var document = JsonDocument.Parse(serializer.Serialize(message)); - var root = document.RootElement; - evidence.Add(( - root.GetProperty("type").GetString()!, - root.GetProperty("id").GetString()!, - ReadText(root), - root.GetProperty("time").GetProperty("created").GetDouble())); - } - - return evidence; - } - - private static string ReadText(JsonElement message) - { - if (message.TryGetProperty("text", out var text)) - { - return text.GetString()!; - } - - if (!message.TryGetProperty("content", out var content)) - { - return string.Empty; - } - - return string.Concat(content.EnumerateArray() - .Where(item => item.GetProperty("type").GetString() == "text") - .Select(item => item.GetProperty("text").GetString())); - } - private static async Task RemoveOwnedSessionAsync( SessionClient session, string sessionId, diff --git a/tests/OpenCode.Sdk.Tests/Support/OwnedDriveInvocation.cs b/tests/OpenCode.Sdk.Tests/Support/OwnedDriveInvocation.cs index 3c0cfd4c..d51830ad 100644 --- a/tests/OpenCode.Sdk.Tests/Support/OwnedDriveInvocation.cs +++ b/tests/OpenCode.Sdk.Tests/Support/OwnedDriveInvocation.cs @@ -4,14 +4,34 @@ namespace OpenCode.Sdk.Tests.Support; internal sealed class OwnedDriveInvocation { - private readonly DriveController _controller; + private readonly Func _disconnect; + private readonly Func _finish; + private readonly Func _chunkText; public OwnedDriveInvocation(DriveController controller, DriveInvocation invocation) + : this( + invocation, + (id, text) => controller.ChunkTextAsync(id, text), + id => controller.FinishAsync(id), + controller.DisconnectAsync) { ArgumentNullException.ThrowIfNull(controller); + } + + internal OwnedDriveInvocation( + DriveInvocation invocation, + Func chunkText, + Func finish, + Func disconnect) + { ArgumentNullException.ThrowIfNull(invocation); - _controller = controller; + ArgumentNullException.ThrowIfNull(chunkText); + ArgumentNullException.ThrowIfNull(finish); + ArgumentNullException.ThrowIfNull(disconnect); Invocation = invocation; + _chunkText = chunkText; + _finish = finish; + _disconnect = disconnect; } public DriveInvocation Invocation { get; } @@ -19,11 +39,11 @@ public OwnedDriveInvocation(DriveController controller, DriveInvocation invocati public bool IsFinished { get; private set; } public Task ChunkTextAsync(string text) => - _controller.ChunkTextAsync(Invocation.Id, text); + _chunkText(Invocation.Id, text); public async Task FinishAsync() { - await _controller.FinishAsync(Invocation.Id); + await _finish(Invocation.Id); IsFinished = true; } @@ -34,7 +54,7 @@ public async Task DisconnectAsync(CancellationToken _) return; } - await _controller.DisconnectAsync(Invocation.Id); + await _disconnect(Invocation.Id); IsFinished = true; } } diff --git a/tests/OpenCode.Sdk.Tests/Support/OwnedSessionInboxCleanup.cs b/tests/OpenCode.Sdk.Tests/Support/OwnedSessionInboxCleanup.cs new file mode 100644 index 00000000..ebb88175 --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Support/OwnedSessionInboxCleanup.cs @@ -0,0 +1,172 @@ +using System.Globalization; +using System.Runtime.ExceptionServices; +using OpenCode.Sdk.Models; +using OpenCode.Sdk.TestSupport; +using OpenCode.Sdk.TestSupport.Ownership; +using OpenCode.Sdk.TestSupport.Ownership.Abstractions; + +namespace OpenCode.Sdk.Tests.Support; + +internal sealed class OwnedSessionInboxCleanup +{ + private readonly IOwnedOperationDeadline _deadline; + private readonly OwnedCleanup _interruptCleanup; + private readonly Func _invocationOwner; + private readonly List _invocations = []; + private readonly OwnedCleanup _sessionFinalization; + private readonly TimeSpan _timeout; + private OwnedCleanup? _retainedWaitCleanup; + + public OwnedSessionInboxCleanup( + SessionClient session, + string sessionId, + DriveController controller, + TimeSpan timeout) + : this( + token => InterruptAsync(session, sessionId, token), + token => WaitAsync(session, sessionId, token), + token => RemoveAsync(session, sessionId, token), + invocation => new OwnedDriveInvocation(controller, invocation), + timeout) + { + ArgumentNullException.ThrowIfNull(session); + ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); + ArgumentNullException.ThrowIfNull(controller); + } + + internal OwnedSessionInboxCleanup( + Func interrupt, + Func wait, + Func remove, + Func invocationOwner, + TimeSpan timeout, + IOwnedOperationDeadline? deadline = null) + { + ArgumentNullException.ThrowIfNull(interrupt); + ArgumentNullException.ThrowIfNull(wait); + ArgumentNullException.ThrowIfNull(remove); + ArgumentNullException.ThrowIfNull(invocationOwner); + _timeout = timeout; + _deadline = deadline ?? new OwnedOperationDeadline(); + _invocationOwner = invocationOwner; + _interruptCleanup = new OwnedCleanup(timeout, _deadline); + _interruptCleanup.Own("session interrupt", interrupt); + _sessionFinalization = new OwnedCleanup(timeout, _deadline); + _sessionFinalization.Own("session wait", wait); + _sessionFinalization.Own("session removal", remove); + } + + public OwnedDriveInvocation RetainInvocation(DriveInvocation invocation) + { + ArgumentNullException.ThrowIfNull(invocation); + var owned = _invocationOwner(invocation); + _invocations.Add(owned); + return owned; + } + + public void RetainWait(Task pending) + { + ArgumentNullException.ThrowIfNull(pending); + if (_retainedWaitCleanup is not null) + { + throw new InvalidOperationException("A session wait task is already retained."); + } + + _retainedWaitCleanup = new OwnedCleanup(_timeout, _deadline); + _retainedWaitCleanup.Own("original session wait", pending); + } + + public async Task CompleteAsync(Exception? primaryFailure) + { + var failure = primaryFailure; + var sessionCleanupFailed = false; + + failure = await CompleteStageAsync(_interruptCleanup, failure); + sessionCleanupFailed |= _interruptCleanup.OperationFailures.Count > 0; + + if (_retainedWaitCleanup is { } retainedWaitCleanup) + { + failure = await CompleteStageAsync(retainedWaitCleanup, failure); + sessionCleanupFailed |= retainedWaitCleanup.OperationFailures.Count > 0; + } + + failure = await CompleteStageAsync(_sessionFinalization, failure); + sessionCleanupFailed |= _sessionFinalization.OperationFailures.Count > 0; + + if (sessionCleanupFailed) + { + var driveCleanup = new OwnedCleanup(_timeout, _deadline); + foreach (var invocation in _invocations.Where(invocation => !invocation.IsFinished)) + { + driveCleanup.Own("drive invocation " + invocation.Invocation.Id, invocation.DisconnectAsync); + } + + failure = await CompleteStageAsync(driveCleanup, failure); + } + + if (failure is not null) + { + ExceptionDispatchInfo.Capture(failure).Throw(); + } + } + + private static async Task CompleteStageAsync(OwnedCleanup cleanup, Exception? primaryFailure) + { + try + { + await cleanup.CompleteAsync(primaryFailure); + return null; + } + catch (Exception exception) + { + return exception; + } + } + + private static async Task InterruptAsync( + SessionClient session, + string sessionId, + CancellationToken cancellationToken) + { + var response = await session.PostInterruptAsync( + requestOptions: OpenCodeRequestOptions.NoThrow, + cancellationToken: cancellationToken); + RequireResponse(response, sessionId, "interrupt", 200); + } + + private static async Task WaitAsync( + SessionClient session, + string sessionId, + CancellationToken cancellationToken) + { + var response = await session.PostWaitAsync(OpenCodeRequestOptions.NoThrow, cancellationToken); + RequireResponse(response, sessionId, "wait", 204); + } + + private static async Task RemoveAsync( + SessionClient session, + string sessionId, + CancellationToken cancellationToken) + { + var response = await session.RemoveSessionAsync(OpenCodeRequestOptions.NoThrow, cancellationToken); + RequireResponse(response, sessionId, "removal", 204); + } + + private static void RequireResponse( + OpenCodeResponse response, + string sessionId, + string operation, + int successStatus) + { + if ((response.Status == successStatus && !response.IsError) + || (response is { Status: 404, Error: SessionNotFoundError missing } + && missing.SessionId == sessionId)) + { + return; + } + + throw new InvalidOperationException( + "Owned session " + operation + " returned status " + response.Status.ToString(CultureInfo.InvariantCulture) + + ", error " + response.Error?.GetType().Name + ", body " + response.RawBody + "."); + } +} diff --git a/tests/OpenCode.Sdk.Tests/Support/OwnedSessionInboxCleanupTests.cs b/tests/OpenCode.Sdk.Tests/Support/OwnedSessionInboxCleanupTests.cs new file mode 100644 index 00000000..c04e4ba2 --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Support/OwnedSessionInboxCleanupTests.cs @@ -0,0 +1,131 @@ +using OpenCode.Sdk.TestSupport; +using OpenCode.Sdk.TestSupport.Ownership; + +namespace OpenCode.Sdk.Tests.Support; + +public sealed class OwnedSessionInboxCleanupTests +{ + private static readonly TimeSpan CleanupTimeout = TimeSpan.FromSeconds(1); + + [Test] + public async Task CompleteAsync_Should_Observe_The_Retained_Wait_After_Interrupt_And_Preserve_Its_Fault() + { + var primary = new InvalidOperationException("body failed after wait initiation"); + var waitFailure = new InvalidOperationException("original wait failed"); + var freshWaitFailure = new InvalidOperationException("fresh wait failed"); + var removalFailure = new InvalidOperationException("removal failed"); + var disconnectFailure = new InvalidOperationException("drive disconnect failed"); + var wait = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var operations = new List(); + var cleanup = CreateCleanup( + _ => + { + operations.Add("interrupt"); + wait.SetException(waitFailure); + return Task.CompletedTask; + }, + _ => + { + operations.Add("fresh wait"); + return Task.FromException(freshWaitFailure); + }, + _ => + { + operations.Add("removal"); + return Task.FromException(removalFailure); + }, + invocation => new OwnedDriveInvocation( + invocation, + (_, _) => Task.CompletedTask, + _ => Task.CompletedTask, + _ => + { + operations.Add("drive disconnect"); + return Task.FromException(disconnectFailure); + })); + _ = cleanup.RetainInvocation(new DriveInvocation("unfinished", "https://example.test", "sim")); + cleanup.RetainWait(wait.Task); + + var thrown = await Assert.That(() => cleanup.CompleteAsync(primary)).Throws(); + + await Assert.That(thrown).IsSameReferenceAs(primary); + await Assert.That(primary.Data[OwnedCleanup.FailuresKey]).IsTypeOf(); + var failures = (AggregateException)primary.Data[OwnedCleanup.FailuresKey]!; + await Assert.That(failures.InnerExceptions).Count().IsEqualTo(4); + await Assert.That(failures.InnerExceptions[0]).IsSameReferenceAs(waitFailure); + await Assert.That(failures.InnerExceptions[1]).IsSameReferenceAs(freshWaitFailure); + await Assert.That(failures.InnerExceptions[2]).IsSameReferenceAs(removalFailure); + await Assert.That(failures.InnerExceptions[3]).IsSameReferenceAs(disconnectFailure); + await Assert.That(operations).Count().IsEqualTo(4); + await Assert.That(operations[0]).IsEqualTo("interrupt"); + await Assert.That(operations[1]).IsEqualTo("fresh wait"); + await Assert.That(operations[2]).IsEqualTo("removal"); + await Assert.That(operations[3]).IsEqualTo("drive disconnect"); + } + + [Test] + public async Task CompleteAsync_Should_Disconnect_Only_Unfinished_Invocations_After_Session_Cleanup_Fails() + { + var interruptFailure = new InvalidOperationException("interrupt failed"); + var disconnected = new List(); + var cleanup = CreateCleanup( + _ => Task.FromException(interruptFailure), + _ => Task.CompletedTask, + _ => Task.CompletedTask, + invocation => CreateInvocation(invocation, disconnected)); + var finished = cleanup.RetainInvocation(new DriveInvocation("finished", "https://example.test", "sim")); + _ = cleanup.RetainInvocation(new DriveInvocation("unfinished", "https://example.test", "sim")); + await finished.FinishAsync(); + + var thrown = await Assert.That(() => cleanup.CompleteAsync(null)).Throws(); + + await Assert.That(thrown).IsSameReferenceAs(interruptFailure); + await Assert.That(disconnected).Count().IsEqualTo(1); + await Assert.That(disconnected[0]).IsEqualTo("unfinished"); + } + + [Test] + public async Task CompleteAsync_Should_Not_Disconnect_An_Invocation_For_A_Primary_Failure_Alone() + { + var primary = new InvalidOperationException("body failed"); + var disconnected = new List(); + var cleanup = CreateCleanup( + _ => Task.CompletedTask, + _ => Task.CompletedTask, + _ => Task.CompletedTask, + invocation => CreateInvocation(invocation, disconnected)); + _ = cleanup.RetainInvocation(new DriveInvocation("unfinished", "https://example.test", "sim")); + + var thrown = await Assert.That(() => cleanup.CompleteAsync(primary)).Throws(); + + await Assert.That(thrown).IsSameReferenceAs(primary); + await Assert.That(disconnected).IsEmpty(); + } + + private static OwnedSessionInboxCleanup CreateCleanup( + Func interrupt, + Func wait, + Func remove, + Func? invocationOwner = null) + { + var deadline = new OperationDeadlineScenario().Deadline; + return new OwnedSessionInboxCleanup( + interrupt, + wait, + remove, + invocationOwner ?? (invocation => CreateInvocation(invocation, [])), + CleanupTimeout, + deadline); + } + + private static OwnedDriveInvocation CreateInvocation(DriveInvocation invocation, List disconnected) => + new( + invocation, + (_, _) => Task.CompletedTask, + _ => Task.CompletedTask, + id => + { + disconnected.Add(id); + return Task.CompletedTask; + }); +} diff --git a/tests/OpenCode.Sdk.Tests/Support/SessionMessageEvidence.cs b/tests/OpenCode.Sdk.Tests/Support/SessionMessageEvidence.cs new file mode 100644 index 00000000..4d9e8002 --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Support/SessionMessageEvidence.cs @@ -0,0 +1,3 @@ +namespace OpenCode.Sdk.Tests.Support; + +internal sealed record SessionMessageEvidence(string Type, string Id, string Text, double Created); diff --git a/tests/OpenCode.Sdk.Tests/Support/SessionMessageEvidenceProjector.cs b/tests/OpenCode.Sdk.Tests/Support/SessionMessageEvidenceProjector.cs new file mode 100644 index 00000000..94b2fb90 --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Support/SessionMessageEvidenceProjector.cs @@ -0,0 +1,44 @@ +using System.Text.Json; +using OpenCode.Sdk.Models; + +namespace OpenCode.Sdk.Tests.Support; + +internal sealed class SessionMessageEvidenceProjector +{ + private readonly GeneratedJsonSerializer _serializer = new(); + + public IReadOnlyList Project(IEnumerable messages) + { + ArgumentNullException.ThrowIfNull(messages); + var evidence = new List(); + foreach (var message in messages) + { + using var document = JsonDocument.Parse(_serializer.Serialize(message)); + var root = document.RootElement; + evidence.Add(new SessionMessageEvidence( + root.GetProperty("type").GetString()!, + root.GetProperty("id").GetString()!, + ReadText(root), + root.GetProperty("time").GetProperty("created").GetDouble())); + } + + return evidence; + } + + private static string ReadText(JsonElement message) + { + if (message.TryGetProperty("text", out var text)) + { + return text.GetString()!; + } + + if (!message.TryGetProperty("content", out var content)) + { + return string.Empty; + } + + return string.Concat(content.EnumerateArray() + .Where(item => item.GetProperty("type").GetString() == "text") + .Select(item => item.GetProperty("text").GetString())); + } +} From 08ddae7fdfca05b55c1fd9cfa67547adfcd85f0b Mon Sep 17 00:00:00 2001 From: "denizirgin@gmail.com" Date: Mon, 7 Sep 2026 01:43:29 +0300 Subject: [PATCH 32/62] fix(ci): repair filesystem live prerequisites --- .github/workflows/ci.yml | 49 ++++++ README.md | 6 + .../FileSystem/FileSystemClientLiveTests.cs | 7 +- .../Projects/ProjectsClientLiveTests.cs | 10 +- .../OpenCode.Sdk.Tests/TestWorkspaceTests.cs | 15 ++ tests/Shared/SimulatedDriveServerFixture.cs | 143 ++++++++++++------ tests/Shared/TestWorkspace.cs | 47 +++++- 7 files changed, 217 insertions(+), 60 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9e3cd4dd..de329965 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,6 +63,55 @@ jobs: # The pin's own packageManager row (external/opencode/package.json). bun-version: 1.3.14 + # fs.find uses upstream's ripgrep resolver. Keep the simulated server offline by placing the + # exact resolver version on the job PATH before tests instead of allowing its runtime download. + - name: Provision ripgrep for pinned server + shell: pwsh + run: | + $ErrorActionPreference = "Stop" + $PSNativeCommandUseErrorActionPreference = $true + $version = "15.1.0" + switch ("${{ runner.os }}-${{ runner.arch }}") { + "Windows-X64" { + $archive = "ripgrep-$version-x86_64-pc-windows-msvc.zip" + $sha256 = "124510b94b6baa3380d051fdf4650eaa80a302c876d611e9dba0b2e18d87493a" + } + "Linux-X64" { + $archive = "ripgrep-$version-x86_64-unknown-linux-musl.tar.gz" + $sha256 = "1c9297be4a084eea7ecaedf93eb03d058d6faae29bbc57ecdaf5063921491599" + } + "macOS-ARM64" { + $archive = "ripgrep-$version-aarch64-apple-darwin.tar.gz" + $sha256 = "378e973289176ca0c6054054ee7f631a065874a352bf43f0fa60ef079b6ba715" + } + default { throw "Unsupported ripgrep runner: ${{ runner.os }}-${{ runner.arch }}" } + } + + $download = Join-Path $env:RUNNER_TEMP $archive + Invoke-WebRequest -Uri "https://github.com/BurntSushi/ripgrep/releases/download/$version/$archive" -OutFile $download + $actual = (Get-FileHash -LiteralPath $download -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actual -ne $sha256) { + throw "ripgrep archive digest mismatch: expected $sha256, received $actual" + } + + $toolRoot = Join-Path $env:RUNNER_TEMP "ripgrep-$version-${{ runner.os }}-${{ runner.arch }}" + $null = New-Item -ItemType Directory -Path $toolRoot + if ($archive.EndsWith(".zip", [StringComparison]::Ordinal)) { + Expand-Archive -LiteralPath $download -DestinationPath $toolRoot + } else { + tar -xzf $download -C $toolRoot + } + + $executableName = if ($IsWindows) { "rg.exe" } else { "rg" } + $executables = @(Get-ChildItem -LiteralPath $toolRoot -Recurse -File -Filter $executableName) + if ($executables.Count -ne 1) { + throw "Expected exactly one $executableName in $archive, found $($executables.Count)" + } + + $executable = $executables[0].FullName + Split-Path -Parent $executable | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + & $executable --version + - name: Cache bun install uses: actions/cache@v6 with: diff --git a/README.md b/README.md index fbc6bac1..ef484da9 100644 --- a/README.md +++ b/README.md @@ -355,6 +355,12 @@ dotnet build --configuration Release `external/` holds read-only upstream submodules used as protocol evidence and as the pinned-server test fixture; `--recurse-submodules` is what makes the fixture-backed tests runnable. +Fixture-backed tests also require the Bun version named by `external/opencode/package.json`, the +pinned server dependencies (`bun install --frozen-lockfile --ignore-scripts` from +`external/opencode`), and ripgrep 15.1.0 on `PATH`. The simulated server deliberately denies +unregistered network access, so its `fs.find` test cannot use upstream's fallback download. CI +provisions the matching official ripgrep archive and verifies its SHA-256 digest before use. + ### Sandbox Application [`tests/OpenCode.Sdk.Sandbox`](tests/OpenCode.Sdk.Sandbox) is a committed playground that drives diff --git a/tests/OpenCode.Sdk.Tests/FileSystem/FileSystemClientLiveTests.cs b/tests/OpenCode.Sdk.Tests/FileSystem/FileSystemClientLiveTests.cs index f42b5c8e..af17c3cc 100644 --- a/tests/OpenCode.Sdk.Tests/FileSystem/FileSystemClientLiveTests.cs +++ b/tests/OpenCode.Sdk.Tests/FileSystem/FileSystemClientLiveTests.cs @@ -18,7 +18,8 @@ public async Task FindEntriesAsync_And_ListEntriesAsync_Should_Report_The_Worksp CancellationToken cancellationToken) { using var workspace = server.CreateWorkspace(); - _ = workspace.WriteTextFile(RootFile, "root entry"); + var owner = Guid.NewGuid().ToString("N"); + _ = workspace.WriteTextFile(RootFile, owner); _ = workspace.WriteTextFile(ChildFile, "find target"); using var client = server.CreateClient(new LocationSelector { Directory = workspace.Path }); @@ -33,7 +34,6 @@ public async Task FindEntriesAsync_And_ListEntriesAsync_Should_Report_The_Worksp await Assert.That(found.Status).IsEqualTo(200); await Assert.That(found.IsError).IsFalse(); - await Assert.That(found.Location.Directory).IsEqualTo(workspace.Path); var foundFile = found.Entries.Single(item => NormalizeSeparators(item.Path) == ChildFile); await Assert.That(foundFile.Type).IsEqualTo(FileSystemEntryType.File); @@ -42,7 +42,8 @@ public async Task FindEntriesAsync_And_ListEntriesAsync_Should_Report_The_Worksp await Assert.That(listed.Status).IsEqualTo(200); await Assert.That(listed.IsError).IsFalse(); - await Assert.That(listed.Location.Directory).IsEqualTo(workspace.Path); + await Assert.That(listed.Location.Directory).IsEqualTo(found.Location.Directory); + await Assert.That(workspace.HasTextFile(listed.Location.Directory, RootFile, owner)).IsTrue(); var rootFile = listed.Entries.Single(item => NormalizeSeparators(item.Path) == RootFile); await Assert.That(rootFile.Type).IsEqualTo(FileSystemEntryType.File); var childDirectory = listed.Entries.Single( diff --git a/tests/OpenCode.Sdk.Tests/Projects/ProjectsClientLiveTests.cs b/tests/OpenCode.Sdk.Tests/Projects/ProjectsClientLiveTests.cs index fc6a5f25..17a340d7 100644 --- a/tests/OpenCode.Sdk.Tests/Projects/ProjectsClientLiveTests.cs +++ b/tests/OpenCode.Sdk.Tests/Projects/ProjectsClientLiveTests.cs @@ -7,12 +7,16 @@ namespace OpenCode.Sdk.Tests; [NotInParallel(ParallelConstraintKeys.ServerProcess)] public sealed class ProjectsClientLiveTests(SimulatedDriveServerFixture server) { + private const string OwnerFile = "sdk-live-project-owner.txt"; + [Test] [Timeout(60_000)] public async Task GetCurrentAsync_And_ListProjectsAsync_Should_Report_The_Workspace_Project( CancellationToken cancellationToken) { using var workspace = server.CreateWorkspace(); + var owner = Guid.NewGuid().ToString("N"); + _ = workspace.WriteTextFile(OwnerFile, owner); using var client = server.CreateClient(); var current = await client.Projects.GetCurrentAsync( @@ -25,8 +29,8 @@ public async Task GetCurrentAsync_And_ListProjectsAsync_Should_Report_The_Worksp await Assert.That(current.Status).IsEqualTo(200); await Assert.That(current.IsError).IsFalse(); await Assert.That(string.IsNullOrWhiteSpace(current.Current.Id)).IsFalse(); - await Assert.That(current.Current.Directory).IsEqualTo(workspace.Path); - await Assert.That(current.Current.Canonical).IsEqualTo(workspace.Path); + await Assert.That(current.Current.Canonical).IsEqualTo(current.Current.Directory); + await Assert.That(workspace.HasTextFile(current.Current.Directory, OwnerFile, owner)).IsTrue(); var listed = await client.Projects.ListProjectsAsync(cancellationToken: cancellationToken); @@ -34,7 +38,7 @@ public async Task GetCurrentAsync_And_ListProjectsAsync_Should_Report_The_Worksp await Assert.That(listed.IsError).IsFalse(); var project = listed.Projects.Single(item => item.Id == current.Current.Id); await Assert.That(project.Canonical).IsEqualTo(current.Current.Canonical); - await Assert.That(project.Canonical).IsEqualTo(workspace.Path); + await Assert.That(workspace.HasTextFile(project.Canonical, OwnerFile, owner)).IsTrue(); Console.WriteLine( "projects-live: current=" + Number(current.Status) + diff --git a/tests/OpenCode.Sdk.Tests/TestWorkspaceTests.cs b/tests/OpenCode.Sdk.Tests/TestWorkspaceTests.cs index 8a1b4aa5..0512b174 100644 --- a/tests/OpenCode.Sdk.Tests/TestWorkspaceTests.cs +++ b/tests/OpenCode.Sdk.Tests/TestWorkspaceTests.cs @@ -54,4 +54,19 @@ public async Task WriteTextFile_Should_Write_A_Valid_Nested_Path() using var reader = fileSystem.File.OpenText(expected); await Assert.That(await reader.ReadToEndAsync()).IsEqualTo("owned content"); } + + [Test] + public async Task HasTextFile_Should_Distinguish_The_Owned_Marker_From_Wrong_Or_Missing_Content() + { + var fileSystem = new MockFileSystem(); + var runRoot = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), "test-workspace-marker"); + using var workspace = new TestWorkspace(fileSystem, runRoot); + const string marker = "owned-marker.txt"; + const string owner = "independently-seeded-owner"; + _ = workspace.WriteTextFile(marker, owner); + + await Assert.That(workspace.HasTextFile(workspace.Path, marker, owner)).IsTrue(); + await Assert.That(workspace.HasTextFile(workspace.Path, marker, "another-owner")).IsFalse(); + await Assert.That(workspace.HasTextFile(workspace.Path, "missing-marker.txt", owner)).IsFalse(); + } } diff --git a/tests/Shared/SimulatedDriveServerFixture.cs b/tests/Shared/SimulatedDriveServerFixture.cs index 24c6de01..cce4af64 100644 --- a/tests/Shared/SimulatedDriveServerFixture.cs +++ b/tests/Shared/SimulatedDriveServerFixture.cs @@ -1,4 +1,5 @@ using System.Runtime.ExceptionServices; +using OpenCode.Sdk.TestSupport.Ownership; using Testably.Abstractions; using TUnit.Core.Interfaces; @@ -10,7 +11,7 @@ namespace OpenCode.Sdk.TestSupport; /// attached drive controller. Simulation denies all unregistered outbound network by /// construction (backend/index.ts:29-35), so the workflow runs with no provider credentials. /// -public sealed class SimulatedDriveServerFixture : IAsyncInitializer, IAsyncDisposable +public sealed class SimulatedDriveServerFixture : IAsyncInitializer, IAsyncDisposable, ITestEndEventReceiver { private static readonly TimeSpan ReadinessTimeout = TimeSpan.FromMinutes(3); @@ -30,9 +31,14 @@ public sealed class SimulatedDriveServerFixture : IAsyncInitializer, IAsyncDispo private string? _preReadinessDiagnostic; private TestRunRoot? _runRoot; private bool _retainLogs; + private ServerFailureArtifacts? _artifacts; + private int _disposed; + private readonly List _lateReports = []; public Uri Endpoint => Adapter.Endpoint; + public int Order => 0; + internal DriveController Controller => _controller ?? throw new InvalidOperationException("The fixture has not initialized."); @@ -57,9 +63,10 @@ public async Task InitializeAsync() await _controller.HandshakeAsync(); await _controller.AttachAsync(); } - catch + catch (Exception exception) { _retainLogs = true; + MarkFailure(exception, "fixture initialization", "phase=server startup"); throw; } } @@ -135,72 +142,117 @@ public OpenCodeClient CreateClient(LocationSelector? location = null) => public TestWorkspace CreateWorkspace() => new(_fileSystem, RunRoot.Path); - public async ValueTask DisposeAsync() + internal async Task DrainDiagnosticsAsync(CancellationToken cancellationToken) { - var keep = _retainLogs || - string.Equals( - Environment.GetEnvironmentVariable("OPENCODE_SDK_TESTS_KEEP_LOGS"), - "1", - StringComparison.Ordinal); - var failures = new List(); - try + foreach (var report in _lateReports) + { + await report.WaitForAllAsync(cancellationToken); + } + + if (_adapter?.LateFailures is { } adapterReport) { - if (_controller is not null) + await adapterReport.WaitForAllAsync(cancellationToken); + } + } + + private ServerFailureArtifacts Artifacts => _artifacts ??= new ServerFailureArtifacts( + _fileSystem, new TestResultsDirectory(_fileSystem).Resolve(Environment.GetCommandLineArgs())); + + private void MarkFailure(Exception exception, string test, string details) + { + ArgumentNullException.ThrowIfNull(exception); + _ = Artifacts.Mark(exception, test, details, TestContext.Current?.Id); + } + + public ValueTask OnTestEnd(TestContext context) + { + ArgumentNullException.ThrowIfNull(context); + if (context.Execution.Result?.Exception is { } failure) + { + var path = Artifacts.Mark( + failure, + context.Metadata.TestDetails.ClassType.FullName + "." + context.Metadata.TestName, + "phase=test-body; receiver observes the body result, before later teardown outcomes", + context.Id); + if (Artifacts.Report(failure, context.Id)) { - await _controller.DisposeAsync(); + context.Output.AttachArtifact(path, "Owned simulated server failure diagnostics"); } } - catch (Exception exception) + + return default; + } + + public async ValueTask DisposeAsync() + { + if (Interlocked.Exchange(ref _disposed, 1) is 1) + { + return; + } + + var keep = ShouldRetainLogs; + var recorded = _artifacts?.Failures; + var primary = recorded is { Count: > 0 } ? recorded[0].Exception : null; + var teardown = new OwnedCleanup(TimeSpan.FromSeconds(25)); + if (_controller is not null) { - failures.Add(exception); + teardown.Own("simulation controller teardown", async _ => await _controller.DisposeAsync()); } if (_adapter is not null) { - try + teardown.Own("simulated server teardown", async _ => await _adapter.DisposeAsync()); + teardown.Own("simulated server diagnostic contract", _ => { - await _adapter.DisposeAsync(); - } - catch (Exception exception) - { - failures.Add(exception); - } + EnsureDiagnosticContract(_adapter); + return Task.CompletedTask; + }); + } - CaptureDiagnosticFailure(_adapter, failures); + Exception? failure = null; + try + { + await teardown.CompleteAsync(primary); + } + catch (Exception exception) + { + failure = exception; + } - if (keep && _runRoot is not null) - { - try - { - await _adapter.WriteLogsAsync(_fileSystem, _fileSystem.Path.Combine(_runRoot.Path, "logs")); - Console.WriteLine($"Simulated server logs retained under: {_runRoot.Path}"); - } - catch (Exception exception) - { - failures.Add(exception); - } - } + if (teardown.LateFailures is { } late) + { + _lateReports.Add(late); } - if (!keep) + if (failure is not null || keep) { - _runRoot?.Dispose(); + if (failure is not null && !Artifacts.Failures.Any(item => ReferenceEquals(item.Exception, failure))) + { + _ = Artifacts.Mark(failure, "fixture disposal", "phase=owned-server-teardown"); + } + + var capture = new ServerFailureCapture( + Artifacts, _fileSystem, new OwnedOperationDeadline()); + failure = await capture.CaptureAsync(_adapter, external: false, failure, teardown.OperationFailures); + _lateReports.AddRange(capture.LateReports); + Console.WriteLine("Simulated server diagnostics: " + Artifacts.Directory); } - if (failures.Count is 1) + if (failure is null && !keep) { - ExceptionDispatchInfo.Capture(failures[0]).Throw(); + _runRoot?.Dispose(); } - if (failures.Count > 1) + if (failure is not null && !(_artifacts?.IsReported(failure) ?? false)) { - throw new AggregateException("Multiple simulated server teardown failures occurred.", failures); + ExceptionDispatchInfo.Capture(failure).Throw(); } } - private void CaptureDiagnosticFailure( - CliWrapServerAdapter adapter, - List failures) + private bool ShouldRetainLogs => _retainLogs || _artifacts?.Failures.Count > 0 || string.Equals( + Environment.GetEnvironmentVariable("OPENCODE_SDK_TESTS_KEEP_LOGS"), "1", StringComparison.Ordinal); + + private void EnsureDiagnosticContract(CliWrapServerAdapter adapter) { var missing = new List(); if (_preReadinessDiagnostic is null) @@ -220,9 +272,8 @@ private void CaptureDiagnosticFailure( if (missing.Count > 0) { - failures.Add(new InvalidOperationException( - "The persistent simulation host did not retain diagnostics: " + string.Join(", ", missing) + ".")); - return; + throw new InvalidOperationException( + "The persistent simulation host did not retain diagnostics: " + string.Join(", ", missing) + "."); } Console.WriteLine( diff --git a/tests/Shared/TestWorkspace.cs b/tests/Shared/TestWorkspace.cs index f3dbdea7..a6727ea0 100644 --- a/tests/Shared/TestWorkspace.cs +++ b/tests/Shared/TestWorkspace.cs @@ -25,24 +25,55 @@ public string WriteTextFile(string relativePath, string content) ArgumentException.ThrowIfNullOrWhiteSpace(relativePath); ArgumentNullException.ThrowIfNull(content); + var filePath = ResolvePathBelow(Path, relativePath); + var directory = _fileSystem.Path.GetDirectoryName(filePath)!; + _ = _fileSystem.Directory.CreateDirectory(directory); + _fileSystem.File.WriteAllText(filePath, content); + return filePath; + } + + /// Checks an independently seeded marker through a server-reported directory. + public bool HasTextFile(string directory, string relativePath, string expectedContent) + { + ArgumentException.ThrowIfNullOrWhiteSpace(directory); + ArgumentException.ThrowIfNullOrWhiteSpace(relativePath); + ArgumentNullException.ThrowIfNull(expectedContent); + + try + { + return string.Equals( + _fileSystem.File.ReadAllText(ResolvePathBelow(directory, relativePath)), + expectedContent, + StringComparison.Ordinal); + } + catch (FileNotFoundException) + { + return false; + } + catch (DirectoryNotFoundException) + { + return false; + } + } + + private string ResolvePathBelow(string root, string relativePath) + { if (_fileSystem.Path.IsPathRooted(relativePath)) { - throw new ArgumentException("The path must remain below the workspace.", nameof(relativePath)); + throw new ArgumentException("The path must remain below the supplied directory.", nameof(relativePath)); } - var filePath = _fileSystem.Path.GetFullPath(_fileSystem.Path.Combine(Path, relativePath)); - var workspacePrefix = Path + _fileSystem.Path.DirectorySeparatorChar; + var absoluteRoot = _fileSystem.Path.GetFullPath(root); + var filePath = _fileSystem.Path.GetFullPath(_fileSystem.Path.Combine(absoluteRoot, relativePath)); + var rootPrefix = absoluteRoot + _fileSystem.Path.DirectorySeparatorChar; var comparison = _fileSystem.Path.DirectorySeparatorChar == '\\' ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; - if (!filePath.StartsWith(workspacePrefix, comparison)) + if (!filePath.StartsWith(rootPrefix, comparison)) { - throw new ArgumentException("The path must remain below the workspace.", nameof(relativePath)); + throw new ArgumentException("The path must remain below the supplied directory.", nameof(relativePath)); } - var directory = _fileSystem.Path.GetDirectoryName(filePath)!; - _ = _fileSystem.Directory.CreateDirectory(directory); - _fileSystem.File.WriteAllText(filePath, content); return filePath; } From 57785e710dc924f5c040cf94b9e27fdc6b7bb0c4 Mon Sep 17 00:00:00 2001 From: "denizirgin@gmail.com" Date: Mon, 7 Sep 2026 02:08:35 +0300 Subject: [PATCH 33/62] test: remove unused simulated diagnostic drain --- tests/Shared/SimulatedDriveServerFixture.cs | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/tests/Shared/SimulatedDriveServerFixture.cs b/tests/Shared/SimulatedDriveServerFixture.cs index cce4af64..ce2b853b 100644 --- a/tests/Shared/SimulatedDriveServerFixture.cs +++ b/tests/Shared/SimulatedDriveServerFixture.cs @@ -33,8 +33,6 @@ public sealed class SimulatedDriveServerFixture : IAsyncInitializer, IAsyncDispo private bool _retainLogs; private ServerFailureArtifacts? _artifacts; private int _disposed; - private readonly List _lateReports = []; - public Uri Endpoint => Adapter.Endpoint; public int Order => 0; @@ -142,19 +140,6 @@ public OpenCodeClient CreateClient(LocationSelector? location = null) => public TestWorkspace CreateWorkspace() => new(_fileSystem, RunRoot.Path); - internal async Task DrainDiagnosticsAsync(CancellationToken cancellationToken) - { - foreach (var report in _lateReports) - { - await report.WaitForAllAsync(cancellationToken); - } - - if (_adapter?.LateFailures is { } adapterReport) - { - await adapterReport.WaitForAllAsync(cancellationToken); - } - } - private ServerFailureArtifacts Artifacts => _artifacts ??= new ServerFailureArtifacts( _fileSystem, new TestResultsDirectory(_fileSystem).Resolve(Environment.GetCommandLineArgs())); @@ -219,11 +204,6 @@ public async ValueTask DisposeAsync() failure = exception; } - if (teardown.LateFailures is { } late) - { - _lateReports.Add(late); - } - if (failure is not null || keep) { if (failure is not null && !Artifacts.Failures.Any(item => ReferenceEquals(item.Exception, failure))) @@ -234,7 +214,6 @@ public async ValueTask DisposeAsync() var capture = new ServerFailureCapture( Artifacts, _fileSystem, new OwnedOperationDeadline()); failure = await capture.CaptureAsync(_adapter, external: false, failure, teardown.OperationFailures); - _lateReports.AddRange(capture.LateReports); Console.WriteLine("Simulated server diagnostics: " + Artifacts.Directory); } From d5cba709cb9f75aaa81fccc855a3448acd900b7f Mon Sep 17 00:00:00 2001 From: "denizirgin@gmail.com" Date: Mon, 7 Sep 2026 03:16:22 +0300 Subject: [PATCH 34/62] test: cover live VCS metadata and diffs --- .../GitRepositoryWorkspaceTests.cs | 41 +++++++ .../Vcs/VcsClientLiveTests.cs | 101 ++++++++++++++++++ tests/Shared/Abstractions/IGitProcess.cs | 6 ++ tests/Shared/GitProcess.cs | 94 ++++++++++++++++ tests/Shared/GitRepositoryWorkspace.cs | 97 +++++++++++++++++ tests/Shared/SimulatedDriveServerFixture.cs | 5 + 6 files changed, 344 insertions(+) create mode 100644 tests/OpenCode.Sdk.Tests/GitRepositoryWorkspaceTests.cs create mode 100644 tests/OpenCode.Sdk.Tests/Vcs/VcsClientLiveTests.cs create mode 100644 tests/Shared/Abstractions/IGitProcess.cs create mode 100644 tests/Shared/GitProcess.cs create mode 100644 tests/Shared/GitRepositoryWorkspace.cs diff --git a/tests/OpenCode.Sdk.Tests/GitRepositoryWorkspaceTests.cs b/tests/OpenCode.Sdk.Tests/GitRepositoryWorkspaceTests.cs new file mode 100644 index 00000000..9d9de9a8 --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/GitRepositoryWorkspaceTests.cs @@ -0,0 +1,41 @@ +using System.IO.Abstractions; +using NSubstitute; +using OpenCode.Sdk.TestSupport; +using OpenCode.Sdk.TestSupport.Abstractions; +using Testably.Abstractions.Testing; + +namespace OpenCode.Sdk.Tests; + +public sealed class GitRepositoryWorkspaceTests +{ + [Test] + public async Task CreateAsync_Should_Preserve_Initialization_And_Cleanup_Failures() + { + var cleanupFailure = new InvalidOperationException("Owned workspace cleanup failed."); + var initializationFailure = new InvalidOperationException("Git initialization failed."); + var inner = new MockFileSystem(); + var directory = Substitute.For(); + directory.CreateDirectory(Arg.Any()).Returns(call => + inner.Directory.CreateDirectory(call.Arg()!)); + directory.When(value => value.Delete(Arg.Any(), recursive: true)).Do(_ => throw cleanupFailure); + var fileSystem = Substitute.For(); + fileSystem.Directory.Returns(directory); + fileSystem.File.Returns(inner.File); + fileSystem.Path.Returns(inner.Path); + var gitProcess = Substitute.For(); + gitProcess.RunAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromException(initializationFailure)); + + var caught = await Assert.That(async () => _ = await GitRepositoryWorkspace.CreateAsync( + fileSystem, + gitProcess, + inner.Path.Combine(inner.Path.GetTempPath(), "run"), + CancellationToken.None)).Throws(); + + await Assert.That(ReferenceEquals(caught, initializationFailure)).IsTrue(); + await Assert.That(caught!.Data[GitRepositoryWorkspace.CleanupFailuresKey]).IsTypeOf(); + var cleanup = caught.Data[GitRepositoryWorkspace.CleanupFailuresKey] as AggregateException; + await Assert.That(cleanup!.InnerExceptions.Count).IsEqualTo(1); + await Assert.That(ReferenceEquals(cleanup.InnerExceptions[0], cleanupFailure)).IsTrue(); + } +} diff --git a/tests/OpenCode.Sdk.Tests/Vcs/VcsClientLiveTests.cs b/tests/OpenCode.Sdk.Tests/Vcs/VcsClientLiveTests.cs new file mode 100644 index 00000000..da51626e --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Vcs/VcsClientLiveTests.cs @@ -0,0 +1,101 @@ +using System.Globalization; +using OpenCode.Sdk.Models; +using OpenCode.Sdk.TestSupport; + +namespace OpenCode.Sdk.Tests; + +[ClassDataSource(Shared = SharedType.PerTestSession)] +[NotInParallel(ParallelConstraintKeys.ServerProcess)] +public sealed class VcsClientLiveTests(SimulatedDriveServerFixture server) +{ + [Test] + [Timeout(60_000)] + public async Task GetVcsAsync_GetBranchesAsync_And_GetBaseAsync_Should_Report_The_Initialized_Main_Branch( + CancellationToken cancellationToken) + { + using var repository = await server.CreateGitRepositoryWorkspaceAsync(cancellationToken); + var location = repository.Location; + using var client = server.CreateClient(); + var activation = await client.Plugins.AwaitPluginActivationAsync( + new PluginAwaitActivationPostRequest { Location = location }, + cancellationToken: cancellationToken); + await Assert.That(activation.Status).IsEqualTo(204); + + var vcs = await client.Vcs.GetVcsAsync( + new VcsRequest { Location = location }, cancellationToken: cancellationToken); + await Assert.That(vcs.Status).IsEqualTo(200); + await Assert.That(vcs.IsError).IsFalse(); + await Assert.That(repository.OwnsDirectory(vcs.Location.Directory)).IsTrue(); + await Assert.That(vcs.Vcs.Branch.Current).IsEqualTo("main"); + await Assert.That(vcs.Vcs.Branch.Default).IsEqualTo("main"); + + var branches = await client.Vcs.GetBranchesAsync( + new VcsBranchesRequest { Location = location, Search = "mai", Limit = "1" }, + cancellationToken: cancellationToken); + await Assert.That(branches.Status).IsEqualTo(200); + await Assert.That(branches.IsError).IsFalse(); + await Assert.That(branches.Location.Directory).IsEqualTo(vcs.Location.Directory); + await Assert.That(branches.Branches).IsEquivalentTo(["main"]); + + var reviewBase = await client.Vcs.GetBaseAsync( + new VcsBaseRequest { Location = location }, cancellationToken: cancellationToken); + await Assert.That(reviewBase.Status).IsEqualTo(200); + await Assert.That(reviewBase.IsError).IsFalse(); + await Assert.That(reviewBase.Location.Directory).IsEqualTo(vcs.Location.Directory); + await Assert.That(reviewBase.Base).IsNotNull(); + await Assert.That(reviewBase.Base!.Name).IsEqualTo("main"); + await Assert.That(reviewBase.Base.Ref).IsEqualTo("refs/heads/main"); + await Assert.That(reviewBase.Base.Source).IsEqualTo(VcsBaseSource.Default); + + Console.WriteLine( + "vcs-live: operation=vcs.get+branches+base arm=success mode=metadata status=" + + Number(vcs.Status) + "/" + Number(branches.Status) + "/" + Number(reviewBase.Status)); + } + + [Test] + [Timeout(60_000)] + public async Task GetStatusAsync_And_GetDiffAsync_Should_Report_A_Modified_Tracked_File( + CancellationToken cancellationToken) + { + using var repository = await server.CreateGitRepositoryWorkspaceAsync(cancellationToken); + var location = repository.Location; + using var client = server.CreateClient(); + var activation = await client.Plugins.AwaitPluginActivationAsync( + new PluginAwaitActivationPostRequest { Location = location }, + cancellationToken: cancellationToken); + await Assert.That(activation.Status).IsEqualTo(204); + repository.WriteModifiedTrackedFile(); + + var status = await client.Vcs.GetStatusAsync( + new VcsStatusRequest { Location = location }, cancellationToken: cancellationToken); + await Assert.That(status.Status).IsEqualTo(200); + await Assert.That(status.IsError).IsFalse(); + await Assert.That(repository.OwnsDirectory(status.Location.Directory)).IsTrue(); + var change = status.Changes.Single(); + await Assert.That(change.File).IsEqualTo("tracked.txt"); + await Assert.That(change.Status).IsEqualTo(VcsFileStatusStatus.Modified); + await Assert.That(change.Additions).IsEqualTo(1); + await Assert.That(change.Deletions).IsEqualTo(1); + + var diff = await client.Vcs.GetDiffAsync( + new VcsDiffRequest { Location = location, Mode = VcsMode.Branch, Base = "main", Context = "0" }, + cancellationToken: cancellationToken); + await Assert.That(diff.Status).IsEqualTo(200); + await Assert.That(diff.IsError).IsFalse(); + await Assert.That(diff.Location.Directory).IsEqualTo(status.Location.Directory); + var file = diff.Diffs.Single(); + await Assert.That(file.File).IsEqualTo("tracked.txt"); + await Assert.That(file.Status).IsEqualTo(FileDiffInfoStatus.Modified); + await Assert.That(file.Additions).IsEqualTo(1); + await Assert.That(file.Deletions).IsEqualTo(1); + await Assert.That(string.IsNullOrWhiteSpace(file.Patch)).IsFalse(); + await Assert.That(file.Patch).Contains("-before"); + await Assert.That(file.Patch).Contains("+after"); + + Console.WriteLine( + "vcs-live: operation=vcs.status+diff arm=success mode=branch base=main context=0 status=" + + Number(status.Status) + "/" + Number(diff.Status)); + } + + private static string Number(int value) => value.ToString(CultureInfo.InvariantCulture); +} diff --git a/tests/Shared/Abstractions/IGitProcess.cs b/tests/Shared/Abstractions/IGitProcess.cs new file mode 100644 index 00000000..5dfda579 --- /dev/null +++ b/tests/Shared/Abstractions/IGitProcess.cs @@ -0,0 +1,6 @@ +namespace OpenCode.Sdk.TestSupport.Abstractions; + +internal interface IGitProcess +{ + public Task RunAsync(string workingDirectory, string arguments, CancellationToken cancellationToken); +} diff --git a/tests/Shared/GitProcess.cs b/tests/Shared/GitProcess.cs new file mode 100644 index 00000000..5d76c91a --- /dev/null +++ b/tests/Shared/GitProcess.cs @@ -0,0 +1,94 @@ +using System.Diagnostics; +using System.Globalization; +using System.Runtime.ExceptionServices; +using OpenCode.Sdk.TestSupport.Abstractions; + +namespace OpenCode.Sdk.TestSupport; + +internal sealed class GitProcess : IGitProcess +{ + private const string CleanupFailuresKey = "GitProcess.CleanupFailures"; + private static readonly TimeSpan CleanupTimeout = TimeSpan.FromSeconds(5); + + public async Task RunAsync(string workingDirectory, string arguments, CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(workingDirectory); + ArgumentException.ThrowIfNullOrWhiteSpace(arguments); + + using var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = "git", + Arguments = arguments, + WorkingDirectory = workingDirectory, + CreateNoWindow = true, + RedirectStandardError = true, + RedirectStandardOutput = true, + UseShellExecute = false, + }, + }; + + Start(process, workingDirectory, arguments); + // The readers keep draining while cancellation tears down the owned process. Giving them + // the caller token could stop consumption before the process exits and fill a pipe. + var standardOutput = process.StandardOutput.ReadToEndAsync(CancellationToken.None); + var standardError = process.StandardError.ReadToEndAsync(CancellationToken.None); + try + { + await process.WaitForExitAsync(cancellationToken); + } + catch (OperationCanceledException exception) when (cancellationToken.IsCancellationRequested) + { + await StopAfterCancellationAsync(process, exception); + ExceptionDispatchInfo.Capture(exception).Throw(); + } + + var output = await standardOutput; + var error = await standardError; + if (process.ExitCode != 0) + { + throw new InvalidOperationException( + "git " + arguments + " failed with exit code " + + process.ExitCode.ToString(CultureInfo.InvariantCulture) + " in '" + workingDirectory + "'." + + Environment.NewLine + "stdout:" + Environment.NewLine + output + + Environment.NewLine + "stderr:" + Environment.NewLine + error); + } + } + + private static void Start(Process process, string workingDirectory, string arguments) + { + try + { + if (!process.Start()) + { + throw new InvalidOperationException("The Git process did not start."); + } + } + catch (Exception exception) + { + throw new InvalidOperationException( + $"git {arguments} could not start in '{workingDirectory}'.", exception); + } + } + + private static async Task StopAfterCancellationAsync( + Process process, + OperationCanceledException primary) + { + using var cleanup = new CancellationTokenSource(CleanupTimeout); + try + { + if (!process.HasExited) + { + process.Kill(); + } + + await process.WaitForExitAsync(cleanup.Token); + } + catch (Exception exception) + { + primary.Data[CleanupFailuresKey] = new AggregateException(exception); + } + } +} diff --git a/tests/Shared/GitRepositoryWorkspace.cs b/tests/Shared/GitRepositoryWorkspace.cs new file mode 100644 index 00000000..50811f50 --- /dev/null +++ b/tests/Shared/GitRepositoryWorkspace.cs @@ -0,0 +1,97 @@ +using System.IO.Abstractions; +using OpenCode.Sdk.TestSupport.Abstractions; + +namespace OpenCode.Sdk.TestSupport; + +public sealed class GitRepositoryWorkspace : IDisposable +{ + internal const string CleanupFailuresKey = "GitRepositoryWorkspace.CleanupFailures"; + private const string InitialContent = "before\n"; + private const string ModifiedContent = "after\n"; + private const string OwnerMarker = ".git/opencode-sdk-test-owner.txt"; + private const string TrackedFile = "tracked.txt"; + private const string InitializeArguments = + "-c core.hooksPath=../hooks init --initial-branch=main --template=../template"; + private const string AddArguments = + "-c core.hooksPath=../hooks -c core.autocrlf=false -c core.fsmonitor=false add -- tracked.txt"; + private const string CommitArguments = + "-c core.hooksPath=../hooks -c core.autocrlf=false -c core.fsmonitor=false " + + "-c user.name=OpenCodeSdkTest -c user.email=opencode-sdk@test.invalid " + + "-c commit.gpgSign=false commit --no-verify -m initial"; + + private readonly IFileSystem _fileSystem; + private readonly IGitProcess _gitProcess; + private readonly string _owner = Guid.NewGuid().ToString("N"); + private readonly string _runRoot; + private TestWorkspace? _workspace; + + internal GitRepositoryWorkspace(IFileSystem fileSystem, IGitProcess gitProcess, string runRoot) + { + ArgumentNullException.ThrowIfNull(fileSystem); + ArgumentNullException.ThrowIfNull(gitProcess); + ArgumentException.ThrowIfNullOrWhiteSpace(runRoot); + + _fileSystem = fileSystem; + _gitProcess = gitProcess; + _runRoot = runRoot; + } + + public LocationSelector Location => new() { Directory = RepositoryPath }; + + internal string RepositoryPath => _fileSystem.Path.Combine(Workspace.Path, "repository"); + + private TestWorkspace Workspace => + _workspace ?? throw new InvalidOperationException("The Git repository workspace has not initialized."); + + internal static async Task CreateAsync( + IFileSystem fileSystem, + IGitProcess gitProcess, + string runRoot, + CancellationToken cancellationToken) + { + var workspace = new GitRepositoryWorkspace(fileSystem, gitProcess, runRoot); + try + { + await workspace.InitializeAsync(cancellationToken); + return workspace; + } + catch (Exception initializationFailure) + { + try + { + workspace.Dispose(); + } + catch (Exception cleanupFailure) + { + initializationFailure.Data[CleanupFailuresKey] = new AggregateException(cleanupFailure); + } + + throw; + } + } + + internal async Task InitializeAsync(CancellationToken cancellationToken) + { + if (_workspace is not null) + { + throw new InvalidOperationException("The Git repository workspace has already initialized."); + } + + _workspace = new TestWorkspace(_fileSystem, _runRoot); + _ = _fileSystem.Directory.CreateDirectory(RepositoryPath); + _ = _fileSystem.Directory.CreateDirectory(_fileSystem.Path.Combine(Workspace.Path, "hooks")); + _ = _fileSystem.Directory.CreateDirectory(_fileSystem.Path.Combine(Workspace.Path, "template")); + _ = Workspace.WriteTextFile("repository/" + TrackedFile, InitialContent); + await _gitProcess.RunAsync(RepositoryPath, InitializeArguments, cancellationToken); + _ = Workspace.WriteTextFile("repository/" + OwnerMarker, _owner); + await _gitProcess.RunAsync(RepositoryPath, AddArguments, cancellationToken); + await _gitProcess.RunAsync(RepositoryPath, CommitArguments, cancellationToken); + } + + public bool OwnsDirectory(string directory) => Workspace.HasTextFile(directory, OwnerMarker, _owner); + + public void WriteModifiedTrackedFile() => + _ = Workspace.WriteTextFile("repository/" + TrackedFile, ModifiedContent); + + public void Dispose() => _workspace?.Dispose(); +} diff --git a/tests/Shared/SimulatedDriveServerFixture.cs b/tests/Shared/SimulatedDriveServerFixture.cs index ce2b853b..e7cf93fb 100644 --- a/tests/Shared/SimulatedDriveServerFixture.cs +++ b/tests/Shared/SimulatedDriveServerFixture.cs @@ -1,4 +1,5 @@ using System.Runtime.ExceptionServices; +using OpenCode.Sdk.TestSupport.Abstractions; using OpenCode.Sdk.TestSupport.Ownership; using Testably.Abstractions; using TUnit.Core.Interfaces; @@ -25,6 +26,7 @@ public sealed class SimulatedDriveServerFixture : IAsyncInitializer, IAsyncDispo private static readonly TimeSpan GateTimeout = TimeSpan.FromMinutes(15); private readonly RealFileSystem _fileSystem = new(); + private readonly IGitProcess _gitProcess = new GitProcess(); private CliWrapServerAdapter? _adapter; private DriveController? _controller; private string? _postReadinessDiagnostic; @@ -140,6 +142,9 @@ public OpenCodeClient CreateClient(LocationSelector? location = null) => public TestWorkspace CreateWorkspace() => new(_fileSystem, RunRoot.Path); + public Task CreateGitRepositoryWorkspaceAsync(CancellationToken cancellationToken) => + GitRepositoryWorkspace.CreateAsync(_fileSystem, _gitProcess, RunRoot.Path, cancellationToken); + private ServerFailureArtifacts Artifacts => _artifacts ??= new ServerFailureArtifacts( _fileSystem, new TestResultsDirectory(_fileSystem).Resolve(Environment.GetCommandLineArgs())); From 9eaa6f81cc6e0eb181df9eb6c2ba86673832449d Mon Sep 17 00:00:00 2001 From: "denizirgin@gmail.com" Date: Mon, 7 Sep 2026 03:36:06 +0300 Subject: [PATCH 35/62] test: preserve Git cancellation cleanup failures --- .../GitProcessCancellationTests.cs | 35 +++++++++++++++++++ tests/Shared/GitProcess.cs | 31 ++++------------ tests/Shared/GitProcessCancellation.cs | 31 ++++++++++++++++ 3 files changed, 73 insertions(+), 24 deletions(-) create mode 100644 tests/OpenCode.Sdk.Tests/GitProcessCancellationTests.cs create mode 100644 tests/Shared/GitProcessCancellation.cs diff --git a/tests/OpenCode.Sdk.Tests/GitProcessCancellationTests.cs b/tests/OpenCode.Sdk.Tests/GitProcessCancellationTests.cs new file mode 100644 index 00000000..020f8e9e --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/GitProcessCancellationTests.cs @@ -0,0 +1,35 @@ +using OpenCode.Sdk.TestSupport; +using OpenCode.Sdk.TestSupport.Ownership; + +namespace OpenCode.Sdk.Tests; + +public sealed class GitProcessCancellationTests +{ + [Test] + public async Task StopAsync_Should_Attempt_Exit_Observation_And_Preserve_Every_Failure_When_Interrupt_Fails() + { + var primary = new OperationCanceledException("Git command canceled."); + var interruptFailure = new InvalidOperationException("Git process interruption failed."); + var waitFailure = new IOException("Git process exit observation failed."); + var waitAttempted = false; + var cancellation = new GitProcessCancellation(TimeSpan.FromSeconds(5)); + + var caught = await Assert.That(async () => await cancellation.StopAsync( + () => false, + () => throw interruptFailure, + _ => + { + waitAttempted = true; + return Task.FromException(waitFailure); + }, + primary)).Throws(); + + await Assert.That(ReferenceEquals(caught, primary)).IsTrue(); + await Assert.That(waitAttempted).IsTrue(); + await Assert.That(caught!.Data[OwnedCleanup.FailuresKey]).IsTypeOf(); + var failures = caught.Data[OwnedCleanup.FailuresKey] as AggregateException; + await Assert.That(failures!.InnerExceptions.Count).IsEqualTo(2); + await Assert.That(ReferenceEquals(failures.InnerExceptions[0], interruptFailure)).IsTrue(); + await Assert.That(ReferenceEquals(failures.InnerExceptions[1], waitFailure)).IsTrue(); + } +} diff --git a/tests/Shared/GitProcess.cs b/tests/Shared/GitProcess.cs index 5d76c91a..b566241f 100644 --- a/tests/Shared/GitProcess.cs +++ b/tests/Shared/GitProcess.cs @@ -1,14 +1,13 @@ using System.Diagnostics; using System.Globalization; -using System.Runtime.ExceptionServices; using OpenCode.Sdk.TestSupport.Abstractions; namespace OpenCode.Sdk.TestSupport; internal sealed class GitProcess : IGitProcess { - private const string CleanupFailuresKey = "GitProcess.CleanupFailures"; private static readonly TimeSpan CleanupTimeout = TimeSpan.FromSeconds(5); + private readonly GitProcessCancellation _cancellation = new(CleanupTimeout); public async Task RunAsync(string workingDirectory, string arguments, CancellationToken cancellationToken) { @@ -40,8 +39,12 @@ public async Task RunAsync(string workingDirectory, string arguments, Cancellati } catch (OperationCanceledException exception) when (cancellationToken.IsCancellationRequested) { - await StopAfterCancellationAsync(process, exception); - ExceptionDispatchInfo.Capture(exception).Throw(); + await _cancellation.StopAsync( + () => process.HasExited, + process.Kill, + process.WaitForExitAsync, + exception); + throw; } var output = await standardOutput; @@ -71,24 +74,4 @@ private static void Start(Process process, string workingDirectory, string argum $"git {arguments} could not start in '{workingDirectory}'.", exception); } } - - private static async Task StopAfterCancellationAsync( - Process process, - OperationCanceledException primary) - { - using var cleanup = new CancellationTokenSource(CleanupTimeout); - try - { - if (!process.HasExited) - { - process.Kill(); - } - - await process.WaitForExitAsync(cleanup.Token); - } - catch (Exception exception) - { - primary.Data[CleanupFailuresKey] = new AggregateException(exception); - } - } } diff --git a/tests/Shared/GitProcessCancellation.cs b/tests/Shared/GitProcessCancellation.cs new file mode 100644 index 00000000..29433273 --- /dev/null +++ b/tests/Shared/GitProcessCancellation.cs @@ -0,0 +1,31 @@ +using OpenCode.Sdk.TestSupport.Ownership; + +namespace OpenCode.Sdk.TestSupport; + +internal sealed class GitProcessCancellation(TimeSpan timeout) +{ + public async Task StopAsync( + Func hasExited, + Action interrupt, + Func waitForExit, + OperationCanceledException primary) + { + ArgumentNullException.ThrowIfNull(hasExited); + ArgumentNullException.ThrowIfNull(interrupt); + ArgumentNullException.ThrowIfNull(waitForExit); + ArgumentNullException.ThrowIfNull(primary); + + var cleanup = new OwnedCleanup(timeout); + cleanup.Own("Git process interruption", _ => + { + if (!hasExited()) + { + interrupt(); + } + + return Task.CompletedTask; + }); + cleanup.Own("Git process exit observation", waitForExit); + await cleanup.CompleteAsync(primary); + } +} From 837d4f8bcf7d6614665b7f8172966e3b6ba468e0 Mon Sep 17 00:00:00 2001 From: "denizirgin@gmail.com" Date: Mon, 7 Sep 2026 10:25:38 +0300 Subject: [PATCH 36/62] test: seed owned RPC plugin and guard plugin inventory --- .../Plugins/PluginsClientLiveTests.cs | 83 +++++++++++++++---- tests/Shared/PinnedOpenCodeServerFixture.cs | 17 +++- tests/Shared/Plugins/sdk-test-rpc/index.js | 83 +++++++++++++++++++ tests/Shared/ServerConfigSeed.cs | 33 ++++++++ tests/Shared/TestRpcPlugin.cs | 22 +++++ 5 files changed, 221 insertions(+), 17 deletions(-) create mode 100644 tests/Shared/Plugins/sdk-test-rpc/index.js create mode 100644 tests/Shared/ServerConfigSeed.cs create mode 100644 tests/Shared/TestRpcPlugin.cs diff --git a/tests/OpenCode.Sdk.Tests/Plugins/PluginsClientLiveTests.cs b/tests/OpenCode.Sdk.Tests/Plugins/PluginsClientLiveTests.cs index 4b814ac6..46b496d7 100644 --- a/tests/OpenCode.Sdk.Tests/Plugins/PluginsClientLiveTests.cs +++ b/tests/OpenCode.Sdk.Tests/Plugins/PluginsClientLiveTests.cs @@ -6,11 +6,11 @@ namespace OpenCode.Sdk.Tests; /// /// The plugin family's deterministic arms against the pinned server: activation settles (204), -/// the inventory is the builtin set the pin ships, and plugin.check / plugin.update -/// answer their inventory-only arms - no target (200), an empty target list (204), and a target -/// outside the inventory (400). Every arm here is decided from the server's own inventory before -/// any package is consulted, and the isolated fixture configures no package plugin, so none of -/// these calls can reach a registry. +/// the owned server's inventory contains its known builtins plus the repository-owned local RPC +/// plugin, and plugin.check / plugin.update answer their inventory-only arms - no +/// target (200), an empty target list (204), and a target outside the inventory (400). Every +/// check/update test proves its activated inventory has no package source before it invokes the +/// operation, so none of these calls can reach a registry. /// /// /// Deliberately not covered, because they reach the real npm registry, which no test may touch @@ -50,7 +50,7 @@ public async Task AwaitPluginActivationAsync_Should_Answer_204_Once_Activation_S [Test] [Timeout(60_000)] - public async Task ListPluginsAsync_Should_Report_The_Builtin_Inventory(CancellationToken cancellationToken) + public async Task ListPluginsAsync_Should_Report_The_Expected_Inventory(CancellationToken cancellationToken) { using var client = server.CreateClient(); @@ -64,14 +64,21 @@ public async Task ListPluginsAsync_Should_Report_The_Builtin_Inventory(Cancellat await Assert.That(listed.Status).IsEqualTo(200); await Assert.That(listed.IsError).IsFalse(); await Assert.That(listed.Plugins.Count).IsGreaterThan(0); - // Asserted as "no entry falls outside" rather than "every entry matches" so a failure names - // the offending ids instead of a bare false. - await Assert.That(IdsWhere(listed.Plugins, static plugin => plugin.Source is not PluginSourceBuiltin)).IsEmpty(); - await Assert.That(IdsWhere(listed.Plugins, static plugin => plugin.State is not PluginStateActive)).IsEmpty(); - await Assert.That(listed.Plugins.Select(static plugin => plugin.Id).ToArray()).Contains("opencode.agent"); + + if (server.OwnedRpcPlugin is { } ownedPlugin) + { + await AssertOwnedInventoryAsync(listed.Plugins, ownedPlugin); + } + else + { + await Assert.That(IdsWhere( + listed.Plugins, + static plugin => string.Equals(plugin.Id, TestRpcPlugin.Id, StringComparison.Ordinal))).IsEmpty(); + } Console.WriteLine( - "plugins-live: list status=" + Number(listed.Status) + + "plugins-live: mode=" + (server.IsExternal ? "external" : "owned") + + " list status=" + Number(listed.Status) + " count=" + Number(listed.Plugins.Count) + " ids=" + string.Join(", ", listed.Plugins.Select(static plugin => plugin.Id ?? ""))); } @@ -83,14 +90,14 @@ public async Task CheckPluginUpdatesAsync_Should_Answer_The_Inventory_When_No_Ta { using var client = server.CreateClient(); + var listed = await GetActivatedInventoryAsync(client, cancellationToken); + await Assert.That(IdsWhere(listed, static plugin => plugin.Source is PluginSourcePackage)).IsEmpty(); + var inventory = await client.Plugins.CheckPluginUpdatesAsync(cancellationToken: cancellationToken); await Assert.That(inventory.Status).IsEqualTo(200); await Assert.That(inventory.IsError).IsFalse(); await Assert.That(inventory.Check.Count).IsGreaterThan(0); - // No package-sourced entry is what keeps this arm offline: the handler checks only - // package plugins, so an all-builtin answer is the proof that nothing was resolved. - await Assert.That(IdsWhere(inventory.Check, static plugin => plugin.Source is not PluginSourceBuiltin)).IsEmpty(); Console.WriteLine( "plugins-live: check status=" + Number(inventory.Status) + @@ -106,6 +113,12 @@ public async Task CheckPluginUpdatesAsync_Should_Refuse_A_Target_Outside_The_Inv { using var client = server.CreateClient(); + var listed = await GetActivatedInventoryAsync(client, cancellationToken); + await Assert.That(IdsWhere(listed, static plugin => plugin.Source is PluginSourcePackage)).IsEmpty(); + await Assert.That(IdsWhere( + listed, + static plugin => string.Equals(plugin.Id, AbsentTarget, StringComparison.Ordinal))).IsEmpty(); + var refused = await client.Plugins.CheckPluginUpdatesAsync( new PluginCheckPostRequest { Target = AbsentTarget }, OpenCodeRequestOptions.NoThrow, cancellationToken); @@ -128,6 +141,9 @@ public async Task UpdatePluginsAsync_Should_Answer_204_When_No_Targets_Are_Named { using var client = server.CreateClient(); + var listed = await GetActivatedInventoryAsync(client, cancellationToken); + await Assert.That(IdsWhere(listed, static plugin => plugin.Source is PluginSourcePackage)).IsEmpty(); + var updated = await client.Plugins.UpdatePluginsAsync( new PluginUpdatePostRequest { Targets = [] }, cancellationToken: cancellationToken); @@ -144,6 +160,12 @@ public async Task UpdatePluginsAsync_Should_Refuse_A_Target_Outside_The_Inventor { using var client = server.CreateClient(); + var listed = await GetActivatedInventoryAsync(client, cancellationToken); + await Assert.That(IdsWhere(listed, static plugin => plugin.Source is PluginSourcePackage)).IsEmpty(); + await Assert.That(IdsWhere( + listed, + static plugin => string.Equals(plugin.Id, AbsentTarget, StringComparison.Ordinal))).IsEmpty(); + var refused = await client.Plugins.UpdatePluginsAsync( new PluginUpdatePostRequest { Targets = [AbsentTarget] }, OpenCodeRequestOptions.NoThrow, cancellationToken); @@ -164,6 +186,37 @@ public async Task UpdatePluginsAsync_Should_Refuse_A_Target_Outside_The_Inventor private static string[] IdsWhere(IReadOnlyList plugins, Func predicate) => [.. plugins.Where(predicate).Select(static plugin => plugin.Id ?? "")]; + private static async Task> GetActivatedInventoryAsync( + OpenCodeClient client, + CancellationToken cancellationToken) + { + _ = await client.Plugins.AwaitPluginActivationAsync(cancellationToken: cancellationToken); + var listed = await client.Plugins.ListPluginsAsync(cancellationToken: cancellationToken); + await Assert.That(listed.Status).IsEqualTo(200); + await Assert.That(listed.IsError).IsFalse(); + return listed.Plugins; + } + + private static async Task AssertOwnedInventoryAsync( + IReadOnlyList plugins, + TestRpcPlugin ownedPlugin) + { + await Assert.That(plugins.Select(static plugin => plugin.Id).ToArray()).Contains("opencode.agent"); + await Assert.That(IdsWhere( + plugins, + static plugin => plugin.Source is not PluginSourceBuiltin && + !string.Equals(plugin.Id, TestRpcPlugin.Id, StringComparison.Ordinal))).IsEmpty(); + + var ownedEntries = plugins.Where( + static plugin => string.Equals(plugin.Id, TestRpcPlugin.Id, StringComparison.Ordinal)).ToArray(); + await Assert.That(ownedEntries).Count().IsEqualTo(1); + var owned = ownedEntries.Single(); + await Assert.That(owned.Source).IsTypeOf(); + var local = owned.Source as PluginSourceLocal; + await Assert.That(local?.Path).IsEqualTo(ownedPlugin.EntryPoint); + await Assert.That(owned.State).IsTypeOf(); + } + /// Renders one number for the console line, culture-free. private static string Number(int value) => value.ToString(CultureInfo.InvariantCulture); } diff --git a/tests/Shared/PinnedOpenCodeServerFixture.cs b/tests/Shared/PinnedOpenCodeServerFixture.cs index 1463502e..23f716ed 100644 --- a/tests/Shared/PinnedOpenCodeServerFixture.cs +++ b/tests/Shared/PinnedOpenCodeServerFixture.cs @@ -107,6 +107,8 @@ internal bool IsExternal } } + internal TestRpcPlugin? OwnedRpcPlugin { get; private set; } + internal CliWrapServerAdapter Adapter => _adapter ?? throw new InvalidOperationException("The fixture has not initialized."); @@ -144,6 +146,7 @@ public async Task InitializeAsync() { var pinnedCommand = new PinnedServerCommand(_fileSystem); command = pinnedCommand.Resolve(); + OwnedRpcPlugin = new TestRpcPlugin(_fileSystem, pinnedCommand.RepositoryRoot); // Bun's workspace/tsconfig discovery for the pinned monorepo's JSX packages walks // from the process's working directory, not from the absolute entry-file path (Task @@ -290,8 +293,18 @@ public async ValueTask DisposeAsync() private bool ShouldRetainLogs => _retainLogs || _diagnostics?.RetainLogs is true || string.Equals( Environment.GetEnvironmentVariable("OPENCODE_SDK_TESTS_KEEP_LOGS"), "1", StringComparison.Ordinal); - private Dictionary BuildEnvironment(string runRoot) => - ServerIsolation.Environment(_fileSystem, runRoot); + private Dictionary BuildEnvironment(string runRoot) + { + var environment = ServerIsolation.Environment(_fileSystem, runRoot); + if (OwnedRpcPlugin is { } plugin) + { + environment["OPENCODE_CONFIG_CONTENT"] = new ServerConfigSeed() + .WithPluginDirectory(plugin.Directory) + .Render(); + } + + return environment; + } /// /// Probes the external pair's health through a throw-away client and, once it answers, prints diff --git a/tests/Shared/Plugins/sdk-test-rpc/index.js b/tests/Shared/Plugins/sdk-test-rpc/index.js new file mode 100644 index 00000000..1fd990f9 --- /dev/null +++ b/tests/Shared/Plugins/sdk-test-rpc/index.js @@ -0,0 +1,83 @@ +const nonceSchema = { + type: "object", + properties: { nonce: { type: "string" } }, + required: ["nonce"], + additionalProperties: false, +} + +const echoSchema = { + type: "object", + properties: { + nonce: { type: "string" }, + value: { type: "string" }, + }, + required: ["nonce", "value"], + additionalProperties: false, +} + +const okSchema = { + type: "object", + properties: { ok: { type: "boolean" } }, + required: ["ok"], + additionalProperties: false, +} + +const stringValueSchema = { + type: "object", + properties: { value: { type: "string" } }, + required: ["value"], + additionalProperties: false, +} + +const rejectedDataSchema = { + type: "object", + properties: { + code: { type: "string" }, + nonce: { type: "string" }, + }, + required: ["code", "nonce"], + additionalProperties: false, +} + +export default { + id: "opencode.sdk.test.rpc", + setup: async (host) => { + let registration + registration = await host.rpc.register( + { + id: "opencode.sdk.test.rpc", + methods: { + echo: { input: echoSchema, output: echoSchema }, + emit: { input: nonceSchema, output: nonceSchema }, + declaredError: { + input: nonceSchema, + output: okSchema, + errors: { "sdk.test.rejected": rejectedDataSchema }, + }, + invalidOutput: { input: nonceSchema, output: stringValueSchema }, + internalError: { input: nonceSchema, output: okSchema }, + }, + events: { + observed: { schema: nonceSchema }, + }, + }, + { + echo: async (input) => input, + emit: async (input) => { + await registration.events.emit("observed", { nonce: input.nonce }) + return { nonce: input.nonce } + }, + declaredError: async (input, context) => + context.error( + "sdk.test.rejected", + "SDK test RPC rejected the request.", + { code: "sdk-test-rejected", nonce: input.nonce }, + ), + invalidOutput: async () => ({ value: 42 }), + internalError: async () => { + throw new Error("SDK test RPC internal failure.") + }, + }, + ) + }, +} diff --git a/tests/Shared/ServerConfigSeed.cs b/tests/Shared/ServerConfigSeed.cs new file mode 100644 index 00000000..e36d3de6 --- /dev/null +++ b/tests/Shared/ServerConfigSeed.cs @@ -0,0 +1,33 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization.Metadata; + +namespace OpenCode.Sdk.TestSupport; + +/// Builds the small configuration seed owned by the ordinary pinned server fixture. +internal sealed class ServerConfigSeed +{ + private static readonly JsonSerializerOptions NodeValueOptions = new() + { + TypeInfoResolver = new DefaultJsonTypeInfoResolver(), + }; + + private readonly JsonArray _plugins = []; + + private readonly JsonObject _root; + + internal ServerConfigSeed() + { + _root = new JsonObject { ["plugins"] = _plugins }; + } + + internal ServerConfigSeed WithPluginDirectory(string pluginDirectory) + { + ArgumentException.ThrowIfNullOrWhiteSpace(pluginDirectory); + + _plugins.Add(JsonSerializer.SerializeToNode(pluginDirectory, NodeValueOptions)); + return this; + } + + internal string Render() => _root.ToJsonString(); +} diff --git a/tests/Shared/TestRpcPlugin.cs b/tests/Shared/TestRpcPlugin.cs new file mode 100644 index 00000000..3c98cf11 --- /dev/null +++ b/tests/Shared/TestRpcPlugin.cs @@ -0,0 +1,22 @@ +using System.IO.Abstractions; + +namespace OpenCode.Sdk.TestSupport; + +/// The repository-owned local plugin used by the pinned server's RPC live tests. +internal sealed class TestRpcPlugin +{ + internal const string Id = "opencode.sdk.test.rpc"; + + internal TestRpcPlugin(IFileSystem fileSystem, string repositoryRoot) + { + ArgumentNullException.ThrowIfNull(fileSystem); + ArgumentException.ThrowIfNullOrWhiteSpace(repositoryRoot); + + Directory = fileSystem.Path.Combine(repositoryRoot, "tests", "Shared", "Plugins", "sdk-test-rpc"); + EntryPoint = fileSystem.Path.Combine(Directory, "index.js"); + } + + internal string Directory { get; } + + internal string EntryPoint { get; } +} From b48bc0a97e4bbf56e48290f173984dacb78a83d5 Mon Sep 17 00:00:00 2001 From: "denizirgin@gmail.com" Date: Mon, 7 Sep 2026 10:45:33 +0300 Subject: [PATCH 37/62] test: assert RPC plugin ownership from fixture mode --- .../Plugins/PluginsClientLiveTests.cs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/tests/OpenCode.Sdk.Tests/Plugins/PluginsClientLiveTests.cs b/tests/OpenCode.Sdk.Tests/Plugins/PluginsClientLiveTests.cs index 46b496d7..341253bd 100644 --- a/tests/OpenCode.Sdk.Tests/Plugins/PluginsClientLiveTests.cs +++ b/tests/OpenCode.Sdk.Tests/Plugins/PluginsClientLiveTests.cs @@ -65,16 +65,19 @@ public async Task ListPluginsAsync_Should_Report_The_Expected_Inventory(Cancella await Assert.That(listed.IsError).IsFalse(); await Assert.That(listed.Plugins.Count).IsGreaterThan(0); - if (server.OwnedRpcPlugin is { } ownedPlugin) - { - await AssertOwnedInventoryAsync(listed.Plugins, ownedPlugin); - } - else + if (server.IsExternal) { + await Assert.That(server.OwnedRpcPlugin).IsNull(); await Assert.That(IdsWhere( listed.Plugins, static plugin => string.Equals(plugin.Id, TestRpcPlugin.Id, StringComparison.Ordinal))).IsEmpty(); } + else + { + var ownedPlugin = server.OwnedRpcPlugin ?? + throw new InvalidOperationException("The owned pinned server did not resolve its RPC plugin."); + await AssertOwnedInventoryAsync(listed.Plugins, ownedPlugin); + } Console.WriteLine( "plugins-live: mode=" + (server.IsExternal ? "external" : "owned") + From 0906575be8e5f1915b9f995932d9aa6df7465505 Mon Sep 17 00:00:00 2001 From: "denizirgin@gmail.com" Date: Mon, 7 Sep 2026 15:20:34 +0300 Subject: [PATCH 38/62] feat: refresh upstream snapshot to 89f1943 and align Bun runtime --- .github/CONTRIBUTING.md | 4 ++-- .github/workflows/ci.yml | 11 ++++++++++- CHANGELOG.md | 4 +++- README.md | 2 +- external/opencode | 2 +- spec/SNAPSHOT.md | 4 ++-- spec/openapi.json | 19 ++++++++----------- spec/receipt.json | 12 ++++++------ spec/source-watch.json | 2 +- .../Models/ProjectUpdatePatchRequest.cs | 7 +++++++ .../SessionExecutionInterruptedDataReason.cs | 7 ++++++- src/OpenCode.Sdk/Projects/ProjectsClient.cs | 2 +- .../Snapshots/PublicApi.verified.txt | 5 +++++ 13 files changed, 53 insertions(+), 28 deletions(-) diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 98fb8a20..920f6949 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -33,8 +33,8 @@ sign. [`global.json`](../global.json) (`10.0.302`, `rollForward: latestFeature`) - The **.NET 8 and .NET 9 runtimes**, so the `net8.0` and `net9.0` test legs can run - On Windows, the **.NET Framework 4.7.2 targeting pack** for the `net472` legs -- [Bun](https://bun.sh/) `1.3.14` — the pinned-server test fixture builds `external/opencode` with - it, exactly as CI does +- [Bun](https://bun.sh/) at the exact version in `external/opencode/package.json`'s + `packageManager` field — the pinned-server fixture and CI use that runtime - [Git](https://git-scm.com/downloads) with submodule support - IDE: [Rider](https://www.jetbrains.com/rider/), [Visual Studio](https://visualstudio.microsoft.com/), or [VS Code](https://code.visualstudio.com/) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9e3cd4dd..6d08f925 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,7 +61,16 @@ jobs: uses: oven-sh/setup-bun@v2 with: # The pin's own packageManager row (external/opencode/package.json). - bun-version: 1.3.14 + bun-version-file: external/opencode/package.json + + - name: Verify pinned Bun runtime + shell: pwsh + run: | + $expected = (Get-Content external/opencode/package.json -Raw | ConvertFrom-Json).packageManager + $actual = "bun@$(& bun --version)" + if ($LASTEXITCODE -ne 0 -or $actual -cne $expected) { + throw "Bun runtime mismatch: expected $expected, got $actual." + } - name: Cache bun install uses: actions/cache@v6 diff --git a/CHANGELOG.md b/CHANGELOG.md index 4902aeff..7195ef32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,12 +11,14 @@ Each released version links straight to its GitHub Release tag. ### ✨ New features -- **Refreshed to upstream `b2cecc6`.** Worktree operations now resolve their project through an +- **Refreshed to upstream `89f1943`.** Worktree operations now resolve their project through an optional location query. Call create, list, remove, and refresh directly on `WorktreesClient`; the project-bound `ProjectWorktreesClient` and its factory have been removed. Create accepts the upstream's optional string strategy and inputs; remove retains its required JSON body. The list operation also exposes its declared `WorktreeError` failure arm. Compaction completion payloads now expose the optional model and provider state supplied by the upstream schema. + Project updates accept an optional canonical directory; execution interruption events include + the upstream inactivity reason. - **`OpenCode.Sdk` — the typed client.** **135 of the 140 operations** in the pinned OpenAPI snapshot are callable across **28 client families**: sessions, PTYs, persistent PTYs, shells, events, MCP servers, integrations, projects, worktrees, workspaces, providers, language models, diff --git a/README.md b/README.md index db41d2fe..706bb8bf 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ thing missing is the first published package. **Versioning**: the SDK builds against an accepted OpenAPI snapshot, never a live branch. The exact upstream commit and the refresh procedure live in [`spec/SNAPSHOT.md`](spec/SNAPSHOT.md); today's -pin is `b2cecc63` on upstream's `v2` branch. +pin is `89f1943d` on upstream's `v2` branch. ## 🚀 Platform Compatibility & Quality Status diff --git a/external/opencode b/external/opencode index b2cecc63..89f1943d 160000 --- a/external/opencode +++ b/external/opencode @@ -1 +1 @@ -Subproject commit b2cecc6350d377c382e1ec32ee66ec63ad68f715 +Subproject commit 89f1943d2da6af6087fed06a60c57bd0f384ab9f diff --git a/spec/SNAPSHOT.md b/spec/SNAPSHOT.md index 6b550cce..fb61af1d 100644 --- a/spec/SNAPSHOT.md +++ b/spec/SNAPSHOT.md @@ -1,6 +1,6 @@ # OpenAPI Snapshot -Date: 2026-09-06 +Date: 2026-09-07 `openapi.json` is the accepted snapshot of the upstream opencode OpenAPI 3.1 document — the v2 protocol surface (ADR-0005). The SDK is built against this snapshot, never against a live @@ -14,7 +14,7 @@ their hash-pinned manifests. |---|---| | Upstream file | `packages/protocol/openapi.json` | | Upstream branch | `v2` (active successor line; no release tags yet) | -| Commit | `b2cecc6350d377c382e1ec32ee66ec63ad68f715` | +| Commit | `89f1943d2da6af6087fed06a60c57bd0f384ab9f` | | Upstream product channel | `opencode2` — npm `@opencode-ai/cli@next` (pre-release) | Platform evidence for the v2 line: internal research, 2026-08-16, "The opencode v2 platform: diff --git a/spec/openapi.json b/spec/openapi.json index b8761470..5e25138c 100644 --- a/spec/openapi.json +++ b/spec/openapi.json @@ -1621,14 +1621,7 @@ "content": { "application/json": { "schema": { - "anyOf": [ - { - "$ref": "#/components/schemas/SessionNotFoundErrorEncoded" - }, - { - "$ref": "#/components/schemas/SessionNotFoundErrorEncoded" - } - ] + "$ref": "#/components/schemas/SessionNotFoundErrorEncoded" } } } @@ -7407,7 +7400,7 @@ } } }, - "description": "Update project display metadata and workspace commands.", + "description": "Update the project canonical directory, display metadata, and workspace commands.", "summary": "Update project", "requestBody": { "content": { @@ -7415,6 +7408,9 @@ "schema": { "type": "object", "properties": { + "canonical": { + "type": "string" + }, "name": { "type": "string" }, @@ -14589,7 +14585,8 @@ "anyOf": [ { "type": "string", - "enum": ["random"] + "enum": ["random"], + "description": "Reuse a randomly selected provider until it is rate limited, then switch to another available provider." }, { "type": "string" @@ -22318,7 +22315,7 @@ }, "reason": { "type": "string", - "enum": ["user", "shutdown", "superseded"] + "enum": ["user", "shutdown", "superseded", "inactivity"] } }, "required": ["sessionID", "reason"], diff --git a/spec/receipt.json b/spec/receipt.json index 1e2179f8..d444ae72 100644 --- a/spec/receipt.json +++ b/spec/receipt.json @@ -1,8 +1,8 @@ { "schemaVersion": 1, - "upstreamCommit": "b2cecc6350d377c382e1ec32ee66ec63ad68f715", - "rawDocumentSha256": "359c859bc9edd76059d271d7e5f4e2485d84974cb87b9c3d6d90fb77916bd077", - "generatedBaselineSha256": "359c859bc9edd76059d271d7e5f4e2485d84974cb87b9c3d6d90fb77916bd077", + "upstreamCommit": "89f1943d2da6af6087fed06a60c57bd0f384ab9f", + "rawDocumentSha256": "72cfde2a06296df571e83ef9880a3bc3bdeb5d9679130f230b831d6778c777b7", + "generatedBaselineSha256": "f3e6a1b430db9e036bbe8fb34f1ba6bda11f84610e0710bede38ad520abb00f9", "patches": [ { "manifest": "001-restore-sse-payloads.json", @@ -14,12 +14,12 @@ }, { "path": "packages/protocol/src/groups/session.ts", - "sha256": "2bdbff9a83dd2d0ba32960ef6c5b7370685da7f76e7c90758a115a222863dfe6" + "sha256": "16077799284f6bdbd8190fdd912ee428ac3b2e126190f25862cf67a2e6ee216d" } ] } ], - "normalizedDocumentSha256": "ea918cde7be4037bee1e7315d9555c2278aed754feaae944983aa530a993f2ce", + "normalizedDocumentSha256": "2ecd7091a49a8b1505b8a7e935368129399e156fed6d344c9ca551b218b7e2f3", "normalizedDocumentPath": null, "operationSetDigest": "038fa8d3b8cf12a9a3db9320f5945e5c7aaa5d07158cebc6a0527e4c56a0a2f8", "operationCount": 140, @@ -105,7 +105,7 @@ }, { "path": "packages/core/package.json", - "sha256": "0e44b780c50ee09630574b1bbc49777420e0811fd534057d6cf6bef0fe947813", + "sha256": "4a0c6a39b98501f09acec8bf635e27e4798db9e1c4e1539c6146d0e021f858c2", "anchorMatched": true }, { diff --git a/spec/source-watch.json b/spec/source-watch.json index 365b4f93..4c9a3b94 100644 --- a/spec/source-watch.json +++ b/spec/source-watch.json @@ -138,7 +138,7 @@ }, { "path": "packages/core/package.json", - "sha256": "0e44b780c50ee09630574b1bbc49777420e0811fd534057d6cf6bef0fe947813", + "sha256": "4a0c6a39b98501f09acec8bf635e27e4798db9e1c4e1539c6146d0e021f858c2", "behavior": "The opencode-pty daemon dependency is pinned at 0.1.13, which fixes the daemon protocol the persistent-PTY door speaks.", "anchor": { "type": "contains", diff --git a/src/OpenCode.Sdk/Models/ProjectUpdatePatchRequest.cs b/src/OpenCode.Sdk/Models/ProjectUpdatePatchRequest.cs index 48a4d675..314104df 100644 --- a/src/OpenCode.Sdk/Models/ProjectUpdatePatchRequest.cs +++ b/src/OpenCode.Sdk/Models/ProjectUpdatePatchRequest.cs @@ -8,6 +8,13 @@ namespace OpenCode.Sdk.Models; /// public sealed record ProjectUpdatePatchRequest { + /// + /// Gets the canonical value. + /// + [JsonPropertyName("canonical")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Canonical { get; init; } + /// /// Gets the name value. /// diff --git a/src/OpenCode.Sdk/Models/SessionExecutionInterruptedDataReason.cs b/src/OpenCode.Sdk/Models/SessionExecutionInterruptedDataReason.cs index 71d49a7e..196a6cfa 100644 --- a/src/OpenCode.Sdk/Models/SessionExecutionInterruptedDataReason.cs +++ b/src/OpenCode.Sdk/Models/SessionExecutionInterruptedDataReason.cs @@ -24,5 +24,10 @@ public enum SessionExecutionInterruptedDataReason /// Represents the 'superseded' wire value. /// [JsonStringEnumMemberName("superseded")] - Superseded + Superseded, + /// + /// Represents the 'inactivity' wire value. + /// + [JsonStringEnumMemberName("inactivity")] + Inactivity } diff --git a/src/OpenCode.Sdk/Projects/ProjectsClient.cs b/src/OpenCode.Sdk/Projects/ProjectsClient.cs index 3bf01e28..0b36bba5 100644 --- a/src/OpenCode.Sdk/Projects/ProjectsClient.cs +++ b/src/OpenCode.Sdk/Projects/ProjectsClient.cs @@ -56,7 +56,7 @@ public virtual Task ListProjectsAsync(OpenCodeRequestOption } /// - /// Update project. Update project display metadata and workspace commands. + /// Update project. Update the project canonical directory, display metadata, and workspace commands. /// /// The 'projectID' route value. /// The request body; an empty body is sent when omitted. diff --git a/tests/OpenCode.Sdk.Tests/Snapshots/PublicApi.verified.txt b/tests/OpenCode.Sdk.Tests/Snapshots/PublicApi.verified.txt index ae2a8baf..d0162e59 100644 --- a/tests/OpenCode.Sdk.Tests/Snapshots/PublicApi.verified.txt +++ b/tests/OpenCode.Sdk.Tests/Snapshots/PublicApi.verified.txt @@ -4420,6 +4420,9 @@ namespace OpenCode.Sdk.Models { public ProjectUpdatePatchRequest() { } [System.Text.Json.Serialization.JsonIgnore(Condition=System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] + [System.Text.Json.Serialization.JsonPropertyName("canonical")] + public string? Canonical { get; init; } + [System.Text.Json.Serialization.JsonIgnore(Condition=System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] [System.Text.Json.Serialization.JsonPropertyName("commands")] public OpenCode.Sdk.Models.ProjectCommands? Commands { get; init; } [System.Text.Json.Serialization.JsonIgnore(Condition=System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] @@ -5412,6 +5415,8 @@ namespace OpenCode.Sdk.Models Shutdown = 1, [System.Text.Json.Serialization.JsonStringEnumMemberName("superseded")] Superseded = 2, + [System.Text.Json.Serialization.JsonStringEnumMemberName("inactivity")] + Inactivity = 3, } public sealed class SessionExecutionInterruptedDurable : System.IEquatable { From 8e8b84ab466964ba2608ca7fc6d5cfa2470da9c7 Mon Sep 17 00:00:00 2001 From: "denizirgin@gmail.com" Date: Mon, 7 Sep 2026 15:50:00 +0300 Subject: [PATCH 39/62] test: cover owned RPC success and error responses --- .../Rpc/RpcClientLiveTests.cs | 269 +++++++++++++++++- .../Support/RpcCallRequestBuilder.cs | 39 +++ 2 files changed, 302 insertions(+), 6 deletions(-) create mode 100644 tests/OpenCode.Sdk.Tests/Support/RpcCallRequestBuilder.cs diff --git a/tests/OpenCode.Sdk.Tests/Rpc/RpcClientLiveTests.cs b/tests/OpenCode.Sdk.Tests/Rpc/RpcClientLiveTests.cs index b86be19b..dcf7472f 100644 --- a/tests/OpenCode.Sdk.Tests/Rpc/RpcClientLiveTests.cs +++ b/tests/OpenCode.Sdk.Tests/Rpc/RpcClientLiveTests.cs @@ -1,17 +1,16 @@ using System.Globalization; using System.Text.Json; using OpenCode.Sdk.Models; +using OpenCode.Sdk.Tests.Support; using OpenCode.Sdk.TestSupport; namespace OpenCode.Sdk.Tests; /// -/// The rpc family's live proof against the pinned server: rpc.call for an rpc id nobody -/// registered answers the declared 400 arm carrying the upstream reason -/// rpc.unavailable, on both error channels. Deterministic at the pin: only a plugin can -/// register an rpc (ctx.rpc.register), no builtin plugin does, and the handler awaits -/// plugin activation before it consults the registry, so the empty slot is never a race against -/// startup. The rpc id is checked before the method, so the method name is immaterial here. +/// The rpc family's live proof against the pinned server: the repository-owned local plugin +/// exercises exact input/output plus every deterministic 400/500 RPC arm. An rpc id nobody +/// registered retains the declared unavailable proof on both error channels. +/// External mode first proves the owned plugin id absent, then exercises only that unavailable arm. /// [ClassDataSource(Shared = SharedType.PerTestSession)] [NotInParallel(ParallelConstraintKeys.ServerProcess)] @@ -25,8 +24,195 @@ public sealed class RpcClientLiveTests(PinnedOpenCodeServerFixture server) /// The upstream reason for an empty registration slot (core/src/rpc.ts, Rpc.call). private const string UnavailableReason = "rpc.unavailable"; + private const string MissingMethodReason = "rpc.method_not_found"; + + private const string InvalidInputReason = "rpc.invalid_input"; + + private const string DeclaredErrorReason = "sdk.test.rejected"; + + private const string EchoMethod = "echo"; + + private const string MissingMethod = "missing"; + + private const string DeclaredErrorMethod = "declaredError"; + + private const string InvalidOutputMethod = "invalidOutput"; + + private const string InternalErrorMethod = "internalError"; + + private const string Nonce = "rpc-live-task-2-nonce"; + + private const string EchoValue = "rpc-live-task-2-value"; + + private const string DeclaredErrorCode = "sdk-test-rejected"; + + private const string DeclaredErrorMessage = "SDK test RPC rejected the request."; + + private const string InternalErrorMessage = "RPC call failed"; + private readonly FixtureLoader _fixtures = new(); + [Test] + [Timeout(60_000)] + public async Task CallAsync_Should_Echo_The_Exact_Input_When_The_Owned_Rpc_Is_Registered( + CancellationToken cancellationToken) + { + using var client = server.CreateClient(); + if (await AssertExternalUnavailableAsync(client, "echo", cancellationToken)) + { + return; + } + + var response = await client.Rpc.CallAsync( + TestRpcPlugin.Id, + EchoMethod, + new RpcCallRequestBuilder().WithNonce(Nonce).WithValue(EchoValue).Build(), + cancellationToken: cancellationToken); + + await Assert.That(response.Status).IsEqualTo(200); + await Assert.That(response.IsError).IsFalse(); + await Assert.That(response.Error).IsNull(); + var output = response.Call.Output ?? throw new InvalidOperationException("The echo response had no output."); + await Assert.That(output.GetProperty("nonce").GetString()).IsEqualTo(Nonce); + await Assert.That(output.GetProperty("value").GetString()).IsEqualTo(EchoValue); + + Console.WriteLine("rpc-live: mode=owned arm=echo status=" + Number(response.Status) + " nonce=" + Nonce); + } + + [Test] + [Timeout(60_000)] + public async Task CallAsync_Should_Answer_Method_Not_Found_When_The_Owned_Method_Is_Missing( + CancellationToken cancellationToken) + { + using var client = server.CreateClient(); + if (await AssertExternalUnavailableAsync(client, "method-not-found", cancellationToken)) + { + return; + } + + var response = await client.Rpc.CallAsync( + TestRpcPlugin.Id, + MissingMethod, + new RpcCallRequestBuilder().WithNonce(Nonce).Build(), + OpenCodeRequestOptions.NoThrow, + cancellationToken); + + var error = await RequireRpcErrorAsync(response, 400); + await Assert.That(error.Type).IsEqualTo(MissingMethodReason); + await Assert.That(error.Message).IsEqualTo("Unknown RPC method: " + TestRpcPlugin.Id + "." + MissingMethod); + await Assert.That(error.Data).IsNull(); + + WriteError("method-not-found", response, error.Type); + } + + [Test] + [Timeout(60_000)] + public async Task CallAsync_Should_Answer_Invalid_Input_When_The_Nonce_Is_Numeric( + CancellationToken cancellationToken) + { + using var client = server.CreateClient(); + if (await AssertExternalUnavailableAsync(client, "invalid-input", cancellationToken)) + { + return; + } + + var response = await client.Rpc.CallAsync( + TestRpcPlugin.Id, + EchoMethod, + new RpcCallRequestBuilder().WithNumericNonce(17).WithValue(EchoValue).Build(), + OpenCodeRequestOptions.NoThrow, + cancellationToken); + + var error = await RequireRpcErrorAsync(response, 400); + await Assert.That(error.Type).IsEqualTo(InvalidInputReason); + await Assert.That(string.IsNullOrWhiteSpace(error.Message)).IsFalse(); + await Assert.That(error.Message).Contains("nonce"); + await Assert.That(error.Message).Contains("string"); + await Assert.That(error.Data).IsNull(); + + WriteError("invalid-input", response, error.Type); + } + + [Test] + [Timeout(60_000)] + public async Task CallAsync_Should_Answer_The_Declared_Error_With_Its_Typed_Data( + CancellationToken cancellationToken) + { + using var client = server.CreateClient(); + if (await AssertExternalUnavailableAsync(client, "declared-error", cancellationToken)) + { + return; + } + + var response = await client.Rpc.CallAsync( + TestRpcPlugin.Id, + DeclaredErrorMethod, + new RpcCallRequestBuilder().WithNonce(Nonce).Build(), + OpenCodeRequestOptions.NoThrow, + cancellationToken); + + var error = await RequireRpcErrorAsync(response, 400); + await Assert.That(error.Type).IsEqualTo(DeclaredErrorReason); + await Assert.That(error.Message).IsEqualTo(DeclaredErrorMessage); + var data = error.Data ?? throw new InvalidOperationException("The declared RPC error had no data."); + await Assert.That(data.GetProperty("code").GetString()).IsEqualTo(DeclaredErrorCode); + await Assert.That(data.GetProperty("nonce").GetString()).IsEqualTo(Nonce); + + WriteError("declared-error", response, error.Type); + } + + [Test] + [Timeout(60_000)] + public async Task CallAsync_Should_Answer_Invalid_Output_When_The_Handler_Breaks_Its_Schema( + CancellationToken cancellationToken) + { + using var client = server.CreateClient(); + if (await AssertExternalUnavailableAsync(client, "invalid-output", cancellationToken)) + { + return; + } + + var response = await client.Rpc.CallAsync( + TestRpcPlugin.Id, + InvalidOutputMethod, + new RpcCallRequestBuilder().WithNonce(Nonce).Build(), + OpenCodeRequestOptions.NoThrow, + cancellationToken); + + var error = await RequireRpcInternalErrorAsync(response, "rpc.invalid_output"); + await Assert.That(error.Type).IsEqualTo(RpcInternalErrorType.RpcInvalidOutput); + await Assert.That(string.IsNullOrWhiteSpace(error.Message)).IsFalse(); + await Assert.That(error.Data).IsNull(); + + WriteError("invalid-output", response, "rpc.invalid_output"); + } + + [Test] + [Timeout(60_000)] + public async Task CallAsync_Should_Normalize_A_Thrown_Handler_Error_As_Internal( + CancellationToken cancellationToken) + { + using var client = server.CreateClient(); + if (await AssertExternalUnavailableAsync(client, "internal-error", cancellationToken)) + { + return; + } + + var response = await client.Rpc.CallAsync( + TestRpcPlugin.Id, + InternalErrorMethod, + new RpcCallRequestBuilder().WithNonce(Nonce).Build(), + OpenCodeRequestOptions.NoThrow, + cancellationToken); + + var error = await RequireRpcInternalErrorAsync(response, "rpc.internal"); + await Assert.That(error.Type).IsEqualTo(RpcInternalErrorType.RpcInternal); + await Assert.That(error.Message).IsEqualTo(InternalErrorMessage); + await Assert.That(error.Data).IsNull(); + + WriteError("internal-error", response, "rpc.internal"); + } + [Test] [Timeout(60_000)] public async Task CallAsync_Should_Answer_The_Unavailable_Arm_On_The_NoThrow_Spine_When_No_Rpc_Is_Registered( @@ -91,6 +277,77 @@ private RpcCallPostRequest CallRequest() return new RpcCallPostRequest { Input = document.RootElement.Clone() }; } + private async Task AssertExternalUnavailableAsync( + OpenCodeClient client, + string requestedArm, + CancellationToken cancellationToken) + { + if (!server.IsExternal) + { + _ = server.OwnedRpcPlugin ?? + throw new InvalidOperationException("The owned pinned server did not resolve its RPC plugin."); + return false; + } + + await Assert.That(server.OwnedRpcPlugin).IsNull(); + _ = await client.Plugins.AwaitPluginActivationAsync(cancellationToken: cancellationToken); + var listed = await client.Plugins.ListPluginsAsync(cancellationToken: cancellationToken); + await Assert.That(listed.Status).IsEqualTo(200); + await Assert.That(listed.IsError).IsFalse(); + await Assert.That(listed.Plugins.Where( + static plugin => string.Equals(plugin.Id, TestRpcPlugin.Id, StringComparison.Ordinal)).ToArray()).IsEmpty(); + + var response = await client.Rpc.CallAsync( + TestRpcPlugin.Id, + MissingMethod, + new RpcCallRequestBuilder().WithNonce(Nonce).Build(), + OpenCodeRequestOptions.NoThrow, + cancellationToken); + var error = await RequireRpcErrorAsync(response, 400); + await Assert.That(error.Type).IsEqualTo(UnavailableReason); + await Assert.That(error.Message).IsEqualTo("RPC is unavailable: " + TestRpcPlugin.Id); + await Assert.That(error.Data).IsNull(); + + Console.WriteLine( + "rpc-live: mode=external arm=rpc.unavailable requested-arm=" + requestedArm + + " status=" + Number(response.Status)); + return true; + } + + private static async Task RequireRpcErrorAsync(RpcCallPostResponse response, int status) + { + await Assert.That(response.Status).IsEqualTo(status); + await Assert.That(response.IsError).IsTrue(); + await Assert.That(response.Error).IsTypeOf(); + var error = response.Error as RpcError ?? throw new InvalidOperationException("The RPC error arm was absent."); + var rawBody = response.RawBody ?? throw new InvalidOperationException("The RPC error had no raw body."); + await Assert.That(rawBody).Contains(error.Tag); + await Assert.That(rawBody).Contains(error.Type); + return error; + } + + private static async Task RequireRpcInternalErrorAsync( + RpcCallPostResponse response, + string wireType) + { + await Assert.That(response.Status).IsEqualTo(500); + await Assert.That(response.IsError).IsTrue(); + await Assert.That(response.Error).IsTypeOf(); + var error = response.Error as RpcInternalError ?? + throw new InvalidOperationException("The RPC internal-error arm was absent."); + var rawBody = response.RawBody ?? throw new InvalidOperationException("The RPC internal error had no raw body."); + await Assert.That(rawBody).Contains(error.Tag); + await Assert.That(rawBody).Contains(wireType); + return error; + } + + private static void WriteError(string arm, RpcCallPostResponse response, string type) => + Console.WriteLine( + "rpc-live: mode=owned arm=" + arm + + " status=" + Number(response.Status) + + " type=" + type + + " body=" + response.RawBody); + /// Renders one number for the console line, culture-free. private static string Number(int value) => value.ToString(CultureInfo.InvariantCulture); } diff --git a/tests/OpenCode.Sdk.Tests/Support/RpcCallRequestBuilder.cs b/tests/OpenCode.Sdk.Tests/Support/RpcCallRequestBuilder.cs new file mode 100644 index 00000000..e622127d --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Support/RpcCallRequestBuilder.cs @@ -0,0 +1,39 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using OpenCode.Sdk.Models; + +namespace OpenCode.Sdk.Tests.Support; + +/// Builds the small JSON inputs used by the live RPC contract proofs. +internal sealed class RpcCallRequestBuilder +{ + private readonly JsonObject _input = []; + + public RpcCallRequestBuilder WithNonce(string nonce) + { + ArgumentException.ThrowIfNullOrWhiteSpace(nonce); + + _input["nonce"] = JsonValue.Create(nonce); + return this; + } + + public RpcCallRequestBuilder WithValue(string value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + + _input["value"] = JsonValue.Create(value); + return this; + } + + public RpcCallRequestBuilder WithNumericNonce(int nonce) + { + _input["nonce"] = JsonValue.Create(nonce); + return this; + } + + public RpcCallPostRequest Build() + { + using var document = JsonDocument.Parse(_input.ToJsonString()); + return new RpcCallPostRequest { Input = document.RootElement.Clone() }; + } +} From 258ffe88c26a7488b7e9b2d374f57d68c675e8c5 Mon Sep 17 00:00:00 2001 From: "denizirgin@gmail.com" Date: Mon, 7 Sep 2026 16:32:19 +0300 Subject: [PATCH 40/62] test: guard the retained unavailable RPC arms in external mode The two retained unavailable tests called the unregistered rpc id in every fixture mode. On an operator-supplied external server that id could already be registered, so the tests would invoke a foreign RPC contract before asserting. Route both channels through the shared mode preparation: owned mode keeps the unregistered id; external mode proves the repository-owned plugin id absent after activation and a successful plugin list, then calls only that id, preserving the NoThrow and throwing unavailable assertions. --- .../Rpc/RpcClientLiveTests.cs | 49 ++++++++++++------- 1 file changed, 32 insertions(+), 17 deletions(-) diff --git a/tests/OpenCode.Sdk.Tests/Rpc/RpcClientLiveTests.cs b/tests/OpenCode.Sdk.Tests/Rpc/RpcClientLiveTests.cs index dcf7472f..daa5cf8f 100644 --- a/tests/OpenCode.Sdk.Tests/Rpc/RpcClientLiveTests.cs +++ b/tests/OpenCode.Sdk.Tests/Rpc/RpcClientLiveTests.cs @@ -219,16 +219,18 @@ public async Task CallAsync_Should_Answer_The_Unavailable_Arm_On_The_NoThrow_Spi CancellationToken cancellationToken) { using var client = server.CreateClient(); + var external = await PrepareRpcModeAsync(client, cancellationToken); + var rpcId = external ? TestRpcPlugin.Id : UnregisteredRpcId; var response = await client.Rpc.CallAsync( - UnregisteredRpcId, Method, CallRequest(), OpenCodeRequestOptions.NoThrow, cancellationToken); + rpcId, Method, CallRequest(), OpenCodeRequestOptions.NoThrow, cancellationToken); await Assert.That(response.Status).IsEqualTo(400); await Assert.That(response.IsError).IsTrue(); await Assert.That(response.Error).IsTypeOf(); var error = response.Error as RpcError; await Assert.That(error?.Type).IsEqualTo(UnavailableReason); - await Assert.That(error?.Message).Contains(UnregisteredRpcId); + await Assert.That(error?.Message).Contains(rpcId); // The handler spreads `data` only when the failure carries one, and the unavailable // failure carries none - so the member stays absent on the wire and null here. await Assert.That(error?.Data).IsNull(); @@ -236,7 +238,8 @@ public async Task CallAsync_Should_Answer_The_Unavailable_Arm_On_The_NoThrow_Spi // Every value is what the server answered; the raw body is the evidence a reader can // compare against the upstream handler without trusting this test's typed view of it. Console.WriteLine( - "rpc-live: arm=no-throw status=" + Number(response.Status) + + "rpc-live: mode=" + (external ? "external" : "owned") + + " arm=no-throw status=" + Number(response.Status) + " type=" + error?.Type + " message=" + error?.Message + " body=" + response.RawBody); @@ -248,20 +251,23 @@ public async Task CallAsync_Should_Throw_The_Unavailable_Arm_When_No_Rpc_Is_Regi CancellationToken cancellationToken) { using var client = server.CreateClient(); + var external = await PrepareRpcModeAsync(client, cancellationToken); + var rpcId = external ? TestRpcPlugin.Id : UnregisteredRpcId; var exception = await Assert .That(async () => _ = await client.Rpc.CallAsync( - UnregisteredRpcId, Method, CallRequest(), cancellationToken: cancellationToken)) + rpcId, Method, CallRequest(), cancellationToken: cancellationToken)) .Throws(); await Assert.That(exception!.Status).IsEqualTo(400); await Assert.That(exception.Error).IsTypeOf(); var error = exception.Error as RpcError; await Assert.That(error?.Type).IsEqualTo(UnavailableReason); - await Assert.That(error?.Message).Contains(UnregisteredRpcId); + await Assert.That(error?.Message).Contains(rpcId); Console.WriteLine( - "rpc-live: arm=throw status=" + Number(exception.Status) + + "rpc-live: mode=" + (external ? "external" : "owned") + + " arm=throw status=" + Number(exception.Status) + " type=" + error?.Type + " body=" + exception.RawBody); } @@ -282,21 +288,11 @@ private async Task AssertExternalUnavailableAsync( string requestedArm, CancellationToken cancellationToken) { - if (!server.IsExternal) + if (!await PrepareRpcModeAsync(client, cancellationToken)) { - _ = server.OwnedRpcPlugin ?? - throw new InvalidOperationException("The owned pinned server did not resolve its RPC plugin."); return false; } - await Assert.That(server.OwnedRpcPlugin).IsNull(); - _ = await client.Plugins.AwaitPluginActivationAsync(cancellationToken: cancellationToken); - var listed = await client.Plugins.ListPluginsAsync(cancellationToken: cancellationToken); - await Assert.That(listed.Status).IsEqualTo(200); - await Assert.That(listed.IsError).IsFalse(); - await Assert.That(listed.Plugins.Where( - static plugin => string.Equals(plugin.Id, TestRpcPlugin.Id, StringComparison.Ordinal)).ToArray()).IsEmpty(); - var response = await client.Rpc.CallAsync( TestRpcPlugin.Id, MissingMethod, @@ -314,6 +310,25 @@ await Assert.That(listed.Plugins.Where( return true; } + private async Task PrepareRpcModeAsync(OpenCodeClient client, CancellationToken cancellationToken) + { + if (!server.IsExternal) + { + _ = server.OwnedRpcPlugin ?? + throw new InvalidOperationException("The owned pinned server did not resolve its RPC plugin."); + return false; + } + + await Assert.That(server.OwnedRpcPlugin).IsNull(); + _ = await client.Plugins.AwaitPluginActivationAsync(cancellationToken: cancellationToken); + var listed = await client.Plugins.ListPluginsAsync(cancellationToken: cancellationToken); + await Assert.That(listed.Status).IsEqualTo(200); + await Assert.That(listed.IsError).IsFalse(); + await Assert.That(listed.Plugins.Where( + static plugin => string.Equals(plugin.Id, TestRpcPlugin.Id, StringComparison.Ordinal)).ToArray()).IsEmpty(); + return true; + } + private static async Task RequireRpcErrorAsync(RpcCallPostResponse response, int status) { await Assert.That(response.Status).IsEqualTo(status); From 567b3c5616ba96a62cdd46479673938698a70e5c Mon Sep 17 00:00:00 2001 From: "denizirgin@gmail.com" Date: Mon, 7 Sep 2026 17:02:53 +0300 Subject: [PATCH 41/62] test: prove the owned RPC event reaches the live event bus The repository-owned plugin's emit method publishes a declared event; the SDK's event bus must deliver it typed as EventRpc with the exact prefixed type, the caller's nonce, and the plugin's registration location. The subscription is attached (server.connected observed) before the emit call, so the proof rests on a causal barrier rather than on replay. Owned mode only; an external endpoint has no owned plugin and fails fast. --- .../Events/EventsClientLiveTests.cs | 120 ++++++++++++++++++ .../Support/RpcObservedEvents.cs | 75 +++++++++++ 2 files changed, 195 insertions(+) create mode 100644 tests/OpenCode.Sdk.Tests/Events/EventsClientLiveTests.cs create mode 100644 tests/OpenCode.Sdk.Tests/Support/RpcObservedEvents.cs diff --git a/tests/OpenCode.Sdk.Tests/Events/EventsClientLiveTests.cs b/tests/OpenCode.Sdk.Tests/Events/EventsClientLiveTests.cs new file mode 100644 index 00000000..97b55a33 --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Events/EventsClientLiveTests.cs @@ -0,0 +1,120 @@ +using System.Globalization; +using OpenCode.Sdk.Models; +using OpenCode.Sdk.Tests.Support; +using OpenCode.Sdk.TestSupport; + +namespace OpenCode.Sdk.Tests; + +/// +/// The rpc family's live event proof against the pinned server: the repository-owned plugin's +/// emit method publishes a declared event, and the SDK's event bus types it as +/// with the exact prefixed type, the caller's nonce, and the plugin's +/// registration location. The subscription is attached ( seen) +/// before the emit call, so the proof rests on a causal barrier rather than on replay. +/// This proof needs the owned server; an external endpoint has no owned plugin and fails fast. +/// +[ClassDataSource(Shared = SharedType.PerTestSession)] +[NotInParallel(ParallelConstraintKeys.ServerProcess)] +public sealed class EventsClientLiveTests(PinnedOpenCodeServerFixture server) +{ + /// Upstream forms the type as rpc.<definition id>.<event name> (core/src/rpc.ts). + private const string ObservedEventType = "rpc." + TestRpcPlugin.Id + ".observed"; + + private const string EmitMethod = "emit"; + + private static readonly TimeSpan CleanupTimeout = TimeSpan.FromSeconds(15); + + private static readonly TimeSpan EventWait = TimeSpan.FromSeconds(120); + + [Test] + [Timeout(180_000)] + public async Task SubscribeAsync_Should_Deliver_The_Owned_Rpc_Event_With_Its_Nonce_And_Location( + CancellationToken cancellationToken) + { + var ownedPlugin = server.OwnedRpcPlugin ?? + throw new InvalidOperationException( + "The rpc event proof requires the owned pinned server; an external endpoint has no owned plugin."); + using var client = server.CreateClient(); + await AssertOwnedPluginActiveAsync(client, ownedPlugin, cancellationToken); + var resolved = (await client.GetLocationAsync(cancellationToken: cancellationToken)).ResolvedLocation; + + var nonce = "rpc-event-live-" + Guid.NewGuid().ToString("N"); + var reader = new OwnedEventReader(EventWait, CleanupTimeout, cancellationToken); + var connected = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var events = new RpcObservedEvents(ObservedEventType, nonce, reader); + var observedTask = events.CompleteAsync(client.Events.SubscribeAsync(reader.Token), connected); + Exception? primaryFailure = null; + + try + { + var attached = await Task.WhenAny(connected.Task, observedTask); + if (attached == observedTask) + { + _ = await observedTask; + throw new InvalidOperationException("The event subscription ended before server.connected."); + } + + var emitted = await client.Rpc.CallAsync( + TestRpcPlugin.Id, + EmitMethod, + new RpcCallRequestBuilder().WithNonce(nonce).Build(), + cancellationToken: cancellationToken); + await Assert.That(emitted.Status).IsEqualTo(200); + await Assert.That(emitted.IsError).IsFalse(); + var output = emitted.Call.Output ?? throw new InvalidOperationException("The emit response had no output."); + await Assert.That(output.GetProperty("nonce").GetString()).IsEqualTo(nonce); + + var observed = await observedTask; + await Assert.That(observed.Type).IsEqualTo(ObservedEventType); + await Assert.That(observed.Data["nonce"].GetString()).IsEqualTo(nonce); + await Assert.That(observed.Data.Count).IsEqualTo(1); + await Assert.That(observed.Location.Directory).IsEqualTo(resolved.Directory); + await Assert.That(observed.Location.WorkspaceId).IsEqualTo(resolved.WorkspaceId); + + Console.WriteLine( + "rpc-event-live: mode=owned arm=emit status=" + Number(emitted.Status) + + " type=" + observed.Type + + " nonce=" + nonce + + " directory=" + observed.Location.Directory + + " workspace=" + observed.Location.WorkspaceId); + } + catch (Exception exception) + { + primaryFailure = exception; + if (!exception.Data.Contains(EventDiagnosticSummary.DataKey)) + { + exception.Data[EventDiagnosticSummary.DataKey] = events.DiagnosticSummary; + } + } + finally + { + await reader.CompleteAsync(primaryFailure); + } + } + + /// + /// Activation is awaited in the client's location before the event proof depends on the plugin, + /// and the exact owned entry must be the active local file the fixture seeded. + /// + private static async Task AssertOwnedPluginActiveAsync( + OpenCodeClient client, + TestRpcPlugin ownedPlugin, + CancellationToken cancellationToken) + { + _ = await client.Plugins.AwaitPluginActivationAsync(cancellationToken: cancellationToken); + var listed = await client.Plugins.ListPluginsAsync(cancellationToken: cancellationToken); + await Assert.That(listed.Status).IsEqualTo(200); + await Assert.That(listed.IsError).IsFalse(); + var ownedEntries = listed.Plugins.Where( + static plugin => string.Equals(plugin.Id, TestRpcPlugin.Id, StringComparison.Ordinal)).ToArray(); + await Assert.That(ownedEntries).Count().IsEqualTo(1); + var owned = ownedEntries.Single(); + await Assert.That(owned.Source).IsTypeOf(); + var local = owned.Source as PluginSourceLocal; + await Assert.That(local?.Path).IsEqualTo(ownedPlugin.EntryPoint); + await Assert.That(owned.State).IsTypeOf(); + } + + /// Renders one number for the console line, culture-free. + private static string Number(int value) => value.ToString(CultureInfo.InvariantCulture); +} diff --git a/tests/OpenCode.Sdk.Tests/Support/RpcObservedEvents.cs b/tests/OpenCode.Sdk.Tests/Support/RpcObservedEvents.cs new file mode 100644 index 00000000..9397593c --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Support/RpcObservedEvents.cs @@ -0,0 +1,75 @@ +using System.Text.Json; +using OpenCode.Sdk.Models; + +namespace OpenCode.Sdk.Tests.Support; + +/// +/// Follows the live event bus until the owned rpc plugin's observed event carries the +/// caller's nonce. The connected frame is signalled first so the caller can emit only after the +/// subscription is attached; nothing is claimed about events that arrived before attachment. +/// +internal sealed class RpcObservedEvents(string eventType, string nonce, OwnedEventReader reader) +{ + private readonly EventDiagnosticSummary _observed = new(); + + public string DiagnosticSummary => _observed.ToString(); + + public async Task CompleteAsync( + IAsyncEnumerable events, + TaskCompletionSource connected) + { + try + { + return await reader.Start(_ => ObserveAsync(events, connected)); + } + catch (Exception exception) + { + exception.Data[EventDiagnosticSummary.DataKey] = DiagnosticSummary; + throw; + } + } + + private async Task ObserveAsync( + IAsyncEnumerable events, + TaskCompletionSource connected) + { + ArgumentNullException.ThrowIfNull(events); + ArgumentNullException.ThrowIfNull(connected); + try + { + await foreach (var @event in events.WithCancellation(reader.Token)) + { + _observed.Add(@event.Type); + if (@event is EventServerConnected) + { + _ = connected.TrySetResult(true); + } + + if (@event is EventRpc rpc + && string.Equals(rpc.Type, eventType, StringComparison.Ordinal) + && rpc.Data.TryGetValue("nonce", out var observedNonce) + && observedNonce.ValueKind == JsonValueKind.String + && string.Equals(observedNonce.GetString(), nonce, StringComparison.Ordinal)) + { + return rpc; + } + } + } + catch (OperationCanceledException exception) + when (reader.ObservationTimedOut(exception)) + { + throw new TimeoutException( + $"The rpc event '{eventType}' with nonce '{nonce}' did not arrive within the event window. " + + $"Events: {_observed}.", + exception); + } + catch (Exception exception) + { + exception.Data[EventDiagnosticSummary.DataKey] = DiagnosticSummary; + throw; + } + + throw new InvalidOperationException( + $"The event subscription ended before the rpc event '{eventType}' arrived. Events: {_observed}."); + } +} From 34ada9ed30d0b6fe19e47aea4c2d14c7b0bb9b62 Mon Sep 17 00:00:00 2001 From: "denizirgin@gmail.com" Date: Mon, 7 Sep 2026 17:24:10 +0300 Subject: [PATCH 42/62] fix(sdk): classify a fault the body read's own interruption caused Cancelling a call, or the progress window expiring, while the response body was still being materialized could surface the runtime's own fault instead of the documented outcome. The buffering policy interrupts a stalled read by disposing the content; on .NET 10 HttpContent buffers into a pooled stream that Dispose returns, and its ReadAsStreamAsync path then builds the read stream over a null array (ArgumentNullException) when disposal lands after the buffer is assigned and before the stream is created. That exception is not one the read phase classifies, so it escaped as-is. Once the policy has interrupted the read itself, any fault the BCL raises afterwards is a consequence of that interruption: map it as caller cancellation when the caller's token is cancelled and as a transport failure when the window expired. Map's non-cancellation tail becomes MapFault and is shared. Two deterministic regression tests reproduce the runtime's exact exception through a content whose materialization blocks until disposal. --- .../Internal/FailureClassification.cs | 14 ++++ .../Internal/ResponseBufferingPolicy.cs | 11 +++ tests/OpenCode.Sdk.Tests/PipelineTests.cs | 75 +++++++++++++++++++ .../Support/InterruptedBufferingContent.cs | 73 ++++++++++++++++++ 4 files changed, 173 insertions(+) create mode 100644 tests/OpenCode.Sdk.Tests/Support/InterruptedBufferingContent.cs diff --git a/src/OpenCode.Sdk/Internal/FailureClassification.cs b/src/OpenCode.Sdk/Internal/FailureClassification.cs index 824f1f57..647a8ec6 100644 --- a/src/OpenCode.Sdk/Internal/FailureClassification.cs +++ b/src/OpenCode.Sdk/Internal/FailureClassification.cs @@ -56,6 +56,20 @@ public static Exception Map(Exception exception, FailurePhase phase, Cancellatio : new OpenCodeTransportException(TimeoutMessage(phase), exception); } + return MapFault(exception, phase, cancellationToken); + } + + /// + /// Classifies a non-cancellation fault against the caller's token: the shared tail of + /// , and the whole mapping for a fault the phase's own interruption caused. + /// Once the SDK has interrupted a phase itself — disposing the content under a cancelled + /// progress window — whatever the BCL throws afterwards is a consequence of that + /// interruption, not a new fault, and needs no place in . + /// + public static Exception MapFault(Exception exception, FailurePhase phase, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(exception); + return cancellationToken.IsCancellationRequested ? new OperationCanceledException(CanceledMessage(phase), exception, cancellationToken) : new OpenCodeTransportException(FaultMessage(phase), exception); diff --git a/src/OpenCode.Sdk/Internal/ResponseBufferingPolicy.cs b/src/OpenCode.Sdk/Internal/ResponseBufferingPolicy.cs index 9a545a32..1ceada7c 100644 --- a/src/OpenCode.Sdk/Internal/ResponseBufferingPolicy.cs +++ b/src/OpenCode.Sdk/Internal/ResponseBufferingPolicy.cs @@ -90,6 +90,17 @@ private async Task ReadAsync(PipelineMessage message, Cancellation { throw FailureClassification.Map(exception, FailurePhase.ResponseBodyRead, message.CancellationToken); } + catch (Exception exception) when (progress.IsCancellationRequested) + { + // The interruption above disposed the content while HttpContent's own buffering path + // was still materializing it; the BCL then fails with whatever its torn-down state + // yields rather than ObjectDisposedException. Knowledge source: on .NET 10 the buffer + // is a pooled stream that Dispose returns, and the read stream is then built over a + // null array (ArgumentNullException) when disposal lands mid-materialization. The + // interruption is the cause, so the fault is classified as one — caller cancellation + // or the window expiring. + throw FailureClassification.MapFault(exception, FailurePhase.ResponseBodyRead, message.CancellationToken); + } } private async Task CopyToPooledAsync(Stream body, long? declaredLength, diff --git a/tests/OpenCode.Sdk.Tests/PipelineTests.cs b/tests/OpenCode.Sdk.Tests/PipelineTests.cs index 9dd1aa52..f33fa466 100644 --- a/tests/OpenCode.Sdk.Tests/PipelineTests.cs +++ b/tests/OpenCode.Sdk.Tests/PipelineTests.cs @@ -895,6 +895,81 @@ public async Task ExecuteAsync_Should_Settle_The_Body_Read_After_Caller_Cancella await Assert.That(content.IsDisposed).IsTrue(); } + [Test] + [NotInParallel] + public async Task ExecuteAsync_Should_Classify_A_Fault_The_Interruption_Caused_As_Caller_Cancellation() + { + using var callerCancellation = new CancellationTokenSource(); + using var content = new InterruptedBufferingContent(); + using var handler = new RecordingHttpHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = content, + }); + using var httpClient = new HttpClient(handler); + using var pipeline = CreatePipeline(httpClient); + + var execution = pipeline.ExecuteAsync( + HttpMethod.Get, + "/api/health", + new RecordingResponseAdapter(), + options: null, + callerCancellation.Token); + await content.ReadStarted.WaitAsync(TimeSpan.FromSeconds(5)); + await callerCancellation.CancelAsync(); + + OperationCanceledException? cancellation = null; + try + { + _ = await execution; + } + catch (OperationCanceledException exception) + { + cancellation = exception; + } + + await Assert.That(cancellation).IsNotNull(); +#if NET + // The BCL's own read-stream path surfaces the torn-down buffer's fault, and the + // interruption classifies it. Downlevel, the polyfilled overload races the read against + // the token and its cancellation wins first, so the fault never surfaces there. + await Assert.That(cancellation!.InnerException).IsTypeOf(); +#endif + await content.ReadCompleted.WaitAsync(TimeSpan.FromSeconds(5)); + await Assert.That(content.IsDisposed).IsTrue(); + } + + [Test] + [NotInParallel] + public async Task ExecuteAsync_Should_Classify_A_Fault_The_Interruption_Caused_As_A_Transport_Failure_At_The_Progress_Window() + { + using var callerCancellation = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + using var content = new InterruptedBufferingContent(); + using var handler = new RecordingHttpHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = content, + }); + using var httpClient = new HttpClient(handler); + using var pipeline = CreatePipeline(httpClient, networkTimeout: TimeSpan.FromMilliseconds(200)); + + var failure = await Assert + .That(async () => _ = await pipeline.ExecuteAsync( + HttpMethod.Get, + "/api/health", + new RecordingResponseAdapter(), + options: null, + callerCancellation.Token)) + .Throws(); + + await Assert.That(callerCancellation.IsCancellationRequested).IsFalse(); + await Assert.That(failure!.InnerException).IsNotNull(); +#if NET + await Assert.That(failure.InnerException).IsTypeOf(); +#endif + await content.ReadStarted.WaitAsync(TimeSpan.FromSeconds(5)); + await content.ReadCompleted.WaitAsync(TimeSpan.FromSeconds(5)); + await Assert.That(content.IsDisposed).IsTrue(); + } + [Test] public async Task Dispose_Should_Dispose_The_Owned_Client() { diff --git a/tests/OpenCode.Sdk.Tests/Support/InterruptedBufferingContent.cs b/tests/OpenCode.Sdk.Tests/Support/InterruptedBufferingContent.cs new file mode 100644 index 00000000..34f7e780 --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Support/InterruptedBufferingContent.cs @@ -0,0 +1,73 @@ +using System.Net; + +namespace OpenCode.Sdk.Tests.Support; + +/// +/// Reproduces .NET 10's HttpContent failure mode deterministically: the read-stream +/// materialization blocks until the content is disposed, then fails with the arbitrary +/// exception a torn-down pooled buffer yields ( for the +/// stream's array) instead of . Downlevel, the polyfilled +/// token overload abandons the read once the token cancels, so the fault there is an unobserved +/// task nobody awaits — the real BCL never faults on that path. +/// +internal sealed class InterruptedBufferingContent : HttpContent +{ + private readonly SemaphoreSlim _gate = new(initialCount: 0, maxCount: 1); + private readonly TaskCompletionSource _readCompleted = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _readStarted = new(TaskCreationOptions.RunContinuationsAsynchronously); + private bool _disposed; + + public bool IsDisposed => _disposed; + + public Task ReadCompleted => _readCompleted.Task; + + public Task ReadStarted => _readStarted.Task; + + protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) => + throw new InvalidOperationException("The read-stream path owns this content; it is never serialized."); + + protected override Task CreateContentReadStreamAsync() => MaterializeAsync(); + +#if NET + protected override Task CreateContentReadStreamAsync(CancellationToken cancellationToken) => + MaterializeAsync(); +#endif + + protected override bool TryComputeLength(out long length) + { + length = 0; + return false; + } + + protected override void Dispose(bool disposing) + { + if (disposing && !_disposed) + { + _disposed = true; + _gate.Release(); + _gate.Dispose(); + } + + base.Dispose(disposing); + } + + private async Task MaterializeAsync() + { + _ = _readStarted.TrySetResult(); + try + { + await _gate.WaitAsync(CancellationToken.None).ConfigureAwait(false); + } + finally + { + _ = _readCompleted.TrySetResult(); + } + + // Disposal returned the pooled buffer; the BCL builds the stream over a null array and + // throws exactly what the runtime throws for it. + return new MemoryStream(ReturnedBuffer()!, 0, 0, writable: false); + } + + /// The pooled array after ReturnAllPooledBuffers: gone. + private static byte[]? ReturnedBuffer() => null; +} From 0a271b64fc41ad24f98baf1157c4457a17a8ea96 Mon Sep 17 00:00:00 2001 From: "denizirgin@gmail.com" Date: Mon, 7 Sep 2026 18:04:29 +0300 Subject: [PATCH 43/62] feat(sdk): retain the launched server's output through a caller-owned collector An optional OpenCodeServerOutput collector, supplied through OpenCodeServerOptions.Output, retains a bounded tail of the child's stdout and stderr (the newest 500 lines and at most 1,048,576 characters per stream, the readiness line included) for pull snapshots that report whether either stream lost content. The collector binds to exactly one start before the child is spawned, invokes no caller code on the process readers, stays readable after a failed start, needs no disposal, and is closed after the existing bounded diagnostic drain on disposal so a snapshot taken then is final. Callers that supply no collector see no change. The generator's reserved spine names learn the two new public types, the public API baseline records them, and client-runtime carries the sealed paragraph. Real-process tests cover both streams and empty lines, output after a failed start, single binding before spawn, and finalization on the forced, graceful, and already-exited disposal arms; unit tests cover the bounds and flags. --- CHANGELOG.md | 7 +- README.md | 4 +- docs/architecture/client-runtime.md | 9 +- .../Internal/BoundedLineBuffer.cs | 49 ++++++ src/OpenCode.Sdk/OpenCodeServer.cs | 60 ++++++- src/OpenCode.Sdk/OpenCodeServerOptions.cs | 9 ++ src/OpenCode.Sdk/OpenCodeServerOutput.cs | 82 ++++++++++ .../OpenCodeServerOutputSnapshot.cs | 22 +++ .../OpenCodeServerLifecycleTests.cs | 140 +++++++++++++++++ .../OpenCodeServerOutputTests.cs | 148 ++++++++++++++++++ .../Snapshots/PublicApi.verified.txt | 14 ++ .../Generator/Binding/ReservedNamePolicy.cs | 2 + 12 files changed, 535 insertions(+), 11 deletions(-) create mode 100644 src/OpenCode.Sdk/Internal/BoundedLineBuffer.cs create mode 100644 src/OpenCode.Sdk/OpenCodeServerOutput.cs create mode 100644 src/OpenCode.Sdk/OpenCodeServerOutputSnapshot.cs create mode 100644 tests/OpenCode.Sdk.Tests/OpenCodeServerOutputTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 7195ef32..81fd47d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,8 +42,11 @@ Each released version links straight to its GitHub Release tag. `Updating`; and `PluginListResponse.Plugins` is an `IReadOnlyList`. - **A standalone server launcher.** `OpenCodeServer.StartAsync()` starts, monitors, and stops a private `opencode serve` child — generated lease credential, stdin-EOF ownership, bounded tree - termination — and `CreateClient()` hands back a client already bound to it. Real-process - lifecycle acceptance runs on Windows, Linux, and macOS. + termination — and `CreateClient()` hands back a client already bound to it. An optional + `OpenCodeServerOutput` collector, supplied through `OpenCodeServerOptions.Output`, retains a + bounded tail of the child's stdout and stderr for pull snapshots that report truncation, and it + stays readable after a failed start. Real-process lifecycle acceptance runs on Windows, Linux, + and macOS. - **Server-sent event streaming.** `EventsClient.SubscribeAsync` follows the global bus and `SessionClient.GetLogAsync` follows one session's log, both as `IAsyncEnumerable` of typed frames over the same transport, decoration, and status walls as one-shot calls. A body cut diff --git a/README.md b/README.md index 5beb7223..b1c4afd7 100644 --- a/README.md +++ b/README.md @@ -178,7 +178,9 @@ keep the credentials in environment variables: No ambient process, no endpoint to configure — the launcher starts a private `opencode serve` child, mints its credential, and hands you a client bound to it. Disposing the server stops the -child. +child. To keep the child's output for diagnostics, pass an `OpenCodeServerOutput` collector in +`OpenCodeServerOptions.Output` and read its snapshot whenever you like — bounded, and still +readable when the start failed. ```csharp using OpenCode.Sdk; diff --git a/docs/architecture/client-runtime.md b/docs/architecture/client-runtime.md index de7d347b..3a77d11d 100644 --- a/docs/architecture/client-runtime.md +++ b/docs/architecture/client-runtime.md @@ -1,6 +1,6 @@ # Client Runtime Architecture -Date: 2026-08-31 +Date: 2026-09-07 Canonical current rules for client construction, transport ownership, API errors, streams, and the local server launcher. Protocol and generated-model rules live in @@ -312,6 +312,13 @@ child prints once fully booted; stdin stays open as the ownership lease for as l runs, and every later stdout line plus all of stderr is drained continuously (stderr into a bounded tail kept for failure diagnostics) so a chatty child can never wedge the pipes. +An optional caller-created `OpenCodeServerOutput` collector, supplied through the start options, +retains a bounded tail of both streams, including the first stdout line, for pull snapshots. It +invokes no caller code on the process readers and survives a failed start. Each snapshot reports +whether either stream was truncated; the launcher's startup exception tail remains independent. +Output finalization is best effort under the existing bounded diagnostic drain and never extends +process ownership. + Disposal is a ladder, bounded at every step so it never hangs the caller: stdin EOF (the lease release) first, then the configured grace (`GracefulShutdownTimeout`, default 3 seconds — the reference client's own force-kill window), then a forced whole-tree kill diff --git a/src/OpenCode.Sdk/Internal/BoundedLineBuffer.cs b/src/OpenCode.Sdk/Internal/BoundedLineBuffer.cs new file mode 100644 index 00000000..203798d0 --- /dev/null +++ b/src/OpenCode.Sdk/Internal/BoundedLineBuffer.cs @@ -0,0 +1,49 @@ +using System.Diagnostics; + +namespace OpenCode.Sdk.Internal; + +/// +/// Retains the newest lines of one output stream under two bounds — a line count and a total +/// content length — dropping the oldest lines to satisfy both. An oversized single line keeps +/// its suffix. Any discard, an empty line included, marks the buffer truncated for good: a +/// reader is told that evidence was lost even when the lost content was zero characters. +/// Not thread-safe; the owner serializes access. +/// +internal sealed class BoundedLineBuffer +{ + private readonly Queue _lines = new(); + private readonly int _maximumLines; + private readonly int _maximumCharacters; + private int _characters; + + public BoundedLineBuffer(int maximumLines, int maximumCharacters) + { + Debug.Assert(maximumLines > 0, "A buffer retains at least one line."); + Debug.Assert(maximumCharacters > 0, "A buffer retains at least one character."); + _maximumLines = maximumLines; + _maximumCharacters = maximumCharacters; + } + + /// Gets whether any line or part of a line has ever been discarded. + public bool Truncated { get; private set; } + + public void Append(string line) + { + if (line.Length > _maximumCharacters) + { + line = line[^_maximumCharacters..]; + Truncated = true; + } + + _lines.Enqueue(line); + _characters += line.Length; + while (_lines.Count > _maximumLines || _characters > _maximumCharacters) + { + _characters -= _lines.Dequeue().Length; + Truncated = true; + } + } + + /// Copies the retained lines, oldest first, into a list later appends cannot touch. + public IReadOnlyList Snapshot() => [.. _lines]; +} diff --git a/src/OpenCode.Sdk/OpenCodeServer.cs b/src/OpenCode.Sdk/OpenCodeServer.cs index 9e132b29..957932b7 100644 --- a/src/OpenCode.Sdk/OpenCodeServer.cs +++ b/src/OpenCode.Sdk/OpenCodeServer.cs @@ -33,14 +33,21 @@ public class OpenCodeServer : IAsyncDisposable private readonly Uri? _endpoint; private readonly string? _password; private readonly TimeSpan _gracefulShutdownTimeout; + private readonly OpenCodeServerOutput? _output; private int _disposed; - private OpenCodeServer(Process process, Uri endpoint, string password, TimeSpan gracefulShutdownTimeout) + private OpenCodeServer( + Process process, + Uri endpoint, + string password, + TimeSpan gracefulShutdownTimeout, + OpenCodeServerOutput? output) { _process = process; _endpoint = endpoint; _password = password; _gracefulShutdownTimeout = gracefulShutdownTimeout; + _output = output; } /// @@ -81,7 +88,7 @@ protected OpenCodeServer() /// The launch options; null uses the defaults. /// The cancellation token ending the wait for readiness. /// The started server, disposed by the caller. - /// The options are unusable: an empty command, a blank command entry, a non-positive readiness timeout, or a negative grace. + /// The options are unusable: an empty command, a blank command entry, a non-positive readiness timeout, a negative grace, or an output collector an earlier start already bound. /// The process could not start, exited before readiness, timed out, or broke the readiness contract. public static async Task StartAsync( OpenCodeServerOptions? options = null, @@ -92,6 +99,7 @@ public static async Task StartAsync( ValidateTimeouts(options); var readinessTimeout = options.ReadinessTimeout; var gracefulShutdownTimeout = options.GracefulShutdownTimeout; + var output = BindOutput(options); var password = GeneratePassword(); @@ -106,7 +114,7 @@ public static async Task StartAsync( var stderrGate = new object(); var stderrTail = new Queue(StderrRetainedLines); var readyLine = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - AttachOutputHandlers(process, readyLine, stderrGate, stderrTail); + AttachOutputHandlers(process, readyLine, stderrGate, stderrTail, output); StartChildProcess(process, command[0]); var line = await WaitForReadyLineAsync( @@ -118,12 +126,19 @@ public static async Task StartAsync( $"The server's first stdout line is not the JSON readiness contract: '{line}'.{DescribeStderr(stderrGate, stderrTail)}"); } - var started = new OpenCodeServer(process, endpoint, password, gracefulShutdownTimeout); + var started = new OpenCodeServer(process, endpoint, password, gracefulShutdownTimeout, output); process = null; return started; } finally { + if (process is not null) + { + // A failed start: the child was ended and its output drained (as far as the + // bound allowed) on the way here, so what the collector holds is final. + output?.Complete(); + } + process?.Dispose(); } } @@ -185,6 +200,16 @@ public virtual async ValueTask DisposeAsync() try { await EndOwnedChildAsync(_process, _gracefulShutdownTimeout).ConfigureAwait(false); + if (_output is not null) + { + // The collector's promise is a final snapshot once disposal returns. The bounded + // drain lets the redirected readers reach end-of-stream (or its bound) before the + // collection closes; ownership is unchanged - the child was ended above, and a + // drain that cannot finish inside its bound leaves an honest, possibly + // incomplete, tail. + _ = await FlushOutputDrainAsync(_process).ConfigureAwait(false); + _output.Complete(); + } } finally { @@ -192,6 +217,19 @@ public virtual async ValueTask DisposeAsync() } } + private static OpenCodeServerOutput? BindOutput(OpenCodeServerOptions options) + { + var output = options.Output; + if (output is not null && !output.TryBind()) + { + throw new ArgumentException( + "OpenCodeServerOptions.Output is already bound to an earlier start; create a new OpenCodeServerOutput per start.", + nameof(options)); + } + + return output; + } + private static void ValidateTimeouts(OpenCodeServerOptions options) { if (options.ReadinessTimeout <= TimeSpan.Zero) @@ -209,15 +247,21 @@ private static void AttachOutputHandlers( Process process, TaskCompletionSource readyLine, object stderrGate, - Queue stderrTail) + Queue stderrTail, + OpenCodeServerOutput? output) { process.OutputDataReceived += (_, received) => { // Continuous drain: the first line is the readiness contract; every later stdout - // write is read and dropped so a chatty server can never fill the pipe and wedge - // the probe (Q148; the reference keeps draining too, standalone.ts:42). + // write is read and, unless a collector retains it, dropped so a chatty server can + // never fill the pipe and wedge the probe (Q148; the reference keeps draining too, + // standalone.ts:42). The collector's append takes only its own bounded-data lock. if (received.Data is not null) { + // Retain first, then signal: the readiness continuation can run the moment the + // result is set, and a snapshot taken right after StartAsync returns must + // already hold the line that made it return. + output?.AppendStandardOutput(received.Data); readyLine.TrySetResult(received.Data); } }; @@ -236,6 +280,8 @@ private static void AttachOutputHandlers( stderrTail.Dequeue(); } } + + output?.AppendStandardError(received.Data); }; } diff --git a/src/OpenCode.Sdk/OpenCodeServerOptions.cs b/src/OpenCode.Sdk/OpenCodeServerOptions.cs index e8bbf4cb..92d63a19 100644 --- a/src/OpenCode.Sdk/OpenCodeServerOptions.cs +++ b/src/OpenCode.Sdk/OpenCodeServerOptions.cs @@ -38,4 +38,13 @@ public sealed class OpenCodeServerOptions /// force-kill window. /// public TimeSpan GracefulShutdownTimeout { get; set; } = TimeSpan.FromSeconds(3); + + /// + /// Gets or sets an optional caller-created collector that retains a bounded tail of the + /// child's stdout and stderr for pull snapshots. A collector binds to exactly one start + /// attempt; supplying one an earlier start already bound is refused before anything is + /// spawned. Null keeps the default: output is drained and, apart from the startup failure + /// diagnostics, not retained. + /// + public OpenCodeServerOutput? Output { get; set; } } diff --git a/src/OpenCode.Sdk/OpenCodeServerOutput.cs b/src/OpenCode.Sdk/OpenCodeServerOutput.cs new file mode 100644 index 00000000..5677e62b --- /dev/null +++ b/src/OpenCode.Sdk/OpenCodeServerOutput.cs @@ -0,0 +1,82 @@ +using OpenCode.Sdk.Internal; + +namespace OpenCode.Sdk; + +/// +/// A caller-owned collector of a started server's stdout and stderr, supplied through +/// . It retains a bounded tail of each stream — the +/// newest 500 lines and at most 1,048,576 characters per stream, the first stdout line +/// (the readiness contract) included — for pull snapshots, and it invokes no caller code from +/// the process readers. One collector binds to exactly one start attempt, before the child is +/// spawned; it stays readable after a failed start, and it needs no disposal. Once the owning +/// server has been disposed (or the start has failed) the collection is closed and later +/// arrivals are ignored, so a snapshot taken then is final. +/// +public sealed class OpenCodeServerOutput +{ + /// The newest lines each stream retains. + internal const int RetainedLines = 500; + + /// The most content each stream retains, in UTF-16 characters, line terminators excluded. + internal const int RetainedCharacters = 1_048_576; + + private readonly Lock _gate = new(); + private readonly BoundedLineBuffer _standardOutput = new(RetainedLines, RetainedCharacters); + private readonly BoundedLineBuffer _standardError = new(RetainedLines, RetainedCharacters); + private int _bound; + private bool _completed; + + /// + /// Copies the retained tail of both streams. The snapshot is stable: output arriving later + /// never mutates it. + /// + /// The retained lines of each stream, oldest first, and whether either stream lost content. + public OpenCodeServerOutputSnapshot GetSnapshot() + { + lock (_gate) + { + return new OpenCodeServerOutputSnapshot + { + StandardOutput = _standardOutput.Snapshot(), + StandardError = _standardError.Snapshot(), + StandardOutputTruncated = _standardOutput.Truncated, + StandardErrorTruncated = _standardError.Truncated, + }; + } + } + + /// Claims this collector for one start attempt. + /// True on the first claim; false when an earlier start already owns the collector. + internal bool TryBind() => Interlocked.Exchange(ref _bound, 1) is 0; + + internal void AppendStandardOutput(string line) + { + lock (_gate) + { + if (!_completed) + { + _standardOutput.Append(line); + } + } + } + + internal void AppendStandardError(string line) + { + lock (_gate) + { + if (!_completed) + { + _standardError.Append(line); + } + } + } + + /// Closes the collection: everything retained so far is final, later arrivals are ignored. + internal void Complete() + { + lock (_gate) + { + _completed = true; + } + } +} diff --git a/src/OpenCode.Sdk/OpenCodeServerOutputSnapshot.cs b/src/OpenCode.Sdk/OpenCodeServerOutputSnapshot.cs new file mode 100644 index 00000000..f4289b6b --- /dev/null +++ b/src/OpenCode.Sdk/OpenCodeServerOutputSnapshot.cs @@ -0,0 +1,22 @@ +namespace OpenCode.Sdk; + +/// +/// A stable copy of the output an collector retained: each +/// stream's lines oldest first, and per stream whether any line or part of a line was +/// discarded to stay inside the retention bounds. Order holds within a stream; no ordering +/// between the two streams is promised. +/// +public sealed record OpenCodeServerOutputSnapshot +{ + /// Gets the retained stdout lines, the readiness line first when it was retained. + public required IReadOnlyList StandardOutput { get; init; } + + /// Gets the retained stderr lines. + public required IReadOnlyList StandardError { get; init; } + + /// Gets whether stdout lost any line or part of a line to the retention bounds. + public required bool StandardOutputTruncated { get; init; } + + /// Gets whether stderr lost any line or part of a line to the retention bounds. + public required bool StandardErrorTruncated { get; init; } +} diff --git a/tests/OpenCode.Sdk.Tests/OpenCodeServerLifecycleTests.cs b/tests/OpenCode.Sdk.Tests/OpenCodeServerLifecycleTests.cs index 019c9c97..7208b1e9 100644 --- a/tests/OpenCode.Sdk.Tests/OpenCodeServerLifecycleTests.cs +++ b/tests/OpenCode.Sdk.Tests/OpenCodeServerLifecycleTests.cs @@ -1,5 +1,6 @@ using OpenCode.Sdk.TestSupport; using Testably.Abstractions; +using TUnit.Assertions.Enums; namespace OpenCode.Sdk.Tests; @@ -146,6 +147,145 @@ public async Task StartAsync_Should_Refuse_A_Non_Contract_First_Line(Cancellatio await Assert.That(failure!.Message).Contains("readiness contract"); } + [Test] + [Timeout(120_000)] + public async Task StartAsync_Should_Retain_Both_Streams_For_A_Pull_Snapshot(CancellationToken cancellationToken) + { + const string readyLine = "{\"url\":\"http://127.0.0.1:1\"}"; + var output = new OpenCodeServerOutput(); + var server = await OpenCodeServer.StartAsync( + new OpenCodeServerOptions + { + // A stand-in child: the readiness contract first, then an empty stdout line, a + // later stdout line, and one stderr line, held open until the launcher ends it. + Command = + [ + "bun", "-e", + "console.log('" + readyLine + "'); console.log(''); console.log('later'); console.error('warn-1'); setTimeout(() => {}, 120000)", + ], + GracefulShutdownTimeout = TimeSpan.Zero, + Output = output, + }, + cancellationToken); + + // Readiness parsing stayed independent of the collector: the same first line drove both. + await Assert.That(server.Endpoint).IsEqualTo(new Uri("http://127.0.0.1:1")); + + await server.DisposeAsync(); + + var snapshot = output.GetSnapshot(); + await Assert.That(snapshot.StandardOutput).IsEquivalentTo([readyLine, "", "later"], CollectionOrdering.Matching); + await Assert.That(snapshot.StandardError).IsEquivalentTo(["warn-1"], CollectionOrdering.Matching); + await Assert.That(snapshot.StandardOutputTruncated).IsFalse(); + await Assert.That(snapshot.StandardErrorTruncated).IsFalse(); + } + + [Test] + [Timeout(120_000)] + public async Task DisposeAsync_Should_Finalize_The_Collector_After_A_Graceful_Stdin_Eof_Exit(CancellationToken cancellationToken) + { + const string readyLine = "{\"url\":\"http://127.0.0.1:1\"}"; + var output = new OpenCodeServerOutput(); + var server = await OpenCodeServer.StartAsync( + new OpenCodeServerOptions + { + // The child writes its last lines only once the stdin lease is released, then + // leaves on its own inside the default grace: the graceful arm of disposal. + Command = + [ + "bun", "-e", + "console.log('" + readyLine + "'); process.stdin.resume(); process.stdin.on('end', () => { console.log('FINAL-STDOUT'); console.error('FINAL-STDERR'); });", + ], + Output = output, + }, + cancellationToken); + + await server.DisposeAsync(); + + // Lines written during the shutdown itself are inside the final snapshot: the drain ran + // before the collection closed. + var snapshot = output.GetSnapshot(); + await Assert.That(snapshot.StandardOutput).IsEquivalentTo([readyLine, "FINAL-STDOUT"], CollectionOrdering.Matching); + await Assert.That(snapshot.StandardError).IsEquivalentTo(["FINAL-STDERR"], CollectionOrdering.Matching); + } + + [Test] + [Timeout(120_000)] + public async Task DisposeAsync_Should_Finalize_The_Collector_For_A_Child_That_Already_Exited(CancellationToken cancellationToken) + { + const string readyLine = "{\"url\":\"http://127.0.0.1:1\"}"; + var output = new OpenCodeServerOutput(); + var server = await OpenCodeServer.StartAsync( + new OpenCodeServerOptions + { + // Ready, one stderr line, then a self-initiated exit shortly after: disposal + // finds no child to end and still finalizes what the readers deliver. + Command = + [ + "bun", "-e", + "console.log('" + readyLine + "'); console.error('bye'); setTimeout(() => process.exit(0), 500);", + ], + Output = output, + }, + cancellationToken); + using (var child = System.Diagnostics.Process.GetProcessById(server.ProcessId)) + { + await child.WaitForExitAsync(cancellationToken); + } + + await server.DisposeAsync(); + + var snapshot = output.GetSnapshot(); + await Assert.That(snapshot.StandardOutput).IsEquivalentTo([readyLine], CollectionOrdering.Matching); + await Assert.That(snapshot.StandardError).IsEquivalentTo(["bye"], CollectionOrdering.Matching); + } + + [Test] + [Timeout(120_000)] + public async Task StartAsync_Should_Leave_The_Collector_Readable_After_A_Failed_Start(CancellationToken cancellationToken) + { + var output = new OpenCodeServerOutput(); + + var failure = await Assert.That(async () => await OpenCodeServer.StartAsync( + new OpenCodeServerOptions + { + Command = ["bun", "-e", "console.error('boom'); process.exit(7)"], + Output = output, + }, + cancellationToken)).Throws(); + + // The startup exception's own tail and the collector are independent witnesses. + await Assert.That(failure!.Message).Contains("boom"); + var snapshot = output.GetSnapshot(); + await Assert.That(snapshot.StandardError).IsEquivalentTo(["boom"], CollectionOrdering.Matching); + await Assert.That(snapshot.StandardOutput).IsEmpty(); + } + + [Test] + [Timeout(120_000)] + public async Task StartAsync_Should_Refuse_A_Collector_An_Earlier_Start_Bound_Before_Spawning(CancellationToken cancellationToken) + { + var output = new OpenCodeServerOutput(); + _ = await Assert.That(async () => await OpenCodeServer.StartAsync( + new OpenCodeServerOptions + { + Command = ["bun", "-e", "process.exit(7)"], + Output = output, + }, + cancellationToken)).Throws(); + + // Refused as an options error, not as a spawn failure: the missing executable is never run. + var refusal = await Assert.That(async () => await OpenCodeServer.StartAsync( + new OpenCodeServerOptions + { + Command = ["opencode-sdk-test-missing-executable"], + Output = output, + }, + cancellationToken)).Throws(); + + await Assert.That(refusal!.Message).Contains("already bound"); + } + [Test] [Timeout(120_000)] public async Task StartAsync_Should_Refuse_A_Missing_Executable(CancellationToken cancellationToken) diff --git a/tests/OpenCode.Sdk.Tests/OpenCodeServerOutputTests.cs b/tests/OpenCode.Sdk.Tests/OpenCodeServerOutputTests.cs new file mode 100644 index 00000000..2d4379ff --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/OpenCodeServerOutputTests.cs @@ -0,0 +1,148 @@ +using System.Globalization; +using TUnit.Assertions.Enums; + +namespace OpenCode.Sdk.Tests; + +/// +/// The collector's retention contract in isolation: both bounds, the sticky truncation flags, +/// snapshot stability, single binding, and the closed state. Real-process delivery is proven +/// by . +/// +public sealed class OpenCodeServerOutputTests +{ + [Test] + public async Task GetSnapshot_Should_Be_Empty_And_Untruncated_Before_Any_Output() + { + var output = new OpenCodeServerOutput(); + + var snapshot = output.GetSnapshot(); + + await Assert.That(snapshot.StandardOutput).IsEmpty(); + await Assert.That(snapshot.StandardError).IsEmpty(); + await Assert.That(snapshot.StandardOutputTruncated).IsFalse(); + await Assert.That(snapshot.StandardErrorTruncated).IsFalse(); + } + + [Test] + public async Task GetSnapshot_Should_Preserve_Order_And_Empty_Lines_Per_Stream() + { + var output = new OpenCodeServerOutput(); + output.AppendStandardOutput("{\"url\":\"http://127.0.0.1:1\"}"); + output.AppendStandardOutput(""); + output.AppendStandardError("warn"); + output.AppendStandardOutput("later"); + + var snapshot = output.GetSnapshot(); + + await Assert.That(snapshot.StandardOutput).IsEquivalentTo(["{\"url\":\"http://127.0.0.1:1\"}", "", "later"], CollectionOrdering.Matching); + await Assert.That(snapshot.StandardError).IsEquivalentTo(["warn"], CollectionOrdering.Matching); + await Assert.That(snapshot.StandardOutputTruncated).IsFalse(); + await Assert.That(snapshot.StandardErrorTruncated).IsFalse(); + } + + [Test] + public async Task Append_Should_Evict_The_Oldest_Line_Beyond_The_Line_Bound_And_Flag_That_Stream_Only() + { + var output = new OpenCodeServerOutput(); + for (var index = 0; index <= OpenCodeServerOutput.RetainedLines; index++) + { + output.AppendStandardOutput("line-" + index.ToString(CultureInfo.InvariantCulture)); + } + + output.AppendStandardError("only"); + + var snapshot = output.GetSnapshot(); + + await Assert.That(snapshot.StandardOutput.Count).IsEqualTo(OpenCodeServerOutput.RetainedLines); + await Assert.That(snapshot.StandardOutput[0]).IsEqualTo("line-1"); + await Assert.That(snapshot.StandardOutput[^1]).IsEqualTo("line-" + OpenCodeServerOutput.RetainedLines.ToString(CultureInfo.InvariantCulture)); + await Assert.That(snapshot.StandardOutputTruncated).IsTrue(); + await Assert.That(snapshot.StandardErrorTruncated).IsFalse(); + } + + [Test] + public async Task Append_Should_Evict_Oldest_Lines_To_Satisfy_The_Character_Bound() + { + var output = new OpenCodeServerOutput(); + var half = new string('a', (OpenCodeServerOutput.RetainedCharacters / 2) + 1); + output.AppendStandardError(half); + output.AppendStandardError("middle"); + output.AppendStandardError(half); + + var snapshot = output.GetSnapshot(); + + // The first half-plus line has to go for the second to fit; "middle" survives. + await Assert.That(snapshot.StandardError).IsEquivalentTo(["middle", half], CollectionOrdering.Matching); + await Assert.That(snapshot.StandardErrorTruncated).IsTrue(); + await Assert.That(snapshot.StandardOutputTruncated).IsFalse(); + } + + [Test] + public async Task Append_Should_Keep_The_Suffix_Of_An_Oversized_Line() + { + var output = new OpenCodeServerOutput(); + var oversized = new string('x', 8) + new string('y', OpenCodeServerOutput.RetainedCharacters); + output.AppendStandardOutput(oversized); + + var snapshot = output.GetSnapshot(); + + await Assert.That(snapshot.StandardOutput.Count).IsEqualTo(1); + await Assert.That(snapshot.StandardOutput[0].Length).IsEqualTo(OpenCodeServerOutput.RetainedCharacters); + await Assert.That(snapshot.StandardOutput[0]).IsEqualTo(new string('y', OpenCodeServerOutput.RetainedCharacters)); + await Assert.That(snapshot.StandardOutputTruncated).IsTrue(); + } + + [Test] + public async Task Append_Should_Count_An_Evicted_Empty_Line_As_Truncation() + { + var output = new OpenCodeServerOutput(); + for (var index = 0; index <= OpenCodeServerOutput.RetainedLines; index++) + { + output.AppendStandardError(""); + } + + var snapshot = output.GetSnapshot(); + + await Assert.That(snapshot.StandardError.Count).IsEqualTo(OpenCodeServerOutput.RetainedLines); + await Assert.That(snapshot.StandardErrorTruncated).IsTrue(); + } + + [Test] + public async Task GetSnapshot_Should_Stay_Stable_When_Output_Keeps_Arriving() + { + var output = new OpenCodeServerOutput(); + output.AppendStandardOutput("first"); + + var earlier = output.GetSnapshot(); + output.AppendStandardOutput("second"); + var later = output.GetSnapshot(); + + await Assert.That(earlier.StandardOutput).IsEquivalentTo(["first"], CollectionOrdering.Matching); + await Assert.That(later.StandardOutput).IsEquivalentTo(["first", "second"], CollectionOrdering.Matching); + } + + [Test] + public async Task TryBind_Should_Succeed_Exactly_Once() + { + var output = new OpenCodeServerOutput(); + + await Assert.That(output.TryBind()).IsTrue(); + await Assert.That(output.TryBind()).IsFalse(); + } + + [Test] + public async Task Append_Should_Be_Ignored_Once_The_Collection_Is_Complete() + { + var output = new OpenCodeServerOutput(); + output.AppendStandardOutput("kept"); + output.AppendStandardError("kept"); + output.Complete(); + output.AppendStandardOutput("late"); + output.AppendStandardError("late"); + + var snapshot = output.GetSnapshot(); + + await Assert.That(snapshot.StandardOutput).IsEquivalentTo(["kept"], CollectionOrdering.Matching); + await Assert.That(snapshot.StandardError).IsEquivalentTo(["kept"], CollectionOrdering.Matching); + } +} diff --git a/tests/OpenCode.Sdk.Tests/Snapshots/PublicApi.verified.txt b/tests/OpenCode.Sdk.Tests/Snapshots/PublicApi.verified.txt index d0162e59..3c65bad3 100644 --- a/tests/OpenCode.Sdk.Tests/Snapshots/PublicApi.verified.txt +++ b/tests/OpenCode.Sdk.Tests/Snapshots/PublicApi.verified.txt @@ -896,9 +896,23 @@ public System.Collections.Generic.IReadOnlyList Command { get; set; } public System.Collections.Generic.IReadOnlyDictionary? Environment { get; set; } public System.TimeSpan GracefulShutdownTimeout { get; set; } + public OpenCode.Sdk.OpenCodeServerOutput? Output { get; set; } public System.TimeSpan ReadinessTimeout { get; set; } public string? WorkingDirectory { get; set; } } + public sealed class OpenCodeServerOutput + { + public OpenCodeServerOutput() { } + public OpenCode.Sdk.OpenCodeServerOutputSnapshot GetSnapshot() { } + } + public sealed class OpenCodeServerOutputSnapshot : System.IEquatable + { + public OpenCodeServerOutputSnapshot() { } + public required System.Collections.Generic.IReadOnlyList StandardError { get; init; } + public required bool StandardErrorTruncated { get; init; } + public required System.Collections.Generic.IReadOnlyList StandardOutput { get; init; } + public required bool StandardOutputTruncated { get; init; } + } public sealed class OpenCodeStreamFailureException : OpenCode.Sdk.OpenCodeTransportException { public OpenCodeStreamFailureException() { } diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/ReservedNamePolicy.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/ReservedNamePolicy.cs index 3692579e..3a490bfc 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/ReservedNamePolicy.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/ReservedNamePolicy.cs @@ -49,6 +49,8 @@ internal static class ReservedNamePolicy "OpenCodeServer", "OpenCodeServerException", "OpenCodeServerOptions", + "OpenCodeServerOutput", + "OpenCodeServerOutputSnapshot", "OpenCodeStreamFailureException", "OpenCodeTransportException", "PersistentPtyAttachedFrame", From a8eea5835ff9649364140029af725ca97947a5b4 Mon Sep 17 00:00:00 2001 From: "denizirgin@gmail.com" Date: Mon, 7 Sep 2026 18:15:52 +0300 Subject: [PATCH 44/62] fix(sdk): keep the launched server's process id readable after disposal ProcessId read the live Process handle, so a caller writing diagnostics after DisposeAsync got "No process is associated with this object". The identity is captured while the handle is live and stays readable afterwards. --- src/OpenCode.Sdk/OpenCodeServer.cs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/OpenCode.Sdk/OpenCodeServer.cs b/src/OpenCode.Sdk/OpenCodeServer.cs index 957932b7..9c8da3fb 100644 --- a/src/OpenCode.Sdk/OpenCodeServer.cs +++ b/src/OpenCode.Sdk/OpenCodeServer.cs @@ -34,6 +34,7 @@ public class OpenCodeServer : IAsyncDisposable private readonly string? _password; private readonly TimeSpan _gracefulShutdownTimeout; private readonly OpenCodeServerOutput? _output; + private readonly int? _processId; private int _disposed; private OpenCodeServer( @@ -48,6 +49,10 @@ private OpenCodeServer( _password = password; _gracefulShutdownTimeout = gracefulShutdownTimeout; _output = output; + + // Captured while the handle is live: the identity stays readable for diagnostics after + // disposal has released the process handle. + _processId = process.Id; } /// @@ -76,9 +81,12 @@ protected OpenCodeServer() /// Gets the generated lease credential this start injected into the child. public virtual string Password => _password ?? throw MockSeam.CreateError("OpenCodeServer", "Password"); - /// Gets the child process identifier, for diagnostics and process-truth assertions. + /// + /// Gets the child process identifier, for diagnostics and process-truth assertions. It + /// remains readable after disposal, when the process handle itself is gone. + /// public virtual int ProcessId => - (_process ?? throw MockSeam.CreateError("OpenCodeServer", "ProcessId")).Id; + _processId ?? throw MockSeam.CreateError("OpenCodeServer", "ProcessId"); /// /// Starts a fresh private standalone server: spawns the command with --stdio --port 0 From eb35145952b3a8ceba5189055c426a7eab73d305 Mon Sep 17 00:00:00 2001 From: "denizirgin@gmail.com" Date: Mon, 7 Sep 2026 18:15:53 +0300 Subject: [PATCH 45/62] test: dogfood OpenCodeServer in the fixtures and retire the CliWrap adapter Both server fixtures now start their child through the SDK's own launcher with an output collector, so every live test exercises the product launcher and a launcher regression surfaces in the whole live lane. The test-only CliWrap adapter, its log tail, its tests, and the CliWrap dependency are removed; the persistent simulation launch recipe becomes a shared helper. Output-dependent proofs move from push waits to the collector's final snapshot: the persistent simulation host's three milestones are proven by a dedicated real lifecycle (stdout carries only readiness; starting, ready, stdin-closed reach stderr in order with their severity) and rechecked by the simulated fixture at teardown; the fixture failure proofs wait for the owned server's actual disposal to settle before delivering a controlled teardown timeout, so the output they read back is the launcher's final collection. Adapter-specific deadline proofs (forced-exit verdict, startup teardown timeout) retire with their owner; the product launcher's startup failures are covered by its own lifecycle tests, and the fixture still retains logs under the results root when the start fails. --- .editorconfig | 17 - .../CliWrapServerAdapterTests.cs | 74 ---- .../Server/invalid-diagnostic-peer.js | 6 - .../Fixtures/Server/silent-diagnostic-peer.js | 4 - .../OpenCode.Sdk.Tests.csproj | 1 - .../PersistentSimulationHostTests.cs | 90 ++++- ...PinnedOpenCodeServerFixtureFailureTests.cs | 95 ++--- .../OpenCode.Sdk.Tests/ServerLogTailTests.cs | 25 -- .../Support/ControlledOperationDeadline.cs | 10 +- .../Support/OperationDeadlineScenario.cs | 2 +- .../Support/ServerDiagnosticScenario.cs | 18 - .../Support/StartupDiagnosticMode.cs | 9 - tests/Shared/CliWrapServerAdapter.cs | 330 ------------------ tests/Shared/PinnedOpenCodeServerFixture.cs | 69 ++-- tests/Shared/ServerFailureCapture.cs | 28 +- tests/Shared/ServerLogSnapshot.cs | 8 - tests/Shared/ServerLogTail.cs | 40 --- tests/Shared/SimulatedDriveServerFixture.cs | 124 +++---- tests/Shared/SimulatedServerLaunch.cs | 52 +++ 19 files changed, 270 insertions(+), 732 deletions(-) delete mode 100644 tests/OpenCode.Sdk.Tests/CliWrapServerAdapterTests.cs delete mode 100644 tests/OpenCode.Sdk.Tests/Fixtures/Server/invalid-diagnostic-peer.js delete mode 100644 tests/OpenCode.Sdk.Tests/Fixtures/Server/silent-diagnostic-peer.js delete mode 100644 tests/OpenCode.Sdk.Tests/ServerLogTailTests.cs delete mode 100644 tests/OpenCode.Sdk.Tests/Support/StartupDiagnosticMode.cs delete mode 100644 tests/Shared/CliWrapServerAdapter.cs delete mode 100644 tests/Shared/ServerLogSnapshot.cs delete mode 100644 tests/Shared/ServerLogTail.cs create mode 100644 tests/Shared/SimulatedServerLaunch.cs diff --git a/.editorconfig b/.editorconfig index 4990f0f9..43da4974 100644 --- a/.editorconfig +++ b/.editorconfig @@ -970,23 +970,6 @@ dotnet_diagnostic.VSTHRD003.severity = none [tests/OpenCode.Sdk.Tests/Support/ServerDiagnosticScenario.cs] dotnet_diagnostic.VSTHRD003.severity = none -# CliWrapServerAdapter parks on TaskCompletionSource handles the adapter itself completes — the -# stdin lease that holds the child's ownership pipe open until disposal, and the first stdout -# line that carries the readiness contract — plus the CliWrap execution task it started. That -# externally-completed lease is the stdio ownership mechanism itself, not a foreign task with -# unknown context. Winner: the stdio-lease control adapter; VSTHRD003 remains enabled for every -# other file. -# StartAsync disposes the adapter on every readiness-failure branch before it throws, and returns -# it undisposed only on the success path where the caller becomes the owner - the same -# ownership-transfer shape OpenCodeServer.StartAsync uses for its Process local. CA2000's flow -# analysis does not resolve that transfer through this method's mix of nested try/catch and a -# CliWrap fluent builder capturing the local by reference on the net472 leg only; the other three -# TFMs verify the same method clean. Winner: the readiness-failure disposal already in the method -# body; CA2000 remains enabled for every other file. -[tests/Shared/CliWrapServerAdapter.cs] -dotnet_diagnostic.VSTHRD003.severity = none -dotnet_diagnostic.CA2000.severity = none - # Sandbox application's purpose is primarly for testing [tests/OpenCode.Sdk.Sandbox/**.cs] dotnet_diagnostic.MA0076.severity = none diff --git a/tests/OpenCode.Sdk.Tests/CliWrapServerAdapterTests.cs b/tests/OpenCode.Sdk.Tests/CliWrapServerAdapterTests.cs deleted file mode 100644 index f3d77e59..00000000 --- a/tests/OpenCode.Sdk.Tests/CliWrapServerAdapterTests.cs +++ /dev/null @@ -1,74 +0,0 @@ -using OpenCode.Sdk.TestSupport; -using Testably.Abstractions; - -namespace OpenCode.Sdk.Tests; - -[NotInParallel(ParallelConstraintKeys.ServerProcess)] -public sealed class CliWrapServerAdapterTests -{ - private static readonly RealFileSystem FileSystem = new(); - - private static bool IsProcessRunning(int processId) - { - try - { - using var process = System.Diagnostics.Process.GetProcessById(processId); - return !process.HasExited; - } - catch (ArgumentException) - { - return false; - } - } - - [Test] - [Timeout(240_000)] - public async Task DisposeAsync_Should_End_The_Server_Through_The_Forced_Kill_Escalation(CancellationToken cancellationToken) - { - var runRoot = new TestRunRoot(FileSystem); - using var _ = runRoot; - var pinnedCommand = new PinnedServerCommand(FileSystem); - - // Zero grace: the wait is already expired when disposal reaches it, so the forced - // tree-kill path - and its own secondary bound on the final await - executes - // deterministically (mirrors OpenCodeServerLifecycleTests' equivalent escalation test for - // the production launcher). - var adapter = await CliWrapServerAdapter.StartAsync( - pinnedCommand.Resolve(), - ServerIsolation.Environment(FileSystem, runRoot.Path), - FileSystem.Path.Combine(pinnedCommand.RepositoryRoot, "external", "opencode", "packages", "cli"), - readinessTimeout: TimeSpan.FromMinutes(3), - gracefulShutdownTimeout: TimeSpan.Zero, - cancellationToken: cancellationToken); - var processId = adapter.ProcessId; - - await adapter.DisposeAsync(); - - await Assert.That(IsProcessRunning(processId)).IsFalse(); - } - - [Test] - [Timeout(240_000)] - public async Task DisposeAsync_Should_Be_Idempotent_When_Called_Twice(CancellationToken cancellationToken) - { - var runRoot = new TestRunRoot(FileSystem); - using var _ = runRoot; - var pinnedCommand = new PinnedServerCommand(FileSystem); - - var adapter = await CliWrapServerAdapter.StartAsync( - pinnedCommand.Resolve(), - ServerIsolation.Environment(FileSystem, runRoot.Path), - FileSystem.Path.Combine(pinnedCommand.RepositoryRoot, "external", "opencode", "packages", "cli"), - readinessTimeout: TimeSpan.FromMinutes(3), - cancellationToken: cancellationToken); - - await adapter.DisposeAsync(); - - // The guard's short-circuit makes a second call a no-op unconditionally, regardless of - // whether the first call's forced-kill wait actually raced _execution's completion - the - // exact interleaving the review flagged. That race needs no dedicated timing - // reproduction: the guard returns before touching _forceKill either way, so this - // deterministic double-dispose call is the whole pin. - await adapter.DisposeAsync(); - } -} diff --git a/tests/OpenCode.Sdk.Tests/Fixtures/Server/invalid-diagnostic-peer.js b/tests/OpenCode.Sdk.Tests/Fixtures/Server/invalid-diagnostic-peer.js deleted file mode 100644 index 2b35620e..00000000 --- a/tests/OpenCode.Sdk.Tests/Fixtures/Server/invalid-diagnostic-peer.js +++ /dev/null @@ -1,6 +0,0 @@ -console.log("INVALID-READY"); -process.stdin.resume(); -process.stdin.on("end", () => { - console.log("FINAL-STDOUT"); - console.error("FINAL-STDERR"); -}); diff --git a/tests/OpenCode.Sdk.Tests/Fixtures/Server/silent-diagnostic-peer.js b/tests/OpenCode.Sdk.Tests/Fixtures/Server/silent-diagnostic-peer.js deleted file mode 100644 index 9525a7b9..00000000 --- a/tests/OpenCode.Sdk.Tests/Fixtures/Server/silent-diagnostic-peer.js +++ /dev/null @@ -1,4 +0,0 @@ -process.stdin.resume(); -process.stdin.on("end", () => { - console.error("FINAL-STDERR"); -}); diff --git a/tests/OpenCode.Sdk.Tests/OpenCode.Sdk.Tests.csproj b/tests/OpenCode.Sdk.Tests/OpenCode.Sdk.Tests.csproj index e0ac86e8..c6f02b28 100644 --- a/tests/OpenCode.Sdk.Tests/OpenCode.Sdk.Tests.csproj +++ b/tests/OpenCode.Sdk.Tests/OpenCode.Sdk.Tests.csproj @@ -17,7 +17,6 @@ - all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/tests/OpenCode.Sdk.Tests/PersistentSimulationHostTests.cs b/tests/OpenCode.Sdk.Tests/PersistentSimulationHostTests.cs index e484cf0a..2b2c379a 100644 --- a/tests/OpenCode.Sdk.Tests/PersistentSimulationHostTests.cs +++ b/tests/OpenCode.Sdk.Tests/PersistentSimulationHostTests.cs @@ -1,21 +1,91 @@ +using OpenCode.Sdk.Internal; using OpenCode.Sdk.TestSupport; +using Testably.Abstractions; namespace OpenCode.Sdk.Tests; -[ClassDataSource(Shared = SharedType.PerTestSession)] +/// +/// The persistent simulation host's diagnostic contract, proven over one dedicated real +/// lifecycle through the SDK's own launcher and collector: the readiness line is the only thing +/// on stdout, and the three lifecycle milestones reach stderr with their severity, in order, +/// the last one written only once the stdin lease is released. The shared +/// checks the same three milestones at its own +/// teardown; this test is where the profile's routing is proven on its own. +/// [NotInParallel(ParallelConstraintKeys.ServerProcess)] -public sealed class PersistentSimulationHostTests(SimulatedDriveServerFixture server) +public sealed class PersistentSimulationHostTests { + private static readonly RealFileSystem FileSystem = new(); + + private static readonly TimeSpan ReadinessTimeout = TimeSpan.FromMinutes(3); + + private static readonly TimeSpan GateTimeout = TimeSpan.FromMinutes(15); + [Test] - [Timeout(60_000)] - public async Task Host_Should_Route_Diagnostics_Away_From_Readiness_Stdout( - CancellationToken _) + [Timeout(240_000)] + public async Task Host_Should_Route_Diagnostics_To_Stderr_Around_Readiness_And_Stdin_Eof( + CancellationToken cancellationToken) + { + using var runRoot = new TestRunRoot(FileSystem); + var output = new OpenCodeServerOutput(); + OpenCodeServer server; + using (var gate = await DrivePortGate.AcquireAsync(FileSystem, GateTimeout)) + { + var launch = SimulatedServerLaunch.Prepare(FileSystem, runRoot); + server = await OpenCodeServer.StartAsync( + new OpenCodeServerOptions + { + Command = launch.Command, + WorkingDirectory = launch.WorkingDirectory, + Environment = launch.Environment, + ReadinessTimeout = ReadinessTimeout, + GracefulShutdownTimeout = TimeSpan.FromSeconds(10), + Output = output, + }, + cancellationToken); + + // Readiness proves the manifest's ports are bound (simulation builds its network + // layer eagerly at start), which is what lets the gate go. + } + + await server.DisposeAsync(); + + var snapshot = output.GetSnapshot(); + await Assert.That(snapshot.StandardOutput.Count).IsEqualTo(1); + await Assert.That(ServerReadyLine.TryParse(snapshot.StandardOutput[0], out _)).IsTrue(); + + var starting = RequireIndex(snapshot, "persistent simulation host starting"); + var ready = RequireIndex(snapshot, "persistent simulation host ready"); + var closed = RequireIndex(snapshot, "persistent simulation host stdin closed"); + await Assert.That(starting).IsLessThan(ready); + await Assert.That(ready).IsLessThan(closed); + await Assert.That(snapshot.StandardError[starting]).Contains("level=INFO"); + await Assert.That(snapshot.StandardError[ready]).Contains("level=WARN"); + await Assert.That(snapshot.StandardError[closed]).Contains("level=INFO"); + + Console.WriteLine( + "persistent-host-diagnostics: " + snapshot.StandardError[starting] + + " | " + snapshot.StandardError[ready] + + " | " + snapshot.StandardError[closed]); + } + + /// + /// The milestone's index in the final stderr snapshot. A missing milestone fails naming + /// whether the snapshot lost content: truncation that eats promised evidence is a failure + /// of this proof, never an excuse for it. + /// + private static int RequireIndex(OpenCodeServerOutputSnapshot snapshot, string milestone) { - var beforeReady = server.PreReadinessDiagnostic; - var afterReady = server.PostReadinessDiagnostic; + for (var index = 0; index < snapshot.StandardError.Count; index++) + { + if (snapshot.StandardError[index].Contains(milestone, StringComparison.Ordinal)) + { + return index; + } + } - await Assert.That(beforeReady).Contains("level=INFO"); - await Assert.That(afterReady).Contains("level=WARN"); - Console.WriteLine("persistent-host-diagnostics: " + beforeReady + " | " + afterReady); + throw new InvalidOperationException( + $"The persistent simulation host did not emit '{milestone}' on stderr " + + $"(stderr truncated: {snapshot.StandardErrorTruncated})."); } } diff --git a/tests/OpenCode.Sdk.Tests/PinnedOpenCodeServerFixtureFailureTests.cs b/tests/OpenCode.Sdk.Tests/PinnedOpenCodeServerFixtureFailureTests.cs index 7fe7c68b..a5e06960 100644 --- a/tests/OpenCode.Sdk.Tests/PinnedOpenCodeServerFixtureFailureTests.cs +++ b/tests/OpenCode.Sdk.Tests/PinnedOpenCodeServerFixtureFailureTests.cs @@ -6,9 +6,11 @@ namespace OpenCode.Sdk.Tests; /// /// Pins the failure-path log-retention contract: a real -/// failures retain bounded stdout/stderr and identity beneath the results root, beyond the lower-level -/// adapter's own in-memory buffers. Drives the fixture itself (its internal command-override -/// seam), not the shared per-session instance the other fixture tests share. +/// failure retains bounded stdout/stderr and identity beneath the results root, beyond the +/// launcher's own in-memory collector. Drives the fixture itself (its internal command-override +/// seam), not the shared per-session instance the other fixture tests share. Where a proof holds +/// the fixture's teardown deadline, it first waits for the owned server's actual disposal to +/// settle, so the final output it reads back is the launcher's final collection. /// [NotInParallel(ParallelConstraintKeys.ServerProcess)] public sealed class PinnedOpenCodeServerFixtureFailureTests @@ -122,29 +124,31 @@ public async Task DisposeAsync_Should_Retain_A_Late_Capture_Failure_Without_Dela [Test] [Timeout(30_000)] - public async Task DisposeAsync_Should_Report_An_Unconfirmed_Forced_Exit_Without_A_Body_Failure(CancellationToken cancellationToken) + public async Task DisposeAsync_Should_Report_A_Teardown_Timeout_Without_A_Body_Failure(CancellationToken cancellationToken) { var scenario = new ServerDiagnosticScenario(); - var grace = scenario.Deadlines.Hold("pinned server graceful exit"); - var forced = scenario.Deadlines.Hold("pinned server forced exit"); + var teardown = scenario.Deadlines.Hold("pinned server teardown"); await using var fixture = scenario.CreateFixture(); var completion = Task.CompletedTask; try { await fixture.InitializeAsync(); completion = fixture.DisposeAsync().AsTask(); - await grace.Entered.WaitAsync(cancellationToken); - _ = await fixture.Adapter.WaitForErrorLineAsync("FINAL-STDERR", cancellationToken); - grace.Release(); - await forced.Entered.WaitAsync(cancellationToken); - forced.Release(); + await teardown.Entered.WaitAsync(cancellationToken); + + // The owned server's disposal runs on regardless of the held deadline; let it settle + // (its final output is then collected) before the controlled timeout is delivered. + _ = await Task.WhenAny(await teardown.Operation); + teardown.Expire(); + await teardown.Won.WaitAsync(cancellationToken); + teardown.Deliver(); var observed = await Assert.That(async () => await completion).Throws(); - await Assert.That(observed!.Message).Contains("pinned server forced exit"); - await Assert.That(observed.InnerException).IsSameReferenceAs(forced.Failure); + await Assert.That(observed!.Message).Contains("pinned server teardown"); + await Assert.That(observed.InnerException).IsSameReferenceAs(teardown.Failure); await fixture.DrainDiagnosticsAsync(cancellationToken); await Assert.That(await scenario.ReadLogAsync(fixture, "stderr.log")).Contains("FINAL-STDERR"); - await Assert.That(await scenario.ReadMetadataAsync(fixture)).Contains("pinned server forced exit"); + await Assert.That(await scenario.ReadMetadataAsync(fixture)).Contains("pinned server teardown"); } finally { @@ -152,60 +156,6 @@ public async Task DisposeAsync_Should_Report_An_Unconfirmed_Forced_Exit_Without_ } } - [Test] - [Arguments(StartupDiagnosticMode.InvalidReadiness)] - [Arguments(StartupDiagnosticMode.ReadinessTimeout)] - [Arguments(StartupDiagnosticMode.CallerCancellation)] - [Arguments(StartupDiagnosticMode.EarlyExit)] - [Timeout(30_000)] - public async Task StartAsync_Should_Preserve_Startup_Failure_When_Teardown_Also_Times_Out( - StartupDiagnosticMode mode, CancellationToken cancellationToken) - { - var scenario = new ServerDiagnosticScenario(); - var deadline = scenario.Deadlines.Hold("pinned server startup teardown"); - using var caller = new CancellationTokenSource(); - if (mode is StartupDiagnosticMode.CallerCancellation) - { - await caller.CancelAsync(); - } - - CliWrapServerAdapter? adapter = null; - var startup = scenario.StartAdapterAsync(mode, created => adapter = created, caller.Token); - try - { - await deadline.Entered.WaitAsync(cancellationToken); - var ownedAdapter = adapter ?? throw new InvalidOperationException("The startup adapter was not constructed."); - _ = await ownedAdapter.WaitForErrorLineAsync("FINAL-STDERR", cancellationToken); - deadline.Expire(); - await deadline.Won.WaitAsync(cancellationToken); - deadline.Deliver(); - - var failure = await Assert.That(async () => await startup).ThrowsException(); - if (mode is StartupDiagnosticMode.CallerCancellation) - { - await Assert.That(failure).IsTypeOf(); - await Assert.That(((OperationCanceledException)failure!).CancellationToken).IsEqualTo(caller.Token); - } - else - { - await Assert.That(failure).IsTypeOf(); - } - - var secondary = failure!.Data[OwnedCleanup.FailuresKey] as AggregateException; - await Assert.That(secondary!.InnerExceptions.OfType().Single().InnerException) - .IsSameReferenceAs(deadline.Failure); - await Assert.That(failure.Data["PinnedServer.StartupLogs"] as string).Contains("FINAL-STDERR"); - } - finally - { - await scenario.Deadlines.DrainAsync(startup); - if (adapter is not null) - { - await adapter.DisposeAsync(); - } - } - } - [Test] public async Task DisposeAsync_Should_Capture_Final_Output_And_Preserve_The_Body_Failure() { @@ -250,7 +200,10 @@ public async Task DisposeAsync_Should_Preserve_Teardown_And_Capture_Failures_Wit fixture.MarkFailure(primary, "controlled teardown", "pty-phase=initial READY"); completion = fixture.DisposeAsync().AsTask(); await deadline.Entered.WaitAsync(cancellationToken); - _ = await fixture.Adapter.WaitForErrorLineAsync("FINAL-STDERR", cancellationToken); + + // Let the owned server's disposal settle so its final output is collected, then + // deliver the controlled teardown timeout on top of the body failure. + _ = await Task.WhenAny(await deadline.Operation); deadline.Expire(); await deadline.Won.WaitAsync(cancellationToken); deadline.Deliver(); @@ -276,8 +229,8 @@ public async Task InitializeAsync_Should_Retain_Logs_Under_The_Results_Root_On_F await using var fixture = scenario.CreateFixture("Server.exited-diagnostic-peer.js"); try { - var startup = await Assert.That(fixture.InitializeAsync).Throws(); - var disposal = await Assert.That(async () => await fixture.DisposeAsync()).Throws(); + var startup = await Assert.That(fixture.InitializeAsync).Throws(); + var disposal = await Assert.That(async () => await fixture.DisposeAsync()).Throws(); await Assert.That(disposal).IsSameReferenceAs(startup); await Assert.That(await scenario.ReadLogAsync(fixture, "stderr.log")).Contains("FINAL-STDERR"); await Assert.That(await scenario.ReadMetadataAsync(fixture)).Contains("phase=server startup"); diff --git a/tests/OpenCode.Sdk.Tests/ServerLogTailTests.cs b/tests/OpenCode.Sdk.Tests/ServerLogTailTests.cs deleted file mode 100644 index ac140ff0..00000000 --- a/tests/OpenCode.Sdk.Tests/ServerLogTailTests.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System.Globalization; -using OpenCode.Sdk.TestSupport; - -namespace OpenCode.Sdk.Tests; - -public sealed class ServerLogTailTests -{ - [Test] - public async Task Snapshot_Should_Bound_Long_Lines_And_Report_Discarded_Output() - { - var tail = new ServerLogTail(); - for (var index = 0; index < 501; index++) - { - tail.Append(index.ToString(CultureInfo.InvariantCulture) + new string('x', 5_000)); - } - - var snapshot = tail.Snapshot(); - - await Assert.That(snapshot.Count).IsEqualTo(501); - await Assert.That(snapshot[0]).Contains("discarded-lines=1 shortened-lines=501"); - await Assert.That(snapshot.Skip(1).All(static line => line.Length == 4_096)).IsTrue(); - await Assert.That(snapshot[500]).StartsWith("500"); - await Assert.That(snapshot[500]).EndsWith(" [truncated]"); - } -} diff --git a/tests/OpenCode.Sdk.Tests/Support/ControlledOperationDeadline.cs b/tests/OpenCode.Sdk.Tests/Support/ControlledOperationDeadline.cs index 7aa7d64d..189f445b 100644 --- a/tests/OpenCode.Sdk.Tests/Support/ControlledOperationDeadline.cs +++ b/tests/OpenCode.Sdk.Tests/Support/ControlledOperationDeadline.cs @@ -6,9 +6,16 @@ internal sealed class ControlledOperationDeadline private readonly TaskCompletionSource _expired = new(TaskCreationOptions.RunContinuationsAsynchronously); private readonly TaskCompletionSource _won = new(TaskCreationOptions.RunContinuationsAsynchronously); private readonly TaskCompletionSource _delivery = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _operation = new(TaskCreationOptions.RunContinuationsAsynchronously); public Task Entered => _entered.Task; + /// + /// The actual operation the held deadline was asked to observe, available once entered, so a + /// proof can wait for that operation's own completion before it expires the deadline. + /// + public Task Operation => _operation.Task; + public Task Won => _won.Task; public TimeoutException Failure { get; } = new("The controlled deadline elapsed."); @@ -25,8 +32,9 @@ public void Release() _ = _expired.Task.Exception; } - public async Task WaitAsync() + public async Task WaitAsync(Task operation) { + _ = _operation.TrySetResult(operation); _ = _entered.TrySetResult(true); try { diff --git a/tests/OpenCode.Sdk.Tests/Support/OperationDeadlineScenario.cs b/tests/OpenCode.Sdk.Tests/Support/OperationDeadlineScenario.cs index 50eaa43a..50695c49 100644 --- a/tests/OpenCode.Sdk.Tests/Support/OperationDeadlineScenario.cs +++ b/tests/OpenCode.Sdk.Tests/Support/OperationDeadlineScenario.cs @@ -41,7 +41,7 @@ private async Task WaitAsync(string name, Task operation, CancellationToken canc _operations.Enqueue(operation); if (_deadlines.TryGetValue(name, out var deadline)) { - await deadline.WaitAsync(); + await deadline.WaitAsync(operation); return; } diff --git a/tests/OpenCode.Sdk.Tests/Support/ServerDiagnosticScenario.cs b/tests/OpenCode.Sdk.Tests/Support/ServerDiagnosticScenario.cs index 2c9d7282..b22324bb 100644 --- a/tests/OpenCode.Sdk.Tests/Support/ServerDiagnosticScenario.cs +++ b/tests/OpenCode.Sdk.Tests/Support/ServerDiagnosticScenario.cs @@ -40,24 +40,6 @@ public ServerDiagnosticScenario() public OperationDeadlineScenario Deadlines { get; } = new(); - public async Task StartAdapterAsync(StartupDiagnosticMode mode, Action constructed, - CancellationToken cancellationToken) - { - var fixture = mode switch - { - StartupDiagnosticMode.InvalidReadiness => "invalid-diagnostic-peer.js", - StartupDiagnosticMode.EarlyExit => "exited-diagnostic-peer.js", - StartupDiagnosticMode.CallerCancellation or StartupDiagnosticMode.ReadinessTimeout => "silent-diagnostic-peer.js", - _ => throw new ArgumentOutOfRangeException(nameof(mode)), - }; - await using var adapter = await CliWrapServerAdapter.StartAsync( - ["bun", "-e", _fixtures.LoadText("Server." + fixture)], - new Dictionary(StringComparer.Ordinal), - new PinnedServerCommand(_fileSystem).RepositoryRoot, - mode is StartupDiagnosticMode.ReadinessTimeout ? TimeSpan.Zero : TimeSpan.FromSeconds(10), - onConstructed: constructed, deadline: Deadlines.Deadline, cancellationToken: cancellationToken); - } - public PinnedOpenCodeServerFixture CreateFixture(string peer = "Server.diagnostic-peer.js") => new( ["bun", "-e", _fixtures.LoadText(peer)], new PinnedServerCommand(_fileSystem).RepositoryRoot, diff --git a/tests/OpenCode.Sdk.Tests/Support/StartupDiagnosticMode.cs b/tests/OpenCode.Sdk.Tests/Support/StartupDiagnosticMode.cs deleted file mode 100644 index 1c5585cb..00000000 --- a/tests/OpenCode.Sdk.Tests/Support/StartupDiagnosticMode.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace OpenCode.Sdk.Tests.Support; - -public enum StartupDiagnosticMode -{ - InvalidReadiness, - ReadinessTimeout, - CallerCancellation, - EarlyExit, -} diff --git a/tests/Shared/CliWrapServerAdapter.cs b/tests/Shared/CliWrapServerAdapter.cs deleted file mode 100644 index 276541d0..00000000 --- a/tests/Shared/CliWrapServerAdapter.cs +++ /dev/null @@ -1,330 +0,0 @@ -using System.Globalization; -using System.IO.Abstractions; -using System.Security.Cryptography; -using CliWrap; -using OpenCode.Sdk.Internal; -using OpenCode.Sdk.TestSupport.Ownership; -using OpenCode.Sdk.TestSupport.Ownership.Abstractions; - -namespace OpenCode.Sdk.TestSupport; - -/// -/// Test-only control adapter over the pinned server's stdio contract (design §7.2): spawns the -/// command with the lease credential injected, drains both streams continuously through -/// pull-based line delegates, resolves readiness from the first stdout line via the product's -/// own contract, holds stdin open as the ownership lease, and -/// tears down stdin-EOF → bounded wait → forced kill. It separates launcher failures from -/// SDK/server agreement; production scenarios dogfood instead -/// (design §7.3). CliWrap is a repo-test dependency only (ADR-0001). -/// -internal sealed class CliWrapServerAdapter : IAsyncDisposable -{ - private static readonly TimeSpan DefaultGracefulShutdownTimeout = TimeSpan.FromSeconds(10); - - /// - /// Mirrors OpenCodeServer's own ForcedExitTimeout: the bound the escalation itself gets, so a - /// tree-kill CliWrap cannot confirm promptly never turns disposal into an unbounded hang. - /// - private static readonly TimeSpan ForcedExitTimeout = TimeSpan.FromSeconds(10); - - private readonly TaskCompletionSource _stdinLease = - new(TaskCreationOptions.RunContinuationsAsynchronously); - private readonly TaskCompletionSource _firstLine = - new(TaskCreationOptions.RunContinuationsAsynchronously); - private readonly CancellationTokenSource _forceKill = new(); - private readonly Lock _logGate = new(); - private readonly ServerLogTail _stdout = new(); - private readonly ServerLogTail _stderr = new(); - private TaskCompletionSource _stderrChanged = NewSignal(); - private TimeSpan _gracefulShutdownTimeout = DefaultGracefulShutdownTimeout; - private Task? _execution; - private Uri? _endpoint; - private string? _password; - private int _processId; - private int _disposed; - private IOwnedOperationDeadline _deadline = new OwnedOperationDeadline(); - - private CliWrapServerAdapter() - { - } - - public Uri Endpoint => _endpoint ?? throw new InvalidOperationException("The adapter has not reached readiness."); - - public string Password => _password ?? throw new InvalidOperationException("The adapter has not started."); - - public int ProcessId => _processId; - - public LateCleanupFailureReport? LateFailures { get; private set; } - - /// - /// Starts the adapter and waits for readiness. runs - /// synchronously the instant the adapter object exists - before the process is even spawned - - /// so a caller can retain the reference regardless of whether this method later throws. That - /// is what lets write out the captured stdout/stderr - /// on a startup failure: teardown is bounded, and the adapter retains its buffers and any - /// pending execution until that work actually ends. - /// - public static async Task StartAsync( - IReadOnlyList command, - IReadOnlyDictionary environment, - string workingDirectory, - TimeSpan readinessTimeout, - TimeSpan? gracefulShutdownTimeout = null, - Action? onConstructed = null, - IOwnedOperationDeadline? deadline = null, - CancellationToken cancellationToken = default) - { - var adapter = new CliWrapServerAdapter - { - _password = GeneratePassword(), - _deadline = deadline ?? new OwnedOperationDeadline(), - _gracefulShutdownTimeout = gracefulShutdownTimeout ?? DefaultGracefulShutdownTimeout, - }; - onConstructed?.Invoke(adapter); - var cli = Cli.Wrap(command[0]) - .WithArguments([.. command.Skip(1), "--stdio", "--port", "0"]) - .WithWorkingDirectory(workingDirectory) - .WithEnvironmentVariables(variables => - { - foreach (var entry in environment) - { - _ = variables.Set(entry.Key, entry.Value); - } - - // The last write wins: the lease credential can never be shadowed. - _ = variables.Set("OPENCODE_PASSWORD", adapter._password); - }) - .WithStandardInputPipe(PipeSource.Create(async (_, cancellationToken) => - { - // A pending source keeps the pipe open; completing the lease closes it (EOF). - // (A single "_" parameter is a real, usable identifier in C# - only two or more - // discard-named parameters bind as true discards - so it is never reassigned here.) - using var closeOnKill = cancellationToken.Register( - () => adapter._stdinLease.TrySetResult(null)); - await adapter._stdinLease.Task.ConfigureAwait(false); - })) - .WithStandardOutputPipe(PipeTarget.ToDelegate(adapter.OnOutputLine)) - .WithStandardErrorPipe(PipeTarget.ToDelegate(adapter.OnErrorLine)) - .WithValidation(CommandResultValidation.None); - - var execution = cli.ExecuteAsync(adapter._forceKill.Token); - adapter._processId = execution.ProcessId; - adapter._execution = execution.Task; - - // Linked so a caller-supplied cancellation (e.g. a test's own [Timeout]) and the internal - // readiness bound both reach the same wait, distinguished below exactly as - // OpenCodeServer.WaitForReadyLineAsync distinguishes the same two sources. - using var readiness = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - readiness.CancelAfter(readinessTimeout); - try - { - _ = await Task.WhenAny(adapter._firstLine.Task, execution.Task).WaitAsync(readiness.Token); - } - catch (OperationCanceledException exception) - { - var failure = cancellationToken.IsCancellationRequested - ? (Exception)new OperationCanceledException("The adapter start was canceled.", exception, cancellationToken) - : new InvalidOperationException( - $"The pinned server did not report readiness within {readinessTimeout.TotalSeconds.ToString("F0", CultureInfo.InvariantCulture)}s."); - await adapter.ThrowAfterTeardownAsync(failure); - throw; - } - - if (!adapter._firstLine.Task.IsCompleted) - { - await adapter.ThrowAfterTeardownAsync(new InvalidOperationException( - "The pinned server exited before reporting readiness.")); - } - - var line = await adapter._firstLine.Task; - if (!ServerReadyLine.TryParse(line, out var endpoint)) - { - await adapter.ThrowAfterTeardownAsync(new InvalidOperationException( - $"The pinned server's first stdout line is not the readiness contract: '{line}'.")); - } - - adapter._endpoint = endpoint; - return adapter; - } - - public string DescribeLogs() - { - lock (_logGate) - { - var tail = _stderr.Describe(); - return tail.Length == 0 ? string.Empty : " Recent stderr: " + tail; - } - } - - public bool HasErrorLine(string fragment) - { - ArgumentException.ThrowIfNullOrWhiteSpace(fragment); - lock (_logGate) - { - return _stderr.Find(fragment) is not null; - } - } - - public async Task WaitForErrorLineAsync(string fragment, CancellationToken cancellationToken) - { - ArgumentException.ThrowIfNullOrWhiteSpace(fragment); - while (true) - { - Task signal; - lock (_logGate) - { - var match = _stderr.Find(fragment); - if (match is not null) - { - return match; - } - - signal = _stderrChanged.Task; - } - - await signal.WaitAsync(cancellationToken); - } - } - - public ServerLogSnapshot SnapshotLogs() - { - lock (_logGate) - { - return new ServerLogSnapshot { StandardOutput = _stdout.Snapshot(), StandardError = _stderr.Snapshot() }; - } - } - - public async Task WriteLogsAsync(IFileSystem fileSystem, string directory) - { - var snapshot = SnapshotLogs(); - _ = fileSystem.Directory.CreateDirectory(directory); - var writer = new DiagnosticFileWriter(fileSystem); - await writer.WriteAsync(fileSystem.Path.Combine(directory, "stdout.log"), string.Join(Environment.NewLine, snapshot.StandardOutput)); - await writer.WriteAsync(fileSystem.Path.Combine(directory, "stderr.log"), string.Join(Environment.NewLine, snapshot.StandardError)); - } - - public async ValueTask DisposeAsync() - { - // The onConstructed-captured reference (see StartAsync's doc comment) makes a second call - // on the same adapter a normal occurrence on every init failure: once inside StartAsync's - // own failure branch, once again from the caller's own teardown. Without this guard the - // second call's forced-kill branch could run CancelAsync on the _forceKill token source - // this same method already disposed at the end of the first call - // (OpenCodeServer.DisposeAsync's own Interlocked guard, mirrored here). - if (Interlocked.Exchange(ref _disposed, 1) is 1) - { - return; - } - - _ = _stdinLease.TrySetResult(null); - var cancellation = Task.CompletedTask; - try - { - if (_execution is not null && !await TryAwaitExitAsync(_execution, _gracefulShutdownTimeout)) - { - cancellation = Task.Run(_forceKill.CancelAsync, CancellationToken.None); - var forced = new OwnedCleanup(ForcedExitTimeout, _deadline); - forced.Own("pinned server forced cancellation", cancellation); - forced.Own("pinned server forced exit", _execution, _ => _forceKill.IsCancellationRequested); - try - { - await forced.CompleteAsync(null); - } - finally - { - LateFailures = forced.LateFailures; - } - } - } - finally - { - // A timed-out fixture still owns the execution and cancellation. Release the source - // only when neither can use it; the continuation also observes a late execution fault. - var pending = Task.WhenAll(_execution ?? Task.CompletedTask, cancellation); - if (pending.IsCompleted) - { - _ = pending.Exception; - _forceKill.Dispose(); - } - else - { - _ = pending.ContinueWith( - completed => - { - _ = completed.Exception; - _forceKill.Dispose(); - }, CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); - } - } - } - - /// - /// Waits for the execution to finish inside a bound of its own. - /// - /// - /// True when execution succeeds inside the bound; false when its observation deadline wins. - /// An execution failure remains distinct from a deadline failure. - /// - private async Task TryAwaitExitAsync(Task execution, TimeSpan bound) - { - try - { - await _deadline.WaitAsync("pinned server graceful exit", execution, bound, CancellationToken.None); - } - catch (TimeoutException) - { - return false; - } - - await execution; - return true; - } - - private async Task ThrowAfterTeardownAsync(Exception primary) - { - var cleanup = new OwnedCleanup(TimeSpan.FromSeconds(25), _deadline); - cleanup.Own("pinned server startup teardown", async _ => await DisposeAsync()); - try - { - await cleanup.CompleteAsync(primary); - } - catch (Exception exception) when (ReferenceEquals(exception, primary)) - { - primary.Data["PinnedServer.StartupLogs"] = DescribeLogs(); - throw; - } - } - private void OnOutputLine(string line) - { - _ = _firstLine.TrySetResult(line); - lock (_logGate) - { - _stdout.Append(line); - } - } - - private void OnErrorLine(string line) - { - TaskCompletionSource changed; - lock (_logGate) - { - _stderr.Append(line); - - changed = _stderrChanged; - _stderrChanged = NewSignal(); - } - - _ = changed.TrySetResult(null); - } - - private static TaskCompletionSource NewSignal() => - new(TaskCreationOptions.RunContinuationsAsynchronously); - - private static string GeneratePassword() - { - var bytes = new byte[32]; - using var random = RandomNumberGenerator.Create(); - random.GetBytes(bytes); - return Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_'); - } -} diff --git a/tests/Shared/PinnedOpenCodeServerFixture.cs b/tests/Shared/PinnedOpenCodeServerFixture.cs index 23f716ed..ac50b4c7 100644 --- a/tests/Shared/PinnedOpenCodeServerFixture.cs +++ b/tests/Shared/PinnedOpenCodeServerFixture.cs @@ -8,9 +8,10 @@ namespace OpenCode.Sdk.TestSupport; /// -/// The exact-pin server fixture (design §7.2): one pinned server per test session over the -/// CliWrap control adapter, per-run home/state isolation, isolated workspaces, and logs -/// retained on failure. Consumers declare +/// The exact-pin server fixture (design §7.2): one pinned server per test session started +/// through the SDK's own launcher with its output collector (the +/// launcher is dogfooded by every live test), per-run home/state isolation, isolated +/// workspaces, and logs retained on failure. Consumers declare /// [ClassDataSource<PinnedOpenCodeServerFixture>(Shared = SharedType.PerTestSession)]; /// every consumer adds [NotInParallel(ParallelConstraintKeys.ServerProcess)], the key /// every server-process test shares. @@ -33,8 +34,8 @@ namespace OpenCode.Sdk.TestSupport; /// OPENCODE_SDK_TESTS_PASSWORD name an operator-supplied server - or the internal /// constructor supplies the pair directly - /// spawns nothing: it probes the server's health instead of -/// starting , so stays unset and every member below -/// reads from the external pair. +/// starting an owned server, so stays unset and every member below reads +/// from the external pair. /// public sealed class PinnedOpenCodeServerFixture : IAsyncInitializer, IAsyncDisposable, ITestEndEventReceiver { @@ -42,11 +43,19 @@ public sealed class PinnedOpenCodeServerFixture : IAsyncInitializer, IAsyncDispo private static readonly TimeSpan ExternalHealthProbeTimeout = TimeSpan.FromSeconds(30); + /// + /// The grace between the stdin-EOF lease release and the forced tree kill. The source-run + /// server needs longer than the launcher's 3-second default to leave on its own; ten seconds + /// is the policy the retired test adapter already applied. + /// + private static readonly TimeSpan GracefulShutdownTimeout = TimeSpan.FromSeconds(10); + private readonly RealFileSystem _fileSystem = new(); private readonly IReadOnlyList? _commandOverride; private readonly string? _workingDirectoryOverride; private readonly ExternalServerEndpoint? _externalOverride; - private CliWrapServerAdapter? _adapter; + private OpenCodeServer? _server; + private OpenCodeServerOutput? _output; private TestRunRoot? _runRoot; private ExternalServerEndpoint? _external; private bool _retainLogs; @@ -85,7 +94,7 @@ internal PinnedOpenCodeServerFixture(ExternalServerEndpoint external) _externalOverride = external; } - public Uri Endpoint => _external?.Endpoint ?? Adapter.Endpoint; + public Uri Endpoint => _external?.Endpoint ?? Server.Endpoint; public int Order => 0; @@ -98,7 +107,7 @@ internal bool IsExternal return true; } - if (_adapter is null) + if (_server is null) { throw new InvalidOperationException("The fixture has not initialized."); } @@ -109,8 +118,8 @@ internal bool IsExternal internal TestRpcPlugin? OwnedRpcPlugin { get; private set; } - internal CliWrapServerAdapter Adapter => - _adapter ?? throw new InvalidOperationException("The fixture has not initialized."); + internal OpenCodeServer Server => + _server ?? throw new InvalidOperationException("The fixture has not initialized."); internal TestRunRoot RunRoot => _runRoot ?? throw new InvalidOperationException("The fixture has not initialized."); @@ -160,20 +169,21 @@ public async Task InitializeAsync() pinnedCommand.RepositoryRoot, "external", "opencode", "packages", "cli"); } + // The collector exists before the start and stays readable when the start fails, so a + // startup failure still has stdout/stderr to write out on teardown. + _output = new OpenCodeServerOutput(); try { - _adapter = await CliWrapServerAdapter.StartAsync( - command, - BuildEnvironment(_runRoot.Path), - workingDirectory, - ReadinessTimeout, - // Captured the instant the adapter object exists, regardless of whether StartAsync - // later throws: a startup failure otherwise leaves _adapter null forever (StartAsync - // disposes its own local on every failure path before throwing, but never hands it - // back through its return value), which made the WriteLogs call below unreachable - // dead code on exactly the failure it exists for. - onConstructed: created => _adapter = created, - deadline: _diagnostics?.Deadline); + _server = await OpenCodeServer.StartAsync( + new OpenCodeServerOptions + { + Command = command, + WorkingDirectory = workingDirectory, + Environment = BuildEnvironment(_runRoot.Path), + ReadinessTimeout = ReadinessTimeout, + GracefulShutdownTimeout = GracefulShutdownTimeout, + Output = _output, + }).ConfigureAwait(false); } catch (Exception exception) { @@ -186,8 +196,8 @@ public async Task InitializeAsync() public OpenCodeClient CreateClient(LocationSelector? location = null) => new(new OpenCodeClientOptions { - Endpoint = _external?.Endpoint ?? Adapter.Endpoint, - Password = _external?.Password ?? Adapter.Password, + Endpoint = _external?.Endpoint ?? Server.Endpoint, + Password = _external?.Password ?? Server.Password, Location = location, }); @@ -201,11 +211,6 @@ internal async Task DrainDiagnosticsAsync(CancellationToken cancellationToken) { await report.WaitForAllAsync(cancellationToken); } - - if (_adapter?.LateFailures is { } adapterReport) - { - await adapterReport.WaitForAllAsync(cancellationToken); - } } private ServerFailureArtifacts Artifacts => _artifacts ??= new ServerFailureArtifacts( @@ -247,9 +252,9 @@ public async ValueTask DisposeAsync() var primary = recorded is { Count: > 0 } ? recorded[0].Exception : null; var teardown = new OwnedCleanup(TimeSpan.FromSeconds(25), _diagnostics?.Deadline ?? new OwnedOperationDeadline()); - if (_adapter is not null) + if (_server is not null) { - teardown.Own("pinned server teardown", async _ => await _adapter.DisposeAsync()); + teardown.Own("pinned server teardown", async _ => await _server.DisposeAsync()); } Exception? failure = null; @@ -276,7 +281,7 @@ public async ValueTask DisposeAsync() var capture = new ServerFailureCapture(Artifacts, _diagnostics?.FileSystem ?? _fileSystem, _diagnostics?.Deadline ?? new OwnedOperationDeadline()); - failure = await capture.CaptureAsync(_adapter, _externalMode, failure, teardown.OperationFailures); + failure = await capture.CaptureAsync(_output, _server?.ProcessId, _externalMode, failure, teardown.OperationFailures); _lateReports.AddRange(capture.LateReports); Console.WriteLine("Pinned server diagnostics: " + Artifacts.Directory); } diff --git a/tests/Shared/ServerFailureCapture.cs b/tests/Shared/ServerFailureCapture.cs index b9111e26..6457829a 100644 --- a/tests/Shared/ServerFailureCapture.cs +++ b/tests/Shared/ServerFailureCapture.cs @@ -9,19 +9,28 @@ namespace OpenCode.Sdk.TestSupport; internal sealed class ServerFailureCapture( ServerFailureArtifacts artifacts, IFileSystem fileSystem, IOwnedOperationDeadline deadline) { + private const string TruncatedHeader = "[truncated: older output was discarded to stay inside the retention bounds]"; + private readonly List _lateReports = []; public IReadOnlyList LateReports => _lateReports; - public async Task CaptureAsync(CliWrapServerAdapter? adapter, bool external, Exception? failure, - IReadOnlyList teardownFailures) + /// + /// Writes the collector's final snapshot (when a server was owned) and the failure metadata. + /// The snapshot is taken here, after the owned server's disposal has settled, so it is the + /// launcher's final collection rather than a mid-shutdown view. + /// + public async Task CaptureAsync(OpenCodeServerOutput? output, int? processId, bool external, + Exception? failure, IReadOnlyList teardownFailures) { var capture = new OwnedCleanup(TimeSpan.FromSeconds(5), deadline); - if (adapter is not null) + if (output is not null) { - var snapshot = adapter.SnapshotLogs(); - capture.Own("pinned server stdout capture", _ => WriteLogAsync("stdout.log", snapshot.StandardOutput)); - capture.Own("pinned server stderr capture", _ => WriteLogAsync("stderr.log", snapshot.StandardError)); + var snapshot = output.GetSnapshot(); + capture.Own("pinned server stdout capture", + _ => WriteLogAsync("stdout.log", snapshot.StandardOutput, snapshot.StandardOutputTruncated)); + capture.Own("pinned server stderr capture", + _ => WriteLogAsync("stderr.log", snapshot.StandardError, snapshot.StandardErrorTruncated)); } failure = await CaptureFailureAsync(capture, failure); @@ -34,15 +43,16 @@ internal sealed class ServerFailureCapture( var metadata = new OwnedCleanup(TimeSpan.FromSeconds(5), deadline); metadata.Own("pinned server failure metadata", _ => artifacts.WriteMetadataAsync(external ? "external" : "owned", - adapter?.ProcessId.ToString(CultureInfo.InvariantCulture) ?? "not-owned", lifecycleFailures)); + processId?.ToString(CultureInfo.InvariantCulture) ?? "not-owned", lifecycleFailures)); return await CaptureFailureAsync(metadata, failure); } - private async Task WriteLogAsync(string name, IReadOnlyList lines) + private async Task WriteLogAsync(string name, IReadOnlyList lines, bool truncated) { _ = fileSystem.Directory.CreateDirectory(artifacts.Directory); + var content = truncated ? lines.Prepend(TruncatedHeader) : lines; await new DiagnosticFileWriter(fileSystem).WriteAsync( - fileSystem.Path.Combine(artifacts.Directory, name), string.Join(Environment.NewLine, lines)); + fileSystem.Path.Combine(artifacts.Directory, name), string.Join(Environment.NewLine, content)); } private async Task CaptureFailureAsync(OwnedCleanup cleanup, Exception? primary) diff --git a/tests/Shared/ServerLogSnapshot.cs b/tests/Shared/ServerLogSnapshot.cs deleted file mode 100644 index 40b06555..00000000 --- a/tests/Shared/ServerLogSnapshot.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace OpenCode.Sdk.TestSupport; - -internal sealed record ServerLogSnapshot -{ - public required IReadOnlyList StandardOutput { get; init; } - - public required IReadOnlyList StandardError { get; init; } -} diff --git a/tests/Shared/ServerLogTail.cs b/tests/Shared/ServerLogTail.cs deleted file mode 100644 index 07be8e0f..00000000 --- a/tests/Shared/ServerLogTail.cs +++ /dev/null @@ -1,40 +0,0 @@ -using System.Globalization; - -namespace OpenCode.Sdk.TestSupport; - -/// Retains the last 500 lines, each capped at 4,096 characters, for one server stream. -internal sealed class ServerLogTail -{ - internal const int MaximumLines = 500; - internal const int MaximumLineCharacters = 4_096; - private const string Truncated = " [truncated]"; - private readonly Queue _lines = new(); - private long _discardedLines; - private long _shortenedLines; - - public void Append(string line) - { - if (line.Length > MaximumLineCharacters) - { - line = line[..(MaximumLineCharacters - Truncated.Length)] + Truncated; - _shortenedLines++; - } - - _lines.Enqueue(line); - if (_lines.Count > MaximumLines) - { - _ = _lines.Dequeue(); - _discardedLines++; - } - } - - public string? Find(string fragment) => - _lines.FirstOrDefault(line => line.Contains(fragment, StringComparison.Ordinal)); - - public string Describe() => string.Join(" | ", _lines.Skip(Math.Max(0, _lines.Count - 40))); - - public IReadOnlyList Snapshot() => - ["tail: maximum-lines=500 maximum-line-characters=4096 discarded-lines=" + - _discardedLines.ToString(CultureInfo.InvariantCulture) + " shortened-lines=" + - _shortenedLines.ToString(CultureInfo.InvariantCulture), .. _lines]; -} diff --git a/tests/Shared/SimulatedDriveServerFixture.cs b/tests/Shared/SimulatedDriveServerFixture.cs index e7cf93fb..7be2f9b0 100644 --- a/tests/Shared/SimulatedDriveServerFixture.cs +++ b/tests/Shared/SimulatedDriveServerFixture.cs @@ -27,33 +27,26 @@ public sealed class SimulatedDriveServerFixture : IAsyncInitializer, IAsyncDispo private readonly RealFileSystem _fileSystem = new(); private readonly IGitProcess _gitProcess = new GitProcess(); - private CliWrapServerAdapter? _adapter; + private OpenCodeServer? _server; + private OpenCodeServerOutput? _output; private DriveController? _controller; - private string? _postReadinessDiagnostic; - private string? _preReadinessDiagnostic; private TestRunRoot? _runRoot; private bool _retainLogs; private ServerFailureArtifacts? _artifacts; private int _disposed; - public Uri Endpoint => Adapter.Endpoint; + public Uri Endpoint => Server.Endpoint; public int Order => 0; internal DriveController Controller => _controller ?? throw new InvalidOperationException("The fixture has not initialized."); - internal CliWrapServerAdapter Adapter => - _adapter ?? throw new InvalidOperationException("The fixture has not initialized."); + internal OpenCodeServer Server => + _server ?? throw new InvalidOperationException("The fixture has not initialized."); internal TestRunRoot RunRoot => _runRoot ?? throw new InvalidOperationException("The fixture has not initialized."); - internal string PostReadinessDiagnostic => - _postReadinessDiagnostic ?? throw new InvalidOperationException("The fixture has not captured readiness diagnostics."); - - internal string PreReadinessDiagnostic => - _preReadinessDiagnostic ?? throw new InvalidOperationException("The fixture has not captured readiness diagnostics."); - public async Task InitializeAsync() { _runRoot = new TestRunRoot(_fileSystem); @@ -80,49 +73,25 @@ public async Task InitializeAsync() /// private async Task StartAsync(TestRunRoot runRoot) { - var registry = runRoot.CreateSubdirectory("drive"); - var persistentHost = new PersistentSimulationServerCommand(_fileSystem); - var command = persistentHost.Resolve(); using var gate = await DrivePortGate.AcquireAsync(_fileSystem, GateTimeout); - var manifest = DriveManifest.Write(_fileSystem, registry); - var environment = ServerIsolation.Environment(_fileSystem, runRoot.Path); - environment["OPENCODE_SIMULATE"] = "1"; - environment["OPENCODE_DRIVE"] = manifest.InstanceName; - environment["DRIVE_REGISTRY_DIR"] = registry; - environment["OPENCODE_CONFIG_CONTENT"] = SimulationConfigSeed.Json; - environment["OPENCODE_LOG_LEVEL"] = "INFO"; - environment["OPENCODE_PRINT_LOGS"] = "1"; - _adapter = await CliWrapServerAdapter.StartAsync( - command, - environment, - - // Anchored at the pinned CLI package for the same reason - // PinnedOpenCodeServerFixture is: bun resolves the monorepo's workspace and tsconfig - // from the process working directory, not from the absolute entry-file path, and a - // scratch directory outside the checkout fails the source run before readiness. Every - // global root the server touches stays isolated through the environment above - // regardless of this directory. - _fileSystem.Path.Combine( - persistentHost.RepositoryRoot, "external", "opencode", "packages", "cli"), - ReadinessTimeout, - - // Captured the instant the adapter object exists so a startup failure still has - // stdout/stderr to write out on teardown; StartAsync disposes its own local on every - // failure path but never returns it (PinnedOpenCodeServerFixture, same reason). - onConstructed: created => _adapter = created); - - // Capture the two startup milestones while they are current. The adapter intentionally - // retains only a bounded stderr tail, so a later test class must not depend on finding old - // lines after other per-session consumers have produced more diagnostics. - using (var diagnosticWindow = new CancellationTokenSource(ControllerTimeout)) - { - _preReadinessDiagnostic = await _adapter.WaitForErrorLineAsync( - "persistent simulation host starting", - diagnosticWindow.Token); - _postReadinessDiagnostic = await _adapter.WaitForErrorLineAsync( - "persistent simulation host ready", - diagnosticWindow.Token); - } + var launch = SimulatedServerLaunch.Prepare(_fileSystem, runRoot); + + // The collector exists before the start and stays readable when the start fails, so a + // startup failure still has stdout/stderr to write out on teardown. + _output = new OpenCodeServerOutput(); + _server = await OpenCodeServer.StartAsync(new OpenCodeServerOptions + { + Command = launch.Command, + WorkingDirectory = launch.WorkingDirectory, + Environment = launch.Environment, + ReadinessTimeout = ReadinessTimeout, + + // The source-run host needs longer than the launcher's 3-second default to leave on + // stdin EOF; ten seconds is the policy the retired test adapter already applied. + GracefulShutdownTimeout = TimeSpan.FromSeconds(10), + Output = _output, + }); + var manifest = launch.Manifest; // The backend control socket is already listening when the readiness line is printed - // simulation builds the network layer eagerly at server start (backend/index.ts, @@ -135,8 +104,8 @@ private async Task StartAsync(TestRunRoot runRoot) public OpenCodeClient CreateClient(LocationSelector? location = null) => new(new OpenCodeClientOptions { - Endpoint = Adapter.Endpoint, - Password = Adapter.Password, + Endpoint = Server.Endpoint, + Password = Server.Password, Location = location, }); @@ -189,12 +158,15 @@ public async ValueTask DisposeAsync() teardown.Own("simulation controller teardown", async _ => await _controller.DisposeAsync()); } - if (_adapter is not null) + if (_server is not null && _output is not null) { - teardown.Own("simulated server teardown", async _ => await _adapter.DisposeAsync()); + teardown.Own("simulated server teardown", async _ => await _server.DisposeAsync()); + + // Runs after the teardown above has settled, so the collector is final: the last + // milestone is written only once the stdin lease is released. teardown.Own("simulated server diagnostic contract", _ => { - EnsureDiagnosticContract(_adapter); + EnsureDiagnosticContract(_server, _output); return Task.CompletedTask; }); } @@ -218,7 +190,7 @@ public async ValueTask DisposeAsync() var capture = new ServerFailureCapture( Artifacts, _fileSystem, new OwnedOperationDeadline()); - failure = await capture.CaptureAsync(_adapter, external: false, failure, teardown.OperationFailures); + failure = await capture.CaptureAsync(_output, _server?.ProcessId, external: false, failure, teardown.OperationFailures); Console.WriteLine("Simulated server diagnostics: " + Artifacts.Directory); } @@ -236,32 +208,32 @@ public async ValueTask DisposeAsync() private bool ShouldRetainLogs => _retainLogs || _artifacts?.Failures.Count > 0 || string.Equals( Environment.GetEnvironmentVariable("OPENCODE_SDK_TESTS_KEEP_LOGS"), "1", StringComparison.Ordinal); - private void EnsureDiagnosticContract(CliWrapServerAdapter adapter) + private static void EnsureDiagnosticContract(OpenCodeServer server, OpenCodeServerOutput output) { + var snapshot = output.GetSnapshot(); var missing = new List(); - if (_preReadinessDiagnostic is null) - { - missing.Add("persistent simulation host starting"); - } - - if (_postReadinessDiagnostic is null) - { - missing.Add("persistent simulation host ready"); - } - - if (!adapter.HasErrorLine("persistent simulation host stdin closed")) - { - missing.Add("persistent simulation host stdin closed"); + foreach (var milestone in new[] + { + "persistent simulation host starting", + "persistent simulation host ready", + "persistent simulation host stdin closed", + }) + { + if (!snapshot.StandardError.Any(line => line.Contains(milestone, StringComparison.Ordinal))) + { + missing.Add(milestone); + } } if (missing.Count > 0) { throw new InvalidOperationException( - "The persistent simulation host did not retain diagnostics: " + string.Join(", ", missing) + "."); + "The persistent simulation host did not retain diagnostics: " + string.Join(", ", missing) + + " (stderr truncated: " + snapshot.StandardErrorTruncated + ")."); } Console.WriteLine( "Persistent simulation host retained pre-readiness, post-readiness, and stdin-EOF diagnostics for process " + - adapter.ProcessId.ToString(System.Globalization.CultureInfo.InvariantCulture) + "."); + server.ProcessId.ToString(System.Globalization.CultureInfo.InvariantCulture) + "."); } } diff --git a/tests/Shared/SimulatedServerLaunch.cs b/tests/Shared/SimulatedServerLaunch.cs new file mode 100644 index 00000000..c0e507a2 --- /dev/null +++ b/tests/Shared/SimulatedServerLaunch.cs @@ -0,0 +1,52 @@ +using System.IO.Abstractions; + +namespace OpenCode.Sdk.TestSupport; + +/// +/// The launch recipe of the persistent simulation host: its command, the CLI-package working +/// directory bun needs to resolve the monorepo workspace, the isolated environment with the +/// simulation switches and the per-run drive manifest, and that manifest. The caller holds +/// from until the server is ready, because the +/// manifest names the ports the host binds later. +/// +internal sealed record SimulatedServerLaunch +{ + public required IReadOnlyList Command { get; init; } + + public required string WorkingDirectory { get; init; } + + public required IReadOnlyDictionary Environment { get; init; } + + public required DriveManifest Manifest { get; init; } + + public static SimulatedServerLaunch Prepare(IFileSystem fileSystem, TestRunRoot runRoot) + { + ArgumentNullException.ThrowIfNull(fileSystem); + ArgumentNullException.ThrowIfNull(runRoot); + + var registry = runRoot.CreateSubdirectory("drive"); + var persistentHost = new PersistentSimulationServerCommand(fileSystem); + var manifest = DriveManifest.Write(fileSystem, registry); + var environment = ServerIsolation.Environment(fileSystem, runRoot.Path); + environment["OPENCODE_SIMULATE"] = "1"; + environment["OPENCODE_DRIVE"] = manifest.InstanceName; + environment["DRIVE_REGISTRY_DIR"] = registry; + environment["OPENCODE_CONFIG_CONTENT"] = SimulationConfigSeed.Json; + environment["OPENCODE_LOG_LEVEL"] = "INFO"; + environment["OPENCODE_PRINT_LOGS"] = "1"; + return new SimulatedServerLaunch + { + Command = persistentHost.Resolve(), + + // Anchored at the pinned CLI package: bun resolves the monorepo's workspace and + // tsconfig from the process working directory, not from the absolute entry-file + // path, and a scratch directory outside the checkout fails the source run before + // readiness. Every global root the host touches stays isolated through the + // environment regardless of this directory. + WorkingDirectory = fileSystem.Path.Combine( + persistentHost.RepositoryRoot, "external", "opencode", "packages", "cli"), + Environment = environment, + Manifest = manifest, + }; + } +} From 5b1955e25ddae54b92fc149792946ab49d0a5de3 Mon Sep 17 00:00:00 2001 From: "denizirgin@gmail.com" Date: Mon, 7 Sep 2026 18:21:53 +0300 Subject: [PATCH 46/62] test: verify the shared simulated host's stdin-EOF milestone from its final snapshot Over a chatty session the collector's bound evicts the early starting/ready lines, so the shared fixture's teardown proves the one milestone the final snapshot can still hold; the dedicated PersistentSimulationHostTests lifecycle proves all three on a short-lived host. --- tests/Shared/SimulatedDriveServerFixture.cs | 39 ++++++++++----------- 1 file changed, 18 insertions(+), 21 deletions(-) diff --git a/tests/Shared/SimulatedDriveServerFixture.cs b/tests/Shared/SimulatedDriveServerFixture.cs index 7be2f9b0..3e19b2e9 100644 --- a/tests/Shared/SimulatedDriveServerFixture.cs +++ b/tests/Shared/SimulatedDriveServerFixture.cs @@ -162,8 +162,8 @@ public async ValueTask DisposeAsync() { teardown.Own("simulated server teardown", async _ => await _server.DisposeAsync()); - // Runs after the teardown above has settled, so the collector is final: the last - // milestone is written only once the stdin lease is released. + // Runs after the teardown above has settled, so the collector is final: the stdin-EOF + // milestone is written only once the lease is released. teardown.Own("simulated server diagnostic contract", _ => { EnsureDiagnosticContract(_server, _output); @@ -208,32 +208,29 @@ public async ValueTask DisposeAsync() private bool ShouldRetainLogs => _retainLogs || _artifacts?.Failures.Count > 0 || string.Equals( Environment.GetEnvironmentVariable("OPENCODE_SDK_TESTS_KEEP_LOGS"), "1", StringComparison.Ordinal); + /// + /// The shared instance proves the one milestone its final snapshot can still hold: the host + /// logs "stdin closed" only once the launcher releases the lease, so it is the last stderr + /// line and survives the collector's bound. The earlier "starting"/"ready" milestones are + /// evicted over a chatty session (INFO logging retains the newest 500 lines); they are proven + /// per lifecycle by PersistentSimulationHostTests, which starts its own short-lived + /// host and reads a snapshot that lost nothing. This is the explicit migration reduction from + /// the retired adapter's push-based startup capture, not a claim about every shared instance. + /// private static void EnsureDiagnosticContract(OpenCodeServer server, OpenCodeServerOutput output) { var snapshot = output.GetSnapshot(); - var missing = new List(); - foreach (var milestone in new[] - { - "persistent simulation host starting", - "persistent simulation host ready", - "persistent simulation host stdin closed", - }) - { - if (!snapshot.StandardError.Any(line => line.Contains(milestone, StringComparison.Ordinal))) - { - missing.Add(milestone); - } - } - - if (missing.Count > 0) + const string milestone = "persistent simulation host stdin closed"; + if (!snapshot.StandardError.Any(line => line.Contains(milestone, StringComparison.Ordinal))) { throw new InvalidOperationException( - "The persistent simulation host did not retain diagnostics: " + string.Join(", ", missing) + - " (stderr truncated: " + snapshot.StandardErrorTruncated + ")."); + "The persistent simulation host did not retain the stdin-EOF diagnostic '" + milestone + + "' (stderr truncated: " + snapshot.StandardErrorTruncated + ")."); } Console.WriteLine( - "Persistent simulation host retained pre-readiness, post-readiness, and stdin-EOF diagnostics for process " + - server.ProcessId.ToString(System.Globalization.CultureInfo.InvariantCulture) + "."); + "Persistent simulation host retained the stdin-EOF diagnostic for process " + + server.ProcessId.ToString(System.Globalization.CultureInfo.InvariantCulture) + + " (stderr truncated: " + snapshot.StandardErrorTruncated + ")."); } } From 7efcec4b48ef56e7b7021c384a8725fb27791807 Mon Sep 17 00:00:00 2001 From: "denizirgin@gmail.com" Date: Mon, 7 Sep 2026 18:27:34 +0300 Subject: [PATCH 47/62] test: prove bounded descendant termination after a failed start A whole-tree kill is asynchronous: the direct process exiting does not guarantee that every descendant has finished exiting at that instant, which the Linux legs of the parked proofs showed. The startup-tree proofs keep the immediate direct-child exit assertion and separately observe the already identified grandchild terminate inside a ten-second bound of its own, with both authenticated control leases still held; a descendant that survives the bound fails the test with its process evidence. This is bounded descendant termination evidence, never all-descendants-reaped-at-return evidence; the launcher's runtime behavior is unchanged, and the client-runtime canon states the boundary. --- docs/architecture/client-runtime.md | 5 +++ .../OpenCodeServerLifecycleTests.cs | 39 ++++++++++++------- .../Support/ServerStartupTreeScenario.cs | 33 ++++++++++++++++ 3 files changed, 62 insertions(+), 15 deletions(-) diff --git a/docs/architecture/client-runtime.md b/docs/architecture/client-runtime.md index 3a77d11d..9572f4ae 100644 --- a/docs/architecture/client-runtime.md +++ b/docs/architecture/client-runtime.md @@ -330,6 +330,11 @@ operating system closes the lease even when the owner crashes before disposal ru with any other running server is safe by construction, since a started door never discovers or attaches to one. +The forced-exit wait observes the directly owned process. A whole-tree kill is asynchronous: the +direct process exiting does not guarantee that every descendant has finished exiting at that +instant. Launcher acceptance separately proves bounded descendant termination before any test +fallback cleanup. + `CreateClient(Action?)` pins the connection identity fail-closed: the delegate receives a fresh identity-unset options instance, and setting `Endpoint`, `Username`, or `Password` there is refused with `InvalidOperationException`. On success the door never mutates the diff --git a/tests/OpenCode.Sdk.Tests/OpenCodeServerLifecycleTests.cs b/tests/OpenCode.Sdk.Tests/OpenCodeServerLifecycleTests.cs index 4724b767..5cf2da48 100644 --- a/tests/OpenCode.Sdk.Tests/OpenCodeServerLifecycleTests.cs +++ b/tests/OpenCode.Sdk.Tests/OpenCodeServerLifecycleTests.cs @@ -132,12 +132,15 @@ public async Task StartAsync_Should_Refuse_A_Server_That_Never_Reports_Readiness async () => _ = await scenario.WaitForStartupAsync(cancellationToken)).Throws(); await Assert.That(failure!.Message).Contains("did not report readiness"); - // Take both immediate observations before any diagnostic I/O or fallback cleanup. + // The direct child's exit is immediate: the startup result is returned only after it. var rootExited = scenario.RootProcess.HasExited; - var childExited = scenario.ChildProcess.HasExited; - var evidence = rootExited && childExited ? string.Empty : await scenario.DescribeProcessesAsync(); - await Assert.That(rootExited).IsTrue().Because(evidence); - await Assert.That(childExited).IsTrue().Because(evidence); + await Assert.That(rootExited).IsTrue().Because(rootExited ? string.Empty : await scenario.DescribeProcessesAsync()); + + // Bounded descendant termination evidence, both leases still held (disposal releases + // them, after this): a tree kill is asynchronous, so the grandchild is observed to + // terminate inside its own bound rather than asserted gone at that instant. + var descendantTerminated = await scenario.ObserveDescendantTerminationAsync(cancellationToken); + await Assert.That(descendantTerminated).IsTrue().Because(scenario.DescendantEvidence); } [Test] @@ -151,12 +154,15 @@ public async Task StartAsync_Should_Refuse_A_Non_Contract_First_Line(Cancellatio async () => _ = await scenario.WaitForStartupAsync(cancellationToken)).Throws(); await Assert.That(failure!.Message).Contains("readiness contract"); - // Take both immediate observations before any diagnostic I/O or fallback cleanup. + // The direct child's exit is immediate: the startup result is returned only after it. var rootExited = scenario.RootProcess.HasExited; - var childExited = scenario.ChildProcess.HasExited; - var evidence = rootExited && childExited ? string.Empty : await scenario.DescribeProcessesAsync(); - await Assert.That(rootExited).IsTrue().Because(evidence); - await Assert.That(childExited).IsTrue().Because(evidence); + await Assert.That(rootExited).IsTrue().Because(rootExited ? string.Empty : await scenario.DescribeProcessesAsync()); + + // Bounded descendant termination evidence, both leases still held (disposal releases + // them, after this): a tree kill is asynchronous, so the grandchild is observed to + // terminate inside its own bound rather than asserted gone at that instant. + var descendantTerminated = await scenario.ObserveDescendantTerminationAsync(cancellationToken); + await Assert.That(descendantTerminated).IsTrue().Because(scenario.DescendantEvidence); } [Test] @@ -387,11 +393,14 @@ public async Task StartAsync_Should_Surface_Caller_Cancellation(CancellationToke _ = await Assert.That( async () => _ = await scenario.WaitForStartupAsync(cancellationToken)).Throws(); - // Take both immediate observations before any diagnostic I/O or fallback cleanup. + // The direct child's exit is immediate: the startup result is returned only after it. var rootExited = scenario.RootProcess.HasExited; - var childExited = scenario.ChildProcess.HasExited; - var evidence = rootExited && childExited ? string.Empty : await scenario.DescribeProcessesAsync(); - await Assert.That(rootExited).IsTrue().Because(evidence); - await Assert.That(childExited).IsTrue().Because(evidence); + await Assert.That(rootExited).IsTrue().Because(rootExited ? string.Empty : await scenario.DescribeProcessesAsync()); + + // Bounded descendant termination evidence, both leases still held (disposal releases + // them, after this): a tree kill is asynchronous, so the grandchild is observed to + // terminate inside its own bound rather than asserted gone at that instant. + var descendantTerminated = await scenario.ObserveDescendantTerminationAsync(cancellationToken); + await Assert.That(descendantTerminated).IsTrue().Because(scenario.DescendantEvidence); } } diff --git a/tests/OpenCode.Sdk.Tests/Support/ServerStartupTreeScenario.cs b/tests/OpenCode.Sdk.Tests/Support/ServerStartupTreeScenario.cs index eb6de213..79eccf3b 100644 --- a/tests/OpenCode.Sdk.Tests/Support/ServerStartupTreeScenario.cs +++ b/tests/OpenCode.Sdk.Tests/Support/ServerStartupTreeScenario.cs @@ -17,6 +17,13 @@ internal sealed class ServerStartupTreeScenario : IAsyncDisposable private static readonly TimeSpan CleanupTimeout = TimeSpan.FromSeconds(10); + /// + /// The bound inside which the already-observed descendant must terminate after the startup + /// result. A test observation bound of its own: it neither shares nor extends the launcher's + /// forced-exit budget, and no test-side signal, lease release, retry, or sleep can satisfy it. + /// + private static readonly TimeSpan DescendantTerminationBound = TimeSpan.FromSeconds(10); + private readonly ServerStartupTreeHandshake _handshake = new(new RealFileSystem()); private readonly string _mode; private readonly TimeSpan _readinessTimeout; @@ -40,6 +47,9 @@ private ServerStartupTreeScenario( public Process ChildProcess => _handshake.ChildProcess; + /// The evidence gathered when the descendant survived its bound; empty otherwise. + public string DescendantEvidence { get; private set; } = string.Empty; + public Process RootProcess => _handshake.RootProcess; /// @@ -76,6 +86,29 @@ public async Task StartAndObserveAsync(CancellationToken cancellationToken) await observation.WaitAsync(cancellationToken); } + /// + /// Bounded descendant termination evidence with both leases still held: waits at most + /// for the observed grandchild to exit. A whole-tree + /// kill is asynchronous, so the direct process exiting proves nothing about this child at that + /// instant (client-runtime, Launcher); this never proves every descendant reaped at return. + /// + /// True when the descendant exited inside the bound; false with set. + public async Task ObserveDescendantTerminationAsync(CancellationToken cancellationToken) + { + using var bound = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + bound.CancelAfter(DescendantTerminationBound); + try + { + await _handshake.WaitForChildExitAsync(bound.Token); + return true; + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + DescendantEvidence = await DescribeProcessesAsync(); + return false; + } + } + public void ReleaseChildLease() => _handshake.ReleaseChild(); public void ReleaseRootLease() => _handshake.ReleaseRoot(); From 687dd945d11acf5a8ecbefab8b84797f743680e3 Mon Sep 17 00:00:00 2001 From: "denizirgin@gmail.com" Date: Mon, 7 Sep 2026 18:30:22 +0300 Subject: [PATCH 48/62] test: share the owned test server policy across the fixtures The readiness bound and the ten-second grace live in one policy owner, the simulated launch recipe builds the launcher options itself, the dedicated persistent-host lifecycle owns its server for disposal, and the fixtures name the margin their teardown bound keeps above the launcher's worst case. --- CHANGELOG.md | 4 +- .../PersistentSimulationHostTests.cs | 44 ++++++++----------- tests/Shared/OwnedServerPolicy.cs | 14 ++++++ tests/Shared/PinnedOpenCodeServerFixture.cs | 15 +++---- tests/Shared/ServerFailureCapture.cs | 2 +- tests/Shared/SimulatedDriveServerFixture.cs | 26 +++-------- tests/Shared/SimulatedServerLaunch.cs | 23 ++++++++++ 7 files changed, 71 insertions(+), 57 deletions(-) create mode 100644 tests/Shared/OwnedServerPolicy.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 81fd47d2..f91c5612 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,8 +45,8 @@ Each released version links straight to its GitHub Release tag. termination — and `CreateClient()` hands back a client already bound to it. An optional `OpenCodeServerOutput` collector, supplied through `OpenCodeServerOptions.Output`, retains a bounded tail of the child's stdout and stderr for pull snapshots that report truncation, and it - stays readable after a failed start. Real-process lifecycle acceptance runs on Windows, Linux, - and macOS. + stays readable after a failed start; `ProcessId` stays readable after disposal for the same + diagnostic use. Real-process lifecycle acceptance runs on Windows, Linux, and macOS. - **Server-sent event streaming.** `EventsClient.SubscribeAsync` follows the global bus and `SessionClient.GetLogAsync` follows one session's log, both as `IAsyncEnumerable` of typed frames over the same transport, decoration, and status walls as one-shot calls. A body cut diff --git a/tests/OpenCode.Sdk.Tests/PersistentSimulationHostTests.cs b/tests/OpenCode.Sdk.Tests/PersistentSimulationHostTests.cs index 2b2c379a..b4c0f6ac 100644 --- a/tests/OpenCode.Sdk.Tests/PersistentSimulationHostTests.cs +++ b/tests/OpenCode.Sdk.Tests/PersistentSimulationHostTests.cs @@ -9,18 +9,17 @@ namespace OpenCode.Sdk.Tests; /// lifecycle through the SDK's own launcher and collector: the readiness line is the only thing /// on stdout, and the three lifecycle milestones reach stderr with their severity, in order, /// the last one written only once the stdin lease is released. The shared -/// checks the same three milestones at its own -/// teardown; this test is where the profile's routing is proven on its own. +/// rechecks only the stdin-EOF milestone at its own +/// teardown (the earlier two are evicted over a chatty session); this test is where the +/// profile's routing is proven on its own. No drive controller is attached here: the milestones +/// do not depend on the control bootstrap, and readiness already proves the manifest's backend +/// port is bound. /// [NotInParallel(ParallelConstraintKeys.ServerProcess)] public sealed class PersistentSimulationHostTests { private static readonly RealFileSystem FileSystem = new(); - private static readonly TimeSpan ReadinessTimeout = TimeSpan.FromMinutes(3); - - private static readonly TimeSpan GateTimeout = TimeSpan.FromMinutes(15); - [Test] [Timeout(240_000)] public async Task Host_Should_Route_Diagnostics_To_Stderr_Around_Readiness_And_Stdin_Eof( @@ -28,28 +27,11 @@ public async Task Host_Should_Route_Diagnostics_To_Stderr_Around_Readiness_And_S { using var runRoot = new TestRunRoot(FileSystem); var output = new OpenCodeServerOutput(); - OpenCodeServer server; - using (var gate = await DrivePortGate.AcquireAsync(FileSystem, GateTimeout)) + await using (var server = await StartHostAsync(runRoot, output, cancellationToken)) { - var launch = SimulatedServerLaunch.Prepare(FileSystem, runRoot); - server = await OpenCodeServer.StartAsync( - new OpenCodeServerOptions - { - Command = launch.Command, - WorkingDirectory = launch.WorkingDirectory, - Environment = launch.Environment, - ReadinessTimeout = ReadinessTimeout, - GracefulShutdownTimeout = TimeSpan.FromSeconds(10), - Output = output, - }, - cancellationToken); - - // Readiness proves the manifest's ports are bound (simulation builds its network - // layer eagerly at start), which is what lets the gate go. + await Assert.That(server.Endpoint.IsLoopback).IsTrue(); } - await server.DisposeAsync(); - var snapshot = output.GetSnapshot(); await Assert.That(snapshot.StandardOutput.Count).IsEqualTo(1); await Assert.That(ServerReadyLine.TryParse(snapshot.StandardOutput[0], out _)).IsTrue(); @@ -69,6 +51,18 @@ public async Task Host_Should_Route_Diagnostics_To_Stderr_Around_Readiness_And_S " | " + snapshot.StandardError[closed]); } + /// + /// Starts the host under the port gate and releases the gate at readiness: simulation builds + /// its network layer eagerly at start, so readiness proves the manifest's backend port is bound. + /// + private static async Task StartHostAsync( + TestRunRoot runRoot, OpenCodeServerOutput output, CancellationToken cancellationToken) + { + using var gate = await DrivePortGate.AcquireAsync(FileSystem, SimulatedServerLaunch.GateTimeout); + var launch = SimulatedServerLaunch.Prepare(FileSystem, runRoot); + return await OpenCodeServer.StartAsync(launch.Options(output), cancellationToken); + } + /// /// The milestone's index in the final stderr snapshot. A missing milestone fails naming /// whether the snapshot lost content: truncation that eats promised evidence is a failure diff --git a/tests/Shared/OwnedServerPolicy.cs b/tests/Shared/OwnedServerPolicy.cs new file mode 100644 index 00000000..6103bda6 --- /dev/null +++ b/tests/Shared/OwnedServerPolicy.cs @@ -0,0 +1,14 @@ +namespace OpenCode.Sdk.TestSupport; + +/// +/// The launch policy every owned test server shares: how long a source-run server may take to +/// report readiness on a cold runner, and the grace it gets between the stdin-EOF lease release +/// and the forced tree kill. The source-run server needs longer than the launcher's 3-second +/// default to leave on its own; ten seconds is the policy the retired test adapter applied. +/// +internal static class OwnedServerPolicy +{ + public static TimeSpan ReadinessTimeout { get; } = TimeSpan.FromMinutes(3); + + public static TimeSpan GracefulShutdownTimeout { get; } = TimeSpan.FromSeconds(10); +} diff --git a/tests/Shared/PinnedOpenCodeServerFixture.cs b/tests/Shared/PinnedOpenCodeServerFixture.cs index ac50b4c7..e47a3fa0 100644 --- a/tests/Shared/PinnedOpenCodeServerFixture.cs +++ b/tests/Shared/PinnedOpenCodeServerFixture.cs @@ -39,16 +39,13 @@ namespace OpenCode.Sdk.TestSupport; /// public sealed class PinnedOpenCodeServerFixture : IAsyncInitializer, IAsyncDisposable, ITestEndEventReceiver { - private static readonly TimeSpan ReadinessTimeout = TimeSpan.FromMinutes(3); - private static readonly TimeSpan ExternalHealthProbeTimeout = TimeSpan.FromSeconds(30); /// - /// The grace between the stdin-EOF lease release and the forced tree kill. The source-run - /// server needs longer than the launcher's 3-second default to leave on its own; ten seconds - /// is the policy the retired test adapter already applied. + /// The launcher's worst case is the 10-second grace, its 10-second forced-exit wait, and the + /// 2-second output drain; this outer bound keeps a 3-second margin above that. /// - private static readonly TimeSpan GracefulShutdownTimeout = TimeSpan.FromSeconds(10); + private static readonly TimeSpan TeardownTimeout = TimeSpan.FromSeconds(25); private readonly RealFileSystem _fileSystem = new(); private readonly IReadOnlyList? _commandOverride; @@ -180,8 +177,8 @@ public async Task InitializeAsync() Command = command, WorkingDirectory = workingDirectory, Environment = BuildEnvironment(_runRoot.Path), - ReadinessTimeout = ReadinessTimeout, - GracefulShutdownTimeout = GracefulShutdownTimeout, + ReadinessTimeout = OwnedServerPolicy.ReadinessTimeout, + GracefulShutdownTimeout = OwnedServerPolicy.GracefulShutdownTimeout, Output = _output, }).ConfigureAwait(false); } @@ -250,7 +247,7 @@ public async ValueTask DisposeAsync() var keep = ShouldRetainLogs; var recorded = _artifacts?.Failures; var primary = recorded is { Count: > 0 } ? recorded[0].Exception : null; - var teardown = new OwnedCleanup(TimeSpan.FromSeconds(25), + var teardown = new OwnedCleanup(TeardownTimeout, _diagnostics?.Deadline ?? new OwnedOperationDeadline()); if (_server is not null) { diff --git a/tests/Shared/ServerFailureCapture.cs b/tests/Shared/ServerFailureCapture.cs index 6457829a..f30a3f36 100644 --- a/tests/Shared/ServerFailureCapture.cs +++ b/tests/Shared/ServerFailureCapture.cs @@ -9,7 +9,7 @@ namespace OpenCode.Sdk.TestSupport; internal sealed class ServerFailureCapture( ServerFailureArtifacts artifacts, IFileSystem fileSystem, IOwnedOperationDeadline deadline) { - private const string TruncatedHeader = "[truncated: older output was discarded to stay inside the retention bounds]"; + private const string TruncatedHeader = "[truncated: older lines, or part of an oversized line, were discarded to stay inside the retention bounds]"; private readonly List _lateReports = []; diff --git a/tests/Shared/SimulatedDriveServerFixture.cs b/tests/Shared/SimulatedDriveServerFixture.cs index 3e19b2e9..1ed9b7cf 100644 --- a/tests/Shared/SimulatedDriveServerFixture.cs +++ b/tests/Shared/SimulatedDriveServerFixture.cs @@ -14,16 +14,13 @@ namespace OpenCode.Sdk.TestSupport; /// public sealed class SimulatedDriveServerFixture : IAsyncInitializer, IAsyncDisposable, ITestEndEventReceiver { - private static readonly TimeSpan ReadinessTimeout = TimeSpan.FromMinutes(3); - private static readonly TimeSpan ControllerTimeout = TimeSpan.FromSeconds(30); /// - /// Bounded well above the realistic worst case - every local target-framework leg starting a - /// simulated server back to back, each within - so a genuinely - /// wedged holder still fails loudly instead of hanging the suite. + /// The launcher's worst case is the 10-second grace, its 10-second forced-exit wait, and the + /// 2-second output drain; this outer bound keeps a 3-second margin above that. /// - private static readonly TimeSpan GateTimeout = TimeSpan.FromMinutes(15); + private static readonly TimeSpan TeardownTimeout = TimeSpan.FromSeconds(25); private readonly RealFileSystem _fileSystem = new(); private readonly IGitProcess _gitProcess = new GitProcess(); @@ -73,24 +70,13 @@ public async Task InitializeAsync() /// private async Task StartAsync(TestRunRoot runRoot) { - using var gate = await DrivePortGate.AcquireAsync(_fileSystem, GateTimeout); + using var gate = await DrivePortGate.AcquireAsync(_fileSystem, SimulatedServerLaunch.GateTimeout); var launch = SimulatedServerLaunch.Prepare(_fileSystem, runRoot); // The collector exists before the start and stays readable when the start fails, so a // startup failure still has stdout/stderr to write out on teardown. _output = new OpenCodeServerOutput(); - _server = await OpenCodeServer.StartAsync(new OpenCodeServerOptions - { - Command = launch.Command, - WorkingDirectory = launch.WorkingDirectory, - Environment = launch.Environment, - ReadinessTimeout = ReadinessTimeout, - - // The source-run host needs longer than the launcher's 3-second default to leave on - // stdin EOF; ten seconds is the policy the retired test adapter already applied. - GracefulShutdownTimeout = TimeSpan.FromSeconds(10), - Output = _output, - }); + _server = await OpenCodeServer.StartAsync(launch.Options(_output)); var manifest = launch.Manifest; // The backend control socket is already listening when the readiness line is printed - @@ -152,7 +138,7 @@ public async ValueTask DisposeAsync() var keep = ShouldRetainLogs; var recorded = _artifacts?.Failures; var primary = recorded is { Count: > 0 } ? recorded[0].Exception : null; - var teardown = new OwnedCleanup(TimeSpan.FromSeconds(25)); + var teardown = new OwnedCleanup(TeardownTimeout); if (_controller is not null) { teardown.Own("simulation controller teardown", async _ => await _controller.DisposeAsync()); diff --git a/tests/Shared/SimulatedServerLaunch.cs b/tests/Shared/SimulatedServerLaunch.cs index c0e507a2..a0b92c52 100644 --- a/tests/Shared/SimulatedServerLaunch.cs +++ b/tests/Shared/SimulatedServerLaunch.cs @@ -19,6 +19,29 @@ internal sealed record SimulatedServerLaunch public required DriveManifest Manifest { get; init; } + /// + /// Bounded well above the realistic worst case - every local target-framework leg starting a + /// simulated server back to back, each within the readiness bound - so a genuinely wedged + /// gate holder still fails loudly instead of hanging the suite. + /// + public static TimeSpan GateTimeout { get; } = TimeSpan.FromMinutes(15); + + /// The launcher options for this recipe under , retaining output in . + public OpenCodeServerOptions Options(OpenCodeServerOutput output) + { + ArgumentNullException.ThrowIfNull(output); + + return new OpenCodeServerOptions + { + Command = Command, + WorkingDirectory = WorkingDirectory, + Environment = Environment, + ReadinessTimeout = OwnedServerPolicy.ReadinessTimeout, + GracefulShutdownTimeout = OwnedServerPolicy.GracefulShutdownTimeout, + Output = output, + }; + } + public static SimulatedServerLaunch Prepare(IFileSystem fileSystem, TestRunRoot runRoot) { ArgumentNullException.ThrowIfNull(fileSystem); From 61de3b1a47828dc7d619de164d65eb1352e12ad1 Mon Sep 17 00:00:00 2001 From: "denizirgin@gmail.com" Date: Mon, 7 Sep 2026 18:38:18 +0300 Subject: [PATCH 49/62] test: name the failed-start tree contract once and record its platform boundary The three failed-start proofs share one assertion helper stating the accepted contract; the scenario's cleanup message no longer calls a bounded observation immediate; the stdin-lease proof says why it disposes twice; and the roadmap records that the downlevel non-Windows tree-kill arm is untested and that Linux/macOS termination is proven only by the three-OS run. --- docs/ROADMAP.md | 6 +++ .../OpenCodeServerLifecycleTests.cs | 39 ++++++++----------- .../Support/ServerStartupTreeScenario.cs | 2 +- 3 files changed, 24 insertions(+), 23 deletions(-) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 5495e3d4..c280282e 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -118,3 +118,9 @@ is revisited at each boundary. defect: run the gates with `--report-trx --report-trx-filename ` so a recurrence names it. - **`BuildOs`/`BuildArch` in `Directory.Build.props`** need their values adapted to opencode's release-asset naming when the binary-download need lands. +- **The launcher's descendant-termination proof has a platform boundary.** The startup-tree tests + prove the direct child exits immediately and the grandchild terminates inside a ten-second bound + on the modern target frameworks (all three OSes) and on `net472` Windows (`taskkill /T`). The + downlevel non-Windows arm of the tree kill (a plain `Kill()`) is not exercised by any test project, + and the Linux/macOS behavior is established only by the three-OS CI run, never by a Windows-local + suite. diff --git a/tests/OpenCode.Sdk.Tests/OpenCodeServerLifecycleTests.cs b/tests/OpenCode.Sdk.Tests/OpenCodeServerLifecycleTests.cs index 5cf2da48..136d56f3 100644 --- a/tests/OpenCode.Sdk.Tests/OpenCodeServerLifecycleTests.cs +++ b/tests/OpenCode.Sdk.Tests/OpenCodeServerLifecycleTests.cs @@ -84,6 +84,7 @@ public async Task DisposeAsync_Should_End_The_Server_Through_The_Stdin_Lease(Can var processId = server.ProcessId; await server.DisposeAsync(); + // A second disposal is a no-op by contract; this is the idempotence proof, not a stray line. await server.DisposeAsync(); await Assert.That(IsProcessRunning(processId)).IsFalse(); @@ -132,15 +133,7 @@ public async Task StartAsync_Should_Refuse_A_Server_That_Never_Reports_Readiness async () => _ = await scenario.WaitForStartupAsync(cancellationToken)).Throws(); await Assert.That(failure!.Message).Contains("did not report readiness"); - // The direct child's exit is immediate: the startup result is returned only after it. - var rootExited = scenario.RootProcess.HasExited; - await Assert.That(rootExited).IsTrue().Because(rootExited ? string.Empty : await scenario.DescribeProcessesAsync()); - - // Bounded descendant termination evidence, both leases still held (disposal releases - // them, after this): a tree kill is asynchronous, so the grandchild is observed to - // terminate inside its own bound rather than asserted gone at that instant. - var descendantTerminated = await scenario.ObserveDescendantTerminationAsync(cancellationToken); - await Assert.That(descendantTerminated).IsTrue().Because(scenario.DescendantEvidence); + await AssertFailedStartEndedTheTreeAsync(scenario, cancellationToken); } [Test] @@ -154,13 +147,23 @@ public async Task StartAsync_Should_Refuse_A_Non_Contract_First_Line(Cancellatio async () => _ = await scenario.WaitForStartupAsync(cancellationToken)).Throws(); await Assert.That(failure!.Message).Contains("readiness contract"); - // The direct child's exit is immediate: the startup result is returned only after it. + await AssertFailedStartEndedTheTreeAsync(scenario, cancellationToken); + } + + /// + /// The accepted completion contract after a failed start. The direct child's exit is + /// immediate: the startup result is returned only after it. The grandchild is bounded + /// descendant termination evidence, both leases still held (disposal releases them, after + /// this): a tree kill is asynchronous, so it is observed to terminate inside its own bound + /// rather than asserted gone at that instant. + /// + private static async Task AssertFailedStartEndedTheTreeAsync( + ServerStartupTreeScenario scenario, + CancellationToken cancellationToken) + { var rootExited = scenario.RootProcess.HasExited; await Assert.That(rootExited).IsTrue().Because(rootExited ? string.Empty : await scenario.DescribeProcessesAsync()); - // Bounded descendant termination evidence, both leases still held (disposal releases - // them, after this): a tree kill is asynchronous, so the grandchild is observed to - // terminate inside its own bound rather than asserted gone at that instant. var descendantTerminated = await scenario.ObserveDescendantTerminationAsync(cancellationToken); await Assert.That(descendantTerminated).IsTrue().Because(scenario.DescendantEvidence); } @@ -393,14 +396,6 @@ public async Task StartAsync_Should_Surface_Caller_Cancellation(CancellationToke _ = await Assert.That( async () => _ = await scenario.WaitForStartupAsync(cancellationToken)).Throws(); - // The direct child's exit is immediate: the startup result is returned only after it. - var rootExited = scenario.RootProcess.HasExited; - await Assert.That(rootExited).IsTrue().Because(rootExited ? string.Empty : await scenario.DescribeProcessesAsync()); - - // Bounded descendant termination evidence, both leases still held (disposal releases - // them, after this): a tree kill is asynchronous, so the grandchild is observed to - // terminate inside its own bound rather than asserted gone at that instant. - var descendantTerminated = await scenario.ObserveDescendantTerminationAsync(cancellationToken); - await Assert.That(descendantTerminated).IsTrue().Because(scenario.DescendantEvidence); + await AssertFailedStartEndedTheTreeAsync(scenario, cancellationToken); } } diff --git a/tests/OpenCode.Sdk.Tests/Support/ServerStartupTreeScenario.cs b/tests/OpenCode.Sdk.Tests/Support/ServerStartupTreeScenario.cs index 79eccf3b..5c626605 100644 --- a/tests/OpenCode.Sdk.Tests/Support/ServerStartupTreeScenario.cs +++ b/tests/OpenCode.Sdk.Tests/Support/ServerStartupTreeScenario.cs @@ -212,7 +212,7 @@ private void ThrowCleanupFailures(List failures) if (_diagnosticEvidence is { } evidence) { throw new AggregateException( - "Startup tree cleanup failed after immediate exit observations failed. " + evidence, failures); + "Startup tree cleanup failed after an exit observation failed. " + evidence, failures); } if (failures.Count is 1) From 9117276e1431923c2cd6ab9a641b501c28dfaf43 Mon Sep 17 00:00:00 2001 From: "denizirgin@gmail.com" Date: Mon, 7 Sep 2026 19:31:42 +0300 Subject: [PATCH 50/62] test: prove the worktree lifecycle and its refusals against a real Git repository Over the simulated server and an owned local repository: the root row lists with no strategy; a linked Git worktree is created under an owned parent with a fixed absent name and proven by the parent's seeded marker rather than the spelling of a temporary path; the inventory shows root plus the linked row with strategy git; refresh answers 204 and preserves the inventory; a dirty worktree refuses non-forced removal with the typed WorktreeError naming force, and forced removal empties the inventory back to the root; an unavailable strategy answers the exact 400 message and creates no checkout. Cleanup removes the linked worktree through the SDK at its known owned destination and then disposes the workspace under finite budgets that preserve the primary failure. --- .../GitRepositoryWorkspaceTests.cs | 37 ++++ .../Support/OwnedWorktreeScenario.cs | 92 +++++++++ .../Worktrees/WorktreesClientLiveTests.cs | 192 ++++++++++++++++++ tests/Shared/GitRepositoryWorkspace.cs | 56 +++++ 4 files changed, 377 insertions(+) create mode 100644 tests/OpenCode.Sdk.Tests/Support/OwnedWorktreeScenario.cs create mode 100644 tests/OpenCode.Sdk.Tests/Worktrees/WorktreesClientLiveTests.cs diff --git a/tests/OpenCode.Sdk.Tests/GitRepositoryWorkspaceTests.cs b/tests/OpenCode.Sdk.Tests/GitRepositoryWorkspaceTests.cs index 9d9de9a8..83f8d585 100644 --- a/tests/OpenCode.Sdk.Tests/GitRepositoryWorkspaceTests.cs +++ b/tests/OpenCode.Sdk.Tests/GitRepositoryWorkspaceTests.cs @@ -8,6 +8,43 @@ namespace OpenCode.Sdk.Tests; public sealed class GitRepositoryWorkspaceTests { + [Test] + public async Task OwnsWorktreeDirectory_Should_Reject_A_Foreign_Parent_And_Another_Child_Name() + { + var fileSystem = new MockFileSystem(); + var git = Substitute.For(); + git.RunAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.CompletedTask); + using var repository = await GitRepositoryWorkspace.CreateAsync( + fileSystem, git, fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), "run"), CancellationToken.None); + repository.PrepareWorktreeDestination(); + + await Assert.That(repository.OwnsWorktreeDirectory(repository.ExpectedWorktreePath)).IsTrue(); + await Assert.That(repository.OwnsWorktreeDirectory( + fileSystem.Path.Combine(repository.WorktreeParentPath, "other"))).IsFalse(); + await Assert.That(repository.OwnsWorktreeDirectory( + fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), "foreign", GitRepositoryWorkspace.WorktreeName))).IsFalse(); + await Assert.That(repository.WorktreeExists).IsFalse(); + } + + [Test] + public async Task WriteDirtyWorktreeFile_Should_Require_An_Existing_Checkout() + { + var fileSystem = new MockFileSystem(); + var git = Substitute.For(); + git.RunAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.CompletedTask); + using var repository = await GitRepositoryWorkspace.CreateAsync( + fileSystem, git, fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), "run"), CancellationToken.None); + repository.PrepareWorktreeDestination(); + + _ = await Assert.That(repository.WriteDirtyWorktreeFile).Throws(); + await Assert.That(repository.WorktreeExists).IsFalse(); + _ = fileSystem.Directory.CreateDirectory(repository.ExpectedWorktreePath); + repository.WriteDirtyWorktreeFile(); + await Assert.That(repository.DirtyWorktreeFileExists).IsTrue(); + } + [Test] public async Task CreateAsync_Should_Preserve_Initialization_And_Cleanup_Failures() { diff --git a/tests/OpenCode.Sdk.Tests/Support/OwnedWorktreeScenario.cs b/tests/OpenCode.Sdk.Tests/Support/OwnedWorktreeScenario.cs new file mode 100644 index 00000000..fe9a26fa --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Support/OwnedWorktreeScenario.cs @@ -0,0 +1,92 @@ +using System.Globalization; +using OpenCode.Sdk.Models; +using OpenCode.Sdk.TestSupport; +using OpenCode.Sdk.TestSupport.Ownership; + +namespace OpenCode.Sdk.Tests.Support; + +/// +/// Owns a live worktree scenario's Git repository workspace from creation to teardown: the +/// linked worktree is removed through the SDK at its known owned destination (never at an +/// unchecked server-returned path), then the workspace is disposed, each under its own finite +/// budget, with the primary failure and every cleanup failure preserved. +/// +internal sealed class OwnedWorktreeScenario +{ + private static readonly TimeSpan CleanupTimeout = TimeSpan.FromSeconds(10); + + private readonly OpenCodeClient _client; + private readonly OwnedCleanup _cleanup = new(CleanupTimeout); + private GitRepositoryWorkspace? _repository; + + private OwnedWorktreeScenario(OpenCodeClient client) + { + _client = client; + _cleanup.Own("Git worktree removal", RemoveIfPresentAsync); + _cleanup.Own("Git repository workspace", _ => + { + Repository.Dispose(); + return Task.CompletedTask; + }); + } + + public GitRepositoryWorkspace Repository => + _repository ?? throw new InvalidOperationException("The worktree scenario has not initialized."); + + /// Creates the owned repository and seeds the worktree destination's parent marker. + public static async Task CreateAsync( + SimulatedDriveServerFixture server, + OpenCodeClient client, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(server); + ArgumentNullException.ThrowIfNull(client); + + var scenario = new OwnedWorktreeScenario(client); + await scenario.InitializeAsync(server, cancellationToken); + return scenario; + } + + public Task CompleteAsync(Exception? primaryFailure) => _cleanup.CompleteAsync(primaryFailure); + + private async Task InitializeAsync(SimulatedDriveServerFixture server, CancellationToken cancellationToken) + { + // The repository is owned by this scenario from the moment it exists; a failure to seed + // the destination disposes it here, since no caller ever receives the scenario. + _repository = await server.CreateGitRepositoryWorkspaceAsync(cancellationToken); + try + { + _repository.PrepareWorktreeDestination(); + } + catch + { + _repository.Dispose(); + _repository = null; + throw; + } + } + + private async Task RemoveIfPresentAsync(CancellationToken cancellationToken) + { + if (!Repository.WorktreeExists) + { + return; + } + + var response = await _client.Worktrees.RemoveWorktreeAsync( + new WorktreeRemoveRequest + { + Location = Repository.Location, + Directory = Repository.ExpectedWorktreePath, + Force = true, + }, + OpenCodeRequestOptions.NoThrow, + cancellationToken); + if (response.Status != 204 || response.IsError) + { + throw new InvalidOperationException( + "Owned worktree removal returned status " + response.Status.ToString(CultureInfo.InvariantCulture) + + ", body " + response.RawBody + "."); + } + } +} diff --git a/tests/OpenCode.Sdk.Tests/Worktrees/WorktreesClientLiveTests.cs b/tests/OpenCode.Sdk.Tests/Worktrees/WorktreesClientLiveTests.cs new file mode 100644 index 00000000..4395e148 --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Worktrees/WorktreesClientLiveTests.cs @@ -0,0 +1,192 @@ +using System.Globalization; +using OpenCode.Sdk.Models; +using OpenCode.Sdk.Tests.Support; +using OpenCode.Sdk.TestSupport; + +namespace OpenCode.Sdk.Tests; + +/// +/// The worktree family's live proof against the simulated server over an owned local Git +/// repository: the root row, a linked Git worktree created under an owned parent with a fixed +/// absent name, refresh preserving the inventory, the dirty-removal refusal that names force, +/// forced removal, and the unavailable-strategy refusal. Physical ownership of every +/// server-returned directory is proven through independently seeded markers, never through +/// the spelling of a temporary path. +/// +[ClassDataSource(Shared = SharedType.PerTestSession)] +[NotInParallel(ParallelConstraintKeys.ServerProcess)] +public sealed class WorktreesClientLiveTests(SimulatedDriveServerFixture server) +{ + private const string GitStrategy = "git"; + + private const string MissingStrategy = "sdk-live-missing"; + + [Test] + [Timeout(60_000)] + public async Task CreateWorktreeAsync_And_RefreshWorktreesAsync_Should_Preserve_The_Project_Inventory( + CancellationToken cancellationToken) + { + using var client = server.CreateClient(); + var scenario = await OwnedWorktreeScenario.CreateAsync(server, client, cancellationToken); + var repository = scenario.Repository; + Exception? failure = null; + try + { + var request = new WorktreeListRequest { Location = repository.Location }; + var before = await client.Worktrees.ListWorktreesAsync(request, cancellationToken: cancellationToken); + await Assert.That(before.Status).IsEqualTo(200); + await Assert.That(before.IsError).IsFalse(); + var root = before.Worktrees.Single(); + await Assert.That(repository.OwnsDirectory(root.Directory)).IsTrue(); + await Assert.That(root.Strategy).IsNull(); + + var created = await client.Worktrees.CreateWorktreeAsync( + CreateRequest(repository, GitStrategy), cancellationToken: cancellationToken); + await Assert.That(created.Status).IsEqualTo(200); + await Assert.That(created.IsError).IsFalse(); + await Assert.That(repository.OwnsWorktreeDirectory(created.Worktree.Directory)).IsTrue(); + await Assert.That(repository.WorktreeExists).IsTrue(); + + var after = await client.Worktrees.ListWorktreesAsync(request, cancellationToken: cancellationToken); + await Assert.That(after.Status).IsEqualTo(200); + await Assert.That(after.IsError).IsFalse(); + await Assert.That(after.Worktrees.Count).IsEqualTo(2); + await Assert.That(after.Worktrees.Single(row => row.Directory == root.Directory).Strategy).IsNull(); + await Assert.That(after.Worktrees.Single(row => row.Directory == created.Worktree.Directory).Strategy) + .IsEqualTo(GitStrategy); + + var refreshed = await client.Worktrees.RefreshWorktreesAsync( + new WorktreeRefreshPostRequest { Location = repository.Location }, + cancellationToken: cancellationToken); + await Assert.That(refreshed.Status).IsEqualTo(204); + await Assert.That(refreshed.IsError).IsFalse(); + + var final = await client.Worktrees.ListWorktreesAsync(request, cancellationToken: cancellationToken); + await Assert.That(final.Status).IsEqualTo(200); + await Assert.That(final.IsError).IsFalse(); + await Assert.That(final.Worktrees).IsEquivalentTo(after.Worktrees); + + Console.WriteLine( + "worktree-live: mode=owned arm=list-create-list-refresh-list status=" + + Number(before.Status) + "/" + Number(created.Status) + "/" + Number(after.Status) + "/" + + Number(refreshed.Status) + "/" + Number(final.Status)); + } + catch (Exception exception) + { + failure = exception; + } + + await scenario.CompleteAsync(failure); + } + + [Test] + [Timeout(60_000)] + public async Task RemoveWorktreeAsync_Should_Require_Force_For_A_Dirty_Worktree( + CancellationToken cancellationToken) + { + using var client = server.CreateClient(); + var scenario = await OwnedWorktreeScenario.CreateAsync(server, client, cancellationToken); + var repository = scenario.Repository; + Exception? failure = null; + try + { + var created = await client.Worktrees.CreateWorktreeAsync( + CreateRequest(repository, GitStrategy), cancellationToken: cancellationToken); + await Assert.That(created.Status).IsEqualTo(200); + await Assert.That(created.IsError).IsFalse(); + await Assert.That(repository.OwnsWorktreeDirectory(created.Worktree.Directory)).IsTrue(); + await Assert.That(repository.WorktreeExists).IsTrue(); + repository.WriteDirtyWorktreeFile(); + + var remove = new WorktreeRemoveRequest + { + Location = repository.Location, + Directory = created.Worktree.Directory, + Force = false, + }; + var refused = await client.Worktrees.RemoveWorktreeAsync( + remove, OpenCodeRequestOptions.NoThrow, cancellationToken); + await Assert.That(refused.Status).IsEqualTo(400); + await Assert.That(refused.IsError).IsTrue(); + await Assert.That(refused.Error).IsTypeOf(); + var error = refused.Error as WorktreeError ?? throw new InvalidOperationException("The WorktreeError arm was absent."); + await Assert.That(error.Data.ForceRequired).IsTrue(); + await Assert.That(string.IsNullOrWhiteSpace(error.Data.Message)).IsFalse(); + await Assert.That(string.IsNullOrWhiteSpace(refused.RawBody)).IsFalse(); + await Assert.That(repository.WorktreeExists).IsTrue(); + await Assert.That(repository.DirtyWorktreeFileExists).IsTrue(); + + var removed = await client.Worktrees.RemoveWorktreeAsync( + remove with { Force = true }, cancellationToken: cancellationToken); + await Assert.That(removed.Status).IsEqualTo(204); + await Assert.That(removed.IsError).IsFalse(); + + var final = await client.Worktrees.ListWorktreesAsync( + new WorktreeListRequest { Location = repository.Location }, cancellationToken: cancellationToken); + await Assert.That(final.Status).IsEqualTo(200); + await Assert.That(final.IsError).IsFalse(); + var root = final.Worktrees.Single(); + await Assert.That(repository.OwnsDirectory(root.Directory)).IsTrue(); + await Assert.That(root.Strategy).IsNull(); + await Assert.That(repository.WorktreeExists).IsFalse(); + await Assert.That(repository.DirtyWorktreeFileExists).IsFalse(); + + Console.WriteLine( + "worktree-live: mode=owned arm=dirty-remove-refused/forced status=" + Number(refused.Status) + "/" + + Number(removed.Status) + "/" + Number(final.Status) + " message=" + error.Data.Message); + } + catch (Exception exception) + { + failure = exception; + } + + await scenario.CompleteAsync(failure); + } + + [Test] + [Timeout(60_000)] + public async Task CreateWorktreeAsync_Should_Report_An_Unavailable_Strategy_Without_Creating_A_Checkout( + CancellationToken cancellationToken) + { + using var client = server.CreateClient(); + var scenario = await OwnedWorktreeScenario.CreateAsync(server, client, cancellationToken); + var repository = scenario.Repository; + Exception? failure = null; + try + { + var response = await client.Worktrees.CreateWorktreeAsync( + CreateRequest(repository, MissingStrategy), OpenCodeRequestOptions.NoThrow, cancellationToken); + await Assert.That(response.Status).IsEqualTo(400); + await Assert.That(response.IsError).IsTrue(); + await Assert.That(response.Error).IsTypeOf(); + var error = response.Error as WorktreeError ?? throw new InvalidOperationException("The WorktreeError arm was absent."); + await Assert.That(error.Data.Message).IsEqualTo("Worktree strategy unavailable: " + MissingStrategy); + await Assert.That(error.Data.ForceRequired).IsNull(); + await Assert.That(string.IsNullOrWhiteSpace(response.RawBody)).IsFalse(); + await Assert.That(repository.WorktreeExists).IsFalse(); + + Console.WriteLine("worktree-live: mode=owned arm=strategy-unavailable status=" + Number(response.Status)); + } + catch (Exception exception) + { + failure = exception; + } + + await scenario.CompleteAsync(failure); + } + + /// + /// Every create names its strategy, its owned parent, and its fixed absent child name, so + /// nothing is written under server-owned data and nothing depends on plugin defaults. + /// + private static WorktreeCreateRequest CreateRequest(GitRepositoryWorkspace repository, string strategy) => new() + { + Location = repository.Location, + Strategy = strategy, + Directory = repository.WorktreeParentPath, + Name = GitRepositoryWorkspace.WorktreeName, + }; + + /// Renders one number for the console line, culture-free. + private static string Number(int value) => value.ToString(CultureInfo.InvariantCulture); +} diff --git a/tests/Shared/GitRepositoryWorkspace.cs b/tests/Shared/GitRepositoryWorkspace.cs index 50811f50..2550fc32 100644 --- a/tests/Shared/GitRepositoryWorkspace.cs +++ b/tests/Shared/GitRepositoryWorkspace.cs @@ -10,6 +10,17 @@ public sealed class GitRepositoryWorkspace : IDisposable private const string ModifiedContent = "after\n"; private const string OwnerMarker = ".git/opencode-sdk-test-owner.txt"; private const string TrackedFile = "tracked.txt"; + + /// + /// The linked worktree's destination lives beside the repository, under an owned parent + /// carrying its own marker: a linked worktree's .git is a file, so the repository's + /// marker cannot prove a child, and the parent marker stays outside the working-file + /// inventory the VCS proofs assert on. + /// + private const string WorktreeParentMarker = ".opencode-sdk-test-owner.txt"; + private const string WorktreeParentName = "worktrees"; + private const string LinkedWorktreeName = "linked"; + private const string DirtyWorktreeFile = "dirty.txt"; private const string InitializeArguments = "-c core.hooksPath=../hooks init --initial-branch=main --template=../template"; private const string AddArguments = @@ -40,6 +51,20 @@ internal GitRepositoryWorkspace(IFileSystem fileSystem, IGitProcess gitProcess, internal string RepositoryPath => _fileSystem.Path.Combine(Workspace.Path, "repository"); + /// The owned parent a linked worktree is created under. + public string WorktreeParentPath => _fileSystem.Path.Combine(Workspace.Path, WorktreeParentName); + + /// The fixed child name every create names; absent until the server creates it. + public static string WorktreeName => LinkedWorktreeName; + + /// The logical destination the linked worktree is expected at, for cleanup and existence checks. + public string ExpectedWorktreePath => _fileSystem.Path.Combine(WorktreeParentPath, LinkedWorktreeName); + + public bool WorktreeExists => _fileSystem.Directory.Exists(ExpectedWorktreePath); + + public bool DirtyWorktreeFileExists => + _fileSystem.File.Exists(_fileSystem.Path.Combine(ExpectedWorktreePath, DirtyWorktreeFile)); + private TestWorkspace Workspace => _workspace ?? throw new InvalidOperationException("The Git repository workspace has not initialized."); @@ -90,6 +115,37 @@ internal async Task InitializeAsync(CancellationToken cancellationToken) public bool OwnsDirectory(string directory) => Workspace.HasTextFile(directory, OwnerMarker, _owner); + /// Seeds the owned parent's marker; the child itself stays absent for the server to create. + public void PrepareWorktreeDestination() => + _ = Workspace.WriteTextFile(WorktreeParentName + "/" + WorktreeParentMarker, _owner); + + /// + /// Proves a server-returned directory is the intended child: its physical parent carries the + /// seeded marker and its name is the fixed child name. This says nothing about whether the + /// child exists; does. + /// + public bool OwnsWorktreeDirectory(string directory) + { + ArgumentException.ThrowIfNullOrWhiteSpace(directory); + + var parent = _fileSystem.Path.GetDirectoryName(directory); + return parent is not null + && string.Equals(_fileSystem.Path.GetFileName(directory), LinkedWorktreeName, StringComparison.Ordinal) + && Workspace.HasTextFile(parent, WorktreeParentMarker, _owner); + } + + /// Dirties the created worktree at its known path; refuses to invent a checkout that does not exist. + public void WriteDirtyWorktreeFile() + { + if (!WorktreeExists) + { + throw new InvalidOperationException("The owned worktree does not exist."); + } + + _ = Workspace.WriteTextFile( + WorktreeParentName + "/" + LinkedWorktreeName + "/" + DirtyWorktreeFile, "dirty\n"); + } + public void WriteModifiedTrackedFile() => _ = Workspace.WriteTextFile("repository/" + TrackedFile, ModifiedContent); From 9d5d36cb07ac885e1e8ea9243eae1d1c4a72870b Mon Sep 17 00:00:00 2001 From: "denizirgin@gmail.com" Date: Mon, 7 Sep 2026 19:40:58 +0300 Subject: [PATCH 51/62] test: observe the linked worktree's absence before creating it and name the worktree proofs once The create-and-prove block and the WorktreeError requirement become one helper each, the fixed child name is a constant, and the live proofs observe the child absent before the create rather than inferring it. --- .../Worktrees/WorktreesClientLiveTests.cs | 53 ++++++++++++------- tests/Shared/GitRepositoryWorkspace.cs | 2 +- 2 files changed, 34 insertions(+), 21 deletions(-) diff --git a/tests/OpenCode.Sdk.Tests/Worktrees/WorktreesClientLiveTests.cs b/tests/OpenCode.Sdk.Tests/Worktrees/WorktreesClientLiveTests.cs index 4395e148..d429a343 100644 --- a/tests/OpenCode.Sdk.Tests/Worktrees/WorktreesClientLiveTests.cs +++ b/tests/OpenCode.Sdk.Tests/Worktrees/WorktreesClientLiveTests.cs @@ -40,12 +40,7 @@ public async Task CreateWorktreeAsync_And_RefreshWorktreesAsync_Should_Preserve_ await Assert.That(repository.OwnsDirectory(root.Directory)).IsTrue(); await Assert.That(root.Strategy).IsNull(); - var created = await client.Worktrees.CreateWorktreeAsync( - CreateRequest(repository, GitStrategy), cancellationToken: cancellationToken); - await Assert.That(created.Status).IsEqualTo(200); - await Assert.That(created.IsError).IsFalse(); - await Assert.That(repository.OwnsWorktreeDirectory(created.Worktree.Directory)).IsTrue(); - await Assert.That(repository.WorktreeExists).IsTrue(); + var created = await CreateLinkedWorktreeAsync(client, repository, cancellationToken); var after = await client.Worktrees.ListWorktreesAsync(request, cancellationToken: cancellationToken); await Assert.That(after.Status).IsEqualTo(200); @@ -90,12 +85,7 @@ public async Task RemoveWorktreeAsync_Should_Require_Force_For_A_Dirty_Worktree( Exception? failure = null; try { - var created = await client.Worktrees.CreateWorktreeAsync( - CreateRequest(repository, GitStrategy), cancellationToken: cancellationToken); - await Assert.That(created.Status).IsEqualTo(200); - await Assert.That(created.IsError).IsFalse(); - await Assert.That(repository.OwnsWorktreeDirectory(created.Worktree.Directory)).IsTrue(); - await Assert.That(repository.WorktreeExists).IsTrue(); + var created = await CreateLinkedWorktreeAsync(client, repository, cancellationToken); repository.WriteDirtyWorktreeFile(); var remove = new WorktreeRemoveRequest @@ -106,10 +96,7 @@ public async Task RemoveWorktreeAsync_Should_Require_Force_For_A_Dirty_Worktree( }; var refused = await client.Worktrees.RemoveWorktreeAsync( remove, OpenCodeRequestOptions.NoThrow, cancellationToken); - await Assert.That(refused.Status).IsEqualTo(400); - await Assert.That(refused.IsError).IsTrue(); - await Assert.That(refused.Error).IsTypeOf(); - var error = refused.Error as WorktreeError ?? throw new InvalidOperationException("The WorktreeError arm was absent."); + var error = await RequireWorktreeErrorAsync(refused.Status, refused.IsError, refused.Error); await Assert.That(error.Data.ForceRequired).IsTrue(); await Assert.That(string.IsNullOrWhiteSpace(error.Data.Message)).IsFalse(); await Assert.That(string.IsNullOrWhiteSpace(refused.RawBody)).IsFalse(); @@ -156,10 +143,7 @@ public async Task CreateWorktreeAsync_Should_Report_An_Unavailable_Strategy_With { var response = await client.Worktrees.CreateWorktreeAsync( CreateRequest(repository, MissingStrategy), OpenCodeRequestOptions.NoThrow, cancellationToken); - await Assert.That(response.Status).IsEqualTo(400); - await Assert.That(response.IsError).IsTrue(); - await Assert.That(response.Error).IsTypeOf(); - var error = response.Error as WorktreeError ?? throw new InvalidOperationException("The WorktreeError arm was absent."); + var error = await RequireWorktreeErrorAsync(response.Status, response.IsError, response.Error); await Assert.That(error.Data.Message).IsEqualTo("Worktree strategy unavailable: " + MissingStrategy); await Assert.That(error.Data.ForceRequired).IsNull(); await Assert.That(string.IsNullOrWhiteSpace(response.RawBody)).IsFalse(); @@ -175,6 +159,35 @@ public async Task CreateWorktreeAsync_Should_Report_An_Unavailable_Strategy_With await scenario.CompleteAsync(failure); } + /// + /// Creates the linked Git worktree and proves the server created exactly the intended child: + /// the fixed name is observed absent first, the returned directory resolves to the owned + /// parent with that name, and the child physically exists afterwards. + /// + private static async Task CreateLinkedWorktreeAsync( + OpenCodeClient client, + GitRepositoryWorkspace repository, + CancellationToken cancellationToken) + { + await Assert.That(repository.WorktreeExists).IsFalse(); + var created = await client.Worktrees.CreateWorktreeAsync( + CreateRequest(repository, GitStrategy), cancellationToken: cancellationToken); + await Assert.That(created.Status).IsEqualTo(200); + await Assert.That(created.IsError).IsFalse(); + await Assert.That(repository.OwnsWorktreeDirectory(created.Worktree.Directory)).IsTrue(); + await Assert.That(repository.WorktreeExists).IsTrue(); + return created; + } + + /// The declared 400 arm, or a failure naming the arm that arrived instead. + private static async Task RequireWorktreeErrorAsync(int status, bool isError, IOpenCodeError? error) + { + await Assert.That(status).IsEqualTo(400); + await Assert.That(isError).IsTrue(); + await Assert.That(error).IsTypeOf(); + return error as WorktreeError ?? throw new InvalidOperationException("The WorktreeError arm was absent."); + } + /// /// Every create names its strategy, its owned parent, and its fixed absent child name, so /// nothing is written under server-owned data and nothing depends on plugin defaults. diff --git a/tests/Shared/GitRepositoryWorkspace.cs b/tests/Shared/GitRepositoryWorkspace.cs index 2550fc32..b476ce07 100644 --- a/tests/Shared/GitRepositoryWorkspace.cs +++ b/tests/Shared/GitRepositoryWorkspace.cs @@ -55,7 +55,7 @@ internal GitRepositoryWorkspace(IFileSystem fileSystem, IGitProcess gitProcess, public string WorktreeParentPath => _fileSystem.Path.Combine(Workspace.Path, WorktreeParentName); /// The fixed child name every create names; absent until the server creates it. - public static string WorktreeName => LinkedWorktreeName; + public const string WorktreeName = LinkedWorktreeName; /// The logical destination the linked worktree is expected at, for cleanup and existence checks. public string ExpectedWorktreePath => _fileSystem.Path.Combine(WorktreeParentPath, LinkedWorktreeName); From 549eddb2d9005df8a6e14f42c3e5ada6ae34cdd4 Mon Sep 17 00:00:00 2001 From: "denizirgin@gmail.com" Date: Mon, 7 Sep 2026 19:40:58 +0300 Subject: [PATCH 52/62] test: extend the drive controller with the simulated tool protocol The handshake requires the six tool capabilities; the composer gains the canonical toolCall model item and the tool.attach/update/finish/fail requests, writing supplied JSON elements rather than encoded strings; the controller routes llm.request, tool.invocation, and tool.cancel to separate bounded FIFOs, exposes attach/update/finish/fail and the two tool waits, and a model request now carries its cloned body so a proof can require the real content the model loop resumed with. The simulation seed gains the c16 agent whose builtin read is gated by a real permission prompt. Existing simulated workflows are unchanged in behavior. --- .../Support/OwnedSessionInboxCleanupTests.cs | 8 +- tests/Shared/DriveController.cs | 107 ++++++++++++++---- tests/Shared/DriveInvocation.cs | 13 ++- tests/Shared/DriveNotificationQueue.cs | 39 +++++++ tests/Shared/DriveProtocol.cs | 101 ++++++++++++++++- tests/Shared/DriveToolCancellation.cs | 7 ++ tests/Shared/DriveToolInvocation.cs | 18 +++ tests/Shared/DriveToolRegistration.cs | 41 +++++++ tests/Shared/SimulationConfigSeed.cs | 20 ++++ 9 files changed, 321 insertions(+), 33 deletions(-) create mode 100644 tests/Shared/DriveNotificationQueue.cs create mode 100644 tests/Shared/DriveToolCancellation.cs create mode 100644 tests/Shared/DriveToolInvocation.cs create mode 100644 tests/Shared/DriveToolRegistration.cs diff --git a/tests/OpenCode.Sdk.Tests/Support/OwnedSessionInboxCleanupTests.cs b/tests/OpenCode.Sdk.Tests/Support/OwnedSessionInboxCleanupTests.cs index c04e4ba2..05eb6664 100644 --- a/tests/OpenCode.Sdk.Tests/Support/OwnedSessionInboxCleanupTests.cs +++ b/tests/OpenCode.Sdk.Tests/Support/OwnedSessionInboxCleanupTests.cs @@ -43,7 +43,7 @@ public async Task CompleteAsync_Should_Observe_The_Retained_Wait_After_Interrupt operations.Add("drive disconnect"); return Task.FromException(disconnectFailure); })); - _ = cleanup.RetainInvocation(new DriveInvocation("unfinished", "https://example.test", "sim")); + _ = cleanup.RetainInvocation(new DriveInvocation("unfinished", "https://example.test", "sim", default)); cleanup.RetainWait(wait.Task); var thrown = await Assert.That(() => cleanup.CompleteAsync(primary)).Throws(); @@ -73,8 +73,8 @@ public async Task CompleteAsync_Should_Disconnect_Only_Unfinished_Invocations_Af _ => Task.CompletedTask, _ => Task.CompletedTask, invocation => CreateInvocation(invocation, disconnected)); - var finished = cleanup.RetainInvocation(new DriveInvocation("finished", "https://example.test", "sim")); - _ = cleanup.RetainInvocation(new DriveInvocation("unfinished", "https://example.test", "sim")); + var finished = cleanup.RetainInvocation(new DriveInvocation("finished", "https://example.test", "sim", default)); + _ = cleanup.RetainInvocation(new DriveInvocation("unfinished", "https://example.test", "sim", default)); await finished.FinishAsync(); var thrown = await Assert.That(() => cleanup.CompleteAsync(null)).Throws(); @@ -94,7 +94,7 @@ public async Task CompleteAsync_Should_Not_Disconnect_An_Invocation_For_A_Primar _ => Task.CompletedTask, _ => Task.CompletedTask, invocation => CreateInvocation(invocation, disconnected)); - _ = cleanup.RetainInvocation(new DriveInvocation("unfinished", "https://example.test", "sim")); + _ = cleanup.RetainInvocation(new DriveInvocation("unfinished", "https://example.test", "sim", default)); var thrown = await Assert.That(() => cleanup.CompleteAsync(primary)).Throws(); diff --git a/tests/Shared/DriveController.cs b/tests/Shared/DriveController.cs index 40dea997..7736cc9a 100644 --- a/tests/Shared/DriveController.cs +++ b/tests/Shared/DriveController.cs @@ -8,9 +8,10 @@ namespace OpenCode.Sdk.TestSupport; /// /// The repository-owned drive controller (design §7.4): a WebSocket JSON-RPC client for the /// simulation backend. One JSON-RPC message per WebSocket text message -/// (control-server.ts:149-169); responses correlate by numeric id; llm.request arrives as an -/// id-less notification (simulated-provider.ts:334). Every wait is bounded — an unattached or -/// wedged controller must fail the suite fast, never hang it. +/// (control-server.ts:149-169); responses correlate by numeric id; llm.request, +/// tool.invocation, and tool.cancel arrive as id-less notifications, each on its own FIFO so +/// one kind can never be mistaken for another. Every wait is bounded — an unattached or wedged +/// controller must fail the suite fast, never hang it. /// internal sealed class DriveController : IAsyncDisposable { @@ -21,8 +22,9 @@ internal sealed class DriveController : IAsyncDisposable private readonly ClientWebSocket _socket; private readonly ConcurrentDictionary> _responses = new(); - private readonly ConcurrentQueue _invocations = new(); - private readonly SemaphoreSlim _invocationSignal = new(0); + private readonly DriveNotificationQueue _requests = new("llm.request"); + private readonly DriveNotificationQueue _toolInvocations = new("tool.invocation"); + private readonly DriveNotificationQueue _toolCancellations = new("tool.cancel"); private readonly SemaphoreSlim _sendGate = new(1, 1); private readonly CancellationTokenSource _lifetime = new(); private readonly Task _receiveLoop; @@ -90,17 +92,53 @@ public async Task AttachAsync() } } - public async Task WaitForRequestAsync(TimeSpan timeout) + public Task WaitForRequestAsync(TimeSpan timeout) => + _requests.DequeueAsync(timeout, DescribeLoopState, _lifetime.Token); + + public Task WaitForToolInvocationAsync(TimeSpan timeout) => + _toolInvocations.DequeueAsync(timeout, DescribeLoopState, _lifetime.Token); + + public Task WaitForToolCancellationAsync(TimeSpan timeout) => + _toolCancellations.DequeueAsync(timeout, DescribeLoopState, _lifetime.Token); + + /// Registers exactly these direct tools with the backend; an empty list resets the registration. + public async Task AttachToolsAsync(IReadOnlyList tools) { - if (!await _invocationSignal.WaitAsync(timeout, _lifetime.Token)) + ArgumentNullException.ThrowIfNull(tools); + + using var response = await RoundTripAsync(id => DriveProtocol.ToolAttach(id, tools), "tool.attach"); + if (!response.RootElement.GetProperty("result").GetProperty("attached").GetBoolean()) { - throw new TimeoutException( - $"No llm.request arrived within {timeout.TotalSeconds.ToString("F0", CultureInfo.InvariantCulture)}s.{DescribeLoopState()}"); + throw new InvalidOperationException("The drive backend refused the tool registration."); } + } - return _invocations.TryDequeue(out var invocation) - ? invocation - : throw new InvalidOperationException("The invocation signal fired without a queued invocation."); + public async Task ChunkToolCallAsync(string invocationId, string callId, string name, JsonElement input) + { + using var response = await RoundTripAsync( + id => DriveProtocol.ChunkToolCall(id, invocationId, callId, name, input), "llm.chunk"); + EnsureOk(response, "llm.chunk"); + } + + public async Task UpdateToolAsync(string toolId, int sequence, JsonElement update) + { + using var response = await RoundTripAsync( + id => DriveProtocol.ToolUpdate(id, toolId, sequence, update), "tool.update"); + EnsureOk(response, "tool.update"); + } + + public async Task FinishToolAsync(string toolId, JsonElement structured, string text) + { + using var response = await RoundTripAsync( + id => DriveProtocol.ToolFinish(id, toolId, structured, text), "tool.finish"); + EnsureOk(response, "tool.finish"); + } + + public async Task FailToolAsync(string toolId, string message) + { + using var response = await RoundTripAsync( + id => DriveProtocol.ToolFail(id, toolId, message), "tool.fail"); + EnsureOk(response, "tool.fail"); } public async Task ChunkTextAsync(string invocationId, params string[] deltas) @@ -175,6 +213,9 @@ public async ValueTask DisposeAsync() // second pass able to release them. _socket.Dispose(); _lifetime.Dispose(); + _requests.Dispose(); + _toolInvocations.Dispose(); + _toolCancellations.Dispose(); } } @@ -381,15 +422,41 @@ id.ValueKind is JsonValueKind.Number && using (document) { - if (root.TryGetProperty("method", out var method) && - string.Equals(method.GetString(), "llm.request", StringComparison.Ordinal)) + if (!root.TryGetProperty("method", out var method)) + { + return; + } + + var parameters = root.GetProperty("params"); + switch (method.GetString()) { - var parameters = root.GetProperty("params"); - _invocations.Enqueue(new DriveInvocation( - parameters.GetProperty("id").GetString()!, - parameters.GetProperty("url").GetString()!, - ReadModel(parameters))); - _ = _invocationSignal.Release(); + case "llm.request": + _requests.Enqueue(new DriveInvocation( + parameters.GetProperty("id").GetString()!, + parameters.GetProperty("url").GetString()!, + ReadModel(parameters), + parameters.TryGetProperty("body", out var body) ? body.Clone() : default)); + break; + case "tool.invocation": + var context = parameters.GetProperty("context"); + _toolInvocations.Enqueue(new DriveToolInvocation( + parameters.GetProperty("id").GetString()!, + parameters.GetProperty("name").GetString()!, + parameters.GetProperty("input").Clone(), + context.GetProperty("sessionID").GetString()!, + context.GetProperty("agent").GetString()!, + context.GetProperty("messageID").GetString()!, + context.GetProperty("id").GetString()!)); + break; + case "tool.cancel": + _toolCancellations.Enqueue(new DriveToolCancellation( + parameters.GetProperty("id").GetString()!, + parameters.GetProperty("reason").GetString()!)); + break; + default: + // Other notifications are not this controller's business; they are neither + // queued nor faulted on. + break; } } } diff --git a/tests/Shared/DriveInvocation.cs b/tests/Shared/DriveInvocation.cs index 212e5fe0..173b4e52 100644 --- a/tests/Shared/DriveInvocation.cs +++ b/tests/Shared/DriveInvocation.cs @@ -1,10 +1,11 @@ +using System.Text.Json; + namespace OpenCode.Sdk.TestSupport; /// -/// One provider invocation pushed by the drive backend's llm.request notification. -/// is the model id read out of the provider request body, and is what -/// discriminates one invocation from another when several share a chat route: the url alone -/// cannot tell a scripted turn's request apart from a background model call the server made on -/// its own. It is nullable because the body is provider shaped rather than protocol shaped. +/// An llm.request notification: the pending provider invocation the controller answers +/// through chunk/finish, the provider route it targets, the model the provider request named +/// (null when the provider-shaped body carries none), and the cloned request body itself, so a +/// proof can require the real content the model loop resumed with. /// -internal sealed record DriveInvocation(string Id, string Url, string? Model); +internal sealed record DriveInvocation(string Id, string Url, string? Model, JsonElement Body); diff --git a/tests/Shared/DriveNotificationQueue.cs b/tests/Shared/DriveNotificationQueue.cs new file mode 100644 index 00000000..703806e0 --- /dev/null +++ b/tests/Shared/DriveNotificationQueue.cs @@ -0,0 +1,39 @@ +using System.Collections.Concurrent; +using System.Globalization; + +namespace OpenCode.Sdk.TestSupport; + +/// +/// One FIFO of id-less JSON-RPC notifications of a single method, with a bounded dequeue. The +/// controller keeps one per notification kind so a model request, a tool invocation, and a tool +/// cancellation can never be mistaken for one another, and every wait names what it waited for. +/// +internal sealed class DriveNotificationQueue(string method) : IDisposable +{ + private readonly ConcurrentQueue _items = new(); + private readonly SemaphoreSlim _signal = new(0); + + public void Enqueue(T item) + { + _items.Enqueue(item); + _ = _signal.Release(); + } + + /// Waits for the next notification inside the bound; is quoted on a timeout. + public async Task DequeueAsync(TimeSpan timeout, Func describeLoopState, CancellationToken lifetime) + { + ArgumentNullException.ThrowIfNull(describeLoopState); + + if (!await _signal.WaitAsync(timeout, lifetime)) + { + throw new TimeoutException( + $"No {method} arrived within {timeout.TotalSeconds.ToString("F0", CultureInfo.InvariantCulture)}s.{describeLoopState()}"); + } + + return _items.TryDequeue(out var item) + ? item + : throw new InvalidOperationException($"The {method} signal fired without a queued notification."); + } + + public void Dispose() => _signal.Dispose(); +} diff --git a/tests/Shared/DriveProtocol.cs b/tests/Shared/DriveProtocol.cs index b3b058ee..8cd6f48b 100644 --- a/tests/Shared/DriveProtocol.cs +++ b/tests/Shared/DriveProtocol.cs @@ -4,9 +4,11 @@ namespace OpenCode.Sdk.TestSupport; /// /// Composes the drive backend's JSON-RPC requests (protocol source of truth: -/// packages/protocol/src/simulation.ts at the pin — Handshake.Params :98-107, ChunkParams :549, -/// FinishParams :552, DisconnectParams :564; llm.attach/llm.pending carry no params :576-579). -/// Every request carries a numeric id: id-less requests get no response (simulation.ts:46-49). +/// packages/protocol/src/simulation.ts at the pin — Handshake.Params, the model Item union, +/// ToolRegistration/ToolAttachParams, ToolUpdateParams, ToolFinishParams, ToolFailParams; +/// llm.attach/llm.pending carry no params). Every request carries a numeric id: id-less +/// requests get no response. Supplied values are written with +/// , never as encoded strings. /// internal static class DriveProtocol { @@ -27,6 +29,12 @@ public static byte[] Handshake(long id) => writer.WriteStringValue("llm.request"); writer.WriteStringValue("llm.chunk"); writer.WriteStringValue("llm.finish"); + writer.WriteStringValue("tool.attach"); + writer.WriteStringValue("tool.update"); + writer.WriteStringValue("tool.finish"); + writer.WriteStringValue("tool.fail"); + writer.WriteStringValue("tool.invocation"); + writer.WriteStringValue("tool.cancel"); writer.WriteEndArray(); writer.WriteStartArray("optionalCapabilities"); writer.WriteEndArray(); @@ -53,6 +61,93 @@ public static byte[] ChunkText(long id, string invocationId, IReadOnlyListThe canonical single toolCall model item: the simulator lowers it to a real streamed tool call. + public static byte[] ChunkToolCall(long id, string invocationId, string callId, string name, JsonElement input) => + Compose(id, "llm.chunk", writer => + { + writer.WriteStartObject("params"); + writer.WriteString("id", invocationId); + writer.WriteStartArray("items"); + writer.WriteStartObject(); + writer.WriteString("type", "toolCall"); + writer.WriteNumber("index", 0); + writer.WriteString("id", callId); + writer.WriteString("name", name); + writer.WritePropertyName("input"); + input.WriteTo(writer); + writer.WriteEndObject(); + writer.WriteEndArray(); + writer.WriteEndObject(); + }); + + public static byte[] ToolAttach(long id, IReadOnlyList tools) => + Compose(id, "tool.attach", writer => + { + writer.WriteStartObject("params"); + writer.WriteStartArray("tools"); + foreach (var tool in tools) + { + writer.WriteStartObject(); + writer.WriteString("name", tool.Name); + writer.WriteString("description", tool.Description); + writer.WritePropertyName("inputSchema"); + tool.InputSchema.WriteTo(writer); + if (tool.OutputSchema is { } output) + { + writer.WritePropertyName("outputSchema"); + output.WriteTo(writer); + } + + // Direct tools only: no namespace, and codemode off so the model calls the tool + // by its registered name. + writer.WriteStartObject("options"); + writer.WriteBoolean("codemode", false); + writer.WriteEndObject(); + writer.WriteEndObject(); + } + + writer.WriteEndArray(); + writer.WriteEndObject(); + }); + + public static byte[] ToolUpdate(long id, string toolId, int sequence, JsonElement update) => + Compose(id, "tool.update", writer => + { + writer.WriteStartObject("params"); + writer.WriteString("id", toolId); + writer.WriteNumber("sequence", sequence); + writer.WritePropertyName("update"); + update.WriteTo(writer); + writer.WriteEndObject(); + }); + + public static byte[] ToolFinish(long id, string toolId, JsonElement structured, string text) => + Compose(id, "tool.finish", writer => + { + writer.WriteStartObject("params"); + writer.WriteString("id", toolId); + writer.WriteStartObject("output"); + writer.WritePropertyName("structured"); + structured.WriteTo(writer); + writer.WriteStartArray("content"); + writer.WriteStartObject(); + writer.WriteString("type", "text"); + writer.WriteString("text", text); + writer.WriteEndObject(); + writer.WriteEndArray(); + writer.WriteEndObject(); + writer.WriteEndObject(); + }); + + public static byte[] ToolFail(long id, string toolId, string message) => + Compose(id, "tool.fail", writer => + { + writer.WriteStartObject("params"); + writer.WriteString("id", toolId); + writer.WriteString("message", message); + writer.WriteEndObject(); + }); + public static byte[] Finish(long id, string invocationId, string reason) => Compose(id, "llm.finish", writer => { diff --git a/tests/Shared/DriveToolCancellation.cs b/tests/Shared/DriveToolCancellation.cs new file mode 100644 index 00000000..f4ef2937 --- /dev/null +++ b/tests/Shared/DriveToolCancellation.cs @@ -0,0 +1,7 @@ +namespace OpenCode.Sdk.TestSupport; + +/// +/// A tool.cancel notification: the backend removed the pending simulated-tool invocation +/// because its execution was interrupted, and any later settlement of that id is refused. +/// +internal sealed record DriveToolCancellation(string Id, string Reason); diff --git a/tests/Shared/DriveToolInvocation.cs b/tests/Shared/DriveToolInvocation.cs new file mode 100644 index 00000000..f1e09e57 --- /dev/null +++ b/tests/Shared/DriveToolInvocation.cs @@ -0,0 +1,18 @@ +using System.Text.Json; + +namespace OpenCode.Sdk.TestSupport; + +/// +/// A tool.invocation notification: the pending simulated-tool id the controller settles +/// through update/finish/fail (), the registered tool name, the cloned input the +/// model supplied, and the executing context — the owning session, its agent, the assistant +/// message, and the model's own tool-call id (). +/// +internal sealed record DriveToolInvocation( + string Id, + string Name, + JsonElement Input, + string SessionId, + string Agent, + string MessageId, + string CallId); diff --git a/tests/Shared/DriveToolRegistration.cs b/tests/Shared/DriveToolRegistration.cs new file mode 100644 index 00000000..6a53ec17 --- /dev/null +++ b/tests/Shared/DriveToolRegistration.cs @@ -0,0 +1,41 @@ +using System.Text.Json; + +namespace OpenCode.Sdk.TestSupport; + +/// +/// One direct (non-codemode, un-namespaced) simulated tool registration for tool.attach +/// (protocol ToolRegistration, simulation.ts at the pin). The schemas are cloned +/// elements owned by this record; they are written into the wire message with +/// , never as encoded strings. +/// +internal sealed record DriveToolRegistration +{ + public required string Name { get; init; } + + public required string Description { get; init; } + + public required JsonElement InputSchema { get; init; } + + public JsonElement? OutputSchema { get; init; } + + /// + /// Reads a registration from its embedded JSON form: name, description, + /// inputSchema, and optional outputSchema, cloned out of the parsed document. + /// + public static DriveToolRegistration Parse(string json) + { + ArgumentException.ThrowIfNullOrWhiteSpace(json); + + using var document = JsonDocument.Parse(json); + var root = document.RootElement; + return new DriveToolRegistration + { + Name = root.GetProperty("name").GetString() + ?? throw new InvalidOperationException("The tool registration has no name."), + Description = root.GetProperty("description").GetString() + ?? throw new InvalidOperationException("The tool registration has no description."), + InputSchema = root.GetProperty("inputSchema").Clone(), + OutputSchema = root.TryGetProperty("outputSchema", out var output) ? output.Clone() : null, + }; + } +} diff --git a/tests/Shared/SimulationConfigSeed.cs b/tests/Shared/SimulationConfigSeed.cs index 76c342fb..96e4ff6e 100644 --- a/tests/Shared/SimulationConfigSeed.cs +++ b/tests/Shared/SimulationConfigSeed.cs @@ -21,6 +21,14 @@ internal sealed partial class SimulationConfigSeed internal const string ModelName = "Simulated Model"; internal const string PermissionProbeAction = "sdk.live.permission"; internal const string PermissionProbeAgentId = "permission-probe"; + + /// + /// The agent whose builtin read is gated by a real permission prompt: the live tool + /// proofs run their session under it so the read tool's own permission.assert parks + /// execution until the SDK replies. + /// + internal const string ReadAskAgentId = "c16"; + internal const string ReadPermissionAction = "read"; internal const string ProviderId = "sim"; internal const string ProviderName = "Simulated"; internal const string ProviderPackage = "@opencode-ai/ai/providers/openai-compatible"; @@ -45,6 +53,18 @@ internal sealed partial class SimulationConfigSeed }, ], }, + [ReadAskAgentId] = new AgentConfiguration + { + Permissions = + [ + new PermissionRule + { + Action = ReadPermissionAction, + Resource = "*", + Effect = PermissionEffect.Ask, + }, + ], + }, }, Commands = new Dictionary(StringComparer.Ordinal) { From f1b3e9f3adfea2a9b43d8229a0acacfd229bfc4c Mon Sep 17 00:00:00 2001 From: "denizirgin@gmail.com" Date: Mon, 7 Sep 2026 20:58:37 +0300 Subject: [PATCH 53/62] test: prove the model-originated tool and permission lifecycle end to end Three live proofs over the real server in simulation mode, each advancing only from an observed positive fact. The permission chain runs one session through three model turns: the model calls the owned drive_echo tool, the controller answers progress and a structured result, and the next model request is required to carry that real tool result; that turn calls the builtin read tool, whose own permission assert parks execution until the SDK replies Once, and the following model request is required to carry the real read result before the final text and the terminal success. Per-call order is asserted as a subsequence, so unrelated events may interleave. The lifecycle proofs cover the two remaining protocol arms. A failed tool reaches the SDK as a correlated failure whose real message the model loop resumes with. An interrupted session cancels the held invocation with the exact tool.cancel identity, fails the tool as aborted, settles as a user interruption, and refuses a late finish with the backend's own message. The scenario owns its workspace, registration, session and single event reader, and its cleanup interrupts an unfinished session, requires the exact cancellation for a tool it still holds, waits idle, resets the registration, removes the session and settles the reader under independent budgets. The event probe records what became of its reader so a barrier that expires names the reason. --- .editorconfig | 8 + .../Fixtures/Simulation/drive-echo-tool.json | 16 + .../Sessions/SessionToolLifecycleLiveTests.cs | 144 +++++++++ .../SessionToolPermissionLiveTests.cs | 194 +++++++++++ .../Support/OwnedToolScenario.cs | 303 ++++++++++++++++++ .../Support/SessionEventProbe.cs | 140 ++++++++ tests/Shared/DriveJson.cs | 16 + 7 files changed, 821 insertions(+) create mode 100644 tests/OpenCode.Sdk.Tests/Fixtures/Simulation/drive-echo-tool.json create mode 100644 tests/OpenCode.Sdk.Tests/Sessions/SessionToolLifecycleLiveTests.cs create mode 100644 tests/OpenCode.Sdk.Tests/Sessions/SessionToolPermissionLiveTests.cs create mode 100644 tests/OpenCode.Sdk.Tests/Support/OwnedToolScenario.cs create mode 100644 tests/OpenCode.Sdk.Tests/Support/SessionEventProbe.cs create mode 100644 tests/Shared/DriveJson.cs diff --git a/.editorconfig b/.editorconfig index 43da4974..e4a0af65 100644 --- a/.editorconfig +++ b/.editorconfig @@ -920,6 +920,14 @@ dotnet_diagnostic.VSTHRD003.severity = none [tests/OpenCode.Sdk.Tests/Support/OwnedEventReader.cs] dotnet_diagnostic.VSTHRD003.severity = none +# SessionEventProbe awaits TaskCompletionSource handles completed only by the reader loop this +# same object started and handed to its owning OwnedEventReader: the connected barrier and the +# per-arrival signal. That is the event-to-async bridge which lets a scenario act the moment the +# subscription is attached, and wait for a correlated event, without polling or sleeping. Winner: +# the single-reader event probe; VSTHRD003 remains enabled for every other file. +[tests/OpenCode.Sdk.Tests/Support/SessionEventProbe.cs] +dotnet_diagnostic.VSTHRD003.severity = none + # These per-scenario deadline helpers retain task handles and explicitly control entry, expiry # and result delivery. Their caller releases signals and drains all owned work in finally; # there is no foreign synchronization-context dependency. Winner: deterministic deadline control. diff --git a/tests/OpenCode.Sdk.Tests/Fixtures/Simulation/drive-echo-tool.json b/tests/OpenCode.Sdk.Tests/Fixtures/Simulation/drive-echo-tool.json new file mode 100644 index 00000000..e1cf4ccc --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Fixtures/Simulation/drive-echo-tool.json @@ -0,0 +1,16 @@ +{ + "name": "drive_echo", + "description": "Echo a caller-provided value.", + "inputSchema": { + "type": "object", + "properties": { "value": { "type": "string" } }, + "required": ["value"], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": { "echo": { "type": "string" } }, + "required": ["echo"], + "additionalProperties": false + } +} diff --git a/tests/OpenCode.Sdk.Tests/Sessions/SessionToolLifecycleLiveTests.cs b/tests/OpenCode.Sdk.Tests/Sessions/SessionToolLifecycleLiveTests.cs new file mode 100644 index 00000000..531ef7c4 --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Sessions/SessionToolLifecycleLiveTests.cs @@ -0,0 +1,144 @@ +using System.Text.Json.Nodes; +using OpenCode.Sdk.Models; +using OpenCode.Sdk.Tests.Support; +using OpenCode.Sdk.TestSupport; + +namespace OpenCode.Sdk.Tests; + +/// +/// The owned Drive tool's failure and interruption arms through the real server: a failed tool +/// reaches the SDK as a correlated failure and the model loop resumes with that real error; an +/// interrupted session cancels the held invocation (the exact tool.cancel), fails the tool +/// as aborted, and settles as a user interruption, after which the backend refuses a late finish. +/// +[ClassDataSource(Shared = SharedType.PerTestSession)] +[NotInParallel(ParallelConstraintKeys.ServerProcess)] +public sealed class SessionToolLifecycleLiveTests(SimulatedDriveServerFixture server) +{ + private const string RecoveryText = "Recovered after the tool failure."; + + private static readonly TimeSpan BarrierWait = TimeSpan.FromSeconds(60); + + [Test] + [Timeout(240_000)] + public async Task SessionPrompt_Should_Resume_With_The_Real_Error_When_The_Drive_Tool_Fails( + CancellationToken cancellationToken) + { + var scenario = await OwnedToolScenario.CreateAsync(server, "session-tool-failure-live", cancellationToken); + var failureMessage = "drive failure " + Guid.NewGuid().ToString("N"); + Exception? failure = null; + try + { + var sessionId = scenario.SessionId; + _ = await scenario.Session.PostPromptAsync( + new SessionPromptPostRequest { Text = "use the failing tool" }, cancellationToken: cancellationToken); + + var first = await scenario.WaitForModelRequestAsync(); + await scenario.ScriptToolCallAsync( + first, OwnedToolScenario.DriveCallId, OwnedToolScenario.ToolName, new JsonObject { ["value"] = "fail" }); + var tool = await scenario.WaitForToolInvocationAsync(); + await Assert.That(tool.CallId).IsEqualTo(OwnedToolScenario.DriveCallId); + + await scenario.FailToolAsync(tool, failureMessage); + + using var barrier = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + barrier.CancelAfter(BarrierWait); + var failed = await scenario.Probe.WaitForAsync( + failed => failed.Data.SessionId == sessionId && failed.Data.Id == OwnedToolScenario.DriveCallId, + "session.tool.failed for call_drive", barrier.Token); + await Assert.That(failed.Data.Error.Message).Contains(failureMessage); + + // The model loop resumed with the real error as the tool result for that call. + var second = await scenario.WaitForModelRequestAsync(); + await Assert.That(OwnedToolScenario.RequireToolResult(second, OwnedToolScenario.DriveCallId)).Contains(failureMessage); + await scenario.ScriptFinalTextAsync(second, RecoveryText); + _ = await scenario.Probe.WaitForAsync( + ended => ended.Data.SessionId == sessionId && ended.Data.Text == RecoveryText, + "session.text.ended with the recovery text", barrier.Token); + _ = await scenario.Probe.WaitForAsync( + succeeded => succeeded.Data.SessionId == sessionId, + "session.execution.succeeded", barrier.Token); + scenario.MarkTerminal(); + + Console.WriteLine( + "tool-lifecycle-live: mode=owned arm=tool-fail-then-recover session=" + sessionId + + " error-type=" + failed.Data.Error.Type); + } + catch (Exception exception) + { + failure = exception; + if (!exception.Data.Contains(EventDiagnosticSummary.DataKey)) + { + exception.Data[EventDiagnosticSummary.DataKey] = scenario.Probe.DiagnosticSummary; + } + } + + await scenario.CompleteAsync(failure); + } + + [Test] + [Timeout(240_000)] + public async Task SessionInterrupt_Should_Cancel_The_Held_Drive_Tool_And_Refuse_A_Late_Finish( + CancellationToken cancellationToken) + { + var scenario = await OwnedToolScenario.CreateAsync(server, "session-tool-interrupt-live", cancellationToken); + Exception? failure = null; + try + { + var sessionId = scenario.SessionId; + _ = await scenario.Session.PostPromptAsync( + new SessionPromptPostRequest { Text = "use the tool and get interrupted" }, cancellationToken: cancellationToken); + + var first = await scenario.WaitForModelRequestAsync(); + await scenario.ScriptToolCallAsync( + first, OwnedToolScenario.DriveCallId, OwnedToolScenario.ToolName, new JsonObject { ["value"] = "hold" }); + var tool = await scenario.WaitForToolInvocationAsync(); + + // The invocation is positively held (never settled) when the session is interrupted. + var interrupt = await scenario.Session.PostInterruptAsync(cancellationToken: cancellationToken); + await Assert.That(interrupt.Status).IsEqualTo(200); + await Assert.That(interrupt.IsError).IsFalse(); + await Assert.That(interrupt.Interrupt.Interrupted).IsTrue(); + + var cancellation = await scenario.WaitForToolCancellationAsync(); + await Assert.That(cancellation.Id).IsEqualTo(tool.Id); + await Assert.That(cancellation.Reason).IsEqualTo("interrupted"); + scenario.ReleasePendingTool(); + + var waited = await scenario.Session.PostWaitAsync(cancellationToken: cancellationToken); + await Assert.That(waited.Status).IsEqualTo(204); + + using var barrier = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + barrier.CancelAfter(BarrierWait); + var failed = await scenario.Probe.WaitForAsync( + failed => failed.Data.SessionId == sessionId && failed.Data.Id == OwnedToolScenario.DriveCallId, + "session.tool.failed (aborted) for call_drive", barrier.Token); + await Assert.That(failed.Data.Error.Type).IsEqualTo("aborted"); + await Assert.That(failed.Data.Error.Message).IsEqualTo("Tool execution interrupted"); + var interrupted = await scenario.Probe.WaitForAsync( + interrupted => interrupted.Data.SessionId == sessionId, + "session.execution.interrupted", barrier.Token); + await Assert.That(interrupted.Data.Reason).IsEqualTo(SessionExecutionInterruptedDataReason.User); + scenario.MarkTerminal(); + + // A late settlement of the cancelled invocation is refused with the backend's own message. + var refusal = await Assert.That(async () => await scenario.FinishToolAsync( + tool, new JsonObject { ["echo"] = "late" }, "late")).Throws(); + await Assert.That(refusal!.Message).Contains("Simulated tool invocation not found or already finished: " + tool.Id); + + Console.WriteLine( + "tool-lifecycle-live: mode=owned arm=interrupt-cancel-wait-late-finish session=" + sessionId + + " tool=" + tool.Id); + } + catch (Exception exception) + { + failure = exception; + if (!exception.Data.Contains(EventDiagnosticSummary.DataKey)) + { + exception.Data[EventDiagnosticSummary.DataKey] = scenario.Probe.DiagnosticSummary; + } + } + + await scenario.CompleteAsync(failure); + } +} diff --git a/tests/OpenCode.Sdk.Tests/Sessions/SessionToolPermissionLiveTests.cs b/tests/OpenCode.Sdk.Tests/Sessions/SessionToolPermissionLiveTests.cs new file mode 100644 index 00000000..c069972c --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Sessions/SessionToolPermissionLiveTests.cs @@ -0,0 +1,194 @@ +using System.Globalization; +using System.Text.Json.Nodes; +using OpenCode.Sdk.Models; +using OpenCode.Sdk.Tests.Support; +using OpenCode.Sdk.TestSupport; + +namespace OpenCode.Sdk.Tests; + +/// +/// One model-originated chain through the real server in simulation mode proving two actual +/// upstream mechanisms: a controller-backed Drive tool's progress and completion, whose real +/// result the model loop resumes with, and the builtin read tool's permission gate, which parks +/// execution until the SDK replies. Every barrier is a positive observation (a connected frame, a +/// model request carrying the prior tool result, a typed correlated event); order is asserted as +/// a per-call subsequence, never as adjacency or global counts. +/// +[ClassDataSource(Shared = SharedType.PerTestSession)] +[NotInParallel(ParallelConstraintKeys.ServerProcess)] +public sealed class SessionToolPermissionLiveTests(SimulatedDriveServerFixture server) +{ + private const string FinalText = "Both tools completed."; + + private static readonly TimeSpan BarrierWait = TimeSpan.FromSeconds(60); + + [Test] + [Timeout(240_000)] + public async Task SessionPrompt_Should_Complete_A_Drive_Tool_Then_A_Permission_Gated_Read_Through_The_Real_Server( + CancellationToken cancellationToken) + { + var scenario = await OwnedToolScenario.CreateAsync(server, "session-tool-permission-live", cancellationToken); + var nonce = "drive-" + Guid.NewGuid().ToString("N"); + Exception? failure = null; + try + { + var sessionId = scenario.SessionId; + _ = await scenario.Session.PostPromptAsync( + new SessionPromptPostRequest { Text = "use the tools" }, cancellationToken: cancellationToken); + + await CompleteDriveToolTurnAsync(scenario, sessionId, nonce); + + // Turn 2: the model loop resumed with the real tool result, and now calls builtin read. + var second = await scenario.WaitForModelRequestAsync(); + await Assert.That(OwnedToolScenario.RequireToolResult(second, OwnedToolScenario.DriveCallId)).Contains(nonce); + await scenario.ScriptToolCallAsync(second, OwnedToolScenario.ReadCallId, "read", new JsonObject { ["path"] = "." }); + + using var barrier = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + barrier.CancelAfter(BarrierWait); + var readCalled = await scenario.Probe.WaitForAsync( + called => called.Data.SessionId == sessionId && called.Data.Id == OwnedToolScenario.ReadCallId, + "session.tool.called for call_read", barrier.Token); + var asked = await scenario.Probe.WaitForAsync( + asked => asked.Data.SessionId == sessionId && asked.Data.Source?.Id == OwnedToolScenario.ReadCallId, + "permission.asked for call_read", barrier.Token); + await Assert.That(asked.Data.Action).IsEqualTo(SimulationConfigSeed.ReadPermissionAction); + await Assert.That(asked.Data.Resources).IsNotEmpty(); + await Assert.That(asked.Data.Source!.Type).IsEqualTo("tool"); + await Assert.That(asked.Data.Source.MessageId).IsEqualTo(readCalled.Data.AssistantMessageId); + + var reply = await scenario.Session.PostPermissionReplyAsync( + asked.Data.Id, + new SessionPermissionReplyPostRequest { Reply = PermissionReply.Once }, + cancellationToken: cancellationToken); + await Assert.That(reply.Status).IsEqualTo(204); + await Assert.That(reply.IsError).IsFalse(); + + var replied = await scenario.Probe.WaitForAsync( + replied => replied.Data.SessionId == sessionId && replied.Data.RequestId == asked.Data.Id, + "permission.replied for the read request", barrier.Token); + await Assert.That(replied.Data.Reply).IsEqualTo(PermissionReply.Once); + _ = await scenario.Probe.WaitForAsync( + success => success.Data.SessionId == sessionId && success.Data.Id == OwnedToolScenario.ReadCallId, + "session.tool.success for call_read", barrier.Token); + + // Turn 3: the model loop resumed with the real read result, and finishes with text. + var third = await scenario.WaitForModelRequestAsync(); + await Assert.That(OwnedToolScenario.RequireToolResult(third, OwnedToolScenario.ReadCallId)).IsNotEmpty(); + await scenario.ScriptFinalTextAsync(third, FinalText); + _ = await scenario.Probe.WaitForAsync( + ended => ended.Data.SessionId == sessionId && ended.Data.Text == FinalText, + "session.text.ended with the final text", barrier.Token); + _ = await scenario.Probe.WaitForAsync( + succeeded => succeeded.Data.SessionId == sessionId, + "session.execution.succeeded", barrier.Token); + scenario.MarkTerminal(); + + await AssertCausalSubsequencesAsync(scenario.Probe.Snapshot(), sessionId, nonce); + Console.WriteLine( + "tool-permission-live: mode=owned arms=drive-progress+success,read-permission-once,final-text session=" + + sessionId + " reply-status=" + reply.Status.ToString(CultureInfo.InvariantCulture)); + } + catch (Exception exception) + { + failure = exception; + if (!exception.Data.Contains(EventDiagnosticSummary.DataKey)) + { + exception.Data[EventDiagnosticSummary.DataKey] = scenario.Probe.DiagnosticSummary; + } + } + + await scenario.CompleteAsync(failure); + } + + /// + /// Turn 1: the model calls the owned Drive tool, the controller observes the invocation with + /// its exact identities and input, reports progress, and completes it with the echoed nonce. + /// + private static async Task CompleteDriveToolTurnAsync(OwnedToolScenario scenario, string sessionId, string nonce) + { + var first = await scenario.WaitForModelRequestAsync(); + await scenario.ScriptToolCallAsync( + first, OwnedToolScenario.DriveCallId, OwnedToolScenario.ToolName, new JsonObject { ["value"] = nonce }); + + var tool = await scenario.WaitForToolInvocationAsync(); + await Assert.That(tool.Name).IsEqualTo(OwnedToolScenario.ToolName); + await Assert.That(tool.Input.GetProperty("value").GetString()).IsEqualTo(nonce); + await Assert.That(tool.SessionId).IsEqualTo(sessionId); + await Assert.That(tool.Agent).IsEqualTo(SimulationConfigSeed.ReadAskAgentId); + await Assert.That(tool.MessageId).IsNotEmpty(); + await Assert.That(tool.CallId).IsEqualTo(OwnedToolScenario.DriveCallId); + + await scenario.UpdateToolAsync(tool, 0, new JsonObject { ["phase"] = "working", ["nonce"] = nonce }); + await scenario.FinishToolAsync(tool, new JsonObject { ["echo"] = nonce }, "echo:" + nonce); + } + + /// + /// Per-call order as a subsequence: unrelated events may interleave, but each call's own + /// lifecycle must appear in this order, the Drive call carrying the nonce through progress and + /// success, and neither call may have failed. + /// + private static async Task AssertCausalSubsequencesAsync(IReadOnlyList events, string sessionId, string nonce) + { + var owned = events.Where(candidate => SessionIdOf(candidate) == sessionId).ToList(); + await Assert.That(owned.OfType() + .Select(failed => failed.Data.Id + ": " + failed.Data.Error.Message)).IsEmpty(); + + await AssertToolLifecycleOrderAsync(owned, OwnedToolScenario.DriveCallId); + var progress = owned.OfType().Single(item => item.Data.Id == OwnedToolScenario.DriveCallId); + await Assert.That(progress.Data.Metadata["nonce"].GetString()).IsEqualTo(nonce); + var driveSuccess = owned.OfType().Single(item => item.Data.Id == OwnedToolScenario.DriveCallId); + await Assert.That(driveSuccess.Data.Content.OfType().Select(text => text.Text)) + .Contains("echo:" + nonce); + + await AssertToolLifecycleOrderAsync(owned, OwnedToolScenario.ReadCallId); + var readCalled = IndexOf(owned, called => called.Data.Id == OwnedToolScenario.ReadCallId); + var asked = IndexOf(owned, asked => asked.Data.Source?.Id == OwnedToolScenario.ReadCallId); + var replied = IndexOf(owned, _ => true); + var readSuccess = IndexOf(owned, success => success.Data.Id == OwnedToolScenario.ReadCallId); + await Assert.That(readCalled).IsLessThan(asked); + await Assert.That(asked).IsLessThan(replied); + await Assert.That(replied).IsLessThan(readSuccess); + + var textEnded = IndexOf(owned, ended => ended.Data.Text == FinalText); + var succeeded = IndexOf(owned, _ => true); + await Assert.That(textEnded).IsLessThan(succeeded); + } + + /// One call's own lifecycle in arrival order: input started, input ended, called, success. + private static async Task AssertToolLifecycleOrderAsync(List owned, string callId) + { + var started = IndexOf(owned, item => item.Data.Id == callId); + var ended = IndexOf(owned, item => item.Data.Id == callId); + var called = IndexOf(owned, item => item.Data.Id == callId); + var success = IndexOf(owned, item => item.Data.Id == callId); + await Assert.That(started).IsLessThan(ended); + await Assert.That(ended).IsLessThan(called); + await Assert.That(called).IsLessThan(success); + } + + /// The arrival index of the first matching event; a missing event fails naming what was sought. + private static int IndexOf(List owned, Func predicate) + where T : IEvent + { + var index = owned.FindIndex(candidate => candidate is T typed && predicate(typed)); + return index >= 0 + ? index + : throw new InvalidOperationException( + $"The owned session observed no {typeof(T).Name} matching the expected correlation."); + } + + private static string? SessionIdOf(IEvent @event) => @event switch + { + SessionToolInputStarted started => started.Data.SessionId, + SessionToolInputEnded ended => ended.Data.SessionId, + SessionToolCalled called => called.Data.SessionId, + SessionToolProgress progress => progress.Data.SessionId, + SessionToolSuccess success => success.Data.SessionId, + SessionToolFailed failed => failed.Data.SessionId, + PermissionAsked asked => asked.Data.SessionId, + PermissionReplied replied => replied.Data.SessionId, + SessionTextEnded textEnded => textEnded.Data.SessionId, + SessionExecutionSucceeded succeeded => succeeded.Data.SessionId, + _ => null, + }; +} diff --git a/tests/OpenCode.Sdk.Tests/Support/OwnedToolScenario.cs b/tests/OpenCode.Sdk.Tests/Support/OwnedToolScenario.cs new file mode 100644 index 00000000..871e1352 --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Support/OwnedToolScenario.cs @@ -0,0 +1,303 @@ +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Nodes; +using OpenCode.Sdk.Models; +using OpenCode.Sdk.TestSupport; +using OpenCode.Sdk.TestSupport.Ownership; + +namespace OpenCode.Sdk.Tests.Support; + +/// +/// One owned model-tool scenario over the shared simulated server: an isolated workspace, a +/// client targeting it, the single event reader () attached before anything +/// else happens, the repository-owned drive_echo registration, and one session under the +/// permission-gated agent. Every controller wait is bounded and recorded here so cleanup knows +/// what is still outstanding; cleanup interrupts an unfinished session, requires the exact +/// tool.cancel for a tool it still holds, waits the session idle, resets the registration, +/// removes the session, and settles the reader, each under its own budget, preserving the +/// primary failure and every cleanup failure. +/// +internal sealed class OwnedToolScenario +{ + internal const string ToolName = "drive_echo"; + internal const string ChatCompletionsUrl = "https://api.openai.com/v1/chat/completions"; + internal const string DriveCallId = "call_drive"; + internal const string ReadCallId = "call_read"; + + private static readonly TimeSpan EventWait = TimeSpan.FromSeconds(180); + private static readonly TimeSpan CleanupTimeout = TimeSpan.FromSeconds(15); + private static readonly TimeSpan RequestWait = TimeSpan.FromSeconds(60); + private static readonly TimeSpan CancellationWait = TimeSpan.FromSeconds(30); + + private readonly SimulatedDriveServerFixture _server; + private readonly TestWorkspace _workspace; + private readonly OpenCodeClient _client; + private readonly OwnedEventReader _reader; + private readonly OwnedCleanup _cleanup = new(CleanupTimeout); + private string? _sessionId; + private string? _pendingToolId; + private bool _registered; + private bool _terminal; + + private OwnedToolScenario(SimulatedDriveServerFixture server, CancellationToken cancellationToken) + { + _server = server; + _workspace = server.CreateWorkspace(); + _client = server.CreateClient(new LocationSelector { Directory = _workspace.Path }); + _reader = new OwnedEventReader(EventWait, CleanupTimeout, cancellationToken); + Probe = new SessionEventProbe(_reader); + _cleanup.Own("owned session interrupt", InterruptIfUnfinishedAsync); + _cleanup.Own("owned tool cancellation", ObservePendingToolCancellationAsync); + _cleanup.Own("owned session wait", WaitIdleAsync); + _cleanup.Own("owned tool registration reset", ResetRegistrationAsync); + _cleanup.Own("owned session removal", RemoveSessionAsync); + _cleanup.Own("event reader", _ => _reader.CompleteAsync(null)); + _cleanup.Own("owned workspace", _ => + { + _workspace.Dispose(); + return Task.CompletedTask; + }); + } + + public SessionEventProbe Probe { get; } + + public OpenCodeClient Client => _client; + + public string SessionId => _sessionId ?? throw new InvalidOperationException("The scenario has no session yet."); + + public SessionClient Session => _client.Sessions.GetSessionClient(SessionId); + + /// The pending simulated-tool invocation this scenario still holds, if any. + public string? PendingToolId => _pendingToolId; + + public static async Task CreateAsync( + SimulatedDriveServerFixture server, + string title, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(server); + ArgumentException.ThrowIfNullOrWhiteSpace(title); + + var scenario = new OwnedToolScenario(server, cancellationToken); + Exception? failure = null; + try + { + await scenario.InitializeAsync(title, cancellationToken); + return scenario; + } + catch (Exception exception) + { + failure = exception; + throw; + } + finally + { + if (failure is not null) + { + await scenario.CompleteAsync(failure); + } + } + } + + public async Task WaitForModelRequestAsync() + { + var invocation = await _server.Controller.WaitForRequestAsync(RequestWait); + if (invocation.Url != ChatCompletionsUrl || invocation.Model != SimulationConfigSeed.ModelId) + { + throw new InvalidOperationException( + $"Expected model '{SimulationConfigSeed.ModelId}' at '{ChatCompletionsUrl}', but received model " + + $"'{invocation.Model}' at '{invocation.Url}'."); + } + + return invocation; + } + + /// Waits for the owned tool's invocation and holds it until settled or cancelled. + public async Task WaitForToolInvocationAsync() + { + var invocation = await _server.Controller.WaitForToolInvocationAsync(RequestWait); + _pendingToolId = invocation.Id; + return invocation; + } + + /// Records that the held tool invocation was settled (finished, failed, or observed cancelled). + public void ReleasePendingTool() => _pendingToolId = null; + + public Task WaitForToolCancellationAsync() => + _server.Controller.WaitForToolCancellationAsync(CancellationWait); + + /// Records that the session reached a terminal execution event, so cleanup does not interrupt it. + public void MarkTerminal() => _terminal = true; + + /// Scripts the canonical single tool call for the current model request and ends the turn with tool-calls. + public async Task ScriptToolCallAsync(DriveInvocation invocation, string callId, string name, JsonObject input) + { + ArgumentNullException.ThrowIfNull(invocation); + + await _server.Controller.ChunkToolCallAsync(invocation.Id, callId, name, DriveJson.Element(input)); + await _server.Controller.FinishAsync(invocation.Id, "tool-calls"); + } + + /// Scripts the final text of the current model request and ends the turn with stop. + public async Task ScriptFinalTextAsync(DriveInvocation invocation, string text) + { + ArgumentNullException.ThrowIfNull(invocation); + + await _server.Controller.ChunkTextAsync(invocation.Id, text); + await _server.Controller.FinishAsync(invocation.Id); + } + + public Task UpdateToolAsync(DriveToolInvocation invocation, int sequence, JsonObject update) + { + ArgumentNullException.ThrowIfNull(invocation); + + return _server.Controller.UpdateToolAsync(invocation.Id, sequence, DriveJson.Element(update)); + } + + public async Task FinishToolAsync(DriveToolInvocation invocation, JsonObject structured, string text) + { + ArgumentNullException.ThrowIfNull(invocation); + + await _server.Controller.FinishToolAsync(invocation.Id, DriveJson.Element(structured), text); + ReleasePendingTool(); + } + + public async Task FailToolAsync(DriveToolInvocation invocation, string message) + { + ArgumentNullException.ThrowIfNull(invocation); + + await _server.Controller.FailToolAsync(invocation.Id, message); + ReleasePendingTool(); + } + + /// + /// The real tool-result message the model loop resumed with, as the provider request carried + /// it: the OpenAI chat lowering emits {role:"tool", tool_call_id, content}. + /// + public static string RequireToolResult(DriveInvocation invocation, string callId) + { + ArgumentNullException.ThrowIfNull(invocation); + + if (invocation.Body.ValueKind is JsonValueKind.Object && + invocation.Body.TryGetProperty("messages", out var messages) && + messages.ValueKind is JsonValueKind.Array) + { + foreach (var message in messages.EnumerateArray()) + { + if (message.TryGetProperty("role", out var role) && role.ValueKind is JsonValueKind.String && + string.Equals(role.GetString(), "tool", StringComparison.Ordinal) && + message.TryGetProperty("tool_call_id", out var id) && + string.Equals(id.GetString(), callId, StringComparison.Ordinal)) + { + return message.TryGetProperty("content", out var content) && content.ValueKind is JsonValueKind.String + ? content.GetString()! + : throw new InvalidOperationException($"The tool result for '{callId}' carries no string content."); + } + } + } + + throw new InvalidOperationException( + $"The model request '{invocation.Id}' carries no tool result for '{callId}'. Body: {invocation.Body}"); + } + + public Task CompleteAsync(Exception? primaryFailure) => _cleanup.CompleteAsync(primaryFailure); + + private async Task InitializeAsync(string title, CancellationToken cancellationToken) + { + // The subscription is attached before anything is registered or created, so every + // later event is observed by construction rather than by racing the subscription. + Probe.Start(_client.Events.SubscribeAsync(_reader.Token)); + await Probe.WaitForConnectedAsync(cancellationToken); + + var registration = DriveToolRegistration.Parse(new FixtureLoader().LoadText("Simulation.drive-echo-tool.json")); + await _server.Controller.AttachToolsAsync([registration]); + _registered = true; + + var created = await _client.Sessions.CreateSessionAsync( + new SessionCreateRequest + { + Title = title, + Agent = SimulationConfigSeed.ReadAskAgentId, + Model = new ModelRef { Id = SimulationConfigSeed.ModelId, ProviderId = SimulationConfigSeed.ProviderId }, + Location = new LocationRef { Directory = _workspace.Path }, + }, + cancellationToken: cancellationToken); + _sessionId = created.Session.Id; + } + + private async Task InterruptIfUnfinishedAsync(CancellationToken cancellationToken) + { + if (_sessionId is null || _terminal) + { + return; + } + + // Idle sessions answer Interrupted=false; cleanup accepts either, it only requires the call to succeed. + var response = await Session.PostInterruptAsync(requestOptions: OpenCodeRequestOptions.NoThrow, cancellationToken: cancellationToken); + if (response.IsError) + { + throw new InvalidOperationException( + "Owned session interrupt returned status " + response.Status.ToString(CultureInfo.InvariantCulture) + "."); + } + } + + private async Task ObservePendingToolCancellationAsync(CancellationToken cancellationToken) + { + if (_pendingToolId is not { } pending) + { + return; + } + + // The interruption above must reach the Drive tool: only the exact cancellation proves it, + // and a tool that is neither settled nor cancelled is a leak this cleanup refuses to hide. + var cancellation = await _server.Controller.WaitForToolCancellationAsync(CancellationWait).WaitAsync(cancellationToken); + if (cancellation.Id != pending) + { + throw new InvalidOperationException( + $"Expected the cancellation of tool invocation '{pending}' but observed '{cancellation.Id}'."); + } + + _pendingToolId = null; + } + + private async Task WaitIdleAsync(CancellationToken cancellationToken) + { + if (_sessionId is null) + { + return; + } + + var response = await Session.PostWaitAsync(OpenCodeRequestOptions.NoThrow, cancellationToken); + if (response.Status != 204 || response.IsError) + { + throw new InvalidOperationException( + "Owned session wait returned status " + response.Status.ToString(CultureInfo.InvariantCulture) + "."); + } + } + + private async Task ResetRegistrationAsync(CancellationToken cancellationToken) + { + if (!_registered) + { + return; + } + + await _server.Controller.AttachToolsAsync([]).WaitAsync(cancellationToken); + _registered = false; + } + + private async Task RemoveSessionAsync(CancellationToken cancellationToken) + { + if (_sessionId is null) + { + return; + } + + var response = await Session.RemoveSessionAsync(OpenCodeRequestOptions.NoThrow, cancellationToken); + if (response.Status != 204 || response.IsError) + { + throw new InvalidOperationException( + "Owned session removal returned status " + response.Status.ToString(CultureInfo.InvariantCulture) + "."); + } + } +} diff --git a/tests/OpenCode.Sdk.Tests/Support/SessionEventProbe.cs b/tests/OpenCode.Sdk.Tests/Support/SessionEventProbe.cs new file mode 100644 index 00000000..28fbacf5 --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Support/SessionEventProbe.cs @@ -0,0 +1,140 @@ +using OpenCode.Sdk.Models; + +namespace OpenCode.Sdk.Tests.Support; + +/// +/// One reader over the live event bus for a whole scenario: every event is retained in arrival +/// order for subsequence assertions, the connected frame is signalled so a caller can act only +/// once the subscription is attached, and typed barriers wait for the first event matching a +/// predicate, already retained or still to come. The loop runs until the owning reader ends it. +/// +internal sealed class SessionEventProbe +{ + private readonly OwnedEventReader _reader; + private readonly EventDiagnosticSummary _summary = new(); + private readonly List _events = []; + private readonly Lock _gate = new(); + private readonly TaskCompletionSource _connected = + new(TaskCreationOptions.RunContinuationsAsynchronously); + private TaskCompletionSource _arrival = NewSignal(); + private string? _loopState; + + public SessionEventProbe(OwnedEventReader reader) + { + ArgumentNullException.ThrowIfNull(reader); + + _reader = reader; + } + + /// + /// What arrived, and what became of the reader once it stopped. A barrier that expires while + /// the loop was already torn down, or while the subscription had ended, reads as that rather + /// than as an unexplained missing event. + /// + public string DiagnosticSummary + { + get + { + var reason = Volatile.Read(ref _loopState); + return reason is null ? _summary.ToString() : _summary + ". Reader: " + reason; + } + } + + /// Starts the single reader; the caller awaits before acting. + public void Start(IAsyncEnumerable events) + { + ArgumentNullException.ThrowIfNull(events); + + _reader.Own(Task.Run(() => ObserveAsync(events), CancellationToken.None)); + } + + public Task WaitForConnectedAsync(CancellationToken cancellationToken) => + _connected.Task.WaitAsync(cancellationToken); + + /// The retained events so far, in arrival order. + public IReadOnlyList Snapshot() + { + lock (_gate) + { + return [.. _events]; + } + } + + /// + /// The first event of matching , retained + /// already or arriving later; a wait that ends by cancellation reports what did arrive. + /// + public async Task WaitForAsync(Func predicate, string description, CancellationToken cancellationToken) + where T : IEvent + { + ArgumentNullException.ThrowIfNull(predicate); + ArgumentException.ThrowIfNullOrWhiteSpace(description); + + var scanned = 0; + while (true) + { + Task signal; + lock (_gate) + { + for (; scanned < _events.Count; scanned++) + { + if (_events[scanned] is T candidate && predicate(candidate)) + { + return candidate; + } + } + + signal = _arrival.Task; + } + + try + { + await signal.WaitAsync(cancellationToken); + } + catch (OperationCanceledException exception) + { + throw new TimeoutException( + $"No event satisfied '{description}' before the wait ended. Events: {DiagnosticSummary}.", exception); + } + } + } + + private async Task ObserveAsync(IAsyncEnumerable events) + { + try + { + await foreach (var @event in events.WithCancellation(_reader.Token)) + { + _summary.Add(@event.Type); + if (@event is EventServerConnected) + { + _ = _connected.TrySetResult(true); + } + + TaskCompletionSource arrived; + lock (_gate) + { + _events.Add(@event); + arrived = _arrival; + _arrival = NewSignal(); + } + + _ = arrived.TrySetResult(true); + } + + // The server closed the stream. Recorded rather than thrown: the owning reader + // observes this task, and a barrier still waiting needs to read why nothing more + // arrived instead of reporting an opaque expiry. + Volatile.Write(ref _loopState, "the event subscription ended"); + } + catch (OperationCanceledException exception) when (_reader.IsTeardownCancellation(exception)) + { + // The owning reader ended the loop at teardown: the expected end of a probe, and the + // reason a barrier racing that teardown must be able to name. + Volatile.Write(ref _loopState, "the owning reader ended it at teardown"); + } + } + + private static TaskCompletionSource NewSignal() => + new(TaskCreationOptions.RunContinuationsAsynchronously); +} diff --git a/tests/Shared/DriveJson.cs b/tests/Shared/DriveJson.cs new file mode 100644 index 00000000..15c4580e --- /dev/null +++ b/tests/Shared/DriveJson.cs @@ -0,0 +1,16 @@ +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace OpenCode.Sdk.TestSupport; + +/// Turns a typed into a cloned the drive protocol writers can embed. +internal static class DriveJson +{ + public static JsonElement Element(JsonNode node) + { + ArgumentNullException.ThrowIfNull(node); + + using var document = JsonDocument.Parse(node.ToJsonString()); + return document.RootElement.Clone(); + } +} From fb6740bfb7a389939d1538116f799e0bd24b2950 Mon Sep 17 00:00:00 2001 From: "denizirgin@gmail.com" Date: Mon, 7 Sep 2026 21:16:18 +0300 Subject: [PATCH 54/62] test: correlate the drive tool arms to their session and own the scenario's client The lifecycle proofs now assert the tool invocation's owning session, so a stale invocation left in the controller's shared queue cannot satisfy either arm. The scenario owns the run wrapper, so the tests no longer carry cleanup plumbing, and it disposes the client it created. The event probe races the connected frame against its own reader, so a subscription that ends or faults before its first event surfaces as that rather than as a later barrier expiry, and it records any other fault for the same reason. Progress joins the drive call's asserted order, the permission indexes correlate by request id, cleanup's cancellation bound sits inside its budget so the controller's diagnostic is the one reported, settling releases only the invocation it names, and the chat route has one home. --- .../Sessions/SessionToolLifecycleLiveTests.cs | 54 +++++--------- .../SessionToolPermissionLiveTests.cs | 33 ++++----- .../SimulatedSessionWorkflowTests.cs | 4 +- .../Support/OwnedToolScenario.cs | 70 +++++++++++++++---- .../Support/SessionEventProbe.cs | 32 ++++++++- .../Support/SimulatedSessionTurn.cs | 7 +- tests/Shared/SimulationConfigSeed.cs | 6 +- 7 files changed, 127 insertions(+), 79 deletions(-) diff --git a/tests/OpenCode.Sdk.Tests/Sessions/SessionToolLifecycleLiveTests.cs b/tests/OpenCode.Sdk.Tests/Sessions/SessionToolLifecycleLiveTests.cs index 531ef7c4..323b0899 100644 --- a/tests/OpenCode.Sdk.Tests/Sessions/SessionToolLifecycleLiveTests.cs +++ b/tests/OpenCode.Sdk.Tests/Sessions/SessionToolLifecycleLiveTests.cs @@ -17,8 +17,6 @@ public sealed class SessionToolLifecycleLiveTests(SimulatedDriveServerFixture se { private const string RecoveryText = "Recovered after the tool failure."; - private static readonly TimeSpan BarrierWait = TimeSpan.FromSeconds(60); - [Test] [Timeout(240_000)] public async Task SessionPrompt_Should_Resume_With_The_Real_Error_When_The_Drive_Tool_Fails( @@ -26,8 +24,7 @@ public async Task SessionPrompt_Should_Resume_With_The_Real_Error_When_The_Drive { var scenario = await OwnedToolScenario.CreateAsync(server, "session-tool-failure-live", cancellationToken); var failureMessage = "drive failure " + Guid.NewGuid().ToString("N"); - Exception? failure = null; - try + await scenario.RunAsync(async () => { var sessionId = scenario.SessionId; _ = await scenario.Session.PostPromptAsync( @@ -37,12 +34,13 @@ public async Task SessionPrompt_Should_Resume_With_The_Real_Error_When_The_Drive await scenario.ScriptToolCallAsync( first, OwnedToolScenario.DriveCallId, OwnedToolScenario.ToolName, new JsonObject { ["value"] = "fail" }); var tool = await scenario.WaitForToolInvocationAsync(); + await Assert.That(tool.SessionId).IsEqualTo(sessionId); await Assert.That(tool.CallId).IsEqualTo(OwnedToolScenario.DriveCallId); await scenario.FailToolAsync(tool, failureMessage); using var barrier = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - barrier.CancelAfter(BarrierWait); + barrier.CancelAfter(OwnedToolScenario.BarrierWait); var failed = await scenario.Probe.WaitForAsync( failed => failed.Data.SessionId == sessionId && failed.Data.Id == OwnedToolScenario.DriveCallId, "session.tool.failed for call_drive", barrier.Token); @@ -50,7 +48,8 @@ await scenario.ScriptToolCallAsync( // The model loop resumed with the real error as the tool result for that call. var second = await scenario.WaitForModelRequestAsync(); - await Assert.That(OwnedToolScenario.RequireToolResult(second, OwnedToolScenario.DriveCallId)).Contains(failureMessage); + await Assert.That(OwnedToolScenario.RequireToolResult(second, OwnedToolScenario.DriveCallId)) + .Contains(failureMessage); await scenario.ScriptFinalTextAsync(second, RecoveryText); _ = await scenario.Probe.WaitForAsync( ended => ended.Data.SessionId == sessionId && ended.Data.Text == RecoveryText, @@ -63,17 +62,7 @@ await scenario.ScriptToolCallAsync( Console.WriteLine( "tool-lifecycle-live: mode=owned arm=tool-fail-then-recover session=" + sessionId + " error-type=" + failed.Data.Error.Type); - } - catch (Exception exception) - { - failure = exception; - if (!exception.Data.Contains(EventDiagnosticSummary.DataKey)) - { - exception.Data[EventDiagnosticSummary.DataKey] = scenario.Probe.DiagnosticSummary; - } - } - - await scenario.CompleteAsync(failure); + }); } [Test] @@ -82,17 +71,19 @@ public async Task SessionInterrupt_Should_Cancel_The_Held_Drive_Tool_And_Refuse_ CancellationToken cancellationToken) { var scenario = await OwnedToolScenario.CreateAsync(server, "session-tool-interrupt-live", cancellationToken); - Exception? failure = null; - try + await scenario.RunAsync(async () => { var sessionId = scenario.SessionId; _ = await scenario.Session.PostPromptAsync( - new SessionPromptPostRequest { Text = "use the tool and get interrupted" }, cancellationToken: cancellationToken); + new SessionPromptPostRequest { Text = "use the tool and get interrupted" }, + cancellationToken: cancellationToken); var first = await scenario.WaitForModelRequestAsync(); await scenario.ScriptToolCallAsync( first, OwnedToolScenario.DriveCallId, OwnedToolScenario.ToolName, new JsonObject { ["value"] = "hold" }); var tool = await scenario.WaitForToolInvocationAsync(); + await Assert.That(tool.SessionId).IsEqualTo(sessionId); + await Assert.That(tool.CallId).IsEqualTo(OwnedToolScenario.DriveCallId); // The invocation is positively held (never settled) when the session is interrupted. var interrupt = await scenario.Session.PostInterruptAsync(cancellationToken: cancellationToken); @@ -103,13 +94,13 @@ await scenario.ScriptToolCallAsync( var cancellation = await scenario.WaitForToolCancellationAsync(); await Assert.That(cancellation.Id).IsEqualTo(tool.Id); await Assert.That(cancellation.Reason).IsEqualTo("interrupted"); - scenario.ReleasePendingTool(); + scenario.ReleaseTool(tool); var waited = await scenario.Session.PostWaitAsync(cancellationToken: cancellationToken); await Assert.That(waited.Status).IsEqualTo(204); using var barrier = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - barrier.CancelAfter(BarrierWait); + barrier.CancelAfter(OwnedToolScenario.BarrierWait); var failed = await scenario.Probe.WaitForAsync( failed => failed.Data.SessionId == sessionId && failed.Data.Id == OwnedToolScenario.DriveCallId, "session.tool.failed (aborted) for call_drive", barrier.Token); @@ -121,24 +112,17 @@ await scenario.ScriptToolCallAsync( await Assert.That(interrupted.Data.Reason).IsEqualTo(SessionExecutionInterruptedDataReason.User); scenario.MarkTerminal(); - // A late settlement of the cancelled invocation is refused with the backend's own message. + // A late settlement of the cancelled invocation is refused with the backend's own + // message. The payload deliberately differs from any settled one, so an identical + // retry's silent success cannot be mistaken for this refusal. var refusal = await Assert.That(async () => await scenario.FinishToolAsync( tool, new JsonObject { ["echo"] = "late" }, "late")).Throws(); - await Assert.That(refusal!.Message).Contains("Simulated tool invocation not found or already finished: " + tool.Id); + await Assert.That(refusal!.Message) + .Contains("Simulated tool invocation not found or already finished: " + tool.Id); Console.WriteLine( "tool-lifecycle-live: mode=owned arm=interrupt-cancel-wait-late-finish session=" + sessionId + " tool=" + tool.Id); - } - catch (Exception exception) - { - failure = exception; - if (!exception.Data.Contains(EventDiagnosticSummary.DataKey)) - { - exception.Data[EventDiagnosticSummary.DataKey] = scenario.Probe.DiagnosticSummary; - } - } - - await scenario.CompleteAsync(failure); + }); } } diff --git a/tests/OpenCode.Sdk.Tests/Sessions/SessionToolPermissionLiveTests.cs b/tests/OpenCode.Sdk.Tests/Sessions/SessionToolPermissionLiveTests.cs index c069972c..33d6efc2 100644 --- a/tests/OpenCode.Sdk.Tests/Sessions/SessionToolPermissionLiveTests.cs +++ b/tests/OpenCode.Sdk.Tests/Sessions/SessionToolPermissionLiveTests.cs @@ -20,8 +20,6 @@ public sealed class SessionToolPermissionLiveTests(SimulatedDriveServerFixture s { private const string FinalText = "Both tools completed."; - private static readonly TimeSpan BarrierWait = TimeSpan.FromSeconds(60); - [Test] [Timeout(240_000)] public async Task SessionPrompt_Should_Complete_A_Drive_Tool_Then_A_Permission_Gated_Read_Through_The_Real_Server( @@ -29,8 +27,7 @@ public async Task SessionPrompt_Should_Complete_A_Drive_Tool_Then_A_Permission_G { var scenario = await OwnedToolScenario.CreateAsync(server, "session-tool-permission-live", cancellationToken); var nonce = "drive-" + Guid.NewGuid().ToString("N"); - Exception? failure = null; - try + await scenario.RunAsync(async () => { var sessionId = scenario.SessionId; _ = await scenario.Session.PostPromptAsync( @@ -44,7 +41,7 @@ public async Task SessionPrompt_Should_Complete_A_Drive_Tool_Then_A_Permission_G await scenario.ScriptToolCallAsync(second, OwnedToolScenario.ReadCallId, "read", new JsonObject { ["path"] = "." }); using var barrier = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - barrier.CancelAfter(BarrierWait); + barrier.CancelAfter(OwnedToolScenario.BarrierWait); var readCalled = await scenario.Probe.WaitForAsync( called => called.Data.SessionId == sessionId && called.Data.Id == OwnedToolScenario.ReadCallId, "session.tool.called for call_read", barrier.Token); @@ -83,21 +80,11 @@ public async Task SessionPrompt_Should_Complete_A_Drive_Tool_Then_A_Permission_G "session.execution.succeeded", barrier.Token); scenario.MarkTerminal(); - await AssertCausalSubsequencesAsync(scenario.Probe.Snapshot(), sessionId, nonce); + await AssertCausalSubsequencesAsync(scenario.Probe.Snapshot(), sessionId, nonce, asked.Data.Id); Console.WriteLine( "tool-permission-live: mode=owned arms=drive-progress+success,read-permission-once,final-text session=" + sessionId + " reply-status=" + reply.Status.ToString(CultureInfo.InvariantCulture)); - } - catch (Exception exception) - { - failure = exception; - if (!exception.Data.Contains(EventDiagnosticSummary.DataKey)) - { - exception.Data[EventDiagnosticSummary.DataKey] = scenario.Probe.DiagnosticSummary; - } - } - - await scenario.CompleteAsync(failure); + }); } /// @@ -127,13 +114,19 @@ await scenario.ScriptToolCallAsync( /// lifecycle must appear in this order, the Drive call carrying the nonce through progress and /// success, and neither call may have failed. /// - private static async Task AssertCausalSubsequencesAsync(IReadOnlyList events, string sessionId, string nonce) + private static async Task AssertCausalSubsequencesAsync( + IReadOnlyList events, string sessionId, string nonce, string permissionRequestId) { var owned = events.Where(candidate => SessionIdOf(candidate) == sessionId).ToList(); await Assert.That(owned.OfType() .Select(failed => failed.Data.Id + ": " + failed.Data.Error.Message)).IsEmpty(); await AssertToolLifecycleOrderAsync(owned, OwnedToolScenario.DriveCallId); + var driveCalled = IndexOf(owned, item => item.Data.Id == OwnedToolScenario.DriveCallId); + var driveProgress = IndexOf(owned, item => item.Data.Id == OwnedToolScenario.DriveCallId); + var driveSucceeded = IndexOf(owned, item => item.Data.Id == OwnedToolScenario.DriveCallId); + await Assert.That(driveCalled).IsLessThan(driveProgress); + await Assert.That(driveProgress).IsLessThan(driveSucceeded); var progress = owned.OfType().Single(item => item.Data.Id == OwnedToolScenario.DriveCallId); await Assert.That(progress.Data.Metadata["nonce"].GetString()).IsEqualTo(nonce); var driveSuccess = owned.OfType().Single(item => item.Data.Id == OwnedToolScenario.DriveCallId); @@ -142,8 +135,8 @@ await Assert.That(driveSuccess.Data.Content.OfType().Select(tex await AssertToolLifecycleOrderAsync(owned, OwnedToolScenario.ReadCallId); var readCalled = IndexOf(owned, called => called.Data.Id == OwnedToolScenario.ReadCallId); - var asked = IndexOf(owned, asked => asked.Data.Source?.Id == OwnedToolScenario.ReadCallId); - var replied = IndexOf(owned, _ => true); + var asked = IndexOf(owned, asked => asked.Data.Id == permissionRequestId); + var replied = IndexOf(owned, replied => replied.Data.RequestId == permissionRequestId); var readSuccess = IndexOf(owned, success => success.Data.Id == OwnedToolScenario.ReadCallId); await Assert.That(readCalled).IsLessThan(asked); await Assert.That(asked).IsLessThan(replied); diff --git a/tests/OpenCode.Sdk.Tests/SimulatedSessionWorkflowTests.cs b/tests/OpenCode.Sdk.Tests/SimulatedSessionWorkflowTests.cs index cb2ba897..f12a6ec0 100644 --- a/tests/OpenCode.Sdk.Tests/SimulatedSessionWorkflowTests.cs +++ b/tests/OpenCode.Sdk.Tests/SimulatedSessionWorkflowTests.cs @@ -15,8 +15,6 @@ public sealed class SimulatedSessionWorkflowTests(SimulatedDriveServerFixture se { private const string ScriptedReply = "Hello from the drive."; - private const string ChatCompletionsUrl = "https://api.openai.com/v1/chat/completions"; - /// /// The single model the fixture's provider seed registers /// (SimulatedDriveServerFixture.SimulationConfig): named once here so the session's @@ -217,7 +215,7 @@ public async Task EventSubscription_Should_Surface_Caller_Cancellation(Cancellat /// private static async Task AssertSimulatedInvocationAsync(DriveInvocation invocation) { - await Assert.That(invocation.Url).IsEqualTo(ChatCompletionsUrl); + await Assert.That(invocation.Url).IsEqualTo(SimulationConfigSeed.ChatCompletionsUrl); await Assert.That(invocation.Model).IsEqualTo(SimulatedModelId); } diff --git a/tests/OpenCode.Sdk.Tests/Support/OwnedToolScenario.cs b/tests/OpenCode.Sdk.Tests/Support/OwnedToolScenario.cs index 871e1352..c98024c5 100644 --- a/tests/OpenCode.Sdk.Tests/Support/OwnedToolScenario.cs +++ b/tests/OpenCode.Sdk.Tests/Support/OwnedToolScenario.cs @@ -20,15 +20,24 @@ namespace OpenCode.Sdk.Tests.Support; internal sealed class OwnedToolScenario { internal const string ToolName = "drive_echo"; - internal const string ChatCompletionsUrl = "https://api.openai.com/v1/chat/completions"; internal const string DriveCallId = "call_drive"; internal const string ReadCallId = "call_read"; + /// The bound a consuming test gives one typed event barrier. + internal static readonly TimeSpan BarrierWait = TimeSpan.FromSeconds(60); + private static readonly TimeSpan EventWait = TimeSpan.FromSeconds(180); private static readonly TimeSpan CleanupTimeout = TimeSpan.FromSeconds(15); private static readonly TimeSpan RequestWait = TimeSpan.FromSeconds(60); private static readonly TimeSpan CancellationWait = TimeSpan.FromSeconds(30); + /// + /// Cleanup's own cancellation bound, deliberately inside so the + /// controller's diagnostic naming the awaited notification is the message a stuck teardown + /// reports, rather than the outer deadline's generic one. + /// + private static readonly TimeSpan CleanupCancellationWait = TimeSpan.FromSeconds(10); + private readonly SimulatedDriveServerFixture _server; private readonly TestWorkspace _workspace; private readonly OpenCodeClient _client; @@ -52,6 +61,11 @@ private OwnedToolScenario(SimulatedDriveServerFixture server, CancellationToken _cleanup.Own("owned tool registration reset", ResetRegistrationAsync); _cleanup.Own("owned session removal", RemoveSessionAsync); _cleanup.Own("event reader", _ => _reader.CompleteAsync(null)); + _cleanup.Own("owned client", _ => + { + _client.Dispose(); + return Task.CompletedTask; + }); _cleanup.Own("owned workspace", _ => { _workspace.Dispose(); @@ -61,14 +75,34 @@ private OwnedToolScenario(SimulatedDriveServerFixture server, CancellationToken public SessionEventProbe Probe { get; } - public OpenCodeClient Client => _client; - public string SessionId => _sessionId ?? throw new InvalidOperationException("The scenario has no session yet."); public SessionClient Session => _client.Sessions.GetSessionClient(SessionId); - /// The pending simulated-tool invocation this scenario still holds, if any. - public string? PendingToolId => _pendingToolId; + /// + /// Runs the scenario body and settles every owned resource afterwards, whatever happened. A + /// failure carries the probe's observed events, and the cleanup preserves it as the primary. + /// + public async Task RunAsync(Func body) + { + ArgumentNullException.ThrowIfNull(body); + + Exception? failure = null; + try + { + await body(); + } + catch (Exception exception) + { + failure = exception; + if (!exception.Data.Contains(EventDiagnosticSummary.DataKey)) + { + exception.Data[EventDiagnosticSummary.DataKey] = Probe.DiagnosticSummary; + } + } + + await CompleteAsync(failure); + } public static async Task CreateAsync( SimulatedDriveServerFixture server, @@ -102,11 +136,12 @@ public static async Task CreateAsync( public async Task WaitForModelRequestAsync() { var invocation = await _server.Controller.WaitForRequestAsync(RequestWait); - if (invocation.Url != ChatCompletionsUrl || invocation.Model != SimulationConfigSeed.ModelId) + if (invocation.Url != SimulationConfigSeed.ChatCompletionsUrl || + invocation.Model != SimulationConfigSeed.ModelId) { throw new InvalidOperationException( - $"Expected model '{SimulationConfigSeed.ModelId}' at '{ChatCompletionsUrl}', but received model " + - $"'{invocation.Model}' at '{invocation.Url}'."); + $"Expected model '{SimulationConfigSeed.ModelId}' at '{SimulationConfigSeed.ChatCompletionsUrl}', " + + $"but received model '{invocation.Model}' at '{invocation.Url}'."); } return invocation; @@ -120,8 +155,16 @@ public async Task WaitForToolInvocationAsync() return invocation; } - /// Records that the held tool invocation was settled (finished, failed, or observed cancelled). - public void ReleasePendingTool() => _pendingToolId = null; + /// Records that this exact invocation settled, so cleanup no longer waits for its cancellation. + public void ReleaseTool(DriveToolInvocation invocation) + { + ArgumentNullException.ThrowIfNull(invocation); + + if (string.Equals(_pendingToolId, invocation.Id, StringComparison.Ordinal)) + { + _pendingToolId = null; + } + } public Task WaitForToolCancellationAsync() => _server.Controller.WaitForToolCancellationAsync(CancellationWait); @@ -159,7 +202,7 @@ public async Task FinishToolAsync(DriveToolInvocation invocation, JsonObject str ArgumentNullException.ThrowIfNull(invocation); await _server.Controller.FinishToolAsync(invocation.Id, DriveJson.Element(structured), text); - ReleasePendingTool(); + ReleaseTool(invocation); } public async Task FailToolAsync(DriveToolInvocation invocation, string message) @@ -167,7 +210,7 @@ public async Task FailToolAsync(DriveToolInvocation invocation, string message) ArgumentNullException.ThrowIfNull(invocation); await _server.Controller.FailToolAsync(invocation.Id, message); - ReleasePendingTool(); + ReleaseTool(invocation); } /// @@ -250,7 +293,8 @@ private async Task ObservePendingToolCancellationAsync(CancellationToken cancell // The interruption above must reach the Drive tool: only the exact cancellation proves it, // and a tool that is neither settled nor cancelled is a leak this cleanup refuses to hide. - var cancellation = await _server.Controller.WaitForToolCancellationAsync(CancellationWait).WaitAsync(cancellationToken); + var cancellation = await _server.Controller + .WaitForToolCancellationAsync(CleanupCancellationWait).WaitAsync(cancellationToken); if (cancellation.Id != pending) { throw new InvalidOperationException( diff --git a/tests/OpenCode.Sdk.Tests/Support/SessionEventProbe.cs b/tests/OpenCode.Sdk.Tests/Support/SessionEventProbe.cs index 28fbacf5..211d704c 100644 --- a/tests/OpenCode.Sdk.Tests/Support/SessionEventProbe.cs +++ b/tests/OpenCode.Sdk.Tests/Support/SessionEventProbe.cs @@ -17,6 +17,7 @@ internal sealed class SessionEventProbe private readonly TaskCompletionSource _connected = new(TaskCreationOptions.RunContinuationsAsynchronously); private TaskCompletionSource _arrival = NewSignal(); + private Task? _loop; private string? _loopState; public SessionEventProbe(OwnedEventReader reader) @@ -45,11 +46,29 @@ public void Start(IAsyncEnumerable events) { ArgumentNullException.ThrowIfNull(events); - _reader.Own(Task.Run(() => ObserveAsync(events), CancellationToken.None)); + var loop = Task.Run(() => ObserveAsync(events), CancellationToken.None); + _loop = loop; + _reader.Own(loop); } - public Task WaitForConnectedAsync(CancellationToken cancellationToken) => - _connected.Task.WaitAsync(cancellationToken); + /// + /// Waits for the connected frame, racing the reader itself: a subscription that ends or faults + /// before its first event must surface as its own failure, never as a later barrier expiry that + /// hides the cause. + /// + public async Task WaitForConnectedAsync(CancellationToken cancellationToken) + { + var loop = _loop ?? throw new InvalidOperationException("The event probe has not started."); + var attached = await Task.WhenAny(_connected.Task, loop).WaitAsync(cancellationToken); + if (attached != loop) + { + return; + } + + await loop; + throw new InvalidOperationException( + $"The event subscription ended before its first event. Events: {DiagnosticSummary}."); + } /// The retained events so far, in arrival order. public IReadOnlyList Snapshot() @@ -133,6 +152,13 @@ private async Task ObserveAsync(IAsyncEnumerable events) // reason a barrier racing that teardown must be able to name. Volatile.Write(ref _loopState, "the owning reader ended it at teardown"); } + catch (Exception exception) + { + // Rethrown for the owning reader to observe; recorded first so a barrier racing this + // fault names the connection rather than reporting an unexplained missing event. + Volatile.Write(ref _loopState, $"it faulted ({exception.GetType().Name}: {exception.Message})"); + throw; + } } private static TaskCompletionSource NewSignal() => diff --git a/tests/OpenCode.Sdk.Tests/Support/SimulatedSessionTurn.cs b/tests/OpenCode.Sdk.Tests/Support/SimulatedSessionTurn.cs index 4ea03d56..da006102 100644 --- a/tests/OpenCode.Sdk.Tests/Support/SimulatedSessionTurn.cs +++ b/tests/OpenCode.Sdk.Tests/Support/SimulatedSessionTurn.cs @@ -9,7 +9,6 @@ internal sealed class SimulatedSessionTurn( SessionClient session, string sessionId) { - private const string ChatCompletionsUrl = "https://api.openai.com/v1/chat/completions"; private const string SimulatedModelId = "sim-model"; private static readonly TimeSpan EventWait = TimeSpan.FromSeconds(120); private static readonly TimeSpan RequestWait = TimeSpan.FromSeconds(60); @@ -66,11 +65,11 @@ await DriveAsync( private static void RequireSimulatedInvocation(DriveInvocation invocation) { - if (invocation.Model != SimulatedModelId || invocation.Url != ChatCompletionsUrl) + if (invocation.Model != SimulatedModelId || invocation.Url != SimulationConfigSeed.ChatCompletionsUrl) { throw new InvalidOperationException( - $"Expected model '{SimulatedModelId}' at '{ChatCompletionsUrl}', but received model " + - $"'{invocation.Model}' at '{invocation.Url}'."); + $"Expected model '{SimulatedModelId}' at '{SimulationConfigSeed.ChatCompletionsUrl}', but received " + + $"model '{invocation.Model}' at '{invocation.Url}'."); } } diff --git a/tests/Shared/SimulationConfigSeed.cs b/tests/Shared/SimulationConfigSeed.cs index 96e4ff6e..57ef6ccf 100644 --- a/tests/Shared/SimulationConfigSeed.cs +++ b/tests/Shared/SimulationConfigSeed.cs @@ -32,6 +32,10 @@ internal sealed partial class SimulationConfigSeed internal const string ProviderId = "sim"; internal const string ProviderName = "Simulated"; internal const string ProviderPackage = "@opencode-ai/ai/providers/openai-compatible"; + + /// The exact route the seeded provider claims, and the only one the Drive network answers. + internal const string ChatCompletionsUrl = ProviderBaseUrl + "/chat/completions"; + internal const string ProviderBaseUrl = "https://api.openai.com/v1"; internal const string ReferenceDescription = "The deterministic SDK live reference."; internal const string ReferenceName = "sdk-live-reference"; internal const string ReferencePath = "."; @@ -87,7 +91,7 @@ internal sealed partial class SimulationConfigSeed Package = ProviderPackage, Settings = new ProviderSettings { - BaseUrl = "https://api.openai.com/v1", + BaseUrl = ProviderBaseUrl, ApiKey = "drive-lease", }, Models = new Dictionary(StringComparer.Ordinal) From 09939cc83dc6cf608828fbacbd236c46e094bdcb Mon Sep 17 00:00:00 2001 From: "denizirgin@gmail.com" Date: Mon, 7 Sep 2026 21:20:20 +0300 Subject: [PATCH 55/62] docs: refresh the suite count for the completed live-coverage arc The Windows leg now runs 5,006 tests; the three modern targets Linux and macOS run carry 3,934 of them. --- CHANGELOG.md | 2 +- README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f91c5612..d6027c59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -89,7 +89,7 @@ Each released version links straight to its GitHub Release tag. [`spec/SNAPSHOT.md`](spec/SNAPSHOT.md). - **Generated output is committed and reviewed as source**, locked by a public-API baseline and verified by regeneration, so a protocol refresh arrives as a readable diff. -- **Test suite:** 4,418 tests green on Windows — the fullest leg, and the only one that adds the +- **Test suite:** 5,006 tests green on Windows — the fullest leg, and the only one that adds the `net472` assemblies. Linux and macOS run the same suite on the three modern targets. ### 📋 Important Notes diff --git a/README.md b/README.md index b1c4afd7..9bd1f090 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ thing missing is the first published package. - ✅ **135 of 140 operations** callable, across 28 client families — sessions, PTYs, persistent PTYs, shells, events, MCP servers, integrations, providers, permissions, credentials, VCS, worktrees, websearch, RPC, and more -- ✅ **4,418 tests** green on Windows — the fullest leg, the only one that adds the `net472` +- ✅ **5,006 tests** green on Windows — the fullest leg, the only one that adds the `net472` assemblies. Linux and macOS run the same suite on `net8.0`, `net9.0`, and `net10.0` - ✅ **Server-sent event streams**, global and per-session, over the same transport as one-shot calls - ✅ **PTY and persistent-PTY terminal sessions** through hand-written WebSocket doors From 3188b03230ac32544a78e64a89ab4f838fb2a2ef Mon Sep 17 00:00:00 2001 From: "denizirgin@gmail.com" Date: Mon, 7 Sep 2026 21:53:01 +0300 Subject: [PATCH 56/62] chore(spec): watch the ripgrep version CI provisions CI downloads ripgrep 15.1.0 with pinned digests so the offline simulated server never has to reach the network for it, but the authority for that number is upstream's own constant, which no watched source covered. A refresh that moves it would have reproduced cleanly and broken the filesystem live tests instead. Prepared and applied at the accepted pin, so the snapshot document is byte-identical and only the watch list and receipt grow. --- spec/receipt.json | 5 +++++ spec/source-watch.json | 9 +++++++++ 2 files changed, 14 insertions(+) diff --git a/spec/receipt.json b/spec/receipt.json index d444ae72..30f714fe 100644 --- a/spec/receipt.json +++ b/spec/receipt.json @@ -123,6 +123,11 @@ "sha256": "40151599a91df6321f4f391b86c908eb0b738beb0d5c7bdd592d362c3e438a78", "anchorMatched": true }, + { + "path": "packages/core/src/ripgrep/binary.ts", + "sha256": "5e0398e1fe6bb5c2f694d01997781ef73060acd725468d7d4235a08ed96c41c6", + "anchorMatched": true + }, { "path": "packages/tui/src/component/terminal-pane.tsx", "sha256": "973e352937fe66c218e7ca1c955cdd335eaedf81dc1f57f1a97ba0777e4578ae", diff --git a/spec/source-watch.json b/spec/source-watch.json index 4c9a3b94..76f8942d 100644 --- a/spec/source-watch.json +++ b/spec/source-watch.json @@ -172,6 +172,15 @@ "text": "String(input.cursor)" } }, + { + "path": "packages/core/src/ripgrep/binary.ts", + "sha256": "5e0398e1fe6bb5c2f694d01997781ef73060acd725468d7d4235a08ed96c41c6", + "behavior": "The ripgrep version upstream downloads on demand. CI provisions that exact version with pinned digests so an offline simulated server never has to reach the network for it, so a moved constant must be reviewed with the refresh rather than discovered as a failing filesystem live test.", + "anchor": { + "type": "contains", + "text": "const VERSION = \u002215.1.0\u0022" + } + }, { "path": "packages/tui/src/component/terminal-pane.tsx", "sha256": "973e352937fe66c218e7ca1c955cdd335eaedf81dc1f57f1a97ba0777e4578ae", From 9cca5dca05e0252affee40c513314b356ec7c292 Mon Sep 17 00:00:00 2001 From: "denizirgin@gmail.com" Date: Mon, 7 Sep 2026 21:53:01 +0300 Subject: [PATCH 57/62] test: prove the launched server's id outlives its disposal ProcessId is documented and changelogged as readable after disposal because fixture owners write their diagnostics at teardown, but nothing asserted it; a regression would have surfaced as an exception inside a teardown rather than as a named failure. The tool scenario's reader now gets a budget that fits inside the scenario's own, so its cleanup diagnostic is the one a stuck teardown reports, and the owned-reader arbitration states its winner. --- .editorconfig | 7 ++++++- .../OpenCodeServerLifecycleTests.cs | 21 +++++++++++++++++++ .../Support/OwnedToolScenario.cs | 9 +++++++- 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/.editorconfig b/.editorconfig index e4a0af65..92a49996 100644 --- a/.editorconfig +++ b/.editorconfig @@ -917,6 +917,10 @@ dotnet_diagnostic.VSTHRD003.severity = none [tests/Shared/Ownership/OwnedCleanup.cs] dotnet_diagnostic.VSTHRD003.severity = none +# OwnedEventReader is the reader half of that same contract: it retains the enumeration task a +# caller handed it and observes it under its own bounded cleanup, so the awaited handle is owned +# work rather than a foreign task. Winner: owned event observation; VSTHRD003 remains enabled +# elsewhere. [tests/OpenCode.Sdk.Tests/Support/OwnedEventReader.cs] dotnet_diagnostic.VSTHRD003.severity = none @@ -978,7 +982,8 @@ dotnet_diagnostic.VSTHRD003.severity = none [tests/OpenCode.Sdk.Tests/Support/ServerDiagnosticScenario.cs] dotnet_diagnostic.VSTHRD003.severity = none -# Sandbox application's purpose is primarly for testing + +# Sandbox application's purpose is primarily for testing [tests/OpenCode.Sdk.Sandbox/**.cs] dotnet_diagnostic.MA0076.severity = none diff --git a/tests/OpenCode.Sdk.Tests/OpenCodeServerLifecycleTests.cs b/tests/OpenCode.Sdk.Tests/OpenCodeServerLifecycleTests.cs index 136d56f3..27321351 100644 --- a/tests/OpenCode.Sdk.Tests/OpenCodeServerLifecycleTests.cs +++ b/tests/OpenCode.Sdk.Tests/OpenCodeServerLifecycleTests.cs @@ -369,6 +369,27 @@ public async Task StartAsync_Should_Refuse_A_Collector_An_Earlier_Start_Bound_Be await Assert.That(refusal!.Message).Contains("already bound"); } + [Test] + [Timeout(120_000)] + public async Task ProcessId_Should_Stay_Readable_After_Disposal(CancellationToken cancellationToken) + { + var server = await OpenCodeServer.StartAsync( + new OpenCodeServerOptions + { + Command = ["bun", "-e", "console.log('{\"url\":\"http://127.0.0.1:1\"}'); setTimeout(() => {}, 120000)"], + GracefulShutdownTimeout = TimeSpan.Zero, + }, + cancellationToken); + var processId = server.ProcessId; + + await server.DisposeAsync(); + + // Owners write their failure diagnostics after teardown, so the child's identity has to + // outlive the process handle disposal released. + await Assert.That(server.ProcessId).IsEqualTo(processId); + await Assert.That(IsProcessRunning(processId)).IsFalse(); + } + [Test] [Timeout(120_000)] public async Task StartAsync_Should_Refuse_A_Missing_Executable(CancellationToken cancellationToken) diff --git a/tests/OpenCode.Sdk.Tests/Support/OwnedToolScenario.cs b/tests/OpenCode.Sdk.Tests/Support/OwnedToolScenario.cs index c98024c5..1b313ee1 100644 --- a/tests/OpenCode.Sdk.Tests/Support/OwnedToolScenario.cs +++ b/tests/OpenCode.Sdk.Tests/Support/OwnedToolScenario.cs @@ -28,6 +28,13 @@ internal sealed class OwnedToolScenario private static readonly TimeSpan EventWait = TimeSpan.FromSeconds(180); private static readonly TimeSpan CleanupTimeout = TimeSpan.FromSeconds(15); + + /// + /// The reader's own cleanup budget. It settles two operations in sequence inside one of this + /// scenario's cleanup operations, so it is bounded well under : + /// otherwise the outer deadline expires first and its generic message replaces the reader's. + /// + private static readonly TimeSpan ReaderCleanupTimeout = TimeSpan.FromSeconds(5); private static readonly TimeSpan RequestWait = TimeSpan.FromSeconds(60); private static readonly TimeSpan CancellationWait = TimeSpan.FromSeconds(30); @@ -53,7 +60,7 @@ private OwnedToolScenario(SimulatedDriveServerFixture server, CancellationToken _server = server; _workspace = server.CreateWorkspace(); _client = server.CreateClient(new LocationSelector { Directory = _workspace.Path }); - _reader = new OwnedEventReader(EventWait, CleanupTimeout, cancellationToken); + _reader = new OwnedEventReader(EventWait, ReaderCleanupTimeout, cancellationToken); Probe = new SessionEventProbe(_reader); _cleanup.Own("owned session interrupt", InterruptIfUnfinishedAsync); _cleanup.Own("owned tool cancellation", ObservePendingToolCancellationAsync); From 2187b4bce71ca9641f689edfabe1cd7984231cc0 Mon Sep 17 00:00:00 2001 From: "denizirgin@gmail.com" Date: Mon, 7 Sep 2026 21:53:01 +0300 Subject: [PATCH 58/62] docs: name ripgrep as a prerequisite and refresh the roadmap's state The contributor prerequisite list gains the dependency the README already documents and CI already provisions. The roadmap carries its latest substantive date and records the second launcher caveat, that a Unix grandchild's exit is visible only once an adopting parent reaps it. The changelog says the retired worktree handle is a nightly compile break and names the destination directory that became optional with the strategy. --- .github/CONTRIBUTING.md | 3 +++ CHANGELOG.md | 5 +++-- docs/ROADMAP.md | 6 ++++-- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 920f6949..df03cf80 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -35,6 +35,9 @@ sign. - On Windows, the **.NET Framework 4.7.2 targeting pack** for the `net472` legs - [Bun](https://bun.sh/) at the exact version in `external/opencode/package.json`'s `packageManager` field — the pinned-server fixture and CI use that runtime +- [ripgrep 15.1.0](https://github.com/BurntSushi/ripgrep/releases/tag/15.1.0) on `PATH` — the + filesystem live tests run against an offline simulated server that refuses the on-demand download + upstream would otherwise perform - [Git](https://git-scm.com/downloads) with submodule support - IDE: [Rider](https://www.jetbrains.com/rider/), [Visual Studio](https://visualstudio.microsoft.com/), or [VS Code](https://code.visualstudio.com/) diff --git a/CHANGELOG.md b/CHANGELOG.md index d6027c59..25a1ce5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,8 +13,9 @@ Each released version links straight to its GitHub Release tag. - **Refreshed to upstream `89f1943`.** Worktree operations now resolve their project through an optional location query. Call create, list, remove, and refresh directly on `WorktreesClient`; - the project-bound `ProjectWorktreesClient` and its factory have been removed. Create accepts - the upstream's optional string strategy and inputs; remove retains its required JSON body. + the project-bound `ProjectWorktreesClient` and its factory are gone, which nightly consumers will + meet as a compile break. Create's strategy and destination directory are both optional now, as + upstream declares them; remove retains its required JSON body. The list operation also exposes its declared `WorktreeError` failure arm. Compaction completion payloads now expose the optional model and provider state supplied by the upstream schema. Project updates accept an optional canonical directory; execution interruption events include diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index c280282e..5c5749b3 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,6 +1,6 @@ # Roadmap -Date: 2026-09-02 +Date: 2026-09-07 Operational state: what ships today, what is queued next, what is still open, and what is known to be incomplete. This file is a summary and shrinks as work lands. `../AGENTS.md` routes to the @@ -123,4 +123,6 @@ is revisited at each boundary. on the modern target frameworks (all three OSes) and on `net472` Windows (`taskkill /T`). The downlevel non-Windows arm of the tree kill (a plain `Kill()`) is not exercised by any test project, and the Linux/macOS behavior is established only by the three-OS CI run, never by a Windows-local - suite. + suite. On Unix the observed grandchild is not a child of the test process, so its exit is visible + only once the adopting parent reaps it: an environment without a reaping PID 1 fails that bound + with the process still recorded as a zombie. From 530f5c0cc21140ee862b5ee65d902bb282711657 Mon Sep 17 00:00:00 2001 From: "denizirgin@gmail.com" Date: Mon, 7 Sep 2026 21:56:51 +0300 Subject: [PATCH 59/62] docs: carry the suite count the final arc gate recorded --- CHANGELOG.md | 2 +- README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 25a1ce5e..358d7023 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -90,7 +90,7 @@ Each released version links straight to its GitHub Release tag. [`spec/SNAPSHOT.md`](spec/SNAPSHOT.md). - **Generated output is committed and reviewed as source**, locked by a public-API baseline and verified by regeneration, so a protocol refresh arrives as a readable diff. -- **Test suite:** 5,006 tests green on Windows — the fullest leg, and the only one that adds the +- **Test suite:** 5,010 tests green on Windows — the fullest leg, and the only one that adds the `net472` assemblies. Linux and macOS run the same suite on the three modern targets. ### 📋 Important Notes diff --git a/README.md b/README.md index 9bd1f090..bbc88baf 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ thing missing is the first published package. - ✅ **135 of 140 operations** callable, across 28 client families — sessions, PTYs, persistent PTYs, shells, events, MCP servers, integrations, providers, permissions, credentials, VCS, worktrees, websearch, RPC, and more -- ✅ **5,006 tests** green on Windows — the fullest leg, the only one that adds the `net472` +- ✅ **5,010 tests** green on Windows — the fullest leg, the only one that adds the `net472` assemblies. Linux and macOS run the same suite on `net8.0`, `net9.0`, and `net10.0` - ✅ **Server-sent event streams**, global and per-session, over the same transport as one-shot calls - ✅ **PTY and persistent-PTY terminal sessions** through hand-written WebSocket doors From 1c5d1be8e0f614f8e57562725e3380328621c610 Mon Sep 17 00:00:00 2001 From: "denizirgin@gmail.com" Date: Mon, 7 Sep 2026 22:40:42 +0300 Subject: [PATCH 60/62] test: settle every correlated event through the one session probe SessionMoveEvents, ScriptedTurnEvents and RpcObservedEvents each ran their own loop over the event bus to settle a single correlated event, differing only in that event's predicate and its message. SessionEventProbe already holds one reader for a whole scenario and answers typed barriers from what it retained, so the three are gone and their live consumers wait on it. The probe now races its reader on every wait. A subscription that faults or ends mid-turn reports its own cause instead of expiring the caller's barrier on an event that can never arrive, and a caller cancellation keeps its identity rather than being reported as a timeout, which is the distinction the retired helpers made through the reader's own classifier. An optional anchor restores the other ordering they enforced: the scripted turn's success must be the one that followed the reply it scripted, not an earlier execution's. The barrier bound moves onto the probe that owns it, behind a factory that replaces six copies of the linked-source ritual. OwnedEventReader.Start and the "event observation" deadline it drove have no consumer once the probe hands its loop over through Own, so they go with the classifier only they used. The retired unit proofs move onto the probe, keeping the bounded transcript and the preserved reader failure and gaining the retained-then-arriving barrier that nothing covered before. --- .editorconfig | 12 +- .../Events/EventsClientLiveTests.cs | 27 +-- .../Sessions/SessionClientLiveTests.cs | 34 ++-- .../Sessions/SessionToolLifecycleLiveTests.cs | 6 +- .../SessionToolPermissionLiveTests.cs | 3 +- .../Support/OwnedEventReader.cs | 45 +---- .../Support/OwnedEventReaderTests.cs | 11 +- .../Support/OwnedToolScenario.cs | 3 - .../Support/RpcObservedEvents.cs | 75 --------- .../Support/ScriptedTurnEvents.cs | 74 --------- .../Support/ScriptedTurnEventsTests.cs | 107 ------------ .../Support/SessionEventProbe.cs | 83 +++++++++- .../Support/SessionEventProbeTests.cs | 156 ++++++++++++++++++ .../Support/SessionMoveEvents.cs | 66 -------- .../Support/SimulatedSessionTurn.cs | 44 +++-- 15 files changed, 314 insertions(+), 432 deletions(-) delete mode 100644 tests/OpenCode.Sdk.Tests/Support/RpcObservedEvents.cs delete mode 100644 tests/OpenCode.Sdk.Tests/Support/ScriptedTurnEvents.cs delete mode 100644 tests/OpenCode.Sdk.Tests/Support/ScriptedTurnEventsTests.cs create mode 100644 tests/OpenCode.Sdk.Tests/Support/SessionEventProbeTests.cs delete mode 100644 tests/OpenCode.Sdk.Tests/Support/SessionMoveEvents.cs diff --git a/.editorconfig b/.editorconfig index 92a49996..24cbc5d3 100644 --- a/.editorconfig +++ b/.editorconfig @@ -927,11 +927,16 @@ dotnet_diagnostic.VSTHRD003.severity = none # SessionEventProbe awaits TaskCompletionSource handles completed only by the reader loop this # same object started and handed to its owning OwnedEventReader: the connected barrier and the # per-arrival signal. That is the event-to-async bridge which lets a scenario act the moment the -# subscription is attached, and wait for a correlated event, without polling or sleeping. Winner: -# the single-reader event probe; VSTHRD003 remains enabled for every other file. +# subscription is attached, and wait for a correlated event, without polling or sleeping. Its own +# proofs drive that bridge from a scripted stream, awaiting the same signals plus the scenario's +# stall handle, and release and drain every one of them in finally. Winner: the single-reader +# event probe; VSTHRD003 remains enabled for every other file. [tests/OpenCode.Sdk.Tests/Support/SessionEventProbe.cs] dotnet_diagnostic.VSTHRD003.severity = none +[tests/OpenCode.Sdk.Tests/Support/SessionEventProbeTests.cs] +dotnet_diagnostic.VSTHRD003.severity = none + # These per-scenario deadline helpers retain task handles and explicitly control entry, expiry # and result delivery. Their caller releases signals and drains all owned work in finally; # there is no foreign synchronization-context dependency. Winner: deterministic deadline control. @@ -957,9 +962,6 @@ dotnet_diagnostic.VSTHRD003.severity = none [tests/OpenCode.Sdk.Tests/Support/OwnedOperationDeadlineTests.cs] dotnet_diagnostic.VSTHRD003.severity = none -[tests/OpenCode.Sdk.Tests/Support/ScriptedTurnEventsTests.cs] -dotnet_diagnostic.VSTHRD003.severity = none - [tests/OpenCode.Sdk.Tests/Support/SessionLogTranscriptTests.cs] dotnet_diagnostic.VSTHRD003.severity = none diff --git a/tests/OpenCode.Sdk.Tests/Events/EventsClientLiveTests.cs b/tests/OpenCode.Sdk.Tests/Events/EventsClientLiveTests.cs index 97b55a33..2419eaa3 100644 --- a/tests/OpenCode.Sdk.Tests/Events/EventsClientLiveTests.cs +++ b/tests/OpenCode.Sdk.Tests/Events/EventsClientLiveTests.cs @@ -1,4 +1,5 @@ using System.Globalization; +using System.Text.Json; using OpenCode.Sdk.Models; using OpenCode.Sdk.Tests.Support; using OpenCode.Sdk.TestSupport; @@ -40,19 +41,15 @@ public async Task SubscribeAsync_Should_Deliver_The_Owned_Rpc_Event_With_Its_Non var nonce = "rpc-event-live-" + Guid.NewGuid().ToString("N"); var reader = new OwnedEventReader(EventWait, CleanupTimeout, cancellationToken); - var connected = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var events = new RpcObservedEvents(ObservedEventType, nonce, reader); - var observedTask = events.CompleteAsync(client.Events.SubscribeAsync(reader.Token), connected); + var probe = new SessionEventProbe(reader); Exception? primaryFailure = null; try { - var attached = await Task.WhenAny(connected.Task, observedTask); - if (attached == observedTask) - { - _ = await observedTask; - throw new InvalidOperationException("The event subscription ended before server.connected."); - } + // The subscription is attached before the emit call, so the proof rests on a causal + // barrier rather than on replay. + probe.Start(client.Events.SubscribeAsync(reader.Token)); + await probe.WaitForConnectedAsync(cancellationToken); var emitted = await client.Rpc.CallAsync( TestRpcPlugin.Id, @@ -64,9 +61,13 @@ public async Task SubscribeAsync_Should_Deliver_The_Owned_Rpc_Event_With_Its_Non var output = emitted.Call.Output ?? throw new InvalidOperationException("The emit response had no output."); await Assert.That(output.GetProperty("nonce").GetString()).IsEqualTo(nonce); - var observed = await observedTask; - await Assert.That(observed.Type).IsEqualTo(ObservedEventType); - await Assert.That(observed.Data["nonce"].GetString()).IsEqualTo(nonce); + using var barrier = SessionEventProbe.Barrier(cancellationToken); + var observed = await probe.WaitForAsync( + rpc => string.Equals(rpc.Type, ObservedEventType, StringComparison.Ordinal) + && rpc.Data.TryGetValue("nonce", out var carried) + && carried.ValueKind == JsonValueKind.String + && string.Equals(carried.GetString(), nonce, StringComparison.Ordinal), + "the rpc event '" + ObservedEventType + "' carrying the nonce", barrier.Token); await Assert.That(observed.Data.Count).IsEqualTo(1); await Assert.That(observed.Location.Directory).IsEqualTo(resolved.Directory); await Assert.That(observed.Location.WorkspaceId).IsEqualTo(resolved.WorkspaceId); @@ -83,7 +84,7 @@ public async Task SubscribeAsync_Should_Deliver_The_Owned_Rpc_Event_With_Its_Non primaryFailure = exception; if (!exception.Data.Contains(EventDiagnosticSummary.DataKey)) { - exception.Data[EventDiagnosticSummary.DataKey] = events.DiagnosticSummary; + exception.Data[EventDiagnosticSummary.DataKey] = probe.DiagnosticSummary; } } finally diff --git a/tests/OpenCode.Sdk.Tests/Sessions/SessionClientLiveTests.cs b/tests/OpenCode.Sdk.Tests/Sessions/SessionClientLiveTests.cs index 3c32dfda..fce4dd39 100644 --- a/tests/OpenCode.Sdk.Tests/Sessions/SessionClientLiveTests.cs +++ b/tests/OpenCode.Sdk.Tests/Sessions/SessionClientLiveTests.cs @@ -33,19 +33,15 @@ public async Task PostMoveAsync_Should_Apply_The_Move_Before_The_Destination_Rea var sourceSession = sourceClient.Sessions.GetSessionClient(sessionId); var cleanup = CreateCleanup(sourceSession, sessionId); var reader = new OwnedEventReader(EventWait, CleanupTimeout, cancellationToken); - var connected = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var events = new SessionMoveEvents(sessionId, destination.Path, reader); - var movedTask = events.CompleteAsync(sourceClient.Events.SubscribeAsync(reader.Token), connected); + var probe = new SessionEventProbe(reader); Exception? primaryFailure = null; try { - var attached = await Task.WhenAny(connected.Task, movedTask); - if (attached == movedTask) - { - _ = await movedTask; - throw new InvalidOperationException("The event subscription ended before server.connected."); - } + // The subscription is attached before the move is admitted, so the applied event is + // observed by construction rather than by racing the subscription. + probe.Start(sourceClient.Events.SubscribeAsync(reader.Token)); + await probe.WaitForConnectedAsync(cancellationToken); var admitted = await sourceSession.PostMoveAsync( new SessionMovePostRequest { Directory = destination.Path }, @@ -53,17 +49,19 @@ public async Task PostMoveAsync_Should_Apply_The_Move_Before_The_Destination_Rea await Assert.That(admitted.Status).IsEqualTo(204); await Assert.That(admitted.IsError).IsFalse(); - var moved = await movedTask; - await Assert.That(moved.Data.SessionId).IsEqualTo(sessionId); - await Assert.That(moved.Data.Location.Directory).IsEqualTo(destination.Path); + using var barrier = SessionEventProbe.Barrier(cancellationToken); + var moved = await probe.WaitForAsync( + applied => applied.Data.SessionId == sessionId + && applied.Data.Location.Directory == destination.Path, + "session.moved carrying the owned session at the destination", barrier.Token); await Assert.That(moved.Data.ProjectId).IsNotEmpty(); var destinationSession = destinationClient.Sessions.GetSessionClient(sessionId); - var applied = await destinationSession.GetSessionAsync(cancellationToken: cancellationToken); - await Assert.That(applied.Status).IsEqualTo(200); - await Assert.That(applied.Session.Id).IsEqualTo(sessionId); - await Assert.That(applied.Session.Location.Directory).IsEqualTo(destination.Path); - await Assert.That(applied.Session.ProjectId).IsEqualTo(moved.Data.ProjectId); + var read = await destinationSession.GetSessionAsync(cancellationToken: cancellationToken); + await Assert.That(read.Status).IsEqualTo(200); + await Assert.That(read.Session.Id).IsEqualTo(sessionId); + await Assert.That(read.Session.Location.Directory).IsEqualTo(destination.Path); + await Assert.That(read.Session.ProjectId).IsEqualTo(moved.Data.ProjectId); Console.WriteLine( "session-move-live: admission=" + Number(admitted.Status) + @@ -74,7 +72,7 @@ public async Task PostMoveAsync_Should_Apply_The_Move_Before_The_Destination_Rea primaryFailure = exception; if (!exception.Data.Contains(EventDiagnosticSummary.DataKey)) { - exception.Data[EventDiagnosticSummary.DataKey] = events.DiagnosticSummary; + exception.Data[EventDiagnosticSummary.DataKey] = probe.DiagnosticSummary; } } finally diff --git a/tests/OpenCode.Sdk.Tests/Sessions/SessionToolLifecycleLiveTests.cs b/tests/OpenCode.Sdk.Tests/Sessions/SessionToolLifecycleLiveTests.cs index 323b0899..a2c10e0d 100644 --- a/tests/OpenCode.Sdk.Tests/Sessions/SessionToolLifecycleLiveTests.cs +++ b/tests/OpenCode.Sdk.Tests/Sessions/SessionToolLifecycleLiveTests.cs @@ -39,8 +39,7 @@ await scenario.ScriptToolCallAsync( await scenario.FailToolAsync(tool, failureMessage); - using var barrier = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - barrier.CancelAfter(OwnedToolScenario.BarrierWait); + using var barrier = SessionEventProbe.Barrier(cancellationToken); var failed = await scenario.Probe.WaitForAsync( failed => failed.Data.SessionId == sessionId && failed.Data.Id == OwnedToolScenario.DriveCallId, "session.tool.failed for call_drive", barrier.Token); @@ -99,8 +98,7 @@ await scenario.ScriptToolCallAsync( var waited = await scenario.Session.PostWaitAsync(cancellationToken: cancellationToken); await Assert.That(waited.Status).IsEqualTo(204); - using var barrier = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - barrier.CancelAfter(OwnedToolScenario.BarrierWait); + using var barrier = SessionEventProbe.Barrier(cancellationToken); var failed = await scenario.Probe.WaitForAsync( failed => failed.Data.SessionId == sessionId && failed.Data.Id == OwnedToolScenario.DriveCallId, "session.tool.failed (aborted) for call_drive", barrier.Token); diff --git a/tests/OpenCode.Sdk.Tests/Sessions/SessionToolPermissionLiveTests.cs b/tests/OpenCode.Sdk.Tests/Sessions/SessionToolPermissionLiveTests.cs index 33d6efc2..7d7a5fbf 100644 --- a/tests/OpenCode.Sdk.Tests/Sessions/SessionToolPermissionLiveTests.cs +++ b/tests/OpenCode.Sdk.Tests/Sessions/SessionToolPermissionLiveTests.cs @@ -40,8 +40,7 @@ await scenario.RunAsync(async () => await Assert.That(OwnedToolScenario.RequireToolResult(second, OwnedToolScenario.DriveCallId)).Contains(nonce); await scenario.ScriptToolCallAsync(second, OwnedToolScenario.ReadCallId, "read", new JsonObject { ["path"] = "." }); - using var barrier = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - barrier.CancelAfter(OwnedToolScenario.BarrierWait); + using var barrier = SessionEventProbe.Barrier(cancellationToken); var readCalled = await scenario.Probe.WaitForAsync( called => called.Data.SessionId == sessionId && called.Data.Id == OwnedToolScenario.ReadCallId, "session.tool.called for call_read", barrier.Token); diff --git a/tests/OpenCode.Sdk.Tests/Support/OwnedEventReader.cs b/tests/OpenCode.Sdk.Tests/Support/OwnedEventReader.cs index f0c3f049..c2f108de 100644 --- a/tests/OpenCode.Sdk.Tests/Support/OwnedEventReader.cs +++ b/tests/OpenCode.Sdk.Tests/Support/OwnedEventReader.cs @@ -9,8 +9,6 @@ internal sealed class OwnedEventReader private readonly CancellationTokenSource _window; private readonly CancellationToken _eventToken; private readonly OwnedCleanup _cleanup; - private readonly TimeSpan _observationWindow; - private readonly IOwnedOperationDeadline _deadline; private bool _teardownCancellation; private Task _reader = Task.CompletedTask; private Task _cancellation = Task.CompletedTask; @@ -19,54 +17,26 @@ public OwnedEventReader(TimeSpan observationWindow, TimeSpan cleanupTimeout, Can IOwnedOperationDeadline? deadline = null) { _callerToken = callerToken; - _observationWindow = observationWindow; _window = CancellationTokenSource.CreateLinkedTokenSource(callerToken); _eventToken = _window.Token; _window.CancelAfter(observationWindow); - _deadline = deadline ?? new OwnedOperationDeadline(); - _cleanup = new OwnedCleanup(cleanupTimeout, _deadline); + _cleanup = new OwnedCleanup(cleanupTimeout, deadline ?? new OwnedOperationDeadline()); } public CancellationToken Token => _eventToken; + /// + /// Whether the caller's own token fired. A wait that ends this way is a cancellation and keeps + /// that identity; only the reader's own window expiring is a timeout. + /// + public bool CallerCancellationRequested => _callerToken.IsCancellationRequested; + public LateCleanupFailureReport? LateFailures => _cleanup.LateFailures; public bool IsTeardownCancellation(OperationCanceledException exception) => _teardownCancellation && !_callerToken.IsCancellationRequested && exception.CancellationToken == Token && Token.IsCancellationRequested; - public bool ObservationTimedOut(OperationCanceledException exception) => - !_callerToken.IsCancellationRequested && Token.IsCancellationRequested - && !IsTeardownCancellation(exception); - - public Task Start(Func> read) - { - ArgumentNullException.ThrowIfNull(read); - var pending = Task.Run(() => read(Token), CancellationToken.None); - _reader = pending; - return ObserveAsync(pending); - } - - public Task Start(Func read) - { - ArgumentNullException.ThrowIfNull(read); - var pending = Task.Run(() => read(Token), CancellationToken.None); - _reader = pending; - return ObserveAsync(pending); - } - - private async Task ObserveAsync(Task pending) - { - await _deadline.WaitAsync("event observation", pending, _observationWindow, _callerToken); - return await pending; - } - - private async Task ObserveAsync(Task pending) - { - await _deadline.WaitAsync("event observation", pending, _observationWindow, _callerToken); - await pending; - } - public void Own(Task reader) { ArgumentNullException.ThrowIfNull(reader); @@ -97,5 +67,4 @@ public async Task CompleteAsync(Exception? primaryFailure) TaskScheduler.Default); } } - } diff --git a/tests/OpenCode.Sdk.Tests/Support/OwnedEventReaderTests.cs b/tests/OpenCode.Sdk.Tests/Support/OwnedEventReaderTests.cs index de0cf137..80aa03b7 100644 --- a/tests/OpenCode.Sdk.Tests/Support/OwnedEventReaderTests.cs +++ b/tests/OpenCode.Sdk.Tests/Support/OwnedEventReaderTests.cs @@ -111,12 +111,11 @@ public async Task CompleteAsync_Should_Bound_Stalls_Reach_Removal_And_Attach_Lat public async Task CompleteAsync_Should_Accept_Only_Its_Own_Cooperative_Reader_Cancellation() { var owner = new OwnedEventReader(TimeSpan.FromMinutes(1), TimeSpan.FromSeconds(1), CancellationToken.None, new OperationDeadlineScenario().Deadline); - var pending = owner.Start(async token => - { - var cancellation = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - using var registration = token.Register(() => _ = cancellation.TrySetCanceled(token)); - _ = await cancellation.Task; - }); + var cancellation = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var token = owner.Token; + using var registration = token.Register(() => _ = cancellation.TrySetCanceled(token)); + Task pending = cancellation.Task; + owner.Own(pending); await owner.CompleteAsync(null); diff --git a/tests/OpenCode.Sdk.Tests/Support/OwnedToolScenario.cs b/tests/OpenCode.Sdk.Tests/Support/OwnedToolScenario.cs index 1b313ee1..f9f7deed 100644 --- a/tests/OpenCode.Sdk.Tests/Support/OwnedToolScenario.cs +++ b/tests/OpenCode.Sdk.Tests/Support/OwnedToolScenario.cs @@ -23,9 +23,6 @@ internal sealed class OwnedToolScenario internal const string DriveCallId = "call_drive"; internal const string ReadCallId = "call_read"; - /// The bound a consuming test gives one typed event barrier. - internal static readonly TimeSpan BarrierWait = TimeSpan.FromSeconds(60); - private static readonly TimeSpan EventWait = TimeSpan.FromSeconds(180); private static readonly TimeSpan CleanupTimeout = TimeSpan.FromSeconds(15); diff --git a/tests/OpenCode.Sdk.Tests/Support/RpcObservedEvents.cs b/tests/OpenCode.Sdk.Tests/Support/RpcObservedEvents.cs deleted file mode 100644 index 9397593c..00000000 --- a/tests/OpenCode.Sdk.Tests/Support/RpcObservedEvents.cs +++ /dev/null @@ -1,75 +0,0 @@ -using System.Text.Json; -using OpenCode.Sdk.Models; - -namespace OpenCode.Sdk.Tests.Support; - -/// -/// Follows the live event bus until the owned rpc plugin's observed event carries the -/// caller's nonce. The connected frame is signalled first so the caller can emit only after the -/// subscription is attached; nothing is claimed about events that arrived before attachment. -/// -internal sealed class RpcObservedEvents(string eventType, string nonce, OwnedEventReader reader) -{ - private readonly EventDiagnosticSummary _observed = new(); - - public string DiagnosticSummary => _observed.ToString(); - - public async Task CompleteAsync( - IAsyncEnumerable events, - TaskCompletionSource connected) - { - try - { - return await reader.Start(_ => ObserveAsync(events, connected)); - } - catch (Exception exception) - { - exception.Data[EventDiagnosticSummary.DataKey] = DiagnosticSummary; - throw; - } - } - - private async Task ObserveAsync( - IAsyncEnumerable events, - TaskCompletionSource connected) - { - ArgumentNullException.ThrowIfNull(events); - ArgumentNullException.ThrowIfNull(connected); - try - { - await foreach (var @event in events.WithCancellation(reader.Token)) - { - _observed.Add(@event.Type); - if (@event is EventServerConnected) - { - _ = connected.TrySetResult(true); - } - - if (@event is EventRpc rpc - && string.Equals(rpc.Type, eventType, StringComparison.Ordinal) - && rpc.Data.TryGetValue("nonce", out var observedNonce) - && observedNonce.ValueKind == JsonValueKind.String - && string.Equals(observedNonce.GetString(), nonce, StringComparison.Ordinal)) - { - return rpc; - } - } - } - catch (OperationCanceledException exception) - when (reader.ObservationTimedOut(exception)) - { - throw new TimeoutException( - $"The rpc event '{eventType}' with nonce '{nonce}' did not arrive within the event window. " + - $"Events: {_observed}.", - exception); - } - catch (Exception exception) - { - exception.Data[EventDiagnosticSummary.DataKey] = DiagnosticSummary; - throw; - } - - throw new InvalidOperationException( - $"The event subscription ended before the rpc event '{eventType}' arrived. Events: {_observed}."); - } -} diff --git a/tests/OpenCode.Sdk.Tests/Support/ScriptedTurnEvents.cs b/tests/OpenCode.Sdk.Tests/Support/ScriptedTurnEvents.cs deleted file mode 100644 index b6e7dc01..00000000 --- a/tests/OpenCode.Sdk.Tests/Support/ScriptedTurnEvents.cs +++ /dev/null @@ -1,74 +0,0 @@ -using OpenCode.Sdk.Models; - -namespace OpenCode.Sdk.Tests.Support; - -internal sealed class ScriptedTurnEvents(string reply, string sessionId, OwnedEventReader reader) -{ - private readonly EventDiagnosticSummary _observed = new(); - - public string DiagnosticSummary => _observed.ToString(); - - public async Task CompleteAsync( - IAsyncEnumerable events, TaskCompletionSource connected) - { - try - { - return await reader.Start(_ => ObserveAsync(events, connected)); - } - catch (Exception exception) - { - exception.Data[EventDiagnosticSummary.DataKey] = DiagnosticSummary; - throw; - } - } - - public async Task ObserveAsync( - IAsyncEnumerable events, TaskCompletionSource connected) - { - ArgumentNullException.ThrowIfNull(events); - ArgumentNullException.ThrowIfNull(connected); - var sawOwnedText = false; - try - { - await foreach (var @event in events.WithCancellation(reader.Token)) - { - _observed.Add(@event.Type); - if (@event is EventServerConnected) - { - _ = connected.TrySetResult(true); - } - - if (@event is SessionTextEnded textEnded - && textEnded.Data.SessionId == sessionId - && textEnded.Data.Text == reply) - { - sawOwnedText = true; - continue; - } - - if (sawOwnedText - && @event is SessionExecutionSucceeded succeeded - && succeeded.Data.SessionId == sessionId) - { - return succeeded; - } - } - } - catch (OperationCanceledException exception) - when (reader.ObservationTimedOut(exception)) - { - throw new TimeoutException( - $"The scripted turn did not complete within the event window. Events: {_observed}.", - exception); - } - catch (Exception exception) - { - exception.Data[EventDiagnosticSummary.DataKey] = DiagnosticSummary; - throw; - } - - throw new InvalidOperationException( - $"The event subscription ended before the scripted turn completed. Events: {_observed}."); - } - -} diff --git a/tests/OpenCode.Sdk.Tests/Support/ScriptedTurnEventsTests.cs b/tests/OpenCode.Sdk.Tests/Support/ScriptedTurnEventsTests.cs deleted file mode 100644 index a0b298a8..00000000 --- a/tests/OpenCode.Sdk.Tests/Support/ScriptedTurnEventsTests.cs +++ /dev/null @@ -1,107 +0,0 @@ -using OpenCode.Sdk.TestSupport.Ownership; -namespace OpenCode.Sdk.Tests.Support; - -public sealed class ScriptedTurnEventsTests -{ - [Test] - public async Task CompleteAsync_Should_Bound_A_Noncooperative_Reader_And_Reach_Later_Cleanup() - { - var scenario = new EventDiagnosticScenario { Stall = true }; - var deadlines = new OperationDeadlineScenario(); - var observationDeadline = deadlines.Hold("event observation"); - var cleanupDeadline = deadlines.Hold("event reader"); - var reader = new OwnedEventReader(TimeSpan.FromMinutes(1), TimeSpan.FromMilliseconds(50), CancellationToken.None, deadlines.Deadline); - var events = new ScriptedTurnEvents("reply", "ses_owned", reader); - var connected = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var removed = false; - var sessionCleanup = new OwnedSessionCleanup(_ => Task.CompletedTask, token => - { - removed = !token.IsCancellationRequested; - return Task.CompletedTask; - }, TimeSpan.FromSeconds(1), deadlines.Deadline); - Task observing = events.CompleteAsync(scenario.EventsAsync(), connected); - Task? completing = null; - try - { - await scenario.Stalled.Task; - await observationDeadline.Entered; - observationDeadline.Expire(); - observationDeadline.Deliver(); - var primary = await Assert.That(() => observing).Throws(); - var observed = (string)primary!.Data[EventDiagnosticSummary.DataKey]!; - await Assert.That(observed.Length).IsLessThan(2000); - await Assert.That(observed).Contains("84 events omitted"); - await Assert.That(observed).Contains("event-0000-"); - await Assert.That(observed).Contains("event-0099-"); - await Assert.That(scenario.Pending).IsNotNull(); - await Assert.That(scenario.Pending.Task.IsCompleted).IsFalse(); - - completing = reader.CompleteAsync(primary); - await cleanupDeadline.Entered; - cleanupDeadline.Expire(); - cleanupDeadline.Deliver(); - var thrown = await Assert.That(() => completing).Throws(); - var propagated = await Assert.That(() => sessionCleanup.CompleteAsync(thrown)).Throws(); - await Assert.That(propagated).IsSameReferenceAs(primary); - await Assert.That(removed).IsTrue(); - await Assert.That(primary.Data[EventDiagnosticSummary.DataKey]).IsEqualTo(observed); - var lateFailure = new InvalidOperationException("late stalled reader failure"); - scenario.Pending.SetException(lateFailure); - var report = reader.LateFailures; - await Assert.That(report).IsNotNull(); - await report.WaitForAllAsync(CancellationToken.None); - var attached = (IReadOnlyCollection>)primary.Data[OwnedCleanup.LateFailuresKey]!; - await Assert.That(attached.Single().Value).IsSameReferenceAs(lateFailure); - } - finally - { - _ = scenario.Pending?.TrySetResult(true); - completing ??= reader.CompleteAsync(null); - await deadlines.DrainAsync(Task.WhenAll(observing, completing)); - if (reader.LateFailures is { } report) - { - await report.WaitForAllAsync(CancellationToken.None); - } - } - } - - [Test] - public async Task ObserveAsync_Should_Bound_First_And_Tail_Diagnostics_When_The_Stream_Ends() - { - var scenario = new EventDiagnosticScenario(); - var reader = new OwnedEventReader(TimeSpan.FromMinutes(1), TimeSpan.FromSeconds(1), CancellationToken.None, new OperationDeadlineScenario().Deadline); - var events = new ScriptedTurnEvents("reply", "ses_owned", reader); - var connected = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var thrown = await Assert.That(async () => - { - _ = await reader.Start(_ => events.ObserveAsync(scenario.EventsAsync(), connected)); - }).Throws(); - _ = await Assert.That(() => reader.CompleteAsync(thrown)).Throws(); - - await Assert.That(thrown!.Message.Length).IsLessThan(2000); - await Assert.That(thrown.Message).Contains("event-0000-"); - await Assert.That(thrown.Message).Contains("event-0099-"); - await Assert.That(thrown.Message).Contains("84 events omitted"); - await Assert.That(thrown.Message).DoesNotContain("event-0050-"); - } - - [Test] - public async Task ObserveAsync_Should_Retain_Observations_On_The_Original_Reader_Failure() - { - var failure = new InvalidOperationException("reader transport failed"); - var scenario = new EventDiagnosticScenario { Failure = failure }; - var reader = new OwnedEventReader(TimeSpan.FromMinutes(1), TimeSpan.FromSeconds(1), CancellationToken.None, new OperationDeadlineScenario().Deadline); - var events = new ScriptedTurnEvents("reply", "ses_owned", reader); - var connected = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var thrown = await Assert.That(async () => - { - _ = await reader.Start(_ => events.ObserveAsync(scenario.EventsAsync(), connected)); - }).Throws(); - _ = await Assert.That(() => reader.CompleteAsync(thrown)).Throws(); - - await Assert.That(thrown).IsSameReferenceAs(failure); - var diagnostic = (string)failure.Data[EventDiagnosticSummary.DataKey]!; - await Assert.That(diagnostic.Length).IsLessThan(2000); - await Assert.That(diagnostic).Contains("84 events omitted"); - } -} diff --git a/tests/OpenCode.Sdk.Tests/Support/SessionEventProbe.cs b/tests/OpenCode.Sdk.Tests/Support/SessionEventProbe.cs index 211d704c..4c61b055 100644 --- a/tests/OpenCode.Sdk.Tests/Support/SessionEventProbe.cs +++ b/tests/OpenCode.Sdk.Tests/Support/SessionEventProbe.cs @@ -7,9 +7,14 @@ namespace OpenCode.Sdk.Tests.Support; /// order for subsequence assertions, the connected frame is signalled so a caller can act only /// once the subscription is attached, and typed barriers wait for the first event matching a /// predicate, already retained or still to come. The loop runs until the owning reader ends it. +/// Every wait races that loop, so a reader that faults or a subscription that ends reports its own +/// cause at once instead of expiring a barrier on an event that can never arrive. /// internal sealed class SessionEventProbe { + /// The bound this probe gives one typed event barrier. + private static readonly TimeSpan BarrierWait = TimeSpan.FromSeconds(60); + private readonly OwnedEventReader _reader; private readonly EventDiagnosticSummary _summary = new(); private readonly List _events = []; @@ -41,6 +46,17 @@ public string DiagnosticSummary } } + /// + /// One bounded barrier for this probe's waits. A caller settling several correlated events in + /// sequence shares a single barrier, so the whole sequence has one budget rather than one each. + /// + public static CancellationTokenSource Barrier(CancellationToken cancellationToken) + { + var barrier = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + barrier.CancelAfter(BarrierWait); + return barrier; + } + /// Starts the single reader; the caller awaits before acting. public void Start(IAsyncEnumerable events) { @@ -58,7 +74,7 @@ public void Start(IAsyncEnumerable events) /// public async Task WaitForConnectedAsync(CancellationToken cancellationToken) { - var loop = _loop ?? throw new InvalidOperationException("The event probe has not started."); + var loop = RunningLoop(); var attached = await Task.WhenAny(_connected.Task, loop).WaitAsync(cancellationToken); if (attached != loop) { @@ -81,18 +97,33 @@ public IReadOnlyList Snapshot() /// /// The first event of matching , retained - /// already or arriving later; a wait that ends by cancellation reports what did arrive. + /// already or arriving later. + /// + public Task WaitForAsync(Func predicate, string description, CancellationToken cancellationToken) + where T : IEvent => + WaitForAsync(predicate, description, after: null, cancellationToken); + + /// + /// The same barrier restricted to what arrived after , an event this + /// probe returned earlier. An earlier match belongs to something that preceded that anchor and + /// must not settle this wait. /// - public async Task WaitForAsync(Func predicate, string description, CancellationToken cancellationToken) + public async Task WaitForAsync( + Func predicate, + string description, + IEvent? after, + CancellationToken cancellationToken) where T : IEvent { ArgumentNullException.ThrowIfNull(predicate); ArgumentException.ThrowIfNullOrWhiteSpace(description); - var scanned = 0; + var loop = RunningLoop(); + var scanned = after is null ? 0 : IndexAfter(after); + var ended = false; while (true) { - Task signal; + Task arrival; lock (_gate) { for (; scanned < _events.Count; scanned++) @@ -103,21 +134,57 @@ public async Task WaitForAsync(Func predicate, string description } } - signal = _arrival.Task; + arrival = _arrival.Task; } + // The loop stopped and everything it delivered has now been scanned. + if (ended) + { + throw new InvalidOperationException( + $"The event subscription ended before any event satisfied '{description}'. " + + $"Events: {DiagnosticSummary}."); + } + + Task settled; try { - await signal.WaitAsync(cancellationToken); + settled = await Task.WhenAny(arrival, loop).WaitAsync(cancellationToken); } - catch (OperationCanceledException exception) + catch (OperationCanceledException exception) when (!_reader.CallerCancellationRequested) { throw new TimeoutException( $"No event satisfied '{description}' before the wait ended. Events: {DiagnosticSummary}.", exception); } + + if (settled == loop) + { + // A faulted reader surfaces its own cause here rather than as a barrier expiry. + await loop; + ended = true; + } } } + private Task RunningLoop() => + _loop ?? throw new InvalidOperationException("The event probe has not started."); + + /// The retained position just past an anchor this probe returned earlier. + private int IndexAfter(IEvent anchor) + { + lock (_gate) + { + for (var index = 0; index < _events.Count; index++) + { + if (ReferenceEquals(_events[index], anchor)) + { + return index + 1; + } + } + } + + throw new InvalidOperationException("The anchor event was never observed by this probe."); + } + private async Task ObserveAsync(IAsyncEnumerable events) { try diff --git a/tests/OpenCode.Sdk.Tests/Support/SessionEventProbeTests.cs b/tests/OpenCode.Sdk.Tests/Support/SessionEventProbeTests.cs new file mode 100644 index 00000000..fb81d30e --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Support/SessionEventProbeTests.cs @@ -0,0 +1,156 @@ +using OpenCode.Sdk.Models; + +namespace OpenCode.Sdk.Tests.Support; + +/// +/// The probe's barriers and its three diagnostic contracts. The barriers are proved against a +/// scripted two-part stream; the diagnostics against a stream of a hundred oversized event labels, +/// where an expired barrier names what it waited for, a subscription that ends before its first +/// event says so, and a faulted reader is preserved and named. Every message stays bounded. +/// +public sealed class SessionEventProbeTests +{ + private const int MessageBound = 2000; + + [Test] + public async Task WaitForAsync_Should_Match_A_Retained_Event_Then_One_Arriving_After_It() + { + var first = new Marker("marker-a"); + var second = new Marker("marker-b"); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var reader = NewReader(); + var probe = new SessionEventProbe(reader); + probe.Start(ScriptAsync([first], release.Task, [second])); + Task? anchored = null; + try + { + var retained = await probe.WaitForAsync( + marker => marker.Type == "marker-a", "the first marker", CancellationToken.None); + await Assert.That(retained).IsSameReferenceAs(first); + + // Anchored to the first, so the retained match cannot settle it; only the later one can. + anchored = probe.WaitForAsync(_ => true, "a marker after the first", first, CancellationToken.None); + await Assert.That(anchored.IsCompleted).IsFalse(); + + _ = release.TrySetResult(true); + await Assert.That(await anchored).IsSameReferenceAs(second); + } + finally + { + _ = release.TrySetResult(true); + await Drain(anchored); + await reader.CompleteAsync(null); + } + } + + [Test] + public async Task WaitForAsync_Should_Name_The_Barrier_And_Bound_Its_Diagnostics() + { + var scenario = new EventDiagnosticScenario { Stall = true }; + var reader = NewReader(); + var probe = new SessionEventProbe(reader); + probe.Start(scenario.EventsAsync()); + try + { + await scenario.Stalled.Task; + + using var barrier = new CancellationTokenSource(); + await barrier.CancelAsync(); + var thrown = await Assert.That(async () => + { + _ = await probe.WaitForAsync( + _ => true, "session.moved for the owned session", barrier.Token); + }).Throws(); + + await Assert.That(thrown!.Message).Contains("session.moved for the owned session"); + await AssertBoundedTranscriptAsync(thrown.Message); + } + finally + { + _ = scenario.Pending?.TrySetResult(true); + await reader.CompleteAsync(null); + } + } + + [Test] + public async Task WaitForConnectedAsync_Should_Report_A_Subscription_That_Ended_First() + { + var scenario = new EventDiagnosticScenario(); + var reader = NewReader(); + var probe = new SessionEventProbe(reader); + probe.Start(scenario.EventsAsync()); + try + { + var thrown = await Assert.That(() => probe.WaitForConnectedAsync(CancellationToken.None)) + .Throws(); + + await Assert.That(thrown!.Message).Contains("Reader: the event subscription ended"); + await AssertBoundedTranscriptAsync(thrown.Message); + } + finally + { + await reader.CompleteAsync(null); + } + } + + [Test] + public async Task WaitForConnectedAsync_Should_Preserve_A_Faulted_Reader_And_Name_It() + { + var failure = new InvalidOperationException("reader transport failed"); + var scenario = new EventDiagnosticScenario { Failure = failure }; + var reader = NewReader(); + var probe = new SessionEventProbe(reader); + probe.Start(scenario.EventsAsync()); + + var thrown = await Assert.That(() => probe.WaitForConnectedAsync(CancellationToken.None)) + .Throws(); + _ = await Assert.That(() => reader.CompleteAsync(thrown)).Throws(); + + await Assert.That(thrown).IsSameReferenceAs(failure); + await Assert.That(probe.DiagnosticSummary) + .Contains("it faulted (InvalidOperationException: reader transport failed)"); + await AssertBoundedTranscriptAsync(probe.DiagnosticSummary); + } + + private static OwnedEventReader NewReader() => new( + TimeSpan.FromMinutes(1), TimeSpan.FromSeconds(1), CancellationToken.None, new OperationDeadlineScenario().Deadline); + + /// Yields the first events, waits for the caller's release, then yields the rest. + private static async IAsyncEnumerable ScriptAsync(IEvent[] first, Task release, IEvent[] rest) + { + foreach (var @event in first) + { + yield return @event; + } + + await release; + foreach (var @event in rest) + { + yield return @event; + } + } + + /// + /// Settles a barrier that may still be pending when the body failed. Awaited through WhenAny so + /// the barrier's own outcome is observed without replacing the body's failure. + /// + private static async Task Drain(Task? pending) + { + if (pending is not null) + { + _ = await Task.WhenAny(pending); + } + } + + /// The transcript keeps the first and last labels, drops the middle, and stays short. + private static async Task AssertBoundedTranscriptAsync(string message) + { + await Assert.That(message.Length).IsLessThan(MessageBound); + await Assert.That(message).Contains("84 events omitted"); + await Assert.That(message).Contains("event-0000-"); + await Assert.That(message).Contains("event-0099-"); + await Assert.That(message).DoesNotContain("event-0050-"); + } + + private sealed record Marker(string Type) : IEvent; +} diff --git a/tests/OpenCode.Sdk.Tests/Support/SessionMoveEvents.cs b/tests/OpenCode.Sdk.Tests/Support/SessionMoveEvents.cs deleted file mode 100644 index dc9a4d4d..00000000 --- a/tests/OpenCode.Sdk.Tests/Support/SessionMoveEvents.cs +++ /dev/null @@ -1,66 +0,0 @@ -using OpenCode.Sdk.Models; - -namespace OpenCode.Sdk.Tests.Support; - -internal sealed class SessionMoveEvents(string sessionId, string destination, OwnedEventReader reader) -{ - private readonly EventDiagnosticSummary _observed = new(); - - public string DiagnosticSummary => _observed.ToString(); - - public async Task CompleteAsync( - IAsyncEnumerable events, - TaskCompletionSource connected) - { - try - { - return await reader.Start(_ => ObserveAsync(events, connected)); - } - catch (Exception exception) - { - exception.Data[EventDiagnosticSummary.DataKey] = DiagnosticSummary; - throw; - } - } - - public async Task ObserveAsync( - IAsyncEnumerable events, - TaskCompletionSource connected) - { - ArgumentNullException.ThrowIfNull(events); - ArgumentNullException.ThrowIfNull(connected); - try - { - await foreach (var @event in events.WithCancellation(reader.Token)) - { - _observed.Add(@event.Type); - if (@event is EventServerConnected) - { - _ = connected.TrySetResult(true); - } - - if (@event is SessionMoved moved - && moved.Data.SessionId == sessionId - && moved.Data.Location.Directory == destination) - { - return moved; - } - } - } - catch (OperationCanceledException exception) - when (reader.ObservationTimedOut(exception)) - { - throw new TimeoutException( - $"The session move did not complete within the event window. Events: {_observed}.", - exception); - } - catch (Exception exception) - { - exception.Data[EventDiagnosticSummary.DataKey] = DiagnosticSummary; - throw; - } - - throw new InvalidOperationException( - $"The event subscription ended before the session move completed. Events: {_observed}."); - } -} diff --git a/tests/OpenCode.Sdk.Tests/Support/SimulatedSessionTurn.cs b/tests/OpenCode.Sdk.Tests/Support/SimulatedSessionTurn.cs index da006102..8898f325 100644 --- a/tests/OpenCode.Sdk.Tests/Support/SimulatedSessionTurn.cs +++ b/tests/OpenCode.Sdk.Tests/Support/SimulatedSessionTurn.cs @@ -3,6 +3,11 @@ namespace OpenCode.Sdk.Tests.Support; +/// +/// One scripted turn against the simulated server: the subscription is attached before the prompt, +/// the drive controller answers the model request with the caller's reply, and the turn is settled +/// from the live event bus rather than by polling. +/// internal sealed class SimulatedSessionTurn( SimulatedDriveServerFixture server, OpenCodeClient client, @@ -11,6 +16,7 @@ internal sealed class SimulatedSessionTurn( { private const string SimulatedModelId = "sim-model"; private static readonly TimeSpan EventWait = TimeSpan.FromSeconds(120); + private static readonly TimeSpan CleanupTimeout = TimeSpan.FromSeconds(15); private static readonly TimeSpan RequestWait = TimeSpan.FromSeconds(60); public async Task CompleteAsync( @@ -18,20 +24,14 @@ public async Task CompleteAsync( string reply, CancellationToken cancellationToken) { - var reader = new OwnedEventReader(EventWait, TimeSpan.FromSeconds(15), cancellationToken); - var connected = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var events = new ScriptedTurnEvents(reply, sessionId, reader); - var eventTask = events.CompleteAsync(client.Events.SubscribeAsync(reader.Token), connected); + var reader = new OwnedEventReader(EventWait, CleanupTimeout, cancellationToken); + var probe = new SessionEventProbe(reader); Exception? primaryFailure = null; try { - var attached = await Task.WhenAny(connected.Task, eventTask); - if (attached == eventTask) - { - _ = await eventTask; - throw new InvalidOperationException("The event subscription ended before server.connected."); - } + probe.Start(client.Events.SubscribeAsync(reader.Token)); + await probe.WaitForConnectedAsync(cancellationToken); _ = await session.PostPromptAsync( new SessionPromptPostRequest { Text = prompt }, @@ -49,12 +49,12 @@ await DriveAsync( () => server.Controller.FinishAsync(invocation.Id), "finishing the model turn"); - return await eventTask; + return await SettleAsync(probe, reply, cancellationToken); } catch (Exception exception) { primaryFailure = exception; - exception.Data[EventDiagnosticSummary.DataKey] = events.DiagnosticSummary; + exception.Data[EventDiagnosticSummary.DataKey] = probe.DiagnosticSummary; throw; } finally @@ -63,6 +63,25 @@ await DriveAsync( } } + /// + /// The scripted reply's text end, then the success that followed it. Anchoring the second wait + /// keeps a success that preceded the reply, which belongs to some other execution, from + /// settling this turn. + /// + private async Task SettleAsync( + SessionEventProbe probe, + string reply, + CancellationToken cancellationToken) + { + using var barrier = SessionEventProbe.Barrier(cancellationToken); + var ended = await probe.WaitForAsync( + text => text.Data.SessionId == sessionId && text.Data.Text == reply, + "session.text.ended carrying the scripted reply", barrier.Token); + return await probe.WaitForAsync( + executed => executed.Data.SessionId == sessionId, + "session.execution.succeeded for the owned session", ended, barrier.Token); + } + private static void RequireSimulatedInvocation(DriveInvocation invocation) { if (invocation.Model != SimulatedModelId || invocation.Url != SimulationConfigSeed.ChatCompletionsUrl) @@ -96,5 +115,4 @@ private static async Task DriveAsync(Func operation, string description) throw new TimeoutException($"The drive controller was torn down while {description}.", exception); } } - } From 293f782773ff738edf0d8363294505b2b1e284a3 Mon Sep 17 00:00:00 2001 From: "denizirgin@gmail.com" Date: Mon, 7 Sep 2026 23:21:18 +0300 Subject: [PATCH 61/62] docs: record the undetected half-open event stream as a known gap A successful SSE body stays live until caller cancellation, server completion, or failure, so a peer that disappears without closing hangs a consumer that supplied no cancellation of its own. Ordinary resets, server exits, and killed processes already surface at once, and a locally launched server closes its socket when it dies, so the gap is real only for remote, proxied, or sleep-and-resume endpoints. A consumer cannot bound it from outside the SDK: the server's keepalive comments carry no event, so nothing above the framer can tell an idle session from a dead connection. The bound also cannot be a property of every SSE body, because upstream writes a keepalive every fifteen seconds on v2.event.subscribe and none on v2.session.log, whose follow mode is silent by design while a session is idle. It is therefore queued behind M6's public network-timeout knob, which the milestone now names, so the bound arrives configurable rather than as a behavior no caller can widen. --- docs/ROADMAP.md | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 5c5749b3..662f22d1 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -60,8 +60,9 @@ is revisited at each boundary. document declares, verifier-checked, with the arms no deterministic fixture can reach listed by name rather than skipped silently (ADR-0022). 6. **M6 — Operational closure.** Automation for the upstream observation lanes (tip detector, - candidate refresh), retry/telemetry/hooks with the public network-timeout knob, a quarantine lane, - the nightly source-run canary with the performance suite (ADR-0022), and Restore-patch retirement. + candidate refresh), retry/telemetry/hooks with the public network-timeout knob and the + per-operation event-stream idle bound it gates, a quarantine lane, the nightly source-run + canary with the performance suite (ADR-0022), and Restore-patch retirement. ## Open Questions @@ -101,6 +102,18 @@ is revisited at each boundary. `netstandard2.0` a response body over 1 MB costs one wire-sized copy, and `PtySession.ReadAsync` allocates a fresh 16 KiB receive buffer per call. Both are measured rather than suspected, and both are described for consumers in the README's Known Issues. +- **A half-open event stream is not detected.** A successful SSE body stays live until caller + cancellation, server completion, or failure, so a connection whose peer is gone without closing + hangs a consumer that supplied no cancellation of its own; ordinary resets, server exits, and + killed processes already surface at once. A consumer cannot bound this from outside the SDK, + because the server's keepalive comments carry no event and never reach the enumeration. The bound + has to be per operation rather than a property of every SSE body: upstream writes a keepalive + every fifteen seconds on `v2.event.subscribe` and none on `v2.session.log`, whose follow mode is + silent by design while a session is idle. Queued behind M6's public network-timeout knob so the + bound arrives configurable rather than as a behavior no caller can widen. Upstream's own clients + place this one layer above their core client, which does not reconnect either + (`packages/client/src/solid/connection.ts`: two-second connect, forty-five-second idle abort, + one-second reconnect delay, and an authoritative refetch once reconnected). - **A server-process start stalls in-process `net472` tests for about ten seconds** on hosted Windows. Harmless today, because every timing-bounded test runs alone, and queued as a hygiene candidate: the first suspect is .NET Framework's synchronous pipe reads holding thread-pool From 540f460118f0cae7399022a9e0f2aa472d53e4a7 Mon Sep 17 00:00:00 2001 From: "denizirgin@gmail.com" Date: Tue, 8 Sep 2026 00:24:32 +0300 Subject: [PATCH 62/62] test: accept an already-departed child when arranging the exited-child proof The collector's exited-child proof launches a server that ends itself on a half-second timer, then takes a handle on it to wait for that exit. Taking the handle is itself racing the timer: on a loaded runner with four target frameworks in flight the child is already gone, and Process.GetProcessById answers ArgumentException rather than a handle, failing the arrangement for reaching the exact state it was arranging. Three-OS CI caught it on Windows net8.0 and net9.0 while net10.0, net472, Linux and macOS passed. The wait now treats an absent process as the state it waits for and only awaits a handle it actually obtained, which is the contract IsProcessRunning in the same class already relies on. The proof itself is unchanged: the child is gone before disposal, and disposal still finalizes both retained streams. --- .../OpenCodeServerLifecycleTests.cs | 28 ++++++++++++++++--- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/tests/OpenCode.Sdk.Tests/OpenCodeServerLifecycleTests.cs b/tests/OpenCode.Sdk.Tests/OpenCodeServerLifecycleTests.cs index 27321351..6d647a8e 100644 --- a/tests/OpenCode.Sdk.Tests/OpenCodeServerLifecycleTests.cs +++ b/tests/OpenCode.Sdk.Tests/OpenCodeServerLifecycleTests.cs @@ -54,6 +54,29 @@ private static bool IsProcessRunning(int processId) } } + /// + /// Waits until the launched child is gone. A child that ends itself on a timer can already + /// have exited before this runs, and an absent process is the state this waits for: only a + /// live handle is worth awaiting, so its absence returns rather than failing the arrangement. + /// + private static async Task WaitForProcessExitAsync(int processId, CancellationToken cancellationToken) + { + System.Diagnostics.Process child; + try + { + child = System.Diagnostics.Process.GetProcessById(processId); + } + catch (ArgumentException) + { + return; + } + + using (child) + { + await child.WaitForExitAsync(cancellationToken); + } + } + [Test] [Timeout(240_000)] public async Task StartAsync_Should_Report_Readiness_And_Answer_Health(CancellationToken cancellationToken) @@ -311,10 +334,7 @@ public async Task DisposeAsync_Should_Finalize_The_Collector_For_A_Child_That_Al Output = output, }, cancellationToken); - using (var child = System.Diagnostics.Process.GetProcessById(server.ProcessId)) - { - await child.WaitForExitAsync(cancellationToken); - } + await WaitForProcessExitAsync(server.ProcessId, cancellationToken); await server.DisposeAsync();