Skip to content

fix: the parity gaps the ECS matrices found, closed — Gemini's block is a refusal, a truncated reasoning-only turn commits nothing, images ride the regrouped stream - #2510

Merged
gold-silver-copper merged 1 commit into
mainfrom
fix/parity-gaps-main
Sep 13, 2026

Conversation

@gold-silver-copper

@gold-silver-copper gold-silver-copper commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

Re-targets #2509 at main: that PR merged into ci/main-gate, so its change never reached main. This is its squash (0b7b215) cherry-picked onto main with no conflicts; the description below is #2509's.

Summary

The matrices of #2489#2503 pinned five divergences "as they are" and ledgered them. This closes them in the runtime, one commit each, with the existing cell (or a new unit test where no wire can show it) as the regression. Stacked on #2508.

  1. A provider's refusal of the prompt is a refusal on the report. Gemini's SAFETY / BLOCKLIST / PROHIBITED_CONTENT block was a plain provider error (kind: provider, refusal: false, no code). ProviderResponseError gains refusal (on the wire form, default false, read by every ErrorReport conversion that carries a reply); the block is now the provider's reply without a status, code the block reason, refusal: true. A refusal is never retryable, whatever status or transport verdict travels with it (is_retryable short-circuits; pinned). OTHER and unknown reasons stay transient and are not refusals. The row names the REST wire: rig-gemini-grpc does not read prompt_feedback yet, so a block on the gRPC wire is neither an error nor a refusal — deferred. A refusal the model says (OpenAI's refusal deltas) is an answer, as before. CONTRACT §4 gains the row; gemini::cassette::ecs_faults::refusal and the Gemini stream-fault blocked cells pin it.
  2. A truncated reasoning-only turn commits nothing. rig-agent pushed the reasoning turn into history before the turn_delivered_no_answer check failed the run; rig-ecs keeps nothing. The check now runs first on both runtimes (CONTRACT §4); a_truncated_reasoning_only_turn_commits_nothing pins the run state. The bug: Gemini injects a hardcoded maxOutputTokens=4096 and temperature=1.0 into structured-output and image requests #2322 streaming test that pinned the old ordering in its name and doc asserted only that the streamed reasoning reaches the consumer, which is what keeps a truncated thinking turn debuggable; it is renamed partial_reasoning_reaches_the_consumer_when_the_truncated_turn_errors.
  3. The streamed assembler carries image parts. A regrouped turn (a call or reasoning present) dropped any image the provider delivered. The assembler now applies rig-core's canonical_streamed_choice to its own inputs (its reasoning, the text items it reports, the calls it accepted, the images the provider delivered), so the two runtimes share one grouping rule: reasoning, text, the calls, then the images, each in arrival order.
  4. The model-turn hook reads the reconciled finish reason — it already did. The compat normaliser, the streaming accumulator, the hook and the record all take the reconciled reason (stop beside a call is ToolCalls); a unit test pins it. The two Venice termination tool-turn cells stay ignored for what is now the real reason: their cassettes were removed as orphans in test(ecs): the ECS contract on five more wires — OpenAI Chat, OpenAI Responses, Gemini, DeepSeek, Doubleword, Venice #2501 and this task records none; the attribute names the two scenarios to record, after which the cells pass as written (every test(ecs): the ECS contract on five more wires — OpenAI Chat, OpenAI Responses, Gemini, DeepSeek, Doubleword, Venice #2501/test(ecs): the ECS contract under faults on six wires — setup, retryable statuses, truncated and error-bearing streams, refusals, failing tools, stops, scenes #2503 Venice tool turn reports tool_calls).
  5. A typed tool error is a status: error result — not a gap. ErasedTool::execute renders every tool's Err into the result before the adapter answers; CONTRACT §8.1 says so in one sentence (an error map_error marks a refusal is status: refused instead).

No golden changed.

Testing

