diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a7ec52d..d9f1a85 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,7 +39,7 @@ jobs: dotnet run --project src/SharpClaw.Code.Cli/SharpClaw.Code.Cli.csproj --no-build --configuration Release -- test gates - name: Upload coverage if: matrix.os == 'ubuntu-latest' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: coverage-report path: ./coverage/**/coverage.cobertura.xml diff --git a/docs/testing.md b/docs/testing.md index 8d36c4e..3fd0a17 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -47,7 +47,7 @@ Scenarios are selected via request **`Metadata`** key **`parityScenario`** (**`P - **`streaming_text`** — deterministic deltas → `"Hello world"` - **`stream_failure`** — throws (turn fails; session may become **`Failed`**) -- **`stream_slow`** — delays (cancellation / timeout scenarios) +- **`stream_slow`** — emits an initial delta, then waits for cancellation (timeout / recovery scenarios) **`AddDeterministicMockModelProvider`** registers the provider + **`PostConfigure`** so **`default`** / **`deterministic`** aliases point at the mock. diff --git a/tests/SharpClaw.Code.MockProvider/DeterministicMockModelProvider.cs b/tests/SharpClaw.Code.MockProvider/DeterministicMockModelProvider.cs index 0c4726f..37c5fff 100644 --- a/tests/SharpClaw.Code.MockProvider/DeterministicMockModelProvider.cs +++ b/tests/SharpClaw.Code.MockProvider/DeterministicMockModelProvider.cs @@ -72,8 +72,8 @@ private async IAsyncEnumerable StreamEventsAsync( case ParityProviderScenario.StreamSlow: await Task.Delay(500, cancellationToken).ConfigureAwait(false); yield return CreateDelta(request, sequence: 1, "slow-start"); - await Task.Delay(500, cancellationToken).ConfigureAwait(false); - yield return CreateTerminal(request, sequence: 2); + // Keep cancellation scenarios active even if a loaded runner observes the turn late. + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken).ConfigureAwait(false); yield break; case ParityProviderScenario.ToolCallRoundtrip: // Check if the request already contains tool-result content (second iteration) diff --git a/tests/SharpClaw.Code.MockProvider/ParityProviderScenario.cs b/tests/SharpClaw.Code.MockProvider/ParityProviderScenario.cs index 8447e09..9b8d0d9 100644 --- a/tests/SharpClaw.Code.MockProvider/ParityProviderScenario.cs +++ b/tests/SharpClaw.Code.MockProvider/ParityProviderScenario.cs @@ -16,7 +16,7 @@ public static class ParityProviderScenario public const string StreamFailure = "stream_failure"; /// - /// Delays long enough to let timeout and recovery scenarios cancel the stream. + /// Emits an initial delta, then waits for timeout and recovery scenarios to cancel the stream. /// public const string StreamSlow = "stream_slow"; diff --git a/tests/SharpClaw.Code.ParityHarness/ParityScenarioTests.cs b/tests/SharpClaw.Code.ParityHarness/ParityScenarioTests.cs index 05ba6fc..7e9e6fa 100644 --- a/tests/SharpClaw.Code.ParityHarness/ParityScenarioTests.cs +++ b/tests/SharpClaw.Code.ParityHarness/ParityScenarioTests.cs @@ -334,7 +334,8 @@ private async Task RunPromptWithCancelAfterTurnStartAsync( private async Task WaitForActiveTurnAsync(ISessionStore store, CancellationToken cancellationToken) { - for (var attempt = 0; attempt < 100; attempt++) + // Startup is separate from the cancellation deadline and can be slow on loaded Windows runners. + for (var attempt = 0; attempt < 600; attempt++) { var session = await store.GetLatestAsync(_workspace, cancellationToken).ConfigureAwait(false); if (!string.IsNullOrWhiteSpace(session?.ActiveTurnId)) diff --git a/tests/SharpClaw.Code.UnitTests/Providers/DeterministicMockModelProviderTests.cs b/tests/SharpClaw.Code.UnitTests/Providers/DeterministicMockModelProviderTests.cs index e56f74a..2c11d26 100644 --- a/tests/SharpClaw.Code.UnitTests/Providers/DeterministicMockModelProviderTests.cs +++ b/tests/SharpClaw.Code.UnitTests/Providers/DeterministicMockModelProviderTests.cs @@ -44,4 +44,24 @@ public async Task StartStreamAsync_should_emit_deterministic_ids_and_timestamps( new DateTimeOffset(2026, 4, 6, 0, 0, 0, TimeSpan.Zero).AddMilliseconds(2), new DateTimeOffset(2026, 4, 6, 0, 0, 0, TimeSpan.Zero).AddMilliseconds(3)); } + [Fact] + public async Task Slow_stream_should_remain_active_until_canceled_after_delayed_observation() + { + var provider = new DeterministicMockModelProvider(); + var request = new ProviderRequest( + "slow-request", "session", "turn", DeterministicMockModelProvider.ProviderNameConstant, + DeterministicMockModelProvider.DefaultModelId, "slow", null, OutputFormat.Text, null, + new Dictionary { [ParityMetadataKeys.Scenario] = ParityProviderScenario.StreamSlow }); + using var cancellation = new CancellationTokenSource(); + var handle = await provider.StartStreamAsync(request, cancellation.Token); + await using var events = handle.Events.GetAsyncEnumerator(cancellation.Token); + Assert.True(await events.MoveNextAsync()); + + var nextEvent = events.MoveNextAsync().AsTask(); + // Simulate observing the session after the original one-second fixture would have finished. + await Task.Delay(TimeSpan.FromMilliseconds(1500)); + Assert.False(nextEvent.IsCompleted); + await cancellation.CancelAsync(); + await Assert.ThrowsAnyAsync(async () => await nextEvent); + } }