From 12175b8ea2a7ea1cbdf33f3832680679c07ff917 Mon Sep 17 00:00:00 2001 From: salty-flower <95509524+salty-flower@users.noreply.github.com> Date: Sat, 31 Oct 2026 01:36:54 +0800 Subject: [PATCH] fix(opencode): keep mid-conversation system messages in place Claude Code appends a system message mid-conversation on most turns (output-style notices, hook context, environment updates). The Chat Completions translator hoisted each one into the leading system message, so the start of the prompt changed on every request and the upstream prefix cache could only reuse the part before it: Claude Code sessions on OpenCode Go hit about 8% cache, against 97% for the same model from OpenCode itself. Each such message now stays at its position as a user message wrapped in , so the upstream prompt is append-only. It is not kept as a system message because chat templates such as DeepSeek's gather every system message to the front, which would void the cache again. A reminder that lands between an assistant tool call and its results waits until those results are in place. Co-Authored-By: Claude Opus 5.5 --- src/providers/opencode/chat.rs | 121 +++++++++++++++++++++++++++++---- 1 file changed, 107 insertions(+), 14 deletions(-) diff --git a/src/providers/opencode/chat.rs b/src/providers/opencode/chat.rs index e120c4da..516f7167 100644 --- a/src/providers/opencode/chat.rs +++ b/src/providers/opencode/chat.rs @@ -80,12 +80,19 @@ pub fn prepare_request(req: &MessagesRequest, model: &str) -> anyhow::Result anyhow::Result> { let deepseek = model.to_ascii_lowercase().contains("deepseek"); - let mut system = Vec::new(); + let mut messages = Vec::new(); if let Some(text) = flatten_system_text(req.extra.get("system")) { - system.push(text); + messages.push(json!({"role": "system", "content": text})); } - let mut messages = Vec::new(); + // Claude Code appends a system message mid-conversation on most turns. + // Hoisting it into the leading system message rewrote the prompt's start + // on every request and voided the upstream prefix cache, so it stays at + // its own position as a user message: chat templates such as DeepSeek's + // gather every system message to the front, which would void it again. + // A reminder that lands between an assistant tool call and its results + // waits until those results are in place. + let mut pending_reminders = Vec::new(); for message in &req.messages { let blocks = normalize_content(&message.content, json!({})); match message.role.as_str() { @@ -99,7 +106,7 @@ fn build_messages(req: &MessagesRequest, model: &str) -> anyhow::Result>() .join("\n"); if !text.is_empty() { - system.push(text); + pending_reminders.push(reminder_message(&text)); } } "user" => push_user_messages(&mut messages, &blocks), @@ -110,20 +117,33 @@ fn build_messages(req: &MessagesRequest, model: &str) -> anyhow::Result anyhow::bail!("unexpected message role: {other}"), } + if !awaits_tool_results(messages.last()) { + messages.append(&mut pending_reminders); + } } - - if !system.is_empty() { - messages.insert( - 0, - json!({ - "role": "system", - "content": system.join("\n\n"), - }), - ); - } + messages.append(&mut pending_reminders); Ok(messages) } +fn reminder_message(text: &str) -> Value { + let content = if text.trim_start().starts_with("") { + text.to_string() + } else { + format!("\n{text}\n") + }; + json!({"role": "user", "content": content}) +} + +fn awaits_tool_results(last: Option<&Value>) -> bool { + last.is_some_and(|message| { + message.get("role").and_then(Value::as_str) == Some("assistant") + && message + .get("tool_calls") + .and_then(Value::as_array) + .is_some_and(|calls| !calls.is_empty()) + }) +} + fn push_user_messages(messages: &mut Vec, blocks: &[ContentBlock]) { let mut content = Vec::new(); let mut tool_messages = Vec::new(); @@ -1422,6 +1442,79 @@ mod tests { ); } + #[test] + fn mid_conversation_system_messages_keep_the_upstream_prompt_append_only() { + let turn_one = json!([ + {"role":"user","content":"start"}, + {"role":"assistant","content":[ + {"type":"tool_use","id":"call_1","name":"Read","input":{"file_path":"a"}} + ]}, + {"role":"user","content":[{"type":"tool_result","tool_use_id":"call_1","content":"a"}]}, + {"role":"system","content":"first reminder"} + ]); + let mut turn_two = turn_one.as_array().unwrap().clone(); + turn_two.extend([ + json!({"role":"assistant","content":[{"type":"text","text":"done"}]}), + json!({"role":"user","content":"next"}), + json!({"role":"system","content":"second reminder"}), + ]); + let wire = |messages: Value| { + let req = request(json!({"system":"base", "messages":messages})); + serde_json::to_value(prepare_request(&req, "deepseek-v4.1-flash").unwrap()).unwrap() + ["messages"] + .as_array() + .unwrap() + .clone() + }; + + let first = wire(turn_one); + let second = wire(Value::Array(turn_two)); + assert_eq!(first[0], json!({"role":"system","content":"base"})); + assert_eq!(second[..first.len()], first[..]); + assert_eq!( + first.last().unwrap(), + &json!({"role":"user","content":"\nfirst reminder\n"}) + ); + assert_eq!( + second + .iter() + .filter(|message| message["role"] == "system") + .count(), + 1 + ); + } + + #[test] + fn reminder_between_a_tool_call_and_its_result_waits_for_the_result() { + let req = request(json!({ + "messages":[ + {"role":"assistant","content":[ + {"type":"tool_use","id":"call_1","name":"Bash","input":{"command":"pwd"}} + ]}, + {"role":"system","content":"already tagged"}, + {"role":"user","content":[ + {"type":"tool_result","tool_use_id":"call_1","content":"/tmp"}, + {"type":"text","text":"and then"} + ]} + ] + })); + let wire = serde_json::to_value(prepare_request(&req, "glm-5.2").unwrap()).unwrap(); + let messages = wire["messages"].as_array().unwrap(); + assert_eq!( + messages + .iter() + .map(|message| message["role"].as_str().unwrap()) + .collect::>(), + ["assistant", "tool", "user", "user"] + ); + assert_eq!(messages[1]["tool_call_id"], "call_1"); + assert_eq!(messages[2]["content"], "and then"); + assert_eq!( + messages[3]["content"], + "already tagged" + ); + } + #[test] fn deepseek_replay_always_has_reasoning_content() { let req = request(json!({