fix(tui): preserve chat roles after compaction

Signed-off-by: zhuowp <[email protected]>
This commit is contained in:
zhuowp committed 2026-09-16 21:48:04 +08:00
1 parent 3016ef4928
commit 1f5f3a7e6b
4 files changed
+448 -21

No files matched your search

+303 -2
View File
@@ -2627,6 +2627,23 @@ fn last_chars(value: &str, count: usize) -> String {
chars.into_iter().collect()
}
fn merge_adjacent_user_content(previous: Value, current: Value) -> Value {
match (previous, current) {
(Value::String(left), Value::String(right)) => json!(format!("{left}\n\n{right}")),
(left, right) => {
let mut parts = Vec::new();
for content in [left, right] {
match content {
Value::Array(items) => parts.extend(items),
Value::String(text) => parts.push(json!({"type": "text", "text": text})),
other => parts.push(other),
}
}
Value::Array(parts)
}
}
}
fn build_chat_messages_with_reasoning(
system: Option<&SystemPrompt>,
messages: &[Message],
@@ -2652,7 +2669,53 @@ fn build_chat_messages_with_reasoning(
}));
}
for (message_index, message) in messages.iter().enumerate() {
// Persisted compaction keeps its summary after the bounded last round.
// On strict paired chat templates a user message after a tool result is
// invalid. Reorder only a generated summary immediately after a tool
// result; its independent provenance block rules out quoted user text.
// The session log retains every original message and tool ID.
// Limitation: this normalization applies to Chat Completions only.
let summary_index = messages
.iter()
.enumerate()
.rev()
.find_map(|(index, message)| {
(index > 0
&& crate::compaction::is_wire_compaction_checkpoint_message(message)
&& messages[index - 1]
.content
.iter()
.any(|block| matches!(block, ContentBlock::ToolResult { .. })))
.then_some(index)
});
let summary_target = summary_index.and_then(|summary_index| {
messages[..summary_index]
.iter()
.rposition(|message| {
crate::runtime_handoff::classify_user_turn_prompt(message)
!= crate::runtime_handoff::UserTurnPromptKind::NotPrompt
})
.or_else(|| {
messages[..summary_index]
.iter()
.position(|message| message.role.is_assistant_like())
})
});
let wire_messages = (0..messages.len())
.filter(|index| Some(*index) != summary_index || summary_target.is_none())
.flat_map(|index| {
if Some(index) == summary_target {
[summary_index, Some(index)]
.into_iter()
.flatten()
.collect::<Vec<_>>()
} else {
vec![index]
}
});
for message_index in wire_messages {
let message = &messages[message_index];
// Which wire channel this message belongs in is decided by the shared
// placement table, not by an `if` chain local to this adapter.
let placement = role_placement(&message.role, WireDialect::ChatCompletions);
@@ -2852,7 +2915,26 @@ fn build_chat_messages_with_reasoning(
if include_tool_budget_metadata && let Some(turn_meta) = &turn_meta_budget {
msg["_turn_meta_budget"] = turn_meta_budget_json(turn_meta);
}
out.push(msg);
if (Some(message_index) == summary_index
|| Some(message_index) == summary_target
|| crate::compaction::is_wire_compaction_checkpoint_message(message)
|| crate::runtime_handoff::is_agent_topology_checkpoint(message)
|| crate::runtime_handoff::is_restored_agent_topology_checkpoint(message))
&& let Some(previous) = out.last_mut()
&& previous.get("role").and_then(Value::as_str) == Some("user")
{
let previous_content = previous["content"].take();
let current_content = msg["content"].take();
previous["content"] =
merge_adjacent_user_content(previous_content, current_content);
if previous.get("_turn_meta_budget").is_none()
&& let Some(meta) = msg.get("_turn_meta_budget")
{
previous["_turn_meta_budget"] = meta.clone();
}
} else {
out.push(msg);
}
}
}
@@ -6430,6 +6512,225 @@ mod image_block_wire_tests {
const DATA_URL: &str = "data:image/png;base64,QUJD";
#[test]
fn compaction_checkpoint_keeps_complete_tool_round_on_wire() {
let mut messages: Vec<Message> = serde_json::from_value(serde_json::json!([
{"role":"user","content":[{"type":"text","text":"Analyze the data"}]},
{"role":"assistant","content":[{"type":"tool_use","id":"call_1","name":"read","input":{"path":"a.txt"}}]},
{"role":"user","content":[{"type":"tool_result","tool_use_id":"call_1","content":"ready"}]},
{"role":"assistant","content":[{"type":"tool_use","id":"call_2","name":"read","input":{"path":"b.txt"}}]},
{"role":"user","content":[{"type":"tool_result","tool_use_id":"call_2","content":"done"}]}
])).unwrap();
let summary = codewhale_models::SystemPrompt::Text(
crate::compaction::build_compaction_summary_block_text("Compacted summary", ""),
);
messages.push(crate::compaction::compaction_checkpoint_message(&summary));
crate::runtime_handoff::replace_agent_topology_checkpoint(&mut messages, &[]);
let stored = messages.clone();
let wire = build_chat_messages(None, &messages, "gpt-4o");
assert_eq!(
messages, stored,
"request construction must not alter saved history"
);
let roles: Vec<&str> = wire
.iter()
.map(|message| message["role"].as_str().unwrap())
.collect();
assert_eq!(roles, ["user", "assistant", "tool", "assistant", "tool"]);
let prompt = wire[0]["content"].as_str().unwrap();
assert!(prompt.contains("Compacted summary"));
assert!(prompt.contains("Analyze the data"));
assert!(prompt.contains("codewhale.agent_topology.v1"));
assert_eq!(wire[1]["tool_calls"][0]["id"], "call_1");
assert_eq!(wire[2]["tool_call_id"], "call_1");
assert_eq!(wire[3]["tool_calls"][0]["id"], "call_2");
assert_eq!(wire[4]["tool_call_id"], "call_2");
messages.push(
serde_json::from_value(serde_json::json!({
"role":"assistant","content":[{"type":"text","text":"Analysis complete"}]
}))
.unwrap(),
);
messages.push(
serde_json::from_value(serde_json::json!({
"role":"user","content":[{"type":"text","text":"What happened next?"}]
}))
.unwrap(),
);
let later_wire = build_chat_messages(None, &messages, "gpt-4o");
let later_roles: Vec<&str> = later_wire
.iter()
.map(|message| message["role"].as_str().unwrap())
.collect();
assert_eq!(
later_roles,
[
"user",
"assistant",
"tool",
"assistant",
"tool",
"assistant",
"user"
]
);
assert_eq!(later_wire[6]["content"], "What happened next?");
let restored = crate::compaction::restore_compaction_checkpoint(
crate::runtime_handoff::project_messages_for_restore(&messages),
Some(&summary),
);
let restored_wire = build_chat_messages(None, &restored, "gpt-4o");
let restored_roles: Vec<&str> = restored_wire
.iter()
.map(|message| message["role"].as_str().unwrap())
.collect();
assert_eq!(restored_roles, later_roles);
assert!(
restored_wire[0]["content"]
.as_str()
.unwrap()
.contains("restored Agent topology checkpoint")
);
assert_eq!(restored_wire[6]["content"], "What happened next?");
}
#[test]
fn quoted_compaction_marker_does_not_reorder_user_wire_messages() {
let messages: Vec<Message> = serde_json::from_value(serde_json::json!([
{"role":"user","content":[{"type":"text","text":"First question"}]},
{"role":"assistant","content":[{"type":"text","text":"First answer"}]},
{"role":"user","content":[{"type":"text","text":"Please explain: Another language model started to solve this problem"}]},
{"role":"assistant","content":[{"type":"text","text":"It introduces a summary."}]},
{"role":"user","content":[{"type":"text","text":"Follow-up question"}]}
])).unwrap();
let wire = build_chat_messages(None, &messages, "gpt-4o");
let roles: Vec<&str> = wire
.iter()
.map(|message| message["role"].as_str().unwrap())
.collect();
assert_eq!(roles, ["user", "assistant", "user", "assistant", "user"]);
assert_eq!(wire[0]["content"], "First question");
assert_eq!(
wire[2]["content"],
"Please explain: Another language model started to solve this problem"
);
assert_eq!(wire[4]["content"], "Follow-up question");
let quoted_exact_header = crate::compaction::build_compaction_summary_block_text(
"This text was pasted by a user",
"",
);
let mut with_exact_quote = messages.clone();
with_exact_quote[2] = Message {
role: Role::User,
content: vec![ContentBlock::Text {
text: quoted_exact_header.clone(),
cache_control: None,
}],
};
let exact_wire = build_chat_messages(None, &with_exact_quote, "gpt-4o");
let exact_roles: Vec<&str> = exact_wire
.iter()
.map(|message| message["role"].as_str().unwrap())
.collect();
assert_eq!(exact_roles, roles);
assert_eq!(exact_wire[2]["content"], quoted_exact_header);
let mut after_tool: Vec<Message> = serde_json::from_value(serde_json::json!([
{"role":"user","content":[{"type":"text","text":"Read first"}]},
{"role":"assistant","content":[{"type":"tool_use","id":"call_1","name":"read","input":{"path":"a.txt"}}]},
{"role":"user","content":[{"type":"tool_result","tool_use_id":"call_1","content":"contents"}]},
{"role":"user","content":[{"type":"text","text":"placeholder"}]},
{"role":"assistant","content":[{"type":"text","text":"Answer"}]}
])).unwrap();
after_tool[3] = Message {
role: Role::User,
content: vec![ContentBlock::Text {
text: quoted_exact_header.clone(),
cache_control: None,
}],
};
let after_tool_wire = build_chat_messages(None, &after_tool, "gpt-4o");
let after_tool_roles: Vec<&str> = after_tool_wire
.iter()
.map(|message| message["role"].as_str().unwrap())
.collect();
assert_eq!(
after_tool_roles,
["user", "assistant", "tool", "user", "assistant"]
);
assert_eq!(after_tool_wire[3]["content"], quoted_exact_header);
}
#[test]
fn topology_checkpoint_after_tool_result_keeps_wire_tool_pair() {
let mut messages: Vec<Message> = serde_json::from_value(serde_json::json!([
{"role":"user","content":[{"type":"text","text":"Read the file"}]},
{"role":"assistant","content":[{"type":"tool_use","id":"call_1","name":"read","input":{"path":"a.txt"}}]},
{"role":"user","content":[{"type":"tool_result","tool_use_id":"call_1","content":"contents"}]}
])).unwrap();
crate::runtime_handoff::replace_agent_topology_checkpoint(&mut messages, &[]);
let wire = build_chat_messages(None, &messages, "gpt-4o");
let roles: Vec<&str> = wire
.iter()
.map(|message| message["role"].as_str().unwrap())
.collect();
assert_eq!(roles, ["user", "assistant", "tool"]);
assert!(
wire[0]["content"]
.as_str()
.unwrap()
.contains("agent_topology_v1")
);
assert_eq!(wire[1]["tool_calls"][0]["id"], "call_1");
assert_eq!(wire[2]["tool_call_id"], "call_1");
}
#[test]
fn compaction_without_retained_user_has_one_wire_user_before_tools() {
let mut messages: Vec<Message> = serde_json::from_value(serde_json::json!([
{"role":"assistant","content":[{"type":"tool_use","id":"call_1","name":"read","input":{"path":"a.txt"}}]},
{"role":"user","content":[{"type":"tool_result","tool_use_id":"call_1","content":"contents"}]}
])).unwrap();
let summary = codewhale_models::SystemPrompt::Text(
crate::compaction::build_compaction_summary_block_text("Summary", ""),
);
messages.push(crate::compaction::compaction_checkpoint_message(&summary));
crate::runtime_handoff::replace_agent_topology_checkpoint(&mut messages, &[]);
let wire = build_chat_messages(None, &messages, "gpt-4o");
let roles: Vec<&str> = wire
.iter()
.map(|message| message["role"].as_str().unwrap())
.collect();
assert_eq!(roles, ["user", "assistant", "tool"]);
let prompt = wire[0]["content"].as_str().unwrap();
assert!(prompt.contains("Summary"));
assert!(prompt.contains("agent_topology_v1"));
assert_eq!(wire[2]["tool_call_id"], "call_1");
}
#[test]
fn compaction_after_unanswered_user_prompt_has_one_wire_user() {
let mut messages: Vec<Message> = serde_json::from_value(serde_json::json!([
{"role":"user","content":[{"type":"text","text":"Please continue"}]}
]))
.unwrap();
let summary = codewhale_models::SystemPrompt::Text(
crate::compaction::build_compaction_summary_block_text("Earlier work", ""),
);
messages.push(crate::compaction::compaction_checkpoint_message(&summary));
crate::runtime_handoff::replace_agent_topology_checkpoint(&mut messages, &[]);
let wire = build_chat_messages(None, &messages, "gpt-4o");
assert_eq!(wire.len(), 1);
assert_eq!(wire[0]["role"], "user");
let prompt = wire[0]["content"].as_str().unwrap();
assert!(prompt.contains("Please continue"));
assert!(prompt.contains("Earlier work"));
assert!(prompt.contains("agent_topology_v1"));
}
fn fixture_tool_use(id: &str) -> ContentBlock {
ContentBlock::ToolUse {
id: id.to_string(),
+94 -5
View File
@@ -191,6 +191,7 @@ pub const COMPACTION_SUMMARY_MARKER: &str = "Another language model started to s
/// Marker written by pre-v0.9.6 compaction; sessions saved under the old
/// format must still be recognized so their summary is replaced, not stacked.
pub const LEGACY_COMPACTION_SUMMARY_MARKER: &str = "Conversation Summary (Auto-Generated)";
const COMPACTION_CHECKPOINT_PROVENANCE: &str = "<!-- codewhale.compaction-checkpoint.v1 -->";
const COMPACTION_SUMMARY_BEGIN: &str = "<!-- compaction-summary:begin -->";
const COMPACTION_SUMMARY_END: &str = "<!-- compaction-summary:end -->";
@@ -306,10 +307,16 @@ pub fn summary_prompt_text(prompt: &SystemPrompt) -> String {
pub(crate) fn compaction_checkpoint_message(prompt: &SystemPrompt) -> Message {
Message {
role: Role::User,
content: vec![ContentBlock::Text {
text: summary_prompt_text(prompt),
cache_control: None,
}],
content: vec![
ContentBlock::Text {
text: summary_prompt_text(prompt),
cache_control: None,
},
ContentBlock::Text {
text: COMPACTION_CHECKPOINT_PROVENANCE.to_string(),
cache_control: None,
},
],
}
}
@@ -318,6 +325,54 @@ pub(crate) fn is_compaction_checkpoint_message(message: &Message) -> bool {
user_text_of(message).is_some_and(|text| is_compaction_summary_text(&text))
}
/// Request-time recognition is narrower than legacy summary replacement:
/// user text merely quoting the marker must keep its original wire position.
pub(crate) fn is_wire_compaction_checkpoint_message(message: &Message) -> bool {
let [
ContentBlock::Text {
text,
cache_control: None,
},
ContentBlock::Text {
text: provenance,
cache_control: None,
},
] = message.content.as_slice()
else {
return false;
};
message.role == Role::User
&& text.starts_with(SUMMARY_HEADER)
&& provenance == COMPACTION_CHECKPOINT_PROVENANCE
}
/// Keep the checkpoint at its original historical boundary on session load.
/// Later user turns must remain after the saved compaction boundary.
pub(crate) fn restore_compaction_checkpoint(
mut messages: Vec<Message>,
checkpoint: Option<&SystemPrompt>,
) -> Vec<Message> {
let typed_position = messages
.iter()
.position(is_wire_compaction_checkpoint_message);
let checkpoint_index = if let Some(index) = typed_position {
messages.retain(|message| !is_wire_compaction_checkpoint_message(message));
index
} else {
// Legacy sessions have no independent provenance. Preserve their
// existing broad cleanup behavior; identical user text is ambiguous.
messages.retain(|message| !is_compaction_checkpoint_message(message));
messages.len()
};
if let Some(checkpoint) = checkpoint {
messages.insert(
checkpoint_index.min(messages.len()),
compaction_checkpoint_message(checkpoint),
);
}
messages
}
pub(crate) fn estimate_tokens_for_message(message: &Message, include_thinking: bool) -> usize {
message
.content
@@ -1211,7 +1266,7 @@ pub async fn compact_messages_safe(
.unwrap_or_else(|| anyhow::anyhow!("Compaction failed after {MAX_RETRIES} retries")))
}
fn build_compaction_summary_block_text(summary: &str, anchors: &str) -> String {
pub(crate) fn build_compaction_summary_block_text(summary: &str, anchors: &str) -> String {
let summary = summary.trim();
let summary = if summary.is_empty() {
"(no summary available)"
@@ -1705,6 +1760,40 @@ mod quota_tests;
mod tests {
use codewhale_models::{ImageUrlContent, Message};
#[test]
fn restore_replaces_duplicate_generated_checkpoints_without_deleting_user_quote() {
let summary = SystemPrompt::Text(build_compaction_summary_block_text("Summary", ""));
let generated = compaction_checkpoint_message(&summary);
let user_quote = Message {
role: Role::User,
content: vec![ContentBlock::Text {
text: summary_prompt_text(&summary),
cache_control: None,
}],
};
assert!(!is_wire_compaction_checkpoint_message(&user_quote));
let restored = restore_compaction_checkpoint(
vec![generated.clone(), user_quote.clone(), generated],
Some(&summary),
);
assert_eq!(restored.len(), 2);
assert!(is_wire_compaction_checkpoint_message(&restored[0]));
assert_eq!(restored[1], user_quote);
// No provenance means the historical broad cleanup remains in force.
let legacy = Message {
role: Role::User,
content: vec![ContentBlock::Text {
text: format!("{COMPACTION_SUMMARY_MARKER}\nold summary"),
cache_control: None,
}],
};
let legacy_restored =
restore_compaction_checkpoint(vec![legacy.clone(), legacy], Some(&summary));
assert_eq!(legacy_restored.len(), 1);
assert!(is_wire_compaction_checkpoint_message(&legacy_restored[0]));
}
#[test]
fn inline_image_estimates_nonzero_tokens() {
let msg = Message {
+7 -11
View File
@@ -3034,18 +3034,14 @@ impl Engine {
}
let compaction_checkpoint =
extract_compaction_summary_prompt(system_prompt.clone());
let mut restored_messages =
let restored_messages =
crate::runtime_handoff::project_messages_for_restore(&messages);
// The persisted carrier is authoritative for the one
// history checkpoint. Drop stale projected copies so
// repeated reloads cannot stack or retain an older one.
restored_messages.retain(|message| {
!crate::compaction::is_compaction_checkpoint_message(message)
});
if let Some(checkpoint) = compaction_checkpoint.as_ref() {
restored_messages
.push(crate::compaction::compaction_checkpoint_message(checkpoint));
}
// Replace the checkpoint in place so turns after the
// compaction boundary keep their chronology.
let restored_messages = crate::compaction::restore_compaction_checkpoint(
restored_messages,
compaction_checkpoint.as_ref(),
);
self.session.messages = restored_messages.into();
// Direct field assignment bypasses `add_message` /
// `replace_messages`, which own the messages-revision
+44 -3
View File
@@ -460,7 +460,7 @@ fn render_restored_agent_topology(checkpoint: &SavedAgentTopologyCheckpoint) ->
display
}
fn is_agent_topology_checkpoint(message: &Message) -> bool {
pub(crate) fn is_agent_topology_checkpoint(message: &Message) -> bool {
let [
ContentBlock::Text {
text,
@@ -486,13 +486,47 @@ fn is_agent_topology_checkpoint(message: &Message) -> bool {
/// compaction. A current empty topology is still meaningful: it overrides a
/// narrative summary or old runtime event that says an Agent remains live.
/// Replays are idempotent because the previous sidecar is structurally removed
/// before the replacement is appended.
/// before the replacement is inserted. A trailing compaction summary is not
/// a real user boundary, and a checkpoint after a tool result would split a
/// strict chat template's assistant/tool round.
pub(crate) fn replace_agent_topology_checkpoint(
messages: &mut Vec<Message>,
snapshots: &[SubAgentResult],
) {
messages.retain(|message| !is_agent_topology_checkpoint(message));
messages.push(agent_topology_checkpoint_message(snapshots));
let ends_with_tool_result = messages.last().is_some_and(|message| {
message.content.iter().any(|block| {
matches!(
block,
ContentBlock::ToolResult { .. }
| ContentBlock::ToolSearchToolResult { .. }
| ContentBlock::CodeExecutionToolResult { .. }
)
})
});
let ends_with_summary = messages
.last()
.is_some_and(crate::compaction::is_wire_compaction_checkpoint_message);
let position = if ends_with_tool_result || ends_with_summary {
messages
.iter()
.rposition(|message| {
!crate::compaction::is_wire_compaction_checkpoint_message(message)
&& classify_user_turn_prompt(message) != UserTurnPromptKind::NotPrompt
})
.map_or_else(
|| {
messages
.iter()
.position(|message| message.role.is_assistant_like())
.unwrap_or(0)
},
|index| index + 1,
)
} else {
messages.len()
};
messages.insert(position, agent_topology_checkpoint_message(snapshots));
}
#[cfg(test)]
@@ -985,6 +1019,13 @@ pub(crate) fn restored_subagent_checkpoint_display(message: &Message) -> Option<
Some(text)
}
/// Only the restored topology sidecar belongs to the compaction prompt
/// cluster. Other restored Agent events retain their own wire boundaries.
pub(crate) fn is_restored_agent_topology_checkpoint(message: &Message) -> bool {
restored_subagent_checkpoint_display(message)
.is_some_and(|display| display.starts_with(RESTORED_TOPOLOGY_HEADER))
}
/// Classification used when locating a user-authored turn in the session log.
///
/// Runtime and tool messages are skipped because their provider-compatible