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();