1use serde::{Deserialize, Serialize};
2
3use crate::catalog::LlmModel;
4use crate::model_settings::ModelSettings;
5use crate::reasoning::ReasoningEffort;
6use crate::types::IsoString;
7
8use super::{AssistantReply, ChatMessage, MessageId, ToolCallError, ToolCallResult, ToolDefinition};
9
10#[doc = include_str!("docs/context.md")]
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct Context {
13 messages: Vec<ChatMessage>,
14 tools: Vec<ToolDefinition>,
15 #[serde(skip)]
16 reasoning_effort: ReasoningEffort,
17 #[serde(skip)]
18 model_settings: ModelSettings,
19 #[serde(skip)]
20 prompt_cache_key: Option<String>,
21 #[serde(skip)]
22 session_affinity_key: Option<String>,
23}
24
25impl Context {
26 pub fn new(messages: Vec<ChatMessage>, tools: Vec<ToolDefinition>) -> Self {
27 Self {
28 messages,
29 tools,
30 reasoning_effort: ReasoningEffort::Default,
31 model_settings: ModelSettings::default(),
32 prompt_cache_key: None,
33 session_affinity_key: None,
34 }
35 }
36
37 pub fn prompt_cache_key(&self) -> Option<&str> {
38 self.prompt_cache_key.as_deref()
39 }
40
41 pub fn set_prompt_cache_key(&mut self, key: Option<String>) {
42 self.prompt_cache_key = key;
43 }
44
45 pub fn session_affinity_key(&self) -> Option<&str> {
46 self.session_affinity_key.as_deref()
47 }
48
49 pub fn set_session_affinity_key(&mut self, key: Option<String>) {
50 self.session_affinity_key = key;
51 }
52
53 pub fn reasoning_effort(&self) -> ReasoningEffort {
54 self.reasoning_effort
55 }
56
57 pub fn set_reasoning_effort(&mut self, effort: ReasoningEffort) {
58 self.reasoning_effort = effort;
59 }
60
61 pub fn model_settings(&self) -> &ModelSettings {
62 &self.model_settings
63 }
64
65 pub fn set_model_settings(&mut self, settings: ModelSettings) {
66 self.model_settings = settings;
67 }
68
69 pub fn add_message(&mut self, message: ChatMessage) {
70 self.messages.push(message);
71 }
72
73 pub fn set_tools(&mut self, tools: Vec<ToolDefinition>) {
74 self.tools = tools;
75 }
76
77 pub fn set_system_content(&mut self, content: String) {
78 if let Some(ChatMessage::System { content: existing, .. }) = self.messages.first_mut() {
79 *existing = content;
80 } else {
81 self.messages.insert(0, ChatMessage::system(content));
82 }
83 }
84
85 pub fn system_content(&self) -> Option<&str> {
86 match self.messages.first() {
87 Some(ChatMessage::System { content, .. }) if !content.is_empty() => Some(content),
88 _ => None,
89 }
90 }
91
92 pub fn messages(&self) -> &Vec<ChatMessage> {
93 &self.messages
94 }
95
96 pub fn tools(&self) -> &Vec<ToolDefinition> {
97 &self.tools
98 }
99
100 pub fn message_count(&self) -> usize {
102 self.messages.len()
103 }
104
105 pub fn estimated_token_count(&self) -> u32 {
108 let message_bytes: usize = self.messages.iter().map(ChatMessage::estimated_bytes).sum();
109 let tool_bytes: usize = self
110 .tools
111 .iter()
112 .map(|tool| tool.name.len() + tool.description.len() + tool.parameters.to_string().len())
113 .sum();
114 let total_bytes = message_bytes + tool_bytes;
115 u32::try_from(total_bytes / 4).unwrap_or(u32::MAX)
116 }
117
118 pub fn push_assistant_turn(
119 &mut self,
120 message_id: MessageId,
121 model: Option<LlmModel>,
122 mut content: AssistantReply,
123 results: Vec<Result<ToolCallResult, ToolCallError>>,
124 ) -> &ChatMessage {
125 content.pair_tool_calls(&results);
126 let reply_index = self.messages.len();
127 self.messages.push(ChatMessage::Assistant { message_id, model, content, timestamp: IsoString::now() });
128 self.messages.extend(results.into_iter().map(ChatMessage::ToolCallResult));
129 &self.messages[reply_index]
130 }
131
132 pub fn clear_conversation(&mut self) {
134 self.messages.retain(super::chat_message::ChatMessage::is_system);
135 }
136
137 pub fn replace_conversation(&mut self, messages: Vec<ChatMessage>) {
139 self.messages = self
140 .messages
141 .drain(..)
142 .filter(ChatMessage::is_system)
143 .chain(messages.into_iter().filter(|m| !m.is_system()))
144 .collect();
145 }
146
147 pub fn messages_for_summary(&self) -> Vec<&ChatMessage> {
149 self.messages.iter().filter(|msg| !msg.is_system()).collect()
150 }
151
152 pub fn with_compacted_summary(&self, message_id: MessageId, summary: &str) -> Context {
155 let system_messages: Vec<_> = self.messages.iter().filter(|msg| msg.is_system()).cloned().collect();
156
157 let non_system_count = self.messages.len() - system_messages.len();
158
159 let mut messages = system_messages;
160 if non_system_count > 0 {
161 messages.push(ChatMessage::Summary {
162 message_id,
163 content: summary.to_string(),
164 timestamp: IsoString::now(),
165 messages_compacted: non_system_count,
166 });
167 }
168
169 Context { messages, ..self.clone() }
170 }
171}
172
173#[cfg(test)]
174mod tests {
175 use super::*;
176
177 use crate::AssistantContent;
178 use crate::testing::assistant_message;
179
180 fn create_test_context() -> Context {
181 let messages = vec![
182 ChatMessage::system("You are a helpful assistant."),
183 ChatMessage::user("Hello"),
184 assistant_message().text("Hi there!").build(),
185 ChatMessage::ToolCallResult(Ok(ToolCallResult {
186 id: "1".to_string(),
187 name: "tool1".to_string(),
188 arguments: "{}".to_string(),
189 result: "Result 1".to_string(),
190 })),
191 ChatMessage::ToolCallResult(Ok(ToolCallResult {
192 id: "2".to_string(),
193 name: "tool2".to_string(),
194 arguments: "{}".to_string(),
195 result: "Result 2".to_string(),
196 })),
197 ChatMessage::ToolCallResult(Ok(ToolCallResult {
198 id: "3".to_string(),
199 name: "tool3".to_string(),
200 arguments: "{}".to_string(),
201 result: "Result 3".to_string(),
202 })),
203 ];
204 Context::new(messages, vec![])
205 }
206
207 #[test]
208 fn replace_conversation_preserves_system_message() {
209 let mut ctx = create_test_context();
210 ctx.replace_conversation(vec![ChatMessage::user("new")]);
211
212 assert_eq!(ctx.message_count(), 2);
213 assert!(ctx.messages()[0].is_system());
214 assert!(matches!(ctx.messages()[1], ChatMessage::User { .. }));
215 }
216
217 #[test]
218 fn replace_conversation_replaces_old_non_system_messages() {
219 let mut ctx = create_test_context();
220 ctx.replace_conversation(vec![assistant_message().text("replacement").build()]);
221
222 assert_eq!(ctx.message_count(), 2);
223 assert!(
224 ctx.messages()
225 .iter()
226 .all(|message| { !matches!(message, ChatMessage::User { .. } | ChatMessage::ToolCallResult(_)) })
227 );
228 assert!(
229 matches!(&ctx.messages()[1], ChatMessage::Assistant { content, .. } if content.text() == "replacement")
230 );
231 }
232
233 #[test]
234 fn replace_conversation_filters_incoming_system_messages() {
235 let mut ctx = create_test_context();
236 ctx.replace_conversation(vec![ChatMessage::system("wrong system"), ChatMessage::user("kept")]);
237
238 assert_eq!(ctx.message_count(), 2);
239 assert!(
240 matches!(ctx.messages()[0], ChatMessage::System { ref content, .. } if content == "You are a helpful assistant.")
241 );
242 assert!(matches!(ctx.messages()[1], ChatMessage::User { .. }));
243 }
244
245 #[test]
246 fn replace_conversation_does_not_change_tools() {
247 let tool = ToolDefinition::new("read_file", "Reads a file", serde_json::json!({}));
248 let mut ctx = Context::new(vec![ChatMessage::system("system")], vec![tool.clone()]);
249 ctx.replace_conversation(vec![ChatMessage::user("new")]);
250
251 assert_eq!(ctx.tools(), &vec![tool]);
252 }
253
254 #[test]
255 fn test_message_count() {
256 let ctx = create_test_context();
257 assert_eq!(ctx.message_count(), 6);
258 }
259
260 #[test]
261 fn test_with_compacted_summary_preserves_system_prompt() {
262 let ctx = create_test_context();
263 let compacted = ctx.with_compacted_summary(MessageId::new(), "This is a summary of previous conversation.");
264
265 assert_eq!(compacted.message_count(), 2);
266 assert!(compacted.messages()[0].is_system());
267 assert!(compacted.messages()[1].is_summary());
268 }
269
270 #[test]
271 fn test_with_compacted_summary_empty_context() {
272 let ctx = Context::new(vec![ChatMessage::system("System")], vec![]);
273 let compacted = ctx.with_compacted_summary(MessageId::new(), "Summary");
274
275 assert_eq!(compacted.message_count(), 1);
276 }
277
278 #[test]
279 fn test_messages_for_summary() {
280 let ctx = create_test_context();
281 let msgs = ctx.messages_for_summary();
282
283 assert_eq!(msgs.len(), 5);
284 assert!(msgs.iter().all(|m| !m.is_system()));
285 }
286
287 #[test]
288 fn test_prompt_cache_key_default_is_none() {
289 let ctx = create_test_context();
290 assert_eq!(ctx.prompt_cache_key(), None);
291 }
292
293 #[test]
294 fn test_prompt_cache_key_set_and_get() {
295 let mut ctx = create_test_context();
296 ctx.set_prompt_cache_key(Some("session-123".to_string()));
297 assert_eq!(ctx.prompt_cache_key(), Some("session-123"));
298
299 ctx.set_prompt_cache_key(None);
300 assert_eq!(ctx.prompt_cache_key(), None);
301 }
302
303 #[test]
304 fn test_prompt_cache_key_preserved_through_compaction() {
305 let mut ctx = create_test_context();
306 ctx.set_prompt_cache_key(Some("session-abc".to_string()));
307 let compacted = ctx.with_compacted_summary(MessageId::new(), "Summary");
308 assert_eq!(compacted.prompt_cache_key(), Some("session-abc"));
309 }
310
311 #[test]
312 fn session_affinity_key_is_runtime_metadata_preserved_by_compaction() {
313 let mut context = create_test_context();
314 assert_eq!(context.session_affinity_key(), None);
315
316 context.set_session_affinity_key(Some("conversation-123".to_string()));
317
318 assert_eq!(context.session_affinity_key(), Some("conversation-123"));
319 assert_eq!(
320 context.with_compacted_summary(MessageId::new(), "Summary").session_affinity_key(),
321 Some("conversation-123")
322 );
323 }
324
325 #[test]
326 fn test_reasoning_effort_initializes_to_default() {
327 let ctx = create_test_context();
328 assert_eq!(ctx.reasoning_effort(), ReasoningEffort::Default);
329 }
330
331 #[test]
332 fn test_reasoning_effort_set_and_get() {
333 let mut ctx = create_test_context();
334 ctx.set_reasoning_effort(crate::ReasoningEffort::High);
335 assert_eq!(ctx.reasoning_effort(), crate::ReasoningEffort::High);
336
337 ctx.set_reasoning_effort(ReasoningEffort::Default);
338 assert_eq!(ctx.reasoning_effort(), ReasoningEffort::Default);
339 }
340
341 #[test]
342 fn test_reasoning_effort_preserved_through_compaction() {
343 let mut ctx = create_test_context();
344 ctx.set_reasoning_effort(crate::ReasoningEffort::Disabled);
345 let compacted = ctx.with_compacted_summary(MessageId::new(), "Summary");
346 assert_eq!(compacted.reasoning_effort(), crate::ReasoningEffort::Disabled);
347 assert_eq!(ctx.clone().reasoning_effort(), ReasoningEffort::Disabled);
348 ctx.replace_conversation(vec![]);
349 assert_eq!(ctx.reasoning_effort(), ReasoningEffort::Disabled);
350 }
351
352 #[test]
353 fn test_estimated_token_count() {
354 use crate::ToolDefinition;
355
356 let ctx = create_test_context();
362 let base_estimate = ctx.estimated_token_count();
363
364 assert_eq!(base_estimate, 87 / 4);
366
367 let tool = ToolDefinition::new("read_file", "Reads a file", serde_json::json!({}));
368 let ctx_with_tools = Context::new(ctx.messages().clone(), vec![tool]);
369 let with_tools_estimate = ctx_with_tools.estimated_token_count();
370 assert_eq!(with_tools_estimate, (87 + 9 + 12 + 2) / 4);
371 assert!(with_tools_estimate > base_estimate);
372 }
373
374 #[test]
375 fn compaction_drops_encrypted_reasoning() {
376 let ctx = Context::new(
377 vec![
378 ChatMessage::user("Hello"),
379 assistant_message()
380 .model("anthropic:claude-opus-4-6")
381 .reasoning("thinking")
382 .encrypted_reasoning("r_test", "blob")
383 .text("I see.")
384 .build(),
385 ],
386 vec![],
387 );
388 let compacted = ctx.with_compacted_summary(MessageId::new(), "Summary of conversation");
389
390 assert!(compacted.messages().iter().all(|message| encrypted_reasoning(message).is_empty()));
391 }
392
393 #[test]
394 fn assistant_turn_pairs_every_tool_call_with_a_result() {
395 let mut ctx = Context::new(vec![], vec![]);
396 let model: LlmModel = "anthropic:claude-opus-4-6".parse().unwrap();
397 let content = assistant_message()
398 .tool_call("call_1", "read", "{}")
399 .tool_call("call_cancelled", "read", "{}")
400 .build_reply();
401
402 let result = ToolCallResult {
403 id: "call_1".to_string(),
404 name: "read".to_string(),
405 arguments: "{}".to_string(),
406 result: "ok".to_string(),
407 };
408 let error = ToolCallError {
409 id: "call_retried".to_string(),
410 name: "bash".to_string(),
411 arguments: None,
412 error: "failed".to_string(),
413 };
414
415 let recorded = ctx
416 .push_assistant_turn(MessageId::new(), Some(model.clone()), content, vec![Ok(result), Err(error)])
417 .clone();
418
419 assert_eq!(ctx.messages()[0], recorded);
420 assert_eq!(ctx.message_count(), 3);
421 let ChatMessage::Assistant { model: author, content, .. } = recorded else {
422 panic!("expected assistant message")
423 };
424 let ids: Vec<_> = content.tool_calls().map(|call| call.id.as_str()).collect();
425 assert_eq!(ids, ["call_1", "call_retried"]);
426 assert_eq!(author, Some(model));
427 }
428
429 fn encrypted_reasoning(message: &ChatMessage) -> Vec<&str> {
430 let ChatMessage::Assistant { content, .. } = message else { return vec![] };
431 content
432 .blocks()
433 .iter()
434 .filter_map(|block| match block {
435 AssistantContent::Reasoning { encrypted: Some(encrypted), .. } => Some(encrypted.content.as_str()),
436 _ => None,
437 })
438 .collect()
439 }
440}