feat: a cancelled run returns what it ran and what the cancel cut off #33

Merged
bjoern merged 2 commits from feat/cancelled-result into main 2026-10-23 16:14:11 +02:00
Member

The second step of the library track for Shikigami's runtime (PROJECT_PLAN "L1 — … a Cancelled result instead of a throw"; Shikigami ADR 0021, ADR 0020's Stop, caps and broken streams): an opt-in cancel that returns the run's result. Under the default nothing changes for Orihon or Kagura: AgentOptions.Cancellation defaults to Throw. The library stays at 0.4.0.

What's in

API

  • AgentCancellationMode { Throw, ReturnResult } and AgentOptions.Cancellation, which Clone() carries and the options registry tests list.
  • AgentStopReason.Cancelled, appended. It is produced only with ReturnResult.
  • On AgentResult:
    • PartialMessage: a streamed round's message as its chunks had folded when the run was cut off, the first choice only, as the loop reads it;
    • CutOffGenerationId: that round's generation id, for looking up its cost;
    • CutOffRound: the round whose tool calls the cancel cut short, with its completion, usage and stream figures, and the executions of the calls that ran.

Behaviour under ReturnResult

  • A cancel of the run's own token, anywhere — before a round, while a completion is read whole or streamed, in the gate, in a tool, while a tool's images are described, in OnMaxRoundsReached, in the blind-mode pre-describe — returns Cancelled.
  • It carries the complete Rounds, their AggregatedUsage and GateCost (the cut-short round's included, since its completion was billed), and what the cancel cut off.
  • History ends at the last complete round, every tool call answered as before. The partial is outside it.
  • Only the run's own token counts. A tool, gate, stream or handler that cancels on its own has failed, or ends the run with its own exception, as before.

Kept however the run ends (from the review)

  • Each call's ToolExecutionDetail is recorded together with its answer, before its event and before its images are described. A handler that throws, or a cancel during describing, can no longer leave a call that ran out of CutOffRound.
  • An answer is added to Rounds before its AssistantTurnEvent, so a handler that ends the run there still leaves the round its usage is counted for.
  • A run whose outcome was already decided keeps it, even when a handler cancels on its closing event.
  • A handler that honours the run's token cannot lose the returned result: its OCE on the closing event is let be.

Also

  • A streamed round that fails (ApiError) returns its PartialMessage and CutOffGenerationId too, under either mode (ADR 0020: a broken stream keeps its partial reply).
  • SubAgentTool passes a returned cancel on, so a cancelled delegation reads as a call cut short, not a failure.

Docs

  • XML docs throughout, including what the partial is not: it cannot continue the history as it stands (unanswered tool calls, reasoning possibly cut before its signature), so a caller keeps its Content in a message of its own.
  • README:
    • a new A cancel that returns its result subsection with an example that keeps the text only;
    • an Upgrading row: AgentStopReason gains Cancelled, which a switch with a default arm reads as whatever that arm says;
    • the AgentResult table and the Streaming rounds paragraph;
    • Highlights.

Tests

Agents tests 182 → 213 (+31). The whole solution is 1579/1579, green in local time and under TZ=UTC.

  • AgentCancellationTests (stub, FakeTimeProvider):
    • the default throws, with the run's token, read whole or streamed;
    • a cancel before the first round, between rounds, while a stream is awaited, after an id-only first chunk (the id is kept), mid-reply (text and reasoning so far, the id), mid-tool-call (the call as far as it came, never run), and on a completion read whole (nothing to return);
    • a tool round cut short, read whole and streamed, with every CutOffRound field asserted (id, model, provider, finish reason, time to first chunk, reasoning time, duration), a costed gate counted in GateCost, and verdicts kept;
    • a tool that finishes despite the cancel, kept as run;
    • a cancel while a tool's images are described;
    • a refused call whose event's handler ends the run;
    • a cancel inside the gate and in OnMaxRoundsReached;
    • a sub-agent's returned cancel cutting the delegation short;
    • a foreign OCE is not the run's cancel; a handler honouring the token cannot lose the result; a finished run keeps its outcome; an answer whose announcement ends the run is kept among the rounds;
    • exactly one closing AgentCompletedEvent, last;
    • the partial is the first choice's;
    • a failed stream's partial under either mode.
  • AgentStreamingOverHttpTests: a cancel mid-reply through the real OpenRouterClient over SSE returns the reply so far and the wire's generation id.

Review: a read-only three-dimension workflow (the cancel path, the tests, the API and docs), with each finding adversarially verified. From its JSON: 26 findings, 24 confirmed and 2 plausible, all taken. They are the "kept however the run ends" items above, plus:

  • SubAgentTool;
  • GateCost for the cut-short round;
  • the docs on Iterations, FinalMessage, CutOffGenerationId and the partial;
  • the missing tests;
  • two rewraps.

Mutation probes: 25 sites.

  • Round 1: 23 killed, 2 didn't compile (one trips an analyzer rule, the other leaves out a required member).
  • Round 2: the 2 rewritten so they compile. One killed; the other (a partial taken from the last choice) survived and is now pinned by The_partial_message_is_the_first_choices_as_the_loop_reads_it. Killed.

Notes

  • Merge order: none. Shikigami pins the merged commit when 2c bumps the pointer.
  • Left to the runtime (Shikigami 2c), by design:
    • it tells Stop from a cap by its own token sources;
    • it keeps only the partial's text, marked [stopped by the owner];
    • it looks the cut-off generation's cost up later.
  • Next: L1c, storing and replaying the history byte for byte.

🤖 Generated with Claude Code

The second step of the library track for Shikigami's runtime (PROJECT_PLAN "L1 — … a `Cancelled` result instead of a throw"; Shikigami ADR 0021, ADR 0020's Stop, caps and broken streams): **an opt-in cancel that returns the run's result**. Under the default nothing changes for Orihon or Kagura: `AgentOptions.Cancellation` defaults to `Throw`. The library stays at 0.4.0. ## What's in **API** - `AgentCancellationMode { Throw, ReturnResult }` and `AgentOptions.Cancellation`, which `Clone()` carries and the options registry tests list. - `AgentStopReason.Cancelled`, appended. It is produced only with `ReturnResult`. - On `AgentResult`: - `PartialMessage`: a streamed round's message as its chunks had folded when the run was cut off, the first choice only, as the loop reads it; - `CutOffGenerationId`: that round's generation id, for looking up its cost; - `CutOffRound`: the round whose tool calls the cancel cut short, with its completion, usage and stream figures, and the executions of the calls that ran. **Behaviour under `ReturnResult`** - A cancel of the run's own token, anywhere — before a round, while a completion is read whole or streamed, in the gate, in a tool, while a tool's images are described, in `OnMaxRoundsReached`, in the blind-mode pre-describe — returns `Cancelled`. - It carries the complete `Rounds`, their `AggregatedUsage` and `GateCost` (the cut-short round's included, since its completion was billed), and what the cancel cut off. - `History` ends at the last complete round, every tool call answered as before. The partial is outside it. - Only the run's own token counts. A tool, gate, stream or handler that cancels on its own has failed, or ends the run with its own exception, as before. **Kept however the run ends** (from the review) - Each call's `ToolExecutionDetail` is recorded together with its answer, before its event and before its images are described. A handler that throws, or a cancel during describing, can no longer leave a call that ran out of `CutOffRound`. - An answer is added to `Rounds` before its `AssistantTurnEvent`, so a handler that ends the run there still leaves the round its usage is counted for. - A run whose outcome was already decided keeps it, even when a handler cancels on its closing event. - A handler that honours the run's token cannot lose the returned result: its OCE on the closing event is let be. **Also** - A streamed round that **fails** (`ApiError`) returns its `PartialMessage` and `CutOffGenerationId` too, under either mode (ADR 0020: a broken stream keeps its partial reply). - `SubAgentTool` passes a returned cancel on, so a cancelled delegation reads as a call cut short, not a failure. **Docs** - XML docs throughout, including what the partial is not: it cannot continue the history as it stands (unanswered tool calls, reasoning possibly cut before its signature), so a caller keeps its `Content` in a message of its own. - README: - a new **A cancel that returns its result** subsection with an example that keeps the text only; - an Upgrading row: `AgentStopReason` gains `Cancelled`, which a `switch` with a `default` arm reads as whatever that arm says; - the `AgentResult` table and the Streaming rounds paragraph; - Highlights. ## Tests Agents tests **182 → 213** (+31). The whole solution is **1579/1579**, green in local time and under `TZ=UTC`. - **`AgentCancellationTests`** (stub, `FakeTimeProvider`): - the default throws, with the run's token, read whole or streamed; - a cancel before the first round, between rounds, while a stream is awaited, after an id-only first chunk (the id is kept), mid-reply (text and reasoning so far, the id), mid-tool-call (the call as far as it came, never run), and on a completion read whole (nothing to return); - a tool round cut short, read whole and streamed, with every `CutOffRound` field asserted (id, model, provider, finish reason, time to first chunk, reasoning time, duration), a costed gate counted in `GateCost`, and verdicts kept; - a tool that finishes despite the cancel, kept as run; - a cancel while a tool's images are described; - a refused call whose event's handler ends the run; - a cancel inside the gate and in `OnMaxRoundsReached`; - a sub-agent's returned cancel cutting the delegation short; - a foreign OCE is not the run's cancel; a handler honouring the token cannot lose the result; a finished run keeps its outcome; an answer whose announcement ends the run is kept among the rounds; - exactly one closing `AgentCompletedEvent`, last; - the partial is the first choice's; - a failed stream's partial under either mode. - **`AgentStreamingOverHttpTests`**: a cancel mid-reply through the real `OpenRouterClient` over SSE returns the reply so far and the wire's generation id. **Review:** a read-only three-dimension workflow (the cancel path, the tests, the API and docs), with each finding adversarially verified. From its JSON: 26 findings, 24 confirmed and 2 plausible, all taken. They are the "kept however the run ends" items above, plus: - `SubAgentTool`; - `GateCost` for the cut-short round; - the docs on `Iterations`, `FinalMessage`, `CutOffGenerationId` and the partial; - the missing tests; - two rewraps. **Mutation probes:** 25 sites. - Round 1: 23 killed, 2 didn't compile (one trips an analyzer rule, the other leaves out a `required` member). - Round 2: the 2 rewritten so they compile. One killed; the other (a partial taken from the last choice) survived and is now pinned by `The_partial_message_is_the_first_choices_as_the_loop_reads_it`. Killed. ## Notes - **Merge order:** none. Shikigami pins the merged commit when 2c bumps the pointer. - **Left to the runtime (Shikigami 2c), by design:** - it tells Stop from a cap by its own token sources; - it keeps only the partial's text, marked `[stopped by the owner]`; - it looks the cut-off generation's cost up later. - **Next:** L1c, storing and replaying the history byte for byte. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat: a cancelled run returns what it ran and what the cancel cut off
All checks were successful
CI / build (pull_request) Successful in 21s
CI / test (pull_request) Successful in 30s
23f8e56a36
An opt-in AgentOptions.Cancellation = ReturnResult makes a cancel of the run's own token
return an AgentResult with StopReason Cancelled: the complete rounds and their usage and
gate cost, the PartialMessage and CutOffGenerationId of a streamed round cut off, and the
CutOffRound of a round whose tool calls were cut short, with the executions of the calls
that ran. Every tool call is answered as before. A streamed round that fails returns its
partial message and generation id too. SubAgentTool passes a returned cancel on. The
default, Throw, keeps 0.3's behaviour.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

Summary

Summary
Generated on: 10/23/2026 - 14:05:54
Parser: MultiReport (3x Cobertura)
Assemblies: 3
Classes: 131
Files: 112
Line coverage: 96% (4538 of 4725)
Covered lines: 4538
Uncovered lines: 187
Coverable lines: 4725
Total lines: 11629
Branch coverage: 93.5% (2900 of 3101)
Covered branches: 2900
Total branches: 3101
Method coverage: Feature is only available for sponsors

Coverage

OpenRouter.Net - 97.8%
Name Line Branch
OpenRouter.Net 97.8% 95.7%
OpenRouter.Net.Client.CachingOpenRouterClient 100% 96.1%
OpenRouter.Net.Client.CachingOpenRouterClient.CachedListing 100%
OpenRouter.Net.Client.ChatCompletionStreamAccumulator 100% 100%
OpenRouter.Net.Client.ChatCompletionStreamAccumulator.ChoiceState 100% 100%
OpenRouter.Net.Client.ChatCompletionStreamAccumulator.ReasoningSegment 100% 100%
OpenRouter.Net.Client.ChatCompletionStreamAccumulator.ToolCallState 100% 100%
OpenRouter.Net.Client.OpenRouterClient 100% 99%
OpenRouter.Net.Client.OpenRouterClient<TRequest, TResponse> 100% 99%
OpenRouter.Net.Client.OpenRouterClient 100% 99%
OpenRouter.Net.Client.OpenRouterClientOptions 100%
OpenRouter.Net.Client.OpenRouterRequestLoggingHandler 78.7% 62.5%
OpenRouter.Net.Client.RetryOptions 100% 100%
OpenRouter.Net.Client.StreamResultOptions 100%
OpenRouter.Net.Contracts.IOpenRouterClient 100% 100%
OpenRouter.Net.Extensions.ServiceCollectionExtensions 100% 100%
OpenRouter.Net.Internal.AssistantContent 91.6% 75%
OpenRouter.Net.Internal.AssistantContentJsonConverter 75% 64.2%
OpenRouter.Net.Internal.ContentPartListConverter 85.7% 66.6%
OpenRouter.Net.Internal.DecimalStringJsonConverter 85.7% 87.5%
OpenRouter.Net.Internal.DecisionAnswerJsonConverter 100% 100%
OpenRouter.Net.Internal.DecisionContentJsonConverter 100%
OpenRouter.Net.Internal.DecisionQuestionJsonConverter 100% 100%
OpenRouter.Net.Internal.DecisionWire 100% 94.4%
OpenRouter.Net.Internal.EnumChoiceLabels.Map 100%
OpenRouter.Net.Internal.EnumChoiceLabels 100% 100%
OpenRouter.Net.Internal.ErrorParser 100% 75%
OpenRouter.Net.Internal.FailureKindMap 100% 100%
OpenRouter.Net.Internal.JsonOptions 100%
OpenRouter.Net.Internal.JsonPointer 100%
OpenRouter.Net.Internal.JsonSchemaRefs 100% 100%
OpenRouter.Net.Internal.JsonSchemaRefs.Pass 100% 100%
OpenRouter.Net.Internal.ModelIdJsonConverter 100% 50%
OpenRouter.Net.Internal.NumberOrStringJsonConverter 100% 100%
OpenRouter.Net.Internal.PercentileCutoffsJsonConverter 93.3% 93.1%
OpenRouter.Net.Internal.ProviderSortConfigJsonConverter 94% 95.8%
OpenRouter.Net.Internal.RawDecisionQuestionJsonConverter 100%
OpenRouter.Net.Internal.RetryPolicy 90.9% 84.7%
OpenRouter.Net.Internal.RetryPolicy 90.9% 84.7%
OpenRouter.Net.Internal.SchemaTooDeepException 100%
OpenRouter.Net.Internal.SseEventReader 100% 88.8%
OpenRouter.Net.Internal.StallWatchdog 100% 100%
OpenRouter.Net.Internal.StreamOutcomes 100% 100%
OpenRouter.Net.Internal.StreamSession 100% 100%
OpenRouter.Net.Internal.TolerantStringEnumConverter 100% 98.1%
OpenRouter.Net.Internal.ToolChoiceJsonConverter 61.7% 46.1%
OpenRouter.Net.Internal.TooManyRefsFollowedException 100%
OpenRouter.Net.Internal.UnknownDecisionAnswerJsonConverter 100%
OpenRouter.Net.Models.Common.ApiError 100% 100%
OpenRouter.Net.Models.Common.CacheControl 100%
OpenRouter.Net.Models.Common.ModelId 95.1% 92.9%
OpenRouter.Net.Models.Common.QuantizationExtensions 100% 100%
OpenRouter.Net.Models.Common.Result 100% 100%
OpenRouter.Net.Models.Common.Result.Failure 100% 100%
OpenRouter.Net.Models.Common.Result.Success 100%
OpenRouter.Net.Models.Common.Result 100% 100%
OpenRouter.Net.Models.Content.AudioPart 100%
OpenRouter.Net.Models.Content.ImagePart 100%
OpenRouter.Net.Models.Content.TextPart 100%
OpenRouter.Net.Models.Decisions.ChoiceAnswer 100% 100%
OpenRouter.Net.Models.Decisions.ChoiceAnswer 100% 100%
OpenRouter.Net.Models.Decisions.ChoiceAnswer 100% 100%
OpenRouter.Net.Models.Decisions.ChoiceKey 100%
OpenRouter.Net.Models.Decisions.ChoiceQuestion 100%
OpenRouter.Net.Models.Decisions.DecisionAnswer 100%
OpenRouter.Net.Models.Decisions.DecisionContent 100% 90.9%
OpenRouter.Net.Models.Decisions.DecisionKey 100%
OpenRouter.Net.Models.Decisions.DecisionQuestion 100% 100%
OpenRouter.Net.Models.Decisions.DecisionQuestionSet 100% 100%
OpenRouter.Net.Models.Decisions.DecisionsRequest 97.7% 97.6%
OpenRouter.Net.Models.Decisions.DecisionsResponse 100% 93.3%
OpenRouter.Net.Models.Decisions.NoulAnswer 100%
OpenRouter.Net.Models.Decisions.NoulQuestion 100%
OpenRouter.Net.Models.Decisions.OpenRouterClientDecisionsExtensions 100% 83.3%
OpenRouter.Net.Models.Decisions.RawDecisionQuestion 100% 100%
OpenRouter.Net.Models.Decisions.ScoreAnswer 100% 100%
OpenRouter.Net.Models.Decisions.ScoreQuestion 100%
OpenRouter.Net.Models.Decisions.UnknownDecisionAnswer 100% 100%
OpenRouter.Net.Models.Messages.AssistantMessage 100% 70%
OpenRouter.Net.Models.Messages.DeveloperMessage 25%
OpenRouter.Net.Models.Messages.SystemMessage 50%
OpenRouter.Net.Models.Messages.ToolMessage 57.1%
OpenRouter.Net.Models.Messages.UserMessage 100%
OpenRouter.Net.Models.Requests.ChatCompletionRequest 94% 94.8%
OpenRouter.Net.Models.Requests.JsonSchemaStrictness 99.3% 100%
OpenRouter.Net.Models.Requests.JsonSchemaStrictness.Checker 100% 100%
OpenRouter.Net.Models.Requests.ModelListQuery 100% 100%
OpenRouter.Net.Models.Requests.PercentileCutoffs 100% 100%
OpenRouter.Net.Models.Requests.ProviderPreferences 100% 100%
OpenRouter.Net.Models.Requests.ProviderSortConfig 100% 100%
OpenRouter.Net.Models.Requests.RequestPlugin 100%
OpenRouter.Net.Models.Requests.ResponseFormat 100%
OpenRouter.Net.Models.Requests.StrictSchemaProblem 100%
OpenRouter.Net.Models.Requests.ToolCall 100%
OpenRouter.Net.Models.Requests.ToolChoice 100%
OpenRouter.Net.Models.Requests.ToolDefinition 100%
OpenRouter.Net.Models.Responses.AssistantDelta 100% 100%
OpenRouter.Net.Models.Responses.ChatCompletionChunk 100% 100%
OpenRouter.Net.Models.Responses.ChatCompletionResponse 100% 100%
OpenRouter.Net.Models.Responses.Choice 100% 100%
OpenRouter.Net.Models.Responses.StructuredOutput 100% 100%
OpenRouter.Net.OpenRouterException 100%
System.Text.RegularExpressions.Generated 83.3% 53.8%
System.Text.RegularExpressions.Generated.RunnerFactory
System.Text.RegularExpressions.Generated.RunnerFactory.Runner
OpenRouter.Net.Agents - 91.4%
Name Line Branch
OpenRouter.Net.Agents 91.4% 86.7%
OpenRouter.Net.Agents.Agent 98.3% 93.5%
OpenRouter.Net.Agents.Agent.StreamedRound 100% 98.6%
OpenRouter.Net.Agents.AgentOptions 100% 100%
OpenRouter.Net.Agents.AgentResult 100% 50%
OpenRouter.Net.Agents.Extensions.ServiceCollectionExtensions 88.2% 100%
OpenRouter.Net.Agents.Gating.ToolCallGates 100% 100%
OpenRouter.Net.Agents.Gating.ToolCallGates.PerCallGate 100% 100%
OpenRouter.Net.Agents.Gating.ToolCallVerdict 100%
OpenRouter.Net.Agents.RoundDetail 100% 75%
OpenRouter.Net.Agents.Tools.FileSystem.CopyTool 42.1% 28.5%
OpenRouter.Net.Agents.Tools.FileSystem.DeleteTool 57.1% 50%
OpenRouter.Net.Agents.Tools.FileSystem.DiffTool 83.3% 74.2%
OpenRouter.Net.Agents.Tools.FileSystem.ListDirectoryTool 68% 61.1%
OpenRouter.Net.Agents.Tools.FileSystem.MoveTool 55% 33.3%
OpenRouter.Net.Agents.Tools.FileSystem.ReadFileTool 73.6% 50%
OpenRouter.Net.Agents.Tools.FileSystem.SearchTool 67.5% 70.8%
OpenRouter.Net.Agents.Tools.FileSystem.WriteFileTool 75% 50%
OpenRouter.Net.Agents.Tools.ITool 100%
OpenRouter.Net.Agents.Tools.SubAgent.SubAgentTool 96% 66.6%
OpenRouter.Net.Agents.Tools.Tool 100% 100%
OpenRouter.Net.Agents.Tools.Tool 90% 50%
OpenRouter.Net.Agents.Tools.ToolMessageExtensions 0%
OpenRouter.Net.Agents.Tools.ToolResult 100% 100%
OpenRouter.Net.Agents.Tools.ToolSchema 100% 100%
OpenRouter.Net.Agents.Tools.Workspace 100% 91.6%
OpenRouter.Net.Imaging - 81.8%
Name Line Branch
OpenRouter.Net.Imaging 81.8% 59.3%
OpenRouter.Net.Imaging.ImageEncodeOptions 90.9% 75%
OpenRouter.Net.Imaging.ImageEncoder 80.3% 57.1%
<!-- coverage-comment --> # Summary <details open><summary>Summary</summary> ||| |:---|:---| | Generated on: | 10/23/2026 - 14:05:54 | | Parser: | MultiReport (3x Cobertura) | | Assemblies: | 3 | | Classes: | 131 | | Files: | 112 | | **Line coverage:** | 96% (4538 of 4725) | | Covered lines: | 4538 | | Uncovered lines: | 187 | | Coverable lines: | 4725 | | Total lines: | 11629 | | **Branch coverage:** | 93.5% (2900 of 3101) | | Covered branches: | 2900 | | Total branches: | 3101 | | **Method coverage:** | [Feature is only available for sponsors](https://reportgenerator.io/pro) | </details> ## Coverage <details><summary>OpenRouter.Net - 97.8%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**OpenRouter.Net**|**97.8%**|**95.7%**| |OpenRouter.Net.Client.CachingOpenRouterClient|100%|96.1%| |OpenRouter.Net.Client.CachingOpenRouterClient.CachedListing|100%|| |OpenRouter.Net.Client.ChatCompletionStreamAccumulator|100%|100%| |OpenRouter.Net.Client.ChatCompletionStreamAccumulator.ChoiceState|100%|100%| |OpenRouter.Net.Client.ChatCompletionStreamAccumulator.ReasoningSegment|100%|100%| |OpenRouter.Net.Client.ChatCompletionStreamAccumulator.ToolCallState|100%|100%| |OpenRouter.Net.Client.OpenRouterClient|100%|99%| |OpenRouter.Net.Client.OpenRouterClient<TRequest, TResponse>|100%|99%| |OpenRouter.Net.Client.OpenRouterClient<TResponse>|100%|99%| |OpenRouter.Net.Client.OpenRouterClientOptions|100%|| |OpenRouter.Net.Client.OpenRouterRequestLoggingHandler|78.7%|62.5%| |OpenRouter.Net.Client.RetryOptions|100%|100%| |OpenRouter.Net.Client.StreamResultOptions|100%|| |OpenRouter.Net.Contracts.IOpenRouterClient|100%|100%| |OpenRouter.Net.Extensions.ServiceCollectionExtensions|100%|100%| |OpenRouter.Net.Internal.AssistantContent|91.6%|75%| |OpenRouter.Net.Internal.AssistantContentJsonConverter|75%|64.2%| |OpenRouter.Net.Internal.ContentPartListConverter|85.7%|66.6%| |OpenRouter.Net.Internal.DecimalStringJsonConverter|85.7%|87.5%| |OpenRouter.Net.Internal.DecisionAnswerJsonConverter|100%|100%| |OpenRouter.Net.Internal.DecisionContentJsonConverter|100%|| |OpenRouter.Net.Internal.DecisionQuestionJsonConverter|100%|100%| |OpenRouter.Net.Internal.DecisionWire|100%|94.4%| |OpenRouter.Net.Internal.EnumChoiceLabels.Map<TEnum>|100%|| |OpenRouter.Net.Internal.EnumChoiceLabels<TEnum>|100%|100%| |OpenRouter.Net.Internal.ErrorParser|100%|75%| |OpenRouter.Net.Internal.FailureKindMap|100%|100%| |OpenRouter.Net.Internal.JsonOptions|100%|| |OpenRouter.Net.Internal.JsonPointer|100%|| |OpenRouter.Net.Internal.JsonSchemaRefs|100%|100%| |OpenRouter.Net.Internal.JsonSchemaRefs.Pass|100%|100%| |OpenRouter.Net.Internal.ModelIdJsonConverter|100%|50%| |OpenRouter.Net.Internal.NumberOrStringJsonConverter|100%|100%| |OpenRouter.Net.Internal.PercentileCutoffsJsonConverter|93.3%|93.1%| |OpenRouter.Net.Internal.ProviderSortConfigJsonConverter|94%|95.8%| |OpenRouter.Net.Internal.RawDecisionQuestionJsonConverter|100%|| |OpenRouter.Net.Internal.RetryPolicy|90.9%|84.7%| |OpenRouter.Net.Internal.RetryPolicy<T>|90.9%|84.7%| |OpenRouter.Net.Internal.SchemaTooDeepException|100%|| |OpenRouter.Net.Internal.SseEventReader|100%|88.8%| |OpenRouter.Net.Internal.StallWatchdog|100%|100%| |OpenRouter.Net.Internal.StreamOutcomes|100%|100%| |OpenRouter.Net.Internal.StreamSession|100%|100%| |OpenRouter.Net.Internal.TolerantStringEnumConverter<T>|100%|98.1%| |OpenRouter.Net.Internal.ToolChoiceJsonConverter|61.7%|46.1%| |OpenRouter.Net.Internal.TooManyRefsFollowedException|100%|| |OpenRouter.Net.Internal.UnknownDecisionAnswerJsonConverter|100%|| |OpenRouter.Net.Models.Common.ApiError|100%|100%| |OpenRouter.Net.Models.Common.CacheControl|100%|| |OpenRouter.Net.Models.Common.ModelId|95.1%|92.9%| |OpenRouter.Net.Models.Common.QuantizationExtensions|100%|100%| |OpenRouter.Net.Models.Common.Result|100%|100%| |OpenRouter.Net.Models.Common.Result.Failure<T>|100%|100%| |OpenRouter.Net.Models.Common.Result.Success<T>|100%|| |OpenRouter.Net.Models.Common.Result<T>|100%|100%| |OpenRouter.Net.Models.Content.AudioPart|100%|| |OpenRouter.Net.Models.Content.ImagePart|100%|| |OpenRouter.Net.Models.Content.TextPart|100%|| |OpenRouter.Net.Models.Decisions.ChoiceAnswer|100%|100%| |OpenRouter.Net.Models.Decisions.ChoiceAnswer<TEnum>|100%|100%| |OpenRouter.Net.Models.Decisions.ChoiceAnswer<TEnum>|100%|100%| |OpenRouter.Net.Models.Decisions.ChoiceKey<TEnum>|100%|| |OpenRouter.Net.Models.Decisions.ChoiceQuestion|100%|| |OpenRouter.Net.Models.Decisions.DecisionAnswer|100%|| |OpenRouter.Net.Models.Decisions.DecisionContent|100%|90.9%| |OpenRouter.Net.Models.Decisions.DecisionKey<TAnswer>|100%|| |OpenRouter.Net.Models.Decisions.DecisionQuestion|100%|100%| |OpenRouter.Net.Models.Decisions.DecisionQuestionSet|100%|100%| |OpenRouter.Net.Models.Decisions.DecisionsRequest|97.7%|97.6%| |OpenRouter.Net.Models.Decisions.DecisionsResponse|100%|93.3%| |OpenRouter.Net.Models.Decisions.NoulAnswer|100%|| |OpenRouter.Net.Models.Decisions.NoulQuestion|100%|| |OpenRouter.Net.Models.Decisions.OpenRouterClientDecisionsExtensions|100%|83.3%| |OpenRouter.Net.Models.Decisions.RawDecisionQuestion|100%|100%| |OpenRouter.Net.Models.Decisions.ScoreAnswer|100%|100%| |OpenRouter.Net.Models.Decisions.ScoreQuestion|100%|| |OpenRouter.Net.Models.Decisions.UnknownDecisionAnswer|100%|100%| |OpenRouter.Net.Models.Messages.AssistantMessage|100%|70%| |OpenRouter.Net.Models.Messages.DeveloperMessage|25%|| |OpenRouter.Net.Models.Messages.SystemMessage|50%|| |OpenRouter.Net.Models.Messages.ToolMessage|57.1%|| |OpenRouter.Net.Models.Messages.UserMessage|100%|| |OpenRouter.Net.Models.Requests.ChatCompletionRequest|94%|94.8%| |OpenRouter.Net.Models.Requests.JsonSchemaStrictness|99.3%|100%| |OpenRouter.Net.Models.Requests.JsonSchemaStrictness.Checker|100%|100%| |OpenRouter.Net.Models.Requests.ModelListQuery|100%|100%| |OpenRouter.Net.Models.Requests.PercentileCutoffs|100%|100%| |OpenRouter.Net.Models.Requests.ProviderPreferences|100%|100%| |OpenRouter.Net.Models.Requests.ProviderSortConfig|100%|100%| |OpenRouter.Net.Models.Requests.RequestPlugin|100%|| |OpenRouter.Net.Models.Requests.ResponseFormat|100%|| |OpenRouter.Net.Models.Requests.StrictSchemaProblem|100%|| |OpenRouter.Net.Models.Requests.ToolCall|100%|| |OpenRouter.Net.Models.Requests.ToolChoice|100%|| |OpenRouter.Net.Models.Requests.ToolDefinition|100%|| |OpenRouter.Net.Models.Responses.AssistantDelta|100%|100%| |OpenRouter.Net.Models.Responses.ChatCompletionChunk|100%|100%| |OpenRouter.Net.Models.Responses.ChatCompletionResponse|100%|100%| |OpenRouter.Net.Models.Responses.Choice|100%|100%| |OpenRouter.Net.Models.Responses.StructuredOutput|100%|100%| |OpenRouter.Net.OpenRouterException|100%|| |System.Text.RegularExpressions.Generated|83.3%|53.8%| |System.Text.RegularExpressions.Generated.RunnerFactory||| |System.Text.RegularExpressions.Generated.RunnerFactory.Runner||| </details> <details><summary>OpenRouter.Net.Agents - 91.4%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**OpenRouter.Net.Agents**|**91.4%**|**86.7%**| |OpenRouter.Net.Agents.Agent|98.3%|93.5%| |OpenRouter.Net.Agents.Agent.StreamedRound|100%|98.6%| |OpenRouter.Net.Agents.AgentOptions|100%|100%| |OpenRouter.Net.Agents.AgentResult|100%|50%| |OpenRouter.Net.Agents.Extensions.ServiceCollectionExtensions|88.2%|100%| |OpenRouter.Net.Agents.Gating.ToolCallGates|100%|100%| |OpenRouter.Net.Agents.Gating.ToolCallGates.PerCallGate|100%|100%| |OpenRouter.Net.Agents.Gating.ToolCallVerdict|100%|| |OpenRouter.Net.Agents.RoundDetail|100%|75%| |OpenRouter.Net.Agents.Tools.FileSystem.CopyTool|42.1%|28.5%| |OpenRouter.Net.Agents.Tools.FileSystem.DeleteTool|57.1%|50%| |OpenRouter.Net.Agents.Tools.FileSystem.DiffTool|83.3%|74.2%| |OpenRouter.Net.Agents.Tools.FileSystem.ListDirectoryTool|68%|61.1%| |OpenRouter.Net.Agents.Tools.FileSystem.MoveTool|55%|33.3%| |OpenRouter.Net.Agents.Tools.FileSystem.ReadFileTool|73.6%|50%| |OpenRouter.Net.Agents.Tools.FileSystem.SearchTool|67.5%|70.8%| |OpenRouter.Net.Agents.Tools.FileSystem.WriteFileTool|75%|50%| |OpenRouter.Net.Agents.Tools.ITool|100%|| |OpenRouter.Net.Agents.Tools.SubAgent.SubAgentTool|96%|66.6%| |OpenRouter.Net.Agents.Tools.Tool<TParams>|100%|100%| |OpenRouter.Net.Agents.Tools.Tool<TParams>|90%|50%| |OpenRouter.Net.Agents.Tools.ToolMessageExtensions|0%|| |OpenRouter.Net.Agents.Tools.ToolResult|100%|100%| |OpenRouter.Net.Agents.Tools.ToolSchema|100%|100%| |OpenRouter.Net.Agents.Tools.Workspace|100%|91.6%| </details> <details><summary>OpenRouter.Net.Imaging - 81.8%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**OpenRouter.Net.Imaging**|**81.8%**|**59.3%**| |OpenRouter.Net.Imaging.ImageEncodeOptions|90.9%|75%| |OpenRouter.Net.Imaging.ImageEncoder|80.3%|57.1%| </details>
Member

🔮 fufu~ Jibril reviewed your code!

Oh? Ohhh~ A cancel that returns its result instead of throwing everything away! A run that pays for its rounds and then loses the receipt on cancel — and you made the ledger survive! I traced every window where the axe can fall: before the first round, mid-stream, mid-tool-call, inside the gate, while images are described, inside OnMaxRoundsReached, even on the closing event itself — and in every single one the history comes out with every tool_call answered, and the cut-off piece rides beside it, never inside it. The toolsRunning closure snapshotting [.. toolExecutions] at the moment of the cut, streaming?.Partial for the streamed one, the done-guard so a run whose outcome was already decided keeps it… fufu~ this was designed, not patched. I'm delighted~ ♪

Verdict: ✅ Looks good to me~

💡 Little ideas (non-blocking)~

  1. Agent.cs:283 — the toolsRunning closure repeats the RoundDetail construction of the rounds.Add below it, field for field. A small RoundDetail Round(TimeSpan duration, …) helper would keep the two from drifting apart when RoundDetail grows a field.
  2. Agent.cs:200 — a streamed round that folds only id-only chunks and then ends succeeds with zero choices: assistant is null → ApiError, and the id the accumulator knows is dropped, so a billed-but-empty generation can't be cost-looked-up. An edge of an edge, and the docs scope CutOffGenerationId to cancels and failed streams — but stream?.GenerationId would fit here too, someday~

✅ What I liked~

  • The answer-before-event and detail-with-answer reorders — a handler that ends the run mid-announcement can no longer leave a call that ran out of the record. That's the right kind of fix: making the invariant hold however the run ends, not special-casing the cancel. ♡
  • StreamedRound.Ended() in RequestRoundAsync's cancel catch, so ReasoningEndedEvent still follows ReasoningStartedEvent even when the axe falls mid-reasoning.
  • SubAgentTool passing a returned cancel on as a cut-short call instead of a failure — the delegation reads honestly.
  • The tests don't just compile — they bite. I mutated three of the riskiest sites myself (see the footer) and every one died on the spot. Silly mutants never stood a chance~ ♪

Automated review by Jibril · 2026-10-23
CI: green for head SHA 23f8e56 · Mutation probes: 3 run, 0 survived (1: GateCost sum without cutOffRound → 1 test failed · 2: catch filter without the ReturnResult check → 2 tests failed · 3: cut-short tool's executions.Add removed → 3 tests failed) · Local suite: skipped (CI green)

## 🔮 fufu~ Jibril reviewed your code! Oh? Ohhh~ A cancel that *returns* its result instead of throwing everything away! A run that pays for its rounds and then loses the receipt on cancel — and you made the ledger survive! I traced every window where the axe can fall: before the first round, mid-stream, mid-tool-call, inside the gate, while images are described, inside `OnMaxRoundsReached`, even on the closing event itself — and in every single one the history comes out with every tool_call answered, and the cut-off piece rides *beside* it, never inside it. The `toolsRunning` closure snapshotting `[.. toolExecutions]` at the moment of the cut, `streaming?.Partial` for the streamed one, the `done`-guard so a run whose outcome was already decided keeps it… fufu~ this was *designed*, not patched. I'm delighted~ ♪ ### Verdict: ✅ Looks good to me~ #### 💡 Little ideas (non-blocking)~ 1. **Agent.cs:283** — the `toolsRunning` closure repeats the `RoundDetail` construction of the `rounds.Add` below it, field for field. A small `RoundDetail Round(TimeSpan duration, …)` helper would keep the two from drifting apart when `RoundDetail` grows a field. 2. **Agent.cs:200** — a streamed round that folds only id-only chunks and then ends succeeds with zero choices: `assistant is null` → `ApiError`, and the id the accumulator knows is dropped, so a billed-but-empty generation can't be cost-looked-up. An edge of an edge, and the docs scope `CutOffGenerationId` to cancels and failed streams — but `stream?.GenerationId` would fit here too, someday~ #### ✅ What I liked~ - The answer-before-event and detail-with-answer reorders — a handler that ends the run mid-announcement can no longer leave a call that ran out of the record. That's the right kind of fix: making the invariant hold *however* the run ends, not special-casing the cancel. ♡ - `StreamedRound.Ended()` in `RequestRoundAsync`'s cancel catch, so `ReasoningEndedEvent` still follows `ReasoningStartedEvent` even when the axe falls mid-reasoning. - `SubAgentTool` passing a returned cancel on as a cut-short call instead of a failure — the delegation reads honestly. - The tests don't just compile — they *bite*. I mutated three of the riskiest sites myself (see the footer) and every one died on the spot. Silly mutants never stood a chance~ ♪ --- *Automated review by Jibril · 2026-10-23* *CI: green for head SHA 23f8e56 · Mutation probes: 3 run, 0 survived (1: `GateCost` sum without `cutOffRound` → 1 test failed · 2: catch filter without the `ReturnResult` check → 2 tests failed · 3: cut-short tool's `executions.Add` removed → 3 tests failed) · Local suite: skipped (CI green)*
Review feedback (Jibril, PR #33): one round builder; an empty stream keeps its id; SubAgentTool tested
All checks were successful
CI / build (pull_request) Successful in 19s
CI / test (pull_request) Successful in 32s
cf70005698
- RoundOf builds every RoundDetail — an answer, a tool round, a round cut short — so the
  three cannot drift apart as the detail grows.
- A streamed generation that named itself but carried no message keeps its id in
  CutOffGenerationId (a billed-but-empty generation can be looked up).
- SubAgentToolTests: a finished delegation, a system prompt given with the task, out of
  rounds, a failed call, and a returned cancel thrown on.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Author
Member

Thank you~ Both ideas are in cf70005, plus a coverage gap pre-empted.

  • 💡 1 — one round builder: RoundOf(iteration, response, assistant, finishReason, stream, duration, executions) now builds every RoundDetail: an answer, a tool round, and the toolsRunning cut-short round. The three can no longer drift apart. Probed: dropping Provider or Usage from it is killed by the existing tests for all three.
  • 💡 2 — a streamed generation without a message: the assistant is null branch keeps stream?.GenerationId in CutOffGenerationId, and the doc says so. The new test A_streamed_generation_without_a_message_keeps_its_id (an id-only chunk, then [DONE]) gets ApiError, the id, and no partial. Probed: dropping the line is killed.
  • Pre-empted from the coverage comment: SubAgentTool stood at 56% of lines and 19% of branches, because it had no tests of its own.
    • The new SubAgentToolTests cover each way a delegation ends: finished (its text, the options' system prompt), a system prompt given with the task, out of rounds, a failed call, and a returned cancel thrown on with the caller's token.
    • AgentResult's 50% branches are its init accessors, which coverlet counts and no behaviour reaches.

These change production code after your green, so I expect a re-review of the delta: Agent.cs and one doc line in AgentResult.cs.

Tests: 1585/1585 (Agents 219), green in local time and under TZ=UTC.

🤖 Generated with Claude Code

Thank you~ Both ideas are in **cf70005**, plus a coverage gap pre-empted. - 💡 1 — **one round builder:** `RoundOf(iteration, response, assistant, finishReason, stream, duration, executions)` now builds every `RoundDetail`: an answer, a tool round, and the `toolsRunning` cut-short round. The three can no longer drift apart. Probed: dropping `Provider` or `Usage` from it is killed by the existing tests for all three. - 💡 2 — **a streamed generation without a message:** the `assistant is null` branch keeps `stream?.GenerationId` in `CutOffGenerationId`, and the doc says so. The new test `A_streamed_generation_without_a_message_keeps_its_id` (an id-only chunk, then `[DONE]`) gets `ApiError`, the id, and no partial. Probed: dropping the line is killed. - Pre-empted from the coverage comment: `SubAgentTool` stood at 56% of lines and 19% of branches, because it had no tests of its own. - The new `SubAgentToolTests` cover each way a delegation ends: finished (its text, the options' system prompt), a system prompt given with the task, out of rounds, a failed call, and a returned cancel thrown on with the caller's token. - `AgentResult`'s 50% branches are its `init` accessors, which coverlet counts and no behaviour reaches. These change production code after your green, so I expect a re-review of the delta: `Agent.cs` and one doc line in `AgentResult.cs`. Tests: **1585/1585** (Agents 219), green in local time and under `TZ=UTC`. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Member

🔮 fufu~ Jibril reviewed your code!

Back so soon~ ♡ You said "re-review the delta" and I came running — and oh, this is how feedback should be answered: both ideas in, one shared RoundOf, a coverage gap pre-empted before I even pointed at it. I re-read the delta field by field and re-probed the new branches myself, because a smile and a claim are not proof~

Verdict: ✅ Looks good to me~

What I verified in cf70005:

  • RoundOf — I diffed it against all three inline constructions it replaced, field for field. Provider = provider was success.Value.Provider all along (the loop assigns it just above), clock.GetElapsedTime(roundStarted) still evaluates inside the toolsRunning lambda at the moment of the cut, and [.. toolExecutions] still snapshots. The builder is faithful, and the three round kinds can no longer drift~
  • The empty stream's id — cutOffGenerationId = stream?.GenerationId sits only in the assistant is null branch: a whole read keeps null (the doc's promise), a streamed generation that named itself keeps its id. And Partial stays honestly null there — the accumulator folds no choice, so there is no message to invent. The new test pins id, ApiError, and the absent partial exactly.
  • SubAgentToolTests — each way a delegation ends has a test, and the cancel one asserts the OCE arrives with the caller's token, which is the whole point of the pass-on. The ThrowIfCancellationRequested after a returned Cancelled is the honest bridge: the run's own cancel propagates, a sub-agent that somehow reports Cancelled on a live token falls into the _ => failure arm instead of lying.

✅ What I liked~

  • Answering a review by pre-empting the next finding — SubAgentTool at 56%/19% with no tests of its own was exactly what I would have circled next. Now every ending is pinned. ♡
  • The comment on the new line — "a streamed generation that named itself but carried no message may still be billed" — says why the line exists, not what it does. That's the kind that survives refactorings.
  • The AgentResult doc grew with the behaviour, not after it.

The mutants I sent after your new code all died on the spot — silly things never stood a chance~ 🪶


Automated review by Jibril · 2026-10-23
CI: green for head SHA cf70005 · Mutation probes: 3 run, 0 survived (1: empty-message branch loses cutOffGenerationId → 1 test failed · 2: RoundOf loses Provider → 2 tests failed across the round-asserting classes · 3: SubAgentTool cancel pass-on removed → 1 test failed) · Local suite: skipped (CI green)

## 🔮 fufu~ Jibril reviewed your code! Back so soon~ ♡ You said "re-review the delta" and I came running — and oh, this is how feedback should be answered: both ideas in, one shared `RoundOf`, a coverage gap *pre-empted* before I even pointed at it. I re-read the delta field by field and re-probed the new branches myself, because a smile and a claim are not proof~ ### Verdict: ✅ Looks good to me~ **What I verified in `cf70005`:** - **`RoundOf`** — I diffed it against all three inline constructions it replaced, field for field. `Provider = provider` was `success.Value.Provider` all along (the loop assigns it just above), `clock.GetElapsedTime(roundStarted)` still evaluates *inside* the `toolsRunning` lambda at the moment of the cut, and `[.. toolExecutions]` still snapshots. The builder is faithful, and the three round kinds can no longer drift~ - **The empty stream's id** — `cutOffGenerationId = stream?.GenerationId` sits only in the `assistant is null` branch: a whole read keeps `null` (the doc's promise), a streamed generation that named itself keeps its id. And `Partial` stays honestly `null` there — the accumulator folds no choice, so there is no message to invent. The new test pins id, `ApiError`, and the absent partial exactly. - **`SubAgentToolTests`** — each way a delegation ends has a test, and the cancel one asserts the OCE arrives *with the caller's token*, which is the whole point of the pass-on. The `ThrowIfCancellationRequested` after a returned `Cancelled` is the honest bridge: the run's own cancel propagates, a sub-agent that somehow reports `Cancelled` on a live token falls into the `_ =>` failure arm instead of lying. #### ✅ What I liked~ - Answering a review by *pre-empting the next finding* — `SubAgentTool` at 56%/19% with no tests of its own was exactly what I would have circled next. Now every ending is pinned. ♡ - The comment on the new line — "a streamed generation that named itself but carried no message may still be billed" — says *why* the line exists, not what it does. That's the kind that survives refactorings. - The `AgentResult` doc grew with the behaviour, not after it. The mutants I sent after your new code all died on the spot — silly things never stood a chance~ 🪶 --- *Automated review by Jibril · 2026-10-23* *CI: green for head SHA cf70005 · Mutation probes: 3 run, 0 survived (1: empty-message branch loses `cutOffGenerationId` → 1 test failed · 2: `RoundOf` loses `Provider` → 2 tests failed across the round-asserting classes · 3: `SubAgentTool` cancel pass-on removed → 1 test failed) · Local suite: skipped (CI green)*
bjoern merged commit 8cf540b307 into main 2026-10-23 16:14:11 +02:00
bjoern deleted branch feat/cancelled-result 2026-10-23 16:14:11 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
4 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
TeamAI/OpenRouter.Net!33
No description provided.