1use serde::{Deserialize, Serialize};
2
3use crate::catalog::LlmModel;
4use crate::chat_message::AssistantReasoning;
5use crate::model_settings::ModelSettings;
6use crate::reasoning::ReasoningEffort;
7use crate::types::IsoString;
8
9use super::{ChatMessage, ToolDefinition};
10
11#[doc = include_str!("docs/context.md")]
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct Context {
14 messages: Vec<ChatMessage>,
15 tools: Vec<ToolDefinition>,
16 #[serde(skip)]
17 reasoning_effort: Option<ReasoningEffort>,
18 #[serde(skip)]
19 model_settings: ModelSettings,
20 #[serde(skip)]
21 prompt_cache_key: Option<String>,
22}
23
24impl Context {
25 pub fn new(messages: Vec<ChatMessage>, tools: Vec<ToolDefinition>) -> Self {
26 Self {
27 messages,
28 tools,
29 reasoning_effort: None,
30 model_settings: ModelSettings::default(),
31 prompt_cache_key: None,
32 }
33 }
34
35 pub fn prompt_cache_key(&self) -> Option<&str> {
36 self.prompt_cache_key.as_deref()
37 }
38
39 pub fn set_prompt_cache_key(&mut self, key: Option<String>) {
40 self.prompt_cache_key = key;
41 }
42
43 pub fn reasoning_effort(&self) -> Option<ReasoningEffort> {
44 self.reasoning_effort
45 }
46
47 pub fn set_reasoning_effort(&mut self, effort: Option<ReasoningEffort>) {
48 self.reasoning_effort = effort;
49 }
50
51 pub fn model_settings(&self) -> &ModelSettings {
52 &self.model_settings
53 }
54
55 pub fn set_model_settings(&mut self, settings: ModelSettings) {
56 self.model_settings = settings;
57 }
58
59 pub fn add_message(&mut self, message: ChatMessage) {
60 self.messages.push(message);
61 }
62
63 pub fn set_tools(&mut self, tools: Vec<ToolDefinition>) {
64 self.tools = tools;
65 }
66
67 pub fn set_system_content(&mut self, content: String) {
68 if let Some(ChatMessage::System { content: existing, .. }) = self.messages.first_mut() {
69 *existing = content;
70 } else {
71 self.messages.insert(0, ChatMessage::System { content, timestamp: IsoString::now() });
72 }
73 }
74
75 pub fn messages(&self) -> &Vec<ChatMessage> {
76 &self.messages
77 }
78
79 pub fn tools(&self) -> &Vec<ToolDefinition> {
80 &self.tools
81 }
82
83 pub fn message_count(&self) -> usize {
85 self.messages.len()
86 }
87
88 pub fn estimated_token_count(&self) -> u32 {
91 let message_bytes: usize = self.messages.iter().map(ChatMessage::estimated_bytes).sum();
92 let tool_bytes: usize = self
93 .tools
94 .iter()
95 .map(|tool| tool.name.len() + tool.description.len() + tool.parameters.to_string().len())
96 .sum();
97 let total_bytes = message_bytes + tool_bytes;
98 u32::try_from(total_bytes / 4).unwrap_or(u32::MAX)
99 }
100
101 pub fn push_assistant_turn(
103 &mut self,
104 content: &str,
105 reasoning: AssistantReasoning,
106 completed_tools: Vec<Result<super::ToolCallResult, super::ToolCallError>>,
107 ) {
108 let tool_requests: Vec<_> = completed_tools
109 .iter()
110 .map(|result| match result {
111 Ok(r) => {
112 super::ToolCallRequest { id: r.id.clone(), name: r.name.clone(), arguments: r.arguments.clone() }
113 }
114 Err(e) => super::ToolCallRequest {
115 id: e.id.clone(),
116 name: e.name.clone(),
117 arguments: e.arguments.clone().unwrap_or_default(),
118 },
119 })
120 .collect();
121
122 self.messages.push(ChatMessage::Assistant {
123 content: content.to_string(),
124 reasoning,
125 timestamp: IsoString::now(),
126 tool_calls: tool_requests,
127 });
128
129 for result in completed_tools {
130 self.messages.push(ChatMessage::ToolCallResult(result));
131 }
132 }
133
134 pub fn filter_encrypted_reasoning(&self, model: Option<&LlmModel>) -> Self {
141 let messages = self
142 .messages
143 .iter()
144 .map(|message| match message {
145 ChatMessage::Assistant { content, reasoning, timestamp, tool_calls } => ChatMessage::Assistant {
146 content: content.clone(),
147 reasoning: AssistantReasoning {
148 summary_text: reasoning.summary_text.clone(),
149 encrypted_content: reasoning
150 .encrypted_content
151 .as_ref()
152 .filter(|encrypted| model.is_some_and(|model| &encrypted.model == model))
153 .cloned(),
154 },
155 timestamp: timestamp.clone(),
156 tool_calls: tool_calls.clone(),
157 },
158 other => other.clone(),
159 })
160 .collect();
161 Self { messages, ..self.clone() }
162 }
163
164 pub fn clear_conversation(&mut self) {
166 self.messages.retain(super::chat_message::ChatMessage::is_system);
167 }
168
169 pub fn replace_conversation(&mut self, messages: Vec<ChatMessage>) {
171 self.messages = self
172 .messages
173 .drain(..)
174 .filter(ChatMessage::is_system)
175 .chain(messages.into_iter().filter(|m| !m.is_system()))
176 .collect();
177 }
178
179 pub fn messages_for_summary(&self) -> Vec<&ChatMessage> {
181 self.messages.iter().filter(|msg| !msg.is_system()).collect()
182 }
183
184 pub fn with_compacted_summary(&self, summary: &str) -> Context {
187 let system_messages: Vec<_> = self.messages.iter().filter(|msg| msg.is_system()).cloned().collect();
188
189 let non_system_count = self.messages.len() - system_messages.len();
190
191 let mut messages = system_messages;
192 if non_system_count > 0 {
193 messages.push(ChatMessage::Summary {
194 content: summary.to_string(),
195 timestamp: IsoString::now(),
196 messages_compacted: non_system_count,
197 });
198 }
199
200 Context { messages, ..self.clone() }
201 }
202}
203
204#[cfg(test)]
205mod tests {
206 use super::*;
207
208 use crate::ToolCallResult;
209 use crate::catalog::LlmModel;
210
211 fn create_test_context() -> Context {
212 let messages = vec![
213 ChatMessage::system("You are a helpful assistant."),
214 ChatMessage::user("Hello"),
215 ChatMessage::Assistant {
216 content: "Hi there!".to_string(),
217 reasoning: AssistantReasoning::default(),
218 timestamp: IsoString::now(),
219 tool_calls: vec![],
220 },
221 ChatMessage::ToolCallResult(Ok(ToolCallResult {
222 id: "1".to_string(),
223 name: "tool1".to_string(),
224 arguments: "{}".to_string(),
225 result: "Result 1".to_string(),
226 })),
227 ChatMessage::ToolCallResult(Ok(ToolCallResult {
228 id: "2".to_string(),
229 name: "tool2".to_string(),
230 arguments: "{}".to_string(),
231 result: "Result 2".to_string(),
232 })),
233 ChatMessage::ToolCallResult(Ok(ToolCallResult {
234 id: "3".to_string(),
235 name: "tool3".to_string(),
236 arguments: "{}".to_string(),
237 result: "Result 3".to_string(),
238 })),
239 ];
240 Context::new(messages, vec![])
241 }
242
243 #[test]
244 fn replace_conversation_preserves_system_message() {
245 let mut ctx = create_test_context();
246 ctx.replace_conversation(vec![ChatMessage::user("new")]);
247
248 assert_eq!(ctx.message_count(), 2);
249 assert!(ctx.messages()[0].is_system());
250 assert!(matches!(ctx.messages()[1], ChatMessage::User { .. }));
251 }
252
253 #[test]
254 fn replace_conversation_replaces_old_non_system_messages() {
255 let mut ctx = create_test_context();
256 ctx.replace_conversation(vec![ChatMessage::Assistant {
257 content: "replacement".to_string(),
258 reasoning: AssistantReasoning::default(),
259 timestamp: IsoString::now(),
260 tool_calls: vec![],
261 }]);
262
263 assert_eq!(ctx.message_count(), 2);
264 assert!(
265 ctx.messages()
266 .iter()
267 .all(|message| { !matches!(message, ChatMessage::User { .. } | ChatMessage::ToolCallResult(_)) })
268 );
269 assert!(matches!(ctx.messages()[1], ChatMessage::Assistant { ref content, .. } if content == "replacement"));
270 }
271
272 #[test]
273 fn replace_conversation_filters_incoming_system_messages() {
274 let mut ctx = create_test_context();
275 ctx.replace_conversation(vec![ChatMessage::system("wrong system"), ChatMessage::user("kept")]);
276
277 assert_eq!(ctx.message_count(), 2);
278 assert!(
279 matches!(ctx.messages()[0], ChatMessage::System { ref content, .. } if content == "You are a helpful assistant.")
280 );
281 assert!(matches!(ctx.messages()[1], ChatMessage::User { .. }));
282 }
283
284 #[test]
285 fn replace_conversation_does_not_change_tools() {
286 let tool = ToolDefinition::new("read_file", "Reads a file", serde_json::json!({}));
287 let mut ctx = Context::new(vec![ChatMessage::system("system")], vec![tool.clone()]);
288 ctx.replace_conversation(vec![ChatMessage::user("new")]);
289
290 assert_eq!(ctx.tools(), &vec![tool]);
291 }
292
293 #[test]
294 fn test_message_count() {
295 let ctx = create_test_context();
296 assert_eq!(ctx.message_count(), 6);
297 }
298
299 #[test]
300 fn test_with_compacted_summary_preserves_system_prompt() {
301 let ctx = create_test_context();
302 let compacted = ctx.with_compacted_summary("This is a summary of previous conversation.");
303
304 assert_eq!(compacted.message_count(), 2);
305 assert!(compacted.messages()[0].is_system());
306 assert!(compacted.messages()[1].is_summary());
307 }
308
309 #[test]
310 fn test_with_compacted_summary_empty_context() {
311 let ctx = Context::new(vec![ChatMessage::system("System")], vec![]);
312 let compacted = ctx.with_compacted_summary("Summary");
313
314 assert_eq!(compacted.message_count(), 1);
315 }
316
317 #[test]
318 fn test_messages_for_summary() {
319 let ctx = create_test_context();
320 let msgs = ctx.messages_for_summary();
321
322 assert_eq!(msgs.len(), 5);
323 assert!(msgs.iter().all(|m| !m.is_system()));
324 }
325
326 #[test]
327 fn test_prompt_cache_key_default_is_none() {
328 let ctx = create_test_context();
329 assert_eq!(ctx.prompt_cache_key(), None);
330 }
331
332 #[test]
333 fn test_prompt_cache_key_set_and_get() {
334 let mut ctx = create_test_context();
335 ctx.set_prompt_cache_key(Some("session-123".to_string()));
336 assert_eq!(ctx.prompt_cache_key(), Some("session-123"));
337
338 ctx.set_prompt_cache_key(None);
339 assert_eq!(ctx.prompt_cache_key(), None);
340 }
341
342 #[test]
343 fn test_prompt_cache_key_preserved_through_compaction() {
344 let mut ctx = create_test_context();
345 ctx.set_prompt_cache_key(Some("session-abc".to_string()));
346 let compacted = ctx.with_compacted_summary("Summary");
347 assert_eq!(compacted.prompt_cache_key(), Some("session-abc"));
348 }
349
350 #[test]
351 fn test_prompt_cache_key_preserved_through_projection() {
352 let model: LlmModel = "anthropic:claude-opus-4-6".parse().unwrap();
353 let mut ctx = Context::new(vec![ChatMessage::user("Hello")], vec![]);
354 ctx.set_prompt_cache_key(Some("session-xyz".to_string()));
355 let projected = ctx.filter_encrypted_reasoning(Some(&model));
356 assert_eq!(projected.prompt_cache_key(), Some("session-xyz"));
357 }
358
359 #[test]
360 fn test_reasoning_effort_default_is_none() {
361 let ctx = create_test_context();
362 assert_eq!(ctx.reasoning_effort(), None);
363 }
364
365 #[test]
366 fn test_reasoning_effort_set_and_get() {
367 let mut ctx = create_test_context();
368 ctx.set_reasoning_effort(Some(crate::ReasoningEffort::High));
369 assert_eq!(ctx.reasoning_effort(), Some(crate::ReasoningEffort::High));
370
371 ctx.set_reasoning_effort(None);
372 assert_eq!(ctx.reasoning_effort(), None);
373 }
374
375 #[test]
376 fn test_reasoning_effort_preserved_through_compaction() {
377 let mut ctx = create_test_context();
378 ctx.set_reasoning_effort(Some(crate::ReasoningEffort::Medium));
379 let compacted = ctx.with_compacted_summary("Summary");
380 assert_eq!(compacted.reasoning_effort(), Some(crate::ReasoningEffort::Medium));
381 }
382
383 #[test]
384 fn test_estimated_token_count() {
385 use crate::ToolDefinition;
386
387 let ctx = create_test_context();
393 let base_estimate = ctx.estimated_token_count();
394
395 assert_eq!(base_estimate, 87 / 4);
397
398 let tool = ToolDefinition::new("read_file", "Reads a file", serde_json::json!({}));
399 let ctx_with_tools = Context::new(ctx.messages().clone(), vec![tool]);
400 let with_tools_estimate = ctx_with_tools.estimated_token_count();
401 assert_eq!(with_tools_estimate, (87 + 9 + 12 + 2) / 4);
402 assert!(with_tools_estimate > base_estimate);
403 }
404
405 #[test]
406 fn compaction_drops_encrypted_reasoning() {
407 let model: LlmModel = "anthropic:claude-opus-4-6".parse().unwrap();
408 let ctx = Context::new(
409 vec![
410 ChatMessage::user("Hello"),
411 ChatMessage::Assistant {
412 content: "I see.".to_string(),
413 reasoning: AssistantReasoning {
414 summary_text: Some("thinking".to_string()),
415 encrypted_content: Some(crate::EncryptedReasoningContent {
416 id: "r_test".to_string(),
417 model,
418 content: "blob".to_string(),
419 }),
420 },
421 timestamp: IsoString::now(),
422 tool_calls: vec![],
423 },
424 ],
425 vec![],
426 );
427 let compacted = ctx.with_compacted_summary("Summary of conversation");
428
429 for msg in compacted.messages() {
430 if let ChatMessage::Assistant { reasoning, .. } = msg {
431 assert!(reasoning.encrypted_content.is_none(), "compaction should drop encrypted reasoning");
432 }
433 }
434 }
435
436 #[test]
437 fn projected_for_keeps_matching_model() {
438 let model: LlmModel = "anthropic:claude-opus-4-6".parse().unwrap();
439 let ctx = Context::new(
440 vec![ChatMessage::Assistant {
441 content: "reply".to_string(),
442 reasoning: AssistantReasoning {
443 summary_text: Some("think".to_string()),
444 encrypted_content: Some(crate::EncryptedReasoningContent {
445 id: "r_test".to_string(),
446 model: model.clone(),
447 content: "blob".to_string(),
448 }),
449 },
450 timestamp: IsoString::now(),
451 tool_calls: vec![],
452 }],
453 vec![],
454 );
455 let projected = ctx.filter_encrypted_reasoning(Some(&model));
456 if let ChatMessage::Assistant { reasoning, .. } = &projected.messages()[0] {
457 assert!(reasoning.encrypted_content.is_some());
458 assert_eq!(reasoning.summary_text.as_deref(), Some("think"));
459 } else {
460 panic!("expected assistant message");
461 }
462 }
463
464 #[test]
465 fn projected_for_strips_non_matching_model() {
466 let model_a: LlmModel = "anthropic:claude-opus-4-6".parse().unwrap();
467 let model_b: LlmModel = "anthropic:claude-sonnet-4-5-20250929".parse().unwrap();
468 let ctx = Context::new(
469 vec![ChatMessage::Assistant {
470 content: "reply".to_string(),
471 reasoning: AssistantReasoning {
472 summary_text: Some("think".to_string()),
473 encrypted_content: Some(crate::EncryptedReasoningContent {
474 id: "r_test".to_string(),
475 model: model_a,
476 content: "blob".to_string(),
477 }),
478 },
479 timestamp: IsoString::now(),
480 tool_calls: vec![],
481 }],
482 vec![],
483 );
484 let projected = ctx.filter_encrypted_reasoning(Some(&model_b));
485 if let ChatMessage::Assistant { reasoning, .. } = &projected.messages()[0] {
486 assert!(reasoning.encrypted_content.is_none());
487 assert_eq!(reasoning.summary_text.as_deref(), Some("think"));
488 } else {
489 panic!("expected assistant message");
490 }
491 }
492}