Fix compaction failing in every session that ran a tool

compact_history asked for the summary through complete(None, None), which sends no tools array but left the live history in place. That history is thick with assistant tool_calls and role:"tool" messages, and an endpoint handed tool shapes with no schema to check them against rejects the request. Anthropic answers 400, so compaction failed on every attempt in any session that had run a single tool, no matter how small the history was. It then retried on every prompt and tool round while history kept growing. Send the conversation as a flattened transcript in one user message instead. transcript_for_summary renders tool calls and their results as prose, which keeps what the summary needs and drops the shapes that only mean anything next to a tool schema. It also avoids orphaned tool_call_ids and role-alternation rules. The test stub now hands back the request body it received, so the new test checks what actually goes on the wire rather than internal state. Co-Authored-By: siGit Code <noreply@sigit.si>

Seto Elkahfi committed Sep 7, 2026 at 08:16 UTC e6253fd2fe49e4c3476f959238baf9348aa64469
3 files changed +247 -15
src/backend.rs
+140 -14
@@ -76,6 +76,51 @@ pub const COMPACT_KEEP_LAST: usize = 6;
76 const SUMMARIZE_PROMPT: &str = "Summarize this coding session so far: decisions made, \
77 files touched, current state, open items. Be concise and factual.";
78
79 +/// Render a history snapshot as a plain-text transcript, with tool calls and
80 +/// tool results spelled out as prose rather than left in their wire shapes.
81 +///
82 +/// Compaction summarizes a conversation that is, by definition, thick with
83 +/// `tool_calls` and `role: "tool"` messages — but the summarization round asks
84 +/// for a plain answer and so sends no `tools` array. Forwarding the raw shapes
85 +/// in that request produces tool blocks with no schema to validate against,
86 +/// which strict endpoints reject outright: Anthropic answers 400, so every
87 +/// compaction of a session that had ever run a tool failed, permanently, no
88 +/// matter how small the history was. Flattening to text keeps everything the
89 +/// summary actually needs and drops the shapes that only make sense alongside
90 +/// a tool schema. It also sidesteps orphaned `tool_call_id`s and role-
91 +/// alternation rules, neither of which a transcript can violate.
92 +fn transcript_for_summary(history: &[serde_json::Value]) -> String {
93 + let mut lines: Vec<String> = Vec::new();
94 + for message in history {
95 + let role = message["role"].as_str().unwrap_or("user");
96 + // The system prompt is carried over verbatim, so it needn't be summarized.
97 + if role == "system" {
98 + continue;
99 + }
100 +
101 + let mut parts: Vec<String> = Vec::new();
102 + if let Some(text) = message["content"].as_str()
103 + && !text.trim().is_empty()
104 + {
105 + parts.push(text.to_string());
106 + }
107 + for call in message["tool_calls"].as_array().into_iter().flatten() {
108 + parts.push(format!(
109 + "called {}({})",
110 + call["function"]["name"].as_str().unwrap_or("tool"),
111 + call["function"]["arguments"].as_str().unwrap_or_default(),
112 + ));
113 + }
114 + if parts.is_empty() {
115 + continue;
116 + }
117 +
118 + let label = if role == "tool" { "tool result" } else { role };
119 + lines.push(format!("{label}: {}", parts.join("\n")));
120 + }
121 + lines.join("\n\n")
122 +}
123 +
124 /// Crude token estimate for a history snapshot: serialized characters / 4.
125 /// Deliberately model-agnostic — it only needs to be in the right ballpark to
126 /// decide when compaction is worth an extra inference round.
@@ -818,12 +863,30 @@ impl InferenceBackend for OpenAiBackend {
863 async fn compact_history(&self, keep_last: usize) -> Result<(), BackendError> {
864 let snapshot: Vec<serde_json::Value> = self.history.lock().await.clone();
865
821 - // Ask the endpoint for a summary of the conversation so far, through
822 - // the ordinary completion machinery (non-streaming).
823 - self.history
824 - .lock()
825 - .await
826 - .push(serde_json::json!({ "role": "user", "content": SUMMARIZE_PROMPT }));
866 + let system = snapshot
867 + .first()
868 + .filter(|message| message["role"] == "system")
869 + .cloned();
870 +
871 + // Ask the endpoint for a summary of the conversation so far, through the
872 + // ordinary completion machinery (non-streaming). The request carries the
873 + // conversation as a flattened transcript in a single user message rather
874 + // than the live history: this round offers no tools, and a tool-shaped
875 + // history sent without a tool schema is rejected upstream (see
876 + // `transcript_for_summary`).
877 + let mut request = Vec::new();
878 + if let Some(system) = system.clone() {
879 + request.push(system);
880 + }
881 + request.push(serde_json::json!({
882 + "role": "user",
883 + "content": format!(
884 + "{}\n\n{SUMMARIZE_PROMPT}",
885 + transcript_for_summary(&snapshot),
886 + ),
887 + }));
888 + *self.history.lock().await = request;
889 +
890 let summary = match self.complete(None, None).await {
891 Ok(result) => result.text,
892 Err(error) => {
@@ -833,10 +896,6 @@ impl InferenceBackend for OpenAiBackend {
896 }
897 };
898
836 - let system = snapshot
837 - .first()
838 - .filter(|message| message["role"] == "system")
839 - .cloned();
899 let non_system: Vec<serde_json::Value> = snapshot
900 .iter()
901 .filter(|message| message["role"] != "system")
@@ -1422,12 +1481,16 @@ mod tests {
1481 }
1482
1483 /// Minimal scripted OpenAI-compatible endpoint: accepts one HTTP request on
1425 - /// a std listener and answers with a fixed non-streaming completion.
1426 - fn spawn_completion_stub(summary: &str) -> std::net::SocketAddr {
1484 + /// a std listener and answers with a fixed non-streaming completion. The
1485 + /// receiver yields the request body the backend actually put on the wire.
1486 + fn spawn_completion_stub(
1487 + summary: &str,
1488 + ) -> (std::net::SocketAddr, std::sync::mpsc::Receiver<String>) {
1489 use std::io::{Read, Write};
1490
1491 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1492 let addr = listener.local_addr().unwrap();
1493 + let (sender, receiver) = std::sync::mpsc::channel();
1494 let body = serde_json::json!({
1495 "choices": [{ "message": { "role": "assistant", "content": summary } }]
1496 })
@@ -1456,6 +1519,9 @@ mod tests {
1519 })
1520 .unwrap_or(0);
1521 if request.len() >= headers_end + 4 + content_length {
1522 + let _ = sender.send(
1523 + String::from_utf8_lossy(&request[headers_end + 4..]).into_owned(),
1524 + );
1525 break;
1526 }
1527 }
@@ -1468,12 +1534,12 @@ mod tests {
1534 );
1535 let _ = stream.write_all(response.as_bytes());
1536 });
1471 - addr
1537 + (addr, receiver)
1538 }
1539
1540 #[tokio::test]
1541 async fn compact_history_rebuilds_system_summary_and_tail() {
1476 - let addr = spawn_completion_stub("We refactored backend.rs; tests pass.");
1542 + let (addr, _requests) = spawn_completion_stub("We refactored backend.rs; tests pass.");
1543 let backend = OpenAiBackend::new(
1544 format!("http://{addr}/v1"),
1545 "test-key",
@@ -1510,6 +1576,66 @@ mod tests {
1576 );
1577 }
1578
1579 + /// Compacting a tool-heavy session must not put tool shapes on the wire.
1580 + /// The summarization round offers no `tools`, and endpoints reject tool
1581 + /// calls and tool results that arrive without a schema — which used to make
1582 + /// compaction fail forever in any session that had run a single tool.
1583 + #[tokio::test]
1584 + async fn compact_history_sends_no_tool_artifacts() {
1585 + let (addr, requests) = spawn_completion_stub("Ran git status on main.");
1586 + let backend = OpenAiBackend::new(
1587 + format!("http://{addr}/v1"),
1588 + "test-key",
1589 + "test-model",
1590 + Some("be helpful".into()),
1591 + );
1592 + {
1593 + let mut history = backend.history.lock().await;
1594 + history.push(serde_json::json!({ "role": "user", "content": "check the repo" }));
1595 + history.push(serde_json::json!({
1596 + "role": "assistant",
1597 + "content": null,
1598 + "tool_calls": [{
1599 + "id": "call_1",
1600 + "type": "function",
1601 + "function": { "name": "run_command", "arguments": "{\"command\":\"git status\"}" },
1602 + }],
1603 + }));
1604 + history.push(serde_json::json!({
1605 + "role": "tool",
1606 + "tool_call_id": "call_1",
1607 + "content": "on branch main",
1608 + }));
1609 + }
1610 +
1611 + backend.compact_history(2).await.unwrap();
1612 +
1613 + let body: serde_json::Value =
1614 + serde_json::from_str(&requests.recv().unwrap()).expect("request body is JSON");
1615 + assert!(
1616 + body.get("tools").is_none(),
1617 + "summarization offers no tools: {body}"
1618 + );
1619 + for message in body["messages"].as_array().unwrap() {
1620 + assert!(
1621 + message.get("tool_calls").is_none(),
1622 + "no tool_calls may be sent without a schema: {message}"
1623 + );
1624 + assert_ne!(
1625 + message["role"], "tool",
1626 + "no tool results may be sent without a schema: {message}"
1627 + );
1628 + }
1629 +
1630 + // The tool round still has to survive into the summary request as prose,
1631 + // or the summary loses the work the session actually did.
1632 + let transcript = body["messages"].as_array().unwrap().last().unwrap()["content"]
1633 + .as_str()
1634 + .unwrap();
1635 + assert!(transcript.contains("called run_command({\"command\":\"git status\"})"));
1636 + assert!(transcript.contains("tool result: on branch main"));
1637 + }
1638 +
1639 #[tokio::test]
1640 async fn compact_history_failure_leaves_history_intact() {
1641 // No listener at this address: the summarization request fails, and
src/main.rs
+13 -1
@@ -1637,7 +1637,19 @@ impl SiGitAgent {
1637 let after = backend::estimate_tokens(&backend.history_snapshot().await);
1638 log::info!("prompt({}) compacted to ≈{} tokens", session_id, after);
1639 }
1640 - Err(error) => log::warn!("prompt({}) compaction failed: {error}", session_id),
1640 + Err(error) => {
1641 + log::warn!("prompt({}) compaction failed: {error}", session_id);
1642 + self.send_assistant_message(
1643 + cx,
1644 + session_id,
1645 + format!(
1646 + "This session is too large, and siGit Code could not compact it: \
1647 + {error}. Start a new thread or run `/clear`, then retry."
1648 + ),
1649 + )
1650 + .ok();
1651 + return Ok(PromptResponse::new(StopReason::EndTurn));
1652 + }
1653 }
1654 }
1655
tests/acp_endpoint_errors.rs
+94
@@ -50,6 +50,34 @@ fn error_in_stream_reply() -> Reply {
50 }
51 }
52
53 +fn sse_tool_call(id: &str, name: &str, arguments: &str) -> Reply {
54 + Reply {
55 + status: "200 OK",
56 + content_type: "text/event-stream",
57 + body: format!(
58 + "data: {}\n\ndata: [DONE]\n\n",
59 + json!({
60 + "choices": [{"delta": {"tool_calls": [{
61 + "index": 0,
62 + "id": id,
63 + "function": {"name": name, "arguments": arguments},
64 + }]}}]
65 + })
66 + ),
67 + }
68 +}
69 +
70 +fn upstream_500_reply() -> Reply {
71 + Reply {
72 + status: "500 Internal Server Error",
73 + content_type: "application/json",
74 + body: json!({
75 + "error": {"message": "Onde Cloud upstream error (500)", "type": "server_error"}
76 + })
77 + .to_string(),
78 + }
79 +}
80 +
81 fn start_fake_endpoint(replies: Vec<Reply>) -> u16 {
82 let listener = TcpListener::bind("127.0.0.1:0").expect("bind fake endpoint");
83 let port = listener.local_addr().unwrap().port();
@@ -179,6 +207,23 @@ impl AgentUnderTest {
207 message
208 }
209
210 + fn wait_for_prompt_updates(&mut self, id: u64) -> (Value, Vec<Value>) {
211 + let mut updates = Vec::new();
212 + let deadline = Instant::now() + TIMEOUT;
213 + loop {
214 + let remaining = deadline.saturating_duration_since(Instant::now());
215 + let Ok(message) = self.incoming.recv_timeout(remaining) else {
216 + panic!("timed out waiting for the response to request {id}");
217 + };
218 + if message["method"] == "session/update" {
219 + updates.push(message["params"]["update"].clone());
220 + }
221 + if message["id"] == id && message.get("method").is_none() {
222 + return (message, updates);
223 + }
224 + }
225 + }
226 +
227 fn open_session(&mut self, cwd: &std::path::Path) -> String {
228 let id = self.request(
229 "initialize",
@@ -260,3 +305,52 @@ fn an_error_frame_mid_stream_is_not_swallowed() {
305
306 std::fs::remove_dir_all(&dir).ok();
307 }
308 +
309 +#[test]
310 +fn failed_compaction_in_an_oversized_acp_turn_is_reported_as_chat_text() {
311 + let dir = scratch("compact");
312 + let port = start_fake_endpoint(vec![
313 + sse_tool_call(
314 + "call_1",
315 + "run_command",
316 + r#"{"command":"echo should-not-run"}"#,
317 + ),
318 + upstream_500_reply(),
319 + ]);
320 + let mut agent = spawn_agent(port, &dir.join("config"));
321 + let session_id = agent.open_session(&dir.join("work"));
322 +
323 + let oversized_prompt = "x".repeat(120_000);
324 + let id = agent.request(
325 + "session/prompt",
326 + json!({"sessionId": session_id, "prompt": [{"type": "text", "text": oversized_prompt}]}),
327 + );
328 + let (response, updates) = agent.wait_for_prompt_updates(id);
329 +
330 + assert!(
331 + response.get("error").is_none(),
332 + "compaction failure should be a readable chat message, not an ACP error: {response}"
333 + );
334 + assert_eq!(response["result"]["stopReason"], "end_turn");
335 +
336 + let rendered = updates
337 + .iter()
338 + .filter_map(|update| {
339 + (update["sessionUpdate"] == "agent_message_chunk")
340 + .then(|| update["content"]["text"].as_str())
341 + .flatten()
342 + })
343 + .collect::<String>();
344 + assert!(
345 + rendered.contains("could not compact it: Onde Cloud upstream error (500)"),
346 + "wrong rendered message: {rendered:?}"
347 + );
348 + assert!(
349 + !updates
350 + .iter()
351 + .any(|update| update["sessionUpdate"] == "tool_call"),
352 + "tools should not run after compaction fails: {updates:?}"
353 + );
354 +
355 + std::fs::remove_dir_all(&dir).ok();
356 +}