From 8be91bb556007d8b1a156041b94eaa2bb083abc6 Mon Sep 17 00:00:00 2001 From: BP <11394934+benjipeng@users.noreply.github.com> Date: Wed, 23 Sep 2026 13:43:24 -0400 Subject: [PATCH] fix(provider): keep carried pieces apart in Chat, so a switched-from thought never runs into its answer A model switch carries a finished thought over as text. Messages, Responses and Gemini give each carried piece its own block, but Chat's assistant content is one string, and its degraded encoder appended the pieces with nothing between them: the next model read "...in one sentence.Muse Spark answer 1". Found driving stage 28 in a terminal against two loopback models. Carried pieces now sit a blank line apart. The user chose that over a list of text parts, which some compatible gateways refuse on an assistant message. --- .agents/evidence/provider-adapter.md | 2 +- .agents/specs/provider-adapter.md | 12 +++++++---- crates/plexmaton-provider/src/chat/request.rs | 13 ++++++++---- .../tests/provider_fixtures.rs | 21 +++++++++++++++++++ 4 files changed, 39 insertions(+), 9 deletions(-) diff --git a/.agents/evidence/provider-adapter.md b/.agents/evidence/provider-adapter.md index 5e14caa..9630173 100644 --- a/.agents/evidence/provider-adapter.md +++ b/.agents/evidence/provider-adapter.md @@ -8,7 +8,7 @@ resolves every one against the source, so a renamed or deleted test fails the bu | --- | --- | | PRV-1 | `cin_3_every_codec_renders_collaboration_with_its_sender_named`, `prv_1_collaboration_bodies_cannot_forge_their_own_envelope`, `prv_1_chat_fixture_drives_a_full_stateless_tool_round_trip`, `prv_3_responses_fixture_replays_encrypted_reasoning_exactly_and_round_trips_tools`, `prv_1_both_protocols_preserve_parallel_call_and_result_order`, `prv_1_chat_refuses_cross_kind_order_its_wire_cannot_represent`, `prv_1_protocol_selection_never_falls_back_across_replay_grammars`, `native_catalog_is_exact_unique_and_advertised_by_both_protocols`, `prv_1_messages_signed_tool_round_trip_normalizes_cumulative_usage`, `prv_1_gemini_tool_round_trip_preserves_signatures_and_counts_thoughts` | | PRV-2 | Both fixture round trips, `prv_2_production_tool_argument_limit_remains_64_kibibytes`, `parallel_calls_share_one_aggregate_argument_bound`, `reverse_parallel_call_completion_is_sorted_before_dispatch`, `prv_2_both_protocols_bound_tool_identity_before_emission`, `assistant_output_rechecks_aggregate_text_and_tool_identity_bounds`, `oversized_semantic_text_fails_before_canonical_commit`, `maximal_valid_assistant_output_fits_the_journal_line_envelope`, `prv_2_rejects_duplicate_chat_tool_call_ids_before_emission`, `prv_2_rejects_incremental_duplicate_responses_tool_call_ids`, `prv_2_responses_counts_incrementally_completed_calls_toward_the_step_bound`, `prv_2_rejects_tool_arguments_before_they_can_reach_admission`, `prv_2_bounds_even_empty_responses_output_items`, `prv_2_responses_text_done_confirms_deltas_or_supplies_the_only_copy`, `prv_2_stopped_is_withheld_until_the_stream_trailer_is_valid`, `prv_2_sse_framing_rejects_partial_utf8`, `prv_2_messages_rejects_unfinished_mismatched_and_oversized_blocks`, `prv_2_gemini_call_ids_are_scoped_and_parallel_signatures_keep_their_part`, `prv_2_gemini_rejects_incomplete_duplicate_and_oversized_content`, `server_tool_calls_share_the_tool_argument_bound` | -| PRV-3 | `prv_3_responses_fixture_replays_encrypted_reasoning_exactly_and_round_trips_tools`, `prv_3_any_of_the_four_compatibility_axes_degrades_rather_than_refusing`, `prv_3_a_degraded_call_and_its_result_keep_one_identity`, `prv_3_a_degraded_gemini_call_carries_neither_signature_nor_upstream_id`, `prv_3_an_output_that_was_only_its_replay_degrades_to_no_message`, `prv_3_an_id_every_dialect_accepts_is_carried_through_unchanged`, `prv_3_an_id_the_destination_would_reject_becomes_one_it_accepts`, `prv_3_distinct_rewritten_ids_stay_distinct`, `prv_3_replay_route_owner_encoding_is_unambiguous`, `prv_3_rejects_opaque_replay_before_step_state_can_grow`, `assistant_output_round_trips_order_and_redacts_replay`, `replay_only_wire_without_a_payload_is_rejected`, `reasoning_and_opaque_replay_survive_interrupt_without_sharing_presentation`, `provider_replay_is_named_and_bounded_before_turn_state_can_retain_it`, `prv_3_responses_phase_and_parts_survive_jsonl_reopen`, `prv_3_native_tool_conversations_survive_jsonl_reopen`, `prv_3_cancelled_calls_leave_no_dangling_replay`, `prv_3_chat_reasoning_aliases_round_trip_without_renaming`, `prv_3_chat_accepts_reasoning_details_that_only_restate_the_plain_delta`, `prv_3_gemini_signature_only_and_early_signature_parts_are_replayable`, `prv_3_interrupted_unsigned_reasoning_allows_durable_continuation`, `prv_3_unsigned_reasoning_in_a_tool_turn_is_refused_by_messages_and_omitted_by_responses`, `prv_5_responses_web_search_call_is_carried_as_a_server_tool_call` | +| PRV-3 | `prv_3_responses_fixture_replays_encrypted_reasoning_exactly_and_round_trips_tools`, `prv_3_any_of_the_four_compatibility_axes_degrades_rather_than_refusing`, `prv_3_chat_keeps_carried_pieces_apart_with_a_blank_line`, `prv_3_a_degraded_call_and_its_result_keep_one_identity`, `prv_3_a_degraded_gemini_call_carries_neither_signature_nor_upstream_id`, `prv_3_an_output_that_was_only_its_replay_degrades_to_no_message`, `prv_3_an_id_every_dialect_accepts_is_carried_through_unchanged`, `prv_3_an_id_the_destination_would_reject_becomes_one_it_accepts`, `prv_3_distinct_rewritten_ids_stay_distinct`, `prv_3_replay_route_owner_encoding_is_unambiguous`, `prv_3_rejects_opaque_replay_before_step_state_can_grow`, `assistant_output_round_trips_order_and_redacts_replay`, `replay_only_wire_without_a_payload_is_rejected`, `reasoning_and_opaque_replay_survive_interrupt_without_sharing_presentation`, `provider_replay_is_named_and_bounded_before_turn_state_can_retain_it`, `prv_3_responses_phase_and_parts_survive_jsonl_reopen`, `prv_3_native_tool_conversations_survive_jsonl_reopen`, `prv_3_cancelled_calls_leave_no_dangling_replay`, `prv_3_chat_reasoning_aliases_round_trip_without_renaming`, `prv_3_chat_accepts_reasoning_details_that_only_restate_the_plain_delta`, `prv_3_gemini_signature_only_and_early_signature_parts_are_replayable`, `prv_3_interrupted_unsigned_reasoning_allows_durable_continuation`, `prv_3_unsigned_reasoning_in_a_tool_turn_is_refused_by_messages_and_omitted_by_responses`, `prv_5_responses_web_search_call_is_carried_as_a_server_tool_call` | | PRV-4 | `prv_3_responses_fixture_replays_encrypted_reasoning_exactly_and_round_trips_tools` proves stateless full-record replay without a response ID, `prv_3_responses_phase_and_parts_survive_jsonl_reopen`, `prv_3_native_tool_conversations_survive_jsonl_reopen`, `prv_3_interrupted_unsigned_reasoning_allows_durable_continuation` | | PRV-5 | `http_rate_limit_is_typed_and_keeps_retry_after`, `context_error_is_classified_by_wire_code`, `prv_5_responses_done_only_refusal_is_visible_and_typed`, and both fixture completion reasons, `prv_5_chat_rejects_structured_or_conflicting_reasoning`, `prv_5_messages_context_limits_and_refusals_keep_final_usage`, `native_stream_rate_limits_are_typed`, `gemini_declared_failures_preserve_diagnostics_without_dispatching_calls`, `messages_usage_refuses_unaccounted_server_tools`, `prv_5_gemini_policy_and_unsupported_image_finishes_are_distinct`, `stream_provider_errors_keep_their_category_and_observed_usage`, `dispatched_http_failures_preserve_their_terminal_measurements`, `provider_failure_reopens_as_the_same_non_retryable_outcome`, `prv_5_empty_additive_fields_preserve_text_and_populated_fields_fail`, `prv_5_chat_admits_gateway_accounting_and_still_refuses_everything_else`, `prv_5_messages_pause_turn_is_explicitly_unsupported`, `prv_5_responses_web_search_call_is_carried_as_a_server_tool_call`, `prv_5_an_unknown_server_tool_action_fails_the_step`, `prv_5_a_server_tool_call_without_an_action_fails_the_step`, `prv_5_a_search_without_a_query_is_carried_and_a_repeated_query_is_kept_once`, `prv_5_a_server_tool_calls_outcome_is_typed_from_its_status`, `a_call_still_running_when_the_step_ends_is_shown_failed_and_kept_out_of_the_record` | | PRV-6 | `prv_6_allowed_efforts_are_model_local_and_do_not_invent_capabilities`, `prv_6_invalid_effort_catalogs_and_defaults_fail_closed`, `prv_6_effort_catalogs_cannot_enable_unencodable_levels`, `prv_6_one_provider_resolves_two_exact_models_without_repeating_authority`, `prv_6_selection_and_every_model_fail_closed_before_network_work`, `prv_6_inline_authority_and_legacy_profiles_are_not_a_second_config_path`, `prv_6_resolution_rejects_unsafe_routes_without_echoing_them`, `prv_6_resolves_only_an_override_or_the_user_root`, `prv_6_key_resolution_is_explicit_and_redacted`, `prv_6_inline_key_is_used_when_its_environment_variable_is_absent`, `cmd_2_selected_api_key_environment_is_removed_even_without_credential_shape`, `prv_6_standard_requests_omit_unspecified_reasoning_and_encode_instructions`, `prv_6_native_thinking_options_are_explicit_and_validated`, `prv_6_gemini_38_requests_use_only_frontier_thinking_controls`, `prv_6_messages_cache_and_thinking_match_native_request_contract`, `prv_6_provider_example_selects_each_documented_dialect`, `native_messages_http_uses_api_key_headers_and_terminal_usage`, `completed_messages_without_thinking_breakdown_keep_final_cost`, `native_gemini_http_uses_versioned_endpoint_and_header_authority` ; [SKL-1](./agent-skills.md), `prv_6_hosted_tools_are_declared_per_model_and_absent_by_default`, `prv_6_hosted_tool_declarations_fail_closed`, `prv_6_hosted_tool_declarations_need_a_dialect_spelling`, `prv_6_declared_hosted_tools_are_spelled_by_the_dialect_and_absent_otherwise`, `the_tool_name_is_its_configuration_spelling` | diff --git a/.agents/specs/provider-adapter.md b/.agents/specs/provider-adapter.md index 9299e27..4e21a57 100644 --- a/.agents/specs/provider-adapter.md +++ b/.agents/specs/provider-adapter.md @@ -56,11 +56,15 @@ readable by the next model; an interrupted one is left out, half a sentence attr saying what the model never said, and a sidecar is what tells the two apart; a call keeps its name and arguments, drops every shape legal only beside the replay that authenticates it — Gemini's `thought` flag, any upstream id — and takes an id every dialect accepts, applied to the call and to -its result or to neither; a replay-only block is dropped, having been nothing but its sidecar. A -reply carrying nothing is not sent as an empty one. Sidecars stay in the record, so the model that -wrote them replays them exactly when selected again, and occupancy is measured for what ships. +its result or to neither; a replay-only block is dropped, having been nothing but its sidecar. +Carried pieces stay apart: each is its own block where the dialect has blocks, and Chat, whose +assistant content is one string, puts a blank line between them, so a thought never reads as the +start of the answer. A reply carrying nothing is not sent as an empty one. Sidecars stay in the +record, so the model that wrote them replays them exactly when selected again, and occupancy is +measured for what ships. Rejected: dropping foreign reasoning outright, cheaper and free of the unsigned-thought hazard, but -discarding a finished chain of reasoning the next model could have used. +discarding a finished chain of reasoning the next model could have used; and Chat content as a list +of text parts, which some compatible gateways refuse on an assistant message. Unsigned reasoning — a reply interrupted before its own replay existed — stays in the journal and presentation, and wire encoders omit diff --git a/crates/plexmaton-provider/src/chat/request.rs b/crates/plexmaton-provider/src/chat/request.rs index fd31e06..fce9d5a 100644 --- a/crates/plexmaton-provider/src/chat/request.rs +++ b/crates/plexmaton-provider/src/chat/request.rs @@ -163,10 +163,15 @@ fn degraded_assistant(carried: &[Carried<'_>]) -> Option { let mut pending = PendingAssistant::default(); for block in carried { match block { - Carried::Text(text) => pending - .content - .get_or_insert_with(String::new) - .push_str(text), + // Chat's assistant content is one string, so carried pieces are kept apart by a blank + // line rather than run together: a thought must not read as the start of the answer. + Carried::Text(text) => { + let content = pending.content.get_or_insert_with(String::new); + if !content.is_empty() { + content.push_str("\n\n"); + } + content.push_str(text); + } Carried::Call(call) => pending.calls.push(json!({ "id": degrade::atom_call_id(true, &call.call_id), "type": "function", diff --git a/crates/plexmaton-provider/tests/provider_fixtures.rs b/crates/plexmaton-provider/tests/provider_fixtures.rs index 4dff32b..28793a5 100644 --- a/crates/plexmaton-provider/tests/provider_fixtures.rs +++ b/crates/plexmaton-provider/tests/provider_fixtures.rs @@ -508,6 +508,27 @@ fn prv_3_any_of_the_four_compatibility_axes_degrades_rather_than_refusing() { } } +/// PRV-3: Chat's assistant content is one string, so the pieces a degraded reply carries are kept +/// apart by a blank line. Run together, a finished thought reads as the opening of the answer. +#[test] +fn prv_3_chat_keeps_carried_pieces_apart_with_a_blank_line() { + let selected = profile(ModelApi::OpenaiChatCompletions); + let request = degradable_request(foreign_compatibility()); + let encoded = encode_request(&selected, &request, &[], None) + .unwrap_or_else(|error| panic!("a foreign replay must degrade, not refuse: {error}")); + let assistant: Vec<_> = encoded["messages"] + .as_array() + .unwrap_or_else(|| panic!("Chat messages are an array")) + .iter() + .filter(|message| message["role"] == "assistant") + .collect(); + assert_eq!(assistant.len(), 1, "one reply stays one message"); + assert_eq!( + assistant[0]["content"], "Visible answer.\n\nA finished thought.", + "each carried piece is its own paragraph" + ); +} + /// A compatibility no supported dialect owns, so every one of them has to degrade. fn foreign_compatibility() -> ReplayCompatibility { let expected = profile(ModelApi::OpenaiResponses).replay_compatibility();