@@ -12202,7 +12202,7 @@ fn attach_native_messages_with_tags(
1220212202 return;
1220312203 }
1220412204 let native_messages = encode_full_native_messages(
12205- response.messages() ,
12205+ response,
1220612206 request,
1220712207 reasoning_watermark,
1220812208 tag_numbers,
@@ -12388,14 +12388,16 @@ fn native_reasoning_should_clear(
1238812388}
1238912389
1239012390fn encode_full_native_messages(
12391- served : &[ transform::ServedMessage] ,
12391+ response : &transform::TransformResponse ,
1239212392 request: &TransformRequest,
1239312393 reasoning_watermark: u64,
1239412394 tag_numbers: &BTreeMap<String, u64>,
1239512395 mutation_exempt_mid: Option<&str>,
1239612396 lineage_anchor_mid: Option<&str>,
1239712397 transition_consumed: bool,
1239812398) -> Vec<Value> {
12399+ let served = response.messages();
12400+ let native_reasoning_keep_mids = &response.native_reasoning_keep_mids;
1239912401 let sidecar = request
1240012402 .native_messages
1240112403 .as_deref()
@@ -12433,9 +12435,30 @@ fn encode_full_native_messages(
1243312435 tag_numbers,
1243412436 );
1243512437 }
12438+ replay_native_reasoning_keeps(&mut native_messages, &sidecar, native_reasoning_keep_mids);
1243612439 native_messages
1243712440}
1243812441
12442+ fn replay_native_reasoning_keeps(
12443+ messages: &mut [Value],
12444+ sidecar: &codec::DecodeSidecar,
12445+ keep_mids: &[String],
12446+ ) {
12447+ for message in messages {
12448+ let Some(mid) = message["info"]["id"].as_str() else {
12449+ continue;
12450+ };
12451+ if !keep_mids.iter().any(|keep| keep == mid) {
12452+ continue;
12453+ }
12454+ if let Some(meta) = sidecar.messages.get(mid) {
12455+ if let Some(parts) = meta.raw.get("parts") {
12456+ message["parts"] = parts.clone();
12457+ }
12458+ }
12459+ }
12460+ }
12461+
1243912462fn native_ingress_chunks(
1244012463 request: &TransformRequest,
1244112464 encoded_chunks: &[NativeEncodedChunk],
@@ -12599,7 +12622,13 @@ fn attach_native_messages_incremental(
1259912622 }
1260012623 Some(hash)
1260112624 });
12602- let mutation_exempt = slot.is_some_and(|mid| mutation_exempt_mids.contains(&mid));
12625+ let mutation_exempt = slot.is_some_and(|mid| {
12626+ mutation_exempt_mids.contains(&mid)
12627+ || response
12628+ .native_reasoning_keep_mids
12629+ .iter()
12630+ .any(|keep| keep == mid)
12631+ });
1260312632 let reasoning_exempt = slot.is_some_and(|mid| Some(mid) == newest_assistant_mid);
1260412633 let (tag_number, reasoning_should_clear) = native_reasoning_should_clear(
1260512634 served,
@@ -12713,6 +12742,11 @@ fn attach_native_messages_incremental(
1271312742 tag_numbers,
1271412743 );
1271512744 }
12745+ replay_native_reasoning_keeps(
12746+ &mut suffix_values,
12747+ &sidecar,
12748+ &response.native_reasoning_keep_mids,
12749+ );
1271612750 chunks.extend(
1271712751 encoded_suffix
1271812752 .into_iter()
@@ -12734,7 +12768,7 @@ fn attach_native_messages_incremental(
1273412768
1273512769 if native_attachment_differential_enabled() {
1273612770 let full = encode_full_native_messages(
12737- response.messages() ,
12771+ response,
1273812772 request,
1273912773 reasoning_watermark,
1274012774 tag_numbers,
@@ -12845,7 +12879,7 @@ fn finalize_native_messages_response(
1284512879 fallback_reason.get_or_insert(NativeDeltaFallbackReason::MissingNativeContent);
1284612880 response.native_messages = Some(
1284712881 encode_full_native_messages(
12848- response.messages() ,
12882+ response,
1284912883 request,
1285012884 reasoning_watermark,
1285112885 tag_numbers,
@@ -21708,6 +21742,103 @@ mod tests {
2170821742 .all(|block| block.synthetic));
2170921743 }
2171021744
21745+ #[tokio::test(flavor = "current_thread")]
21746+ async fn post_hard_defer_preserves_demoted_native_thinking_until_priced_pass() {
21747+ let producer = Arc::new(ProducerState::default());
21748+ let (handler, store, _dir, _project) = handler_with_store(producer, default_test_config());
21749+ let mut native = vec![
21750+ json!({"info":{"id":"user","role":"user"},"parts":[{"type":"text","text":"start"}]}),
21751+ json!({"info":{"id":"previous","role":"assistant"},"parts":[{"type":"text","text":"earlier step"}]}),
21752+ ];
21753+ let make_request = |native: &Vec<Value>, model: &str| {
21754+ let decoded = codec::decode_opencode(native);
21755+ let mut req = request(decoded.messages);
21756+ req["serializer_profile"] = json!("opencode-aisdk");
21757+ req["serve_native"] = json!(true);
21758+ req["tool_present"] = json!(true);
21759+ req["provider_id"] = json!("anthropic");
21760+ req["model_key"] = json!(model);
21761+ req["mid_turn"] = json!(false);
21762+ req["native_messages"] = json!(native);
21763+ req
21764+ };
21765+ let warm = call_transform_request(&handler, make_request(&native, "claude-opus-5")).await;
21766+ assert_eq!(warm["action"], "HARD");
21767+ let thinking = json!({"type":"reasoning","text":"signed thinking", "metadata":{"anthropic":{"signature":"fixture-signature"}}});
21768+ native.push(json!({"info":{"id":"thinking","role":"assistant"},"parts":[
21769+ thinking.clone(), {"type":"text","text":"dispatching"},
21770+ {"type":"tool","tool":"work","callID":"call-thinking","state":{"status":"completed","input":{"action":"run"},"output":"dispatched"}}
21771+ ]}));
21772+ native.push(json!({"info":{"id":"continue","role":"user"},"parts":[{"type":"text","text":"continue"}]}));
21773+ let hard =
21774+ call_transform_request(&handler, make_request(&native, "claude-fable-5-1")).await;
21775+ assert_eq!(hard["action"], "HARD");
21776+ let mut previous = hard["native_messages"].as_array().unwrap().clone();
21777+ let hash = |messages: &[Value]| -> String {
21778+ format!(
21779+ "{:x}",
21780+ Sha256::digest(serde_json::to_vec(messages).unwrap())
21781+ )
21782+ };
21783+ for step in 0..2 {
21784+ native.push(json!({"info":{"id":format!("step-{step}"),"role":"assistant"},"parts":[
21785+ {"type":"reasoning","text":format!("adaptive step {step}"),"metadata":{"anthropic":{"signature":format!("signature-{step}")}}},
21786+ {"type":"tool","tool":"work","callID":format!("call-{step}"),"state":{"status":"completed","input":{"action":"show"},"output":"done"}}
21787+ ]}));
21788+ let deferred =
21789+ call_transform_request(&handler, make_request(&native, "claude-fable-5-1")).await;
21790+ assert_eq!(deferred["action"], "SOFT+");
21791+ let served = deferred["native_messages"].as_array().unwrap();
21792+ assert!(served.len() > previous.len());
21793+ assert_eq!(
21794+ hash(&served[..previous.len()]),
21795+ hash(&previous),
21796+ "demotion must not rewrite the served prefix: {:?}",
21797+ served
21798+ .iter()
21799+ .zip(&previous)
21800+ .enumerate()
21801+ .filter(|(_, (new, old))| new != old)
21802+ .collect::<Vec<_>>()
21803+ );
21804+ let target = served
21805+ .iter()
21806+ .find(|message| message["info"]["id"] == "thinking")
21807+ .unwrap();
21808+ assert_eq!(target["parts"][0], thinking);
21809+ assert!(!store
21810+ .load("ses")
21811+ .unwrap()
21812+ .core
21813+ .frozen_units
21814+ .iter()
21815+ .any(|unit| unit.key == "strip:merged_reasoning:thinking"));
21816+ previous = served.clone();
21817+ }
21818+ let mut priced_request = make_request(&native, "claude-fable-5-1");
21819+ priced_request["render_config"] = json!("independent-priced-config-change");
21820+ let priced = call_transform_request(&handler, priced_request).await;
21821+ assert_eq!(priced["action"], "HARD");
21822+ let target = priced["native_messages"]
21823+ .as_array()
21824+ .unwrap()
21825+ .iter()
21826+ .find(|message| message["info"]["id"] == "thinking")
21827+ .unwrap();
21828+ assert!(target["parts"]
21829+ .as_array()
21830+ .unwrap()
21831+ .iter()
21832+ .all(|part| part["type"] != "reasoning"));
21833+ assert!(store
21834+ .load("ses")
21835+ .unwrap()
21836+ .core
21837+ .frozen_units
21838+ .iter()
21839+ .any(|unit| unit.key == "strip:merged_reasoning:thinking"));
21840+ }
21841+
2171121842 #[tokio::test(flavor = "current_thread")]
2171221843 async fn native_attachment_reuses_transform_tag_baseline_and_preserves_bytes() {
2171321844 let producer = Arc::new(ProducerState::default());
0 commit comments