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