880
arguments: Option<String>,
881
}
882
883
+// ── Carrying a conversation across a model switch ────────────────────────────
884
+
885
+/// The part of a history snapshot that survives a model switch: every
886
+/// non-system message, with half-finished tool plumbing repaired.
887
+///
888
+/// System messages are dropped because the backend being switched *to* seeds
889
+/// its own — a different model's prompt, or a freshly pushed session-context
890
+/// message. A switch can land mid-turn, after the assistant asked for a tool
891
+/// but before the results came back; a tool call with no matching result (or a
892
+/// result with no matching call) makes strict OpenAI-compatible endpoints
893
+/// reject every later request in the session, so those halves are stripped
894
+/// instead of carried.
895
+pub fn carryover_history(snapshot: Vec<serde_json::Value>) -> Vec<serde_json::Value> {
896
+ let answered: std::collections::HashSet<String> = snapshot
897
+ .iter()
898
+ .filter(|message| message["role"] == "tool")
899
+ .filter_map(|message| message["tool_call_id"].as_str().map(str::to_string))
900
+ .collect();
901
+
902
+ let mut carried: Vec<serde_json::Value> = Vec::with_capacity(snapshot.len());
903
+ let mut called: std::collections::HashSet<String> = std::collections::HashSet::new();
904
+
905
+ for mut message in snapshot {
906
+ match message["role"].as_str().unwrap_or_default() {
907
+ "system" => continue,
908
+ "assistant" => {
909
+ if let Some(calls) = message["tool_calls"].as_array() {
910
+ let kept: Vec<serde_json::Value> = calls
911
+ .iter()
912
+ .filter(|call| call["id"].as_str().is_some_and(|id| answered.contains(id)))
913
+ .cloned()
914
+ .collect();
915
+ if kept.is_empty() {
916
+ // Only unanswered requests: keep whatever text came
917
+ // with them, drop the message if there was none.
918
+ if let Some(object) = message.as_object_mut() {
919
+ object.remove("tool_calls");
920
+ }
921
+ if message["content"].as_str().unwrap_or_default().is_empty() {
922
+ continue;
923
+ }
924
+ } else {
925
+ for call in &kept {
926
+ called.insert(call["id"].as_str().unwrap_or_default().to_string());
927
+ }
928
+ message["tool_calls"] = serde_json::Value::Array(kept);
929
+ }
930
+ }
931
+ carried.push(message);
932
+ }
933
+ "tool" => {
934
+ let answers_a_kept_call = message["tool_call_id"]
935
+ .as_str()
936
+ .is_some_and(|id| called.contains(id));
937
+ if answers_a_kept_call {
938
+ carried.push(message);
939
+ }
940
+ }
941
+ _ => carried.push(message),
942
+ }
943
+ }
944
+
945
+ carried
946
+}
947
+
948
+/// Replay `carried` (from [`carryover_history`]) into `backend`, on top of the
949
+/// system messages `backend` seeded for itself. Used when a model switch
950
+/// installs a new backend — or reloads the on-device engine, which wipes its
951
+/// history — so the thread continues under the new model instead of restarting.
952
+pub async fn adopt_carryover(backend: &dyn InferenceBackend, carried: Vec<serde_json::Value>) {
953
+ if carried.is_empty() {
954
+ return;
955
+ }
956
+ let mut rebuilt: Vec<serde_json::Value> = backend
957
+ .history_snapshot()
958
+ .await
959
+ .into_iter()
960
+ .take_while(|message| message["role"] == "system")
961
+ .collect();
962
+ rebuilt.extend(carried);
963
+ backend.restore_history(rebuilt).await;
964
+}
965
+
966
#[cfg(test)]
967
mod tests {
968
use super::*;
1051
assert_eq!(last["content"], "cancelled by the user");
1052
}
1053
1054
+ #[test]
1055
+ fn carryover_drops_system_messages_and_keeps_the_turns() {
1056
+ let snapshot = vec![
1057
+ serde_json::json!({ "role": "system", "content": "old prompt" }),
1058
+ serde_json::json!({ "role": "user", "content": "hello" }),
1059
+ serde_json::json!({ "role": "assistant", "content": "hi" }),
1060
+ ];
1061
+
1062
+ let carried = carryover_history(snapshot);
1063
+
1064
+ assert_eq!(carried.len(), 2);
1065
+ assert_eq!(carried[0]["role"], "user");
1066
+ assert_eq!(carried[1]["content"], "hi");
1067
+ }
1068
+
1069
+ #[test]
1070
+ fn carryover_strips_a_tool_call_that_never_got_a_result() {
1071
+ // Switching mid-turn: the assistant asked for a tool, nothing answered.
1072
+ let snapshot = vec![
1073
+ serde_json::json!({ "role": "user", "content": "read a.rs" }),
1074
+ streamed_assistant_history(
1075
+ "on it",
1076
+ &[ToolCall {
1077
+ id: "call_1".to_string(),
1078
+ name: "read_file".to_string(),
1079
+ arguments: "{}".to_string(),
1080
+ }],
1081
+ ),
1082
+ ];
1083
+
1084
+ let carried = carryover_history(snapshot);
1085
+
1086
+ assert_eq!(carried.len(), 2);
1087
+ assert_eq!(carried[1]["content"], "on it");
1088
+ assert!(
1089
+ carried[1].get("tool_calls").is_none(),
1090
+ "an unanswered tool call must not survive the switch"
1091
+ );
1092
+ }
1093
+
1094
+ #[test]
1095
+ fn carryover_drops_a_textless_unanswered_tool_call_and_its_late_result() {
1096
+ let snapshot = vec![
1097
+ streamed_assistant_history(
1098
+ "",
1099
+ &[ToolCall {
1100
+ id: "call_1".to_string(),
1101
+ name: "read_file".to_string(),
1102
+ arguments: "{}".to_string(),
1103
+ }],
1104
+ ),
1105
+ // An orphan: its assistant message is gone with the line above.
1106
+ serde_json::json!({
1107
+ "role": "tool",
1108
+ "tool_call_id": "call_other",
1109
+ "content": "file contents",
1110
+ }),
1111
+ ];
1112
+
1113
+ assert!(carryover_history(snapshot).is_empty());
1114
+ }
1115
+
1116
+ #[test]
1117
+ fn carryover_keeps_an_answered_tool_call_paired_with_its_result() {
1118
+ let snapshot = vec![
1119
+ streamed_assistant_history(
1120
+ "",
1121
+ &[ToolCall {
1122
+ id: "call_1".to_string(),
1123
+ name: "read_file".to_string(),
1124
+ arguments: "{}".to_string(),
1125
+ }],
1126
+ ),
1127
+ serde_json::json!({
1128
+ "role": "tool",
1129
+ "tool_call_id": "call_1",
1130
+ "content": "file contents",
1131
+ }),
1132
+ ];
1133
+
1134
+ let carried = carryover_history(snapshot);
1135
+
1136
+ assert_eq!(carried.len(), 2);
1137
+ assert_eq!(carried[0]["tool_calls"][0]["id"], "call_1");
1138
+ assert_eq!(carried[1]["tool_call_id"], "call_1");
1139
+ }
1140
+
1141
+ #[tokio::test]
1142
+ async fn adopt_carryover_replays_the_thread_under_the_new_system_prompt() {
1143
+ let new_backend =
1144
+ OpenAiBackend::new("http://localhost", "", "m", Some("new prompt".into()));
1145
+
1146
+ let carried = carryover_history(vec![
1147
+ serde_json::json!({ "role": "system", "content": "old prompt" }),
1148
+ serde_json::json!({ "role": "user", "content": "hello" }),
1149
+ serde_json::json!({ "role": "assistant", "content": "hi" }),
1150
+ ]);
1151
+ adopt_carryover(&new_backend, carried).await;
1152
+
1153
+ let history = new_backend.history_snapshot().await;
1154
+ assert_eq!(history.len(), 3);
1155
+ assert_eq!(history[0]["role"], "system");
1156
+ assert_eq!(history[0]["content"], "new prompt");
1157
+ assert_eq!(history[1]["content"], "hello");
1158
+ assert_eq!(history[2]["content"], "hi");
1159
+ }
1160
+
1161
+ #[tokio::test]
1162
+ async fn adopt_carryover_leaves_a_fresh_backend_alone() {
1163
+ let new_backend =
1164
+ OpenAiBackend::new("http://localhost", "", "m", Some("new prompt".into()));
1165
+
1166
+ adopt_carryover(&new_backend, Vec::new()).await;
1167
+
1168
+ let history = new_backend.history_snapshot().await;
1169
+ assert_eq!(history.len(), 1);
1170
+ assert_eq!(history[0]["content"], "new prompt");
1171
+ }
1172
+
1173
#[test]
1174
fn estimate_tokens_scales_with_serialized_size() {
1175
assert_eq!(estimate_tokens(&[]), 0);