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
}