Changelog

  • fix(gemini): a blocked prompt is a refusal on the report — ProviderResponseError::refusal, ErrorReport::refusal from a provider's reply, the block reason as the code.
  • fix(agent): a truncated reasoning-only turn commits nothing (CONTRACT §4).
  • fix(agent): the streamed assembler carries image parts after the calls, through rig_core::completion::message::canonical_streamed_choice.
  • test(agent): the model-turn hook reads the reconciled finish reason.
  • docs(ecs): a typed tool error is a status: error result.

Migration

ErrorReport::refusal is now true for a provider's refusal of the prompt (Gemini's content blocks), which arrive as kind: provider_response with the block reason as code instead of kind: provider; a caller matching Gemini's block on ErrorKind::Provider matches ErrorKind::ProviderResponse with refusal instead. ProviderResponseError gains a refusal field (set through with_refusal); is_retryable is false for a refusal regardless of status.

…is a refusal, a truncated reasoning-only turn commits nothing, images ride the regrouped stream (#2509)

* fix(gemini): a blocked prompt is a refusal on the report

Gemini's SAFETY / BLOCKLIST / PROHIBITED_CONTENT block was
CompletionError::ProviderError(message): kind provider, refusal false,
no code. ProviderResponseError gains refusal (with_refusal; on the wire
form, default false), every ErrorReport conversion that carries a reply
reads it, and the block is now the provider's reply without a status,
code the block reason, refusal true. A refusal is never retryable,
whatever status or transport verdict travels with it: the provider
judged the content, and the same call gets the same verdict. OTHER and
unknown reasons stay transient and are not refusals. A refusal the model
says (OpenAI's refusal deltas) is an answer, as before. CONTRACT §4 gains
the row, naming the REST wire: the gRPC Gemini wire does not read
prompt_feedback yet. The Gemini refusal cell and the stream-fault blocked
cells pin it.

* fix(agent): a truncated reasoning-only turn commits nothing

rig-agent pushed a reasoning-only turn into the run's new messages before
the turn_delivered_no_answer check failed the run; rig-ecs keeps nothing
(CONTRACT §4). The check now runs first, so the failed run commits
nothing on either runtime. a_truncated_reasoning_only_turn_commits_nothing
pins the run state; the CONTRACT row loses its parenthesis.

The #2322 streaming test pinned the old ordering in its name and doc
(reasoning_survives_into_history_when_the_truncated_turn_errors): its
assertion was always about the streamed reasoning reaching the consumer,
which is the surface that keeps a truncated thinking turn debuggable, so
it is renamed partial_reasoning_reaches_the_consumer_when_the_truncated_
turn_errors and its doc says what it pins.

* fix(agent): the streamed assembler carries image parts

A regrouped streamed turn (a call or reasoning present) rebuilt the turn
from the calls, the reasoning and the text; an image the provider
delivered beside a call was dropped. The trailing items are now the
calls in arrival order, then the images in theirs; rig-core's
canonical_streamed_choice groups the same way, and its doc no longer
describes a divergence. finish_carries_images_after_the_calls_when_regrouping
asserts the assembler equals rig-core's rule.

* test(agent): the model-turn hook reads the reconciled finish reason

A wire that reports stop on a turn that carried a tool call reaches the
hook as ToolCalls, the value the response and the record carry
(FinishReason::reconcile_with_output at the compat normaliser and in the
streaming accumulator). Pinned with a mock; no runtime change. The two
Venice termination tool-turn cells stay ignored for the reason that is
true: their cassettes were removed as orphans in #2501 and this task
records none; the attribute names the scenarios to record, after which
the cells pass as written.

* docs(ecs): a typed tool error is a status: error result

Not a gap: ErasedTool::execute renders every tool's Err — a typed
Tool::Error or a ToolExecutionError — into the status: error result
before the adapter answers; the contract's "any other Err report" row is
about handler failures. CONTRACT §8.1 says so in one sentence.
@gold-silver-copper
gold-silver-copper added this pull request to the merge queue Sep 13, 2026
Merged via the queue into main with commit 387abee Sep 13, 2026
16 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant