Skip to content

test(ecs): the ECS contract on five more wires — OpenAI Chat, OpenAI Responses, Gemini, DeepSeek, Doubleword, Venice - #2501

Merged
gold-silver-copper merged 9 commits into
mainfrom
test/ecs-five-wires
Sep 13, 2026
Merged

gold-silver-copper merged 9 commits into
mainfrom
test/ecs-five-wires

Conversation

@gold-silver-copper

Copy link
Copy Markdown
Contributor

Summary

The ECS contract (crates/rig-ecs/CONTRACT.md) was pinned on real provider bytes almost only for Anthropic. This lands the same cell families on five more wires — OpenAI Chat, OpenAI Responses, Gemini REST, DeepSeek, Doubleword, Venice — as one rectangular matrix: rows are contract sections, columns are wires, and every cell drives a committed recording through spawn_run and asserts the world's record equals rig-agent's on the same bytes, then the graph, then (where the section names a cut) a scene saved at the cut and resumed in a fresh world over replayers of the log's tail, then despawn_run.

One shape, six copies. The cells are data (tests/common/ecs_matrix/cells.rs: the rig-verify corpus's Program table plus the tools, store and bus a live cell needs) over two drivers in tests/common/ecs_matrix/: agent.rs (the rig-agent producer, writing the golden) and world.rs (the world cell: the corpus's world_hooks as systems, real adapters, the four assertions). The rig-verify corpus module is included by #[path], as the Anthropic causal cells already did. The per-wire files hold only scenario literals (the cassette census reads them off the call sites), the wire's models and the wire's #[ignore] reasons.

The grid as landed

85 golden-backed cells per wire (rows 1–8, 10, 12) plus the world-only rows (11: batch holds by both approval routes; 13: a recorded 4xx through the world with the witness's facts; 14: Gemini's minted ids; 15: despawn_run on a run cancelled with its stream in flight, and on every settled cell) and row 9 (the termination matrix, ported to DeepSeek, Doubleword and Venice; OpenAI and Gemini already had it).

wire model producers world cells world-only ignored (reason in the attribute) recordings
openai_chat gpt-5-mini, route gpt-5-nano 85 85 5 2 thinking 84 + 1
openai_responses gpt-5-mini / reuse gpt-4o 77 (+8 reused) 85 5 2 thinking, max_output_tokens floor, 3 causal (leaf: true) 71 (8 reused)
gemini gemini-3-flash-preview / reuse gemini-2.5-flash 80 (+5 reused) 85 6 2 thinking, tool-call delta (whole parts), mode NONE (MALFORMED_FUNCTION_CALL) 76 (5 reused)
deepseek deepseek-chat, route deepseek-reasoner 85 85 5 2 thinking 84 + 1
doubleword Qwen/Qwen3.5-397B-A17B-FP8, reasoning_effort: none 85 85 5 2 thinking 83
venice mistral-small-3-2-24b-instruct (the route is the same model) 85 85 5 2 thinking, output tool beside a real tool (gateway hang) 82

Every recording is a short prompt; the whole matrix recorded for well under a dollar per provider. The cassette engine refuses a session that leaves an interaction unplayed, so every distinct consumption pattern is its own recording, as the Anthropic families' are.

Parity defects found and fixed (each with its cell as the regression)

  1. OpenAI Chat sent tool_choice beside an empty tools (400). Dropped when no tool is advertised; unit test tool_choice_is_dropped_when_no_tool_is_advertised.
  2. OpenAI Responses replayed a turn with several text parts as several message items under one id (400 "Duplicate item found"). The turn's text blocks ride one message item; unit test assistant_turn_with_several_text_parts_replays_as_one_message_item.
  3. rig-ecs committed a streamed turn in arrival order; rig-agent commits it reasoning-first. Gemini 3's thought signature rides the last chunk. ordered_assistant_content hoisted to rig_core::message (rig-agent re-exports it), canonical_streamed_choice beside it, and rig-ecs's fold orders a streamed turn canonically when it lands.
  4. rig-ecs reprompted a text answer that already was the structured output; rig-agent accepts it. rig_ecs::policy::text_satisfies_schema; CONTRACT §4's reprompt row now says so.

Open findings (ledger): ErrorReport.code is None on every HTTP wire's 4xx (the body's own code stays on provider_response.body); rig-agent's regrouped streamed branch drops image parts; Venice reports finish_reason: stop on a tool-call turn and rig-agent's ModelTurnFinished reports Stop, not ToolCalls (the two termination tool-turn cells are ignored with that reason).

Changelog

  • test(ecs): the ECS contract matrix on OpenAI Chat, OpenAI Responses, Gemini, DeepSeek, Doubleword and Venice — 85 golden-backed cells per wire mirroring the Anthropic families, the world-only rows (batch holds, error facts through the world, Gemini's minted ids, despawn_run), and the termination matrix on DeepSeek, Doubleword and Venice; 502 new recordings and 969 new goldens (478 rig-agent, 491 world).
  • fix(openai): the Chat wire no longer sends tool_choice when no tool is advertised.
  • fix(openai): the Responses wire replays an assistant turn with several text parts as one message item.
  • fix(ecs): a streamed turn is committed in the canonical order (reasoning, text, calls), as rig-agent commits it; rig_core::message::{ordered_assistant_content, canonical_streamed_choice}.
  • fix(ecs): a text answer that already satisfies the output schema settles the run instead of being reprompted (CONTRACT §4).

Migration

None. rig_agent::run::streamed::ordered_assistant_content is now a re-export of rig_core::message::ordered_assistant_content.

…, the Responses wire replays a turn's text parts as one message item

Found by the ECS contract matrix: gpt-5-mini on the Chat wire answered 400 to an output tool degraded to native output under `tool_choice: none` (no tool advertised, a choice sent), and on the Responses wire to a reprompted turn the model had answered in two output_text parts (two message items under one id, "Duplicate item found"). Each has a unit test beside it; the matrix cells are the cassette regressions. The Moonshot tool-choice tests advertise the tool their choice names.
…nswer that already is the structured output

Two parity divergences the matrix found between rig-ecs's materialise and rig-agent on the same bytes: Gemini 3's thought signature rides the last chunk, so the world committed [text, reasoning] where the agent commits [reasoning, text] (rig-agent's ordered_assistant_content is hoisted to rig_core::message with canonical_streamed_choice; the fold orders a streamed turn canonically when it lands); and a JSON text answer where the output tool was due was reprompted by the world and accepted by the agent (policy::text_satisfies_schema; CONTRACT §4 says so).
…rpus as their shape

tests/common/ecs_matrix: the cells as data over the rig-verify corpus's Program table (included by path, as the Anthropic causal cells include its nesting systems), a rig-agent producer driver, a world driver (the corpus's world_hooks as systems, real adapters, the record/graph/cut/despawn assertions) and the world-only rows (batch holds, error facts, minted ids, despawn on an in-flight stream). Program is Copy, the world interpreter's pieces are pub, Nesting learns no_temperature for a wire whose model takes only its default; the parity helper dumps a diverging pair under RIG_ECS_PARITY_DUMP; world_replay expects the grown corpus.
@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 258377b Sep 13, 2026
15 checks passed
gold-silver-copper added a commit that referenced this pull request Sep 13, 2026
…etry policy identity

#2501 added the OpenAI Chat, OpenAI Responses, Gemini, DeepSeek,
Doubleword and Venice ECS goldens with a policy hash from before #2500
put ProviderRetries into the identity, so main's own stable/test fails
on them. Regenerated in replay mode: 491 files, only the hash in each.
gold-silver-copper added a commit that referenced this pull request Sep 13, 2026
…olicy hash (#2500)

#2500 (724dce2) added `provider_retries` to the effective policy
`rig_ecs::replay::spec_json` hashes into a run's program identity. #2501
was based and CI-checked before it, so its 491 `ecs_parity` goldens carry
the hash without the field and every `ecs_matrix*` world cell diverges on
`main` at 258377b (a clean checkout fails them; the ledger names five).
Regenerated: the `policy` line is the only change in every file.
gold-silver-copper added a commit that referenced this pull request Sep 13, 2026
…ers hoisted

`tests/common/ecs_matrix/faults.rs`: every way a run ends badly as a
`Cell` over the same `Program` table plus the `Fault` it drives and the
`Scene` it saves. The world driver parks a tool or a stream at a cut the
happy paths have no hook for, asserts the failure's facts beside the
record, saves and loads (or is refused) where CONTRACT §13 says; the
producer runs to a failing ending; `run_scripted` runs both over the same
scripted frames and compares them by the golden comparison's own
normalisation (`ecs_goldens::assert_parity`); a cell over another cell's
recording compares to that cell's golden (`compare_to_original`). The
#2490 stream-fault helpers become `SseShape` in
`tests/common/stream_faults.rs` (each wire family's terminal, deltas and
in-band error frame; CRLF-delimited recordings read like LF ones) and the
OpenAI and Gemini cells call them. Row 11's cancel-at-cut driver
(`extra::cancel_at`) generalises the #2501 first-delta cell.
gold-silver-copper added a commit that referenced this pull request Sep 13, 2026
The producers (corpus_faults*.rs: four recordings, the setup 4xx streamed
reuse) and the world cells (ecs_faults*.rs: recorded, reused, scripted
from this wire's #2501 recordings, world-only), with the frames'
provenance, the wire's models and its #[ignore] reasons.
gold-silver-copper added a commit that referenced this pull request Sep 13, 2026
The producers (corpus_faults*.rs: four recordings, the setup 4xx streamed
reuse) and the world cells (ecs_faults*.rs: recorded, reused, scripted
from this wire's #2501 recordings, world-only), with the frames'
provenance, the wire's models and its #[ignore] reasons.
gold-silver-copper added a commit that referenced this pull request Sep 13, 2026
The producers (corpus_faults*.rs: four recordings, the setup 4xx streamed
reuse) and the world cells (ecs_faults*.rs: recorded, reused, scripted
from this wire's #2501 recordings, world-only), with the frames'
provenance, the wire's models and its #[ignore] reasons.
gold-silver-copper added a commit that referenced this pull request Sep 13, 2026
The producers (corpus_faults*.rs: four recordings, the setup 4xx streamed
reuse) and the world cells (ecs_faults*.rs: recorded, reused, scripted
from this wire's #2501 recordings, world-only), with the frames'
provenance, the wire's models and its #[ignore] reasons.
gold-silver-copper added a commit that referenced this pull request Sep 13, 2026
The producers (corpus_faults*.rs: four recordings, the setup 4xx streamed
reuse) and the world cells (ecs_faults*.rs: recorded, reused, scripted
from this wire's #2501 recordings, world-only), with the frames'
provenance, the wire's models and its #[ignore] reasons.
pull Bot pushed a commit to appelgriebsch/rig that referenced this pull request Sep 13, 2026
…etryable (0xPlaygrounds#2502)

* fix(core): restore observe::scrub_diagnostic and diagnostic_url_secrets for hosts that persist diagnostics

0xPlaygrounds#2499 removed both as having no callers. The caller is rigcoder, the
out-of-tree consumer of rig-ecs: its failure records and checkpoint
artifacts scrub their own diagnostics with the adapter's rules, and the
scrub module is private, so nothing else reaches it. Restored as thin
wrappers with a test.

* fix(core): a stream cut before its terminal record is retryable

A truncation is a transport fault, not a provider verdict: the same
request is served whole on the next call, as a reset connection is. The
report from serve::stream_truncated is now retryable, so a run with
budget re-issues the completion (rig-ecs CONTRACT §5) instead of ending
Failed(Provider) on a dropped connection. Pinned by a rig-ecs test whose
first stream closes without a terminal record and whose retry answers.

Found moving rigcoder's pin: its session used to retry truncations by
matching the message text; with the run-level retry the classification
has to be typed.

* test(gemini): the truncation failure test declines the provider-retry budget

A truncation is retryable now, so under the default budget the run
re-issues the completion; this test pins the failure's classification
and the kept prefix, and rig-ecs pins the retry.

* test(openai): the two truncation failure tests decline the provider-retry budget

Same as the gemini one: a truncation is retryable now, so under the
default budget the run re-issues the completion; these pin the failure
and the kept prefix, and rig-ecs pins the retry.

* test(rig-verify): regenerate the five-wire goldens for the provider-retry policy identity

0xPlaygrounds#2501 added the OpenAI Chat, OpenAI Responses, Gemini, DeepSeek,
Doubleword and Venice ECS goldens with a policy hash from before 0xPlaygrounds#2500
put ProviderRetries into the identity, so main's own stable/test fails
on them. Regenerated in replay mode: 491 files, only the hash in each.
pull Bot pushed a commit to appelgriebsch/rig that referenced this pull request Sep 13, 2026
…ble statuses, truncated and error-bearing streams, refusals, failing tools, stops, scenes (0xPlaygrounds#2503)

* fix(cassette): placeholder OAuth token fields at record time

`access_token`, `id_token` and `refresh_token` join the sensitive
string keys the scrubber placeholders on record, beside the generated
ids and the server-tool locators. No committed cassette records a token
exchange (the ChatGPT and Copilot recordings hold completion paths
only); the keys are in the table so one never can. Found by the
pydantic-ai harness comparison; the other three practices it named
(unplayed interactions fail the test, record-time header scrubbing, path
normalisation) were already enforced or moot, see the ledger.

* fix(core): the error report's code is the body's own

`ErrorReport.code` was the transport's machine code alone (a gRPC status
name, an AWS exception type), so every HTTP wire's 4xx reported `None`
while the body named `model_not_found`, `NOT_FOUND` or
`authentication_error`. `ProviderResponseError::machine_code` is the
transport's code when it gave one apart from the body, else the string
the body names under `error.code`, `error.status` or `error.type`
(`provider_response::body_code`; a numeric `code` is the status said
again and is skipped; a prose envelope names none). Every report
conversion that carries a provider reply (completion, embedding, rerank)
reads it. CONTRACT §5 gains the row. Goldens regenerated: the four
`anthropic_outcome_model_error{,_streamed}` (root and ecs_parity),
`code: null` → `"authentication_error"`. The six `ErrorProbe` cells
pin each wire's code (Venice's envelope is prose: `None`).

* test(ecs): the 0xPlaygrounds#2501 native goldens carry the retry budget in their policy hash (0xPlaygrounds#2500)

0xPlaygrounds#2500 (724dce2) added `provider_retries` to the effective policy
`rig_ecs::replay::spec_json` hashes into a run's program identity. 0xPlaygrounds#2501
was based and CI-checked before it, so its 491 `ecs_parity` goldens carry
the hash without the field and every `ecs_matrix*` world cell diverges on
`main` at 258377b (a clean checkout fails them; the ledger names five).
Regenerated: the `policy` line is the only change in every file.

* fix(ecs): an answerless turn the provider cut short fails the run as rig-agent does

A turn with no text, no call and no image that stopped with a truncating
finish reason (`Length`, `ContentFilter`) settled the world's run with an
empty answer; rig-agent fails it (rig#2322). `turn_delivered_no_answer`
moves from rig-agent's transcript to `rig_core::completion::message`
(rig-agent imports it there), `FinishReason::no_answer_message` carries the
one wording, and `Materialise` fails the run `Provider(report)` with
`kind: response` before reading such a turn. CONTRACT §4 gains the row;
every `ecs_faults*::filtered_empty` cell is the regression. The four
DeepSeek `ecs_truncation` cells accept the failure their runner twins
already accept (an error that is not the provider's reply).

* test(ecs): the failure rows' cells and drivers, the stream-fault helpers hoisted

`tests/common/ecs_matrix/faults.rs`: every way a run ends badly as a
`Cell` over the same `Program` table plus the `Fault` it drives and the
`Scene` it saves. The world driver parks a tool or a stream at a cut the
happy paths have no hook for, asserts the failure's facts beside the
record, saves and loads (or is refused) where CONTRACT §13 says; the
producer runs to a failing ending; `run_scripted` runs both over the same
scripted frames and compares them by the golden comparison's own
normalisation (`ecs_goldens::assert_parity`); a cell over another cell's
recording compares to that cell's golden (`compare_to_original`). The
0xPlaygrounds#2490 stream-fault helpers become `SseShape` in
`tests/common/stream_faults.rs` (each wire family's terminal, deltas and
in-band error frame; CRLF-delimited recordings read like LF ones) and the
OpenAI and Gemini cells call them. Row 11's cancel-at-cut driver
(`extra::cancel_at`) generalises the 0xPlaygrounds#2501 first-delta cell.

* test(ecs): the failure rows on the openai wire

The producers (corpus_faults*.rs: four recordings, the setup 4xx streamed
reuse) and the world cells (ecs_faults*.rs: recorded, reused, scripted
from this wire's 0xPlaygrounds#2501 recordings, world-only), with the frames'
provenance, the wire's models and its #[ignore] reasons.

* test(ecs): the failure rows on the gemini wire

The producers (corpus_faults*.rs: four recordings, the setup 4xx streamed
reuse) and the world cells (ecs_faults*.rs: recorded, reused, scripted
from this wire's 0xPlaygrounds#2501 recordings, world-only), with the frames'
provenance, the wire's models and its #[ignore] reasons.

* test(ecs): the failure rows on the deepseek wire

The producers (corpus_faults*.rs: four recordings, the setup 4xx streamed
reuse) and the world cells (ecs_faults*.rs: recorded, reused, scripted
from this wire's 0xPlaygrounds#2501 recordings, world-only), with the frames'
provenance, the wire's models and its #[ignore] reasons.

* test(ecs): the failure rows on the doubleword wire

The producers (corpus_faults*.rs: four recordings, the setup 4xx streamed
reuse) and the world cells (ecs_faults*.rs: recorded, reused, scripted
from this wire's 0xPlaygrounds#2501 recordings, world-only), with the frames'
provenance, the wire's models and its #[ignore] reasons.

* test(ecs): the failure rows on the venice wire

The producers (corpus_faults*.rs: four recordings, the setup 4xx streamed
reuse) and the world cells (ecs_faults*.rs: recorded, reused, scripted
from this wire's 0xPlaygrounds#2501 recordings, world-only), with the frames'
provenance, the wire's models and its #[ignore] reasons.

* test(ecs): catalog the failure rows' scenarios

36 recorded producers mapped to their world cells; the scripted and
world-only cells are native-only, as the 0xPlaygrounds#2490 cells are; the files
inventory gains the sources and goldens. The world-replay corpus count
follows the 36 new producer goldens.
gold-silver-copper added a commit that referenced this pull request Sep 13, 2026
Every native golden carries the hash of the agent's effective policy
(`rig_ecs::replay::spec_json`); a field added there changes every native
golden at once. #2500 added `provider_retries`, #2501 was based before it
and merged green on its own base, and main failed 491 world cells until
#2503 regenerated them. The parity README says what a PR that touches
`spec_json` does, what a PR based before one does, and why no static check
can recompute the hash from a header.
gold-silver-copper added a commit that referenced this pull request Sep 13, 2026
Every native golden carries the hash of the agent's effective policy
(`rig_ecs::replay::spec_json`); a field added there changes every native
golden at once. #2500 added `provider_retries`, #2501 was based before it
and merged green on its own base, and main failed 491 world cells until
#2503 regenerated them. The parity README says what a PR that touches
`spec_json` does, what a PR based before one does, and why no static check
can recompute the hash from a header.
gold-silver-copper added a commit that referenced this pull request Sep 13, 2026
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.
gold-silver-copper added a commit that referenced this pull request Sep 13, 2026
…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.
yermakoffivan pushed a commit to yermakoffivan/rig that referenced this pull request Sep 13, 2026
…ens as a named lane (0xPlaygrounds#2508)

* ci: run the slow lanes wherever the plan job does not

`test-all-features` and `dependency-floors` depend on `plan`, which runs
on pull requests only. Their `if` had no status function, so the implicit
`success()` failed on a skipped `plan` and both lanes were skipped on the
merge queue, on every push to main (cd.yaml's run-slow) and on the nightly
schedule: every recorded non-PR run shows them skipped. `!cancelled()`
ahead of the explicit result check lets the intended condition decide.

* ci: the ECS parity lane

`cargo xtask verify --check ecs-parity`: every producer and world cell of
the agent/ECS matrices (`corpus_*` / `ecs_*` on the root provider targets
under `--features bedrock`), the golden pairing guard, and rig-verify's
replay of every golden through a world; a `stable / ecs parity` CI job;
selected by every --pr plan and by a fixture change in --changed. A subset
of `test` by construction, named so the merge queue can require it and a
failure reads as what it is: the runtimes disagree, or a golden is stale.

* test(ecs): name the policy-hash trap

Every native golden carries the hash of the agent's effective policy
(`rig_ecs::replay::spec_json`); a field added there changes every native
golden at once. 0xPlaygrounds#2500 added `provider_retries`, 0xPlaygrounds#2501 was based before it
and merged green on its own base, and main failed 491 world cells until
0xPlaygrounds#2503 regenerated them. The parity README says what a PR that touches
`spec_json` does, what a PR based before one does, and why no static check
can recompute the hash from a header.

* test(ecs): the despawn cell compares up to the first text delta

`despawning_the_stream_at_the_first_delta_records_a_cancel` compared the
witnessed and plain runs' whole logs. The despawn lands on the tick that
collected the first text delta, and how many deltas that tick collected
is scheduling: on a loaded runner the plain run kept one delta more than
the witnessed one and the parity lane failed on 0xPlaygrounds#2509 with no divergence.
The two records are compared up to the first text delta, which is the
fact the cell pins; what a cancelled record kept after it is not one.
yermakoffivan pushed a commit to yermakoffivan/rig that referenced this pull request Sep 13, 2026
…is a refusal, a truncated reasoning-only turn commits nothing, images ride the regrouped stream (0xPlaygrounds#2509) (0xPlaygrounds#2510)

* 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 0xPlaygrounds#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 0xPlaygrounds#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.
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