mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-09 09:59:45 -10:00
fix(tui): preserve chat roles after compaction
Signed-off-by: zhuowp <[email protected]>
This commit is contained in:
1 parent
3016ef4928
commit
1f5f3a7e6b
4 files changed
+448
-21
No files matched your search
@@ -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(),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user