Keep a failed prompt through a model switch (#171)

* Keep a failed prompt through a model switch A prompt whose inference errored stays in the client's thread, but the carryover dropped its trailing user message on every model switch, and the error path never saved the session. Only a cancelled prompt should be taken back out, so cancellation now removes it from the live history and the error path persists it. Fixes getsigit/sigit#124 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fmt: reflow forget_trailing_user_message multi-line if Co-Authored-By: siGit Code v1.6.1-acp <noreply@sigit.si> --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> Co-authored-by: siGit Code v1.6.1-acp <noreply@sigit.si>

Seto Elkahfi committed Oct 6, 2026 at 14:44 UTC 535b048e6506d6bfa7d7bf899260c0db0300d0ce
2 files changed +47 -12
src/backend.rs
+42 -12
@@ -1944,18 +1944,25 @@ pub fn carryover_history(snapshot: Vec<serde_json::Value>) -> Vec<serde_json::Va
1944 }
1945 }
1946
1947 - // Session switches happen only after the serialized turn has ended. A
1948 - // trailing user message therefore belongs to a cancelled/failed inference
1949 - // request with no answer; carrying it would make the next activation feed
1950 - // the model an orphaned prompt that the client considers cancelled.
1951 - while carried
1947 + carried
1948 +}
1949 +
1950 +/// Take a cancelled prompt's user message back out of the live history.
1951 +///
1952 +/// The client drops a cancelled prompt from its thread, so the model must not
1953 +/// keep answering it. Only the first inference round can cancel with the user
1954 +/// message as the last entry; later rounds end on tool or assistant output.
1955 +/// A prompt that fails with an error is not taken back: the client keeps it,
1956 +/// so the history keeps it too and a model switch carries it over.
1957 +pub async fn forget_trailing_user_message(backend: &dyn InferenceBackend) {
1958 + let mut history = backend.history_snapshot().await;
1959 + if history
1960 .last()
1961 .is_some_and(|message| message["role"] == "user")
1962 {
1955 - carried.pop();
1963 + history.pop();
1964 + backend.restore_history(history).await;
1965 }
1957 -
1958 - carried
1966 }
1967
1968 /// Replay `carried` (from [`carryover_history`]) into `backend`, on top of the
@@ -2161,16 +2168,39 @@ mod tests {
2168 }
2169
2170 #[test]
2164 - fn carryover_drops_a_cancelled_trailing_user_message() {
2171 + fn carryover_keeps_a_trailing_user_message_from_a_failed_turn() {
2172 + // The client keeps a prompt whose inference errored, so a switch must
2173 + // not drop it (issue #124).
2174 let carried = carryover_history(vec![
2175 serde_json::json!({ "role": "system", "content": "prompt" }),
2176 serde_json::json!({ "role": "user", "content": "completed question" }),
2177 serde_json::json!({ "role": "assistant", "content": "completed answer" }),
2169 - serde_json::json!({ "role": "user", "content": "cancelled question" }),
2178 + serde_json::json!({ "role": "user", "content": "failed question" }),
2179 ]);
2180
2172 - assert_eq!(carried.len(), 2, "{carried:#?}");
2173 - assert_eq!(carried.last().unwrap()["content"], "completed answer");
2181 + assert_eq!(carried.len(), 3, "{carried:#?}");
2182 + assert_eq!(carried.last().unwrap()["content"], "failed question");
2183 + }
2184 +
2185 + #[tokio::test]
2186 + async fn forget_trailing_user_message_drops_only_a_cancelled_prompt() {
2187 + let backend = OpenAiBackend::new("http://localhost", "", "m", None);
2188 + backend
2189 + .restore_history(vec![
2190 + serde_json::json!({ "role": "user", "content": "completed question" }),
2191 + serde_json::json!({ "role": "assistant", "content": "completed answer" }),
2192 + serde_json::json!({ "role": "user", "content": "cancelled question" }),
2193 + ])
2194 + .await;
2195 +
2196 + forget_trailing_user_message(&backend).await;
2197 +
2198 + let history = backend.history_snapshot().await;
2199 + assert_eq!(history.len(), 2, "{history:#?}");
2200 + assert_eq!(history[1]["content"], "completed answer");
2201 +
2202 + forget_trailing_user_message(&backend).await;
2203 + assert_eq!(backend.history_snapshot().await.len(), 2);
2204 }
2205
2206 #[test]
src/main.rs
+5
@@ -2615,6 +2615,7 @@ impl SiGitAgent {
2615 {
2616 Ok(result) => result,
2617 Err(DrainTurnError::Cancelled) => {
2618 + backend::forget_trailing_user_message(backend.as_ref()).await;
2619 self.finish_prompt(&session_id, &cancellation);
2620 return Ok(PromptResponse::new(StopReason::Cancelled));
2621 }
@@ -2630,6 +2631,10 @@ impl SiGitAgent {
2631 // something worth reading (see `describe_api_error`), and it is
2632 // what the editor puts in its error banner. The context a
2633 // prefix would add is in the log line above.
2634 + // The client keeps this prompt in its thread after the error,
2635 + // so keep it in the saved conversation too.
2636 + let snapshot = backend.history_snapshot().await;
2637 + self.persist_session(&session_id, &snapshot).await;
2638 self.finish_prompt(&session_id, &cancellation);
2639 return Err(agent_client_protocol::Error::new(-32603, error));
2640 }