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 messages(&self) -> &Vec<ChatMessage> {
51 &self.messages
52 }
53
54 pub fn tools(&self) -> &Vec<ToolDefinition> {
55 &self.tools
56 }
57
58 pub fn message_count(&self) -> usize {
60 self.messages.len()
61 }
62
63 pub fn estimated_token_count(&self) -> u32 {
66 let message_bytes: usize = self.messages.iter().map(ChatMessage::estimated_bytes).sum();
67 let tool_bytes: usize =
68 self.tools.iter().map(|t| t.name.len() + t.description.len() + t.parameters.len()).sum();
69 let total_bytes = message_bytes + tool_bytes;
70 u32::try_from(total_bytes / 4).unwrap_or(u32::MAX)
71 }
72
73 pub fn push_assistant_turn(
75 &mut self,
76 content: &str,
77 reasoning: AssistantReasoning,
78 completed_tools: Vec<Result<super::ToolCallResult, super::ToolCallError>>,
79 ) {
80 let tool_requests: Vec<_> = completed_tools
81 .iter()
82 .map(|result| match result {
83 Ok(r) => {
84 super::ToolCallRequest { id: r.id.clone(), name: r.name.clone(), arguments: r.arguments.clone() }
85 }
86 Err(e) => super::ToolCallRequest {
87 id: e.id.clone(),
88 name: e.name.clone(),
89 arguments: e.arguments.clone().unwrap_or_default(),
90 },
91 })
92 .collect();
93
94 self.messages.push(ChatMessage::Assistant {
95 content: content.to_string(),
96 reasoning,
97 timestamp: IsoString::now(),
98 tool_calls: tool_requests,
99 });
100
101 for result in completed_tools {
102 self.messages.push(ChatMessage::ToolCallResult(result));
103 }
104 }
105
106 pub fn filter_encrypted_reasoning(&self, model: &LlmModel) -> Self {
109 let messages = self
110 .messages
111 .iter()
112 .map(|msg| match msg {
113 ChatMessage::Assistant { content, reasoning, timestamp, tool_calls } => ChatMessage::Assistant {
114 content: content.clone(),
115 reasoning: AssistantReasoning {
116 summary_text: reasoning.summary_text.clone(),
117 encrypted_content: reasoning
118 .encrypted_content
119 .as_ref()
120 .filter(|ec| &ec.model == model)
121 .cloned(),
122 },
123 timestamp: timestamp.clone(),
124 tool_calls: tool_calls.clone(),
125 },
126 other => other.clone(),
127 })
128 .collect();
129 Context {
130 messages,
131 tools: self.tools.clone(),
132 reasoning_effort: self.reasoning_effort,
133 prompt_cache_key: self.prompt_cache_key.clone(),
134 }
135 }
136
137 pub fn clear_conversation(&mut self) {
139 self.messages.retain(super::chat_message::ChatMessage::is_system);
140 }
141
142 pub fn messages_for_summary(&self) -> Vec<&ChatMessage> {
144 self.messages.iter().filter(|msg| !msg.is_system()).collect()
145 }
146
147 pub fn with_compacted_summary(&self, summary: &str) -> Context {
150 let system_messages: Vec<_> = self.messages.iter().filter(|msg| msg.is_system()).cloned().collect();
151
152 let non_system_count = self.messages.len() - system_messages.len();
153
154 let mut messages = system_messages;
155 if non_system_count > 0 {
156 messages.push(ChatMessage::Summary {
157 content: summary.to_string(),
158 timestamp: IsoString::now(),
159 messages_compacted: non_system_count,
160 });
161 }
162
163 Context {
164 messages,
165 tools: self.tools.clone(),
166 reasoning_effort: self.reasoning_effort,
167 prompt_cache_key: self.prompt_cache_key.clone(),
168 }
169 }
170}
171
172#[cfg(test)]
173mod tests {
174 use super::*;
175 use crate::ContentBlock;
176 use crate::ToolCallResult;
177 use crate::catalog::LlmModel;
178
179 fn create_test_context() -> Context {
180 let messages = vec![
181 ChatMessage::System { content: "You are a helpful assistant.".to_string(), timestamp: IsoString::now() },
182 ChatMessage::User { content: vec![ContentBlock::text("Hello")], timestamp: IsoString::now() },
183 ChatMessage::Assistant {
184 content: "Hi there!".to_string(),
185 reasoning: AssistantReasoning::default(),
186 timestamp: IsoString::now(),
187 tool_calls: vec![],
188 },
189 ChatMessage::ToolCallResult(Ok(ToolCallResult {
190 id: "1".to_string(),
191 name: "tool1".to_string(),
192 arguments: "{}".to_string(),
193 result: "Result 1".to_string(),
194 })),
195 ChatMessage::ToolCallResult(Ok(ToolCallResult {
196 id: "2".to_string(),
197 name: "tool2".to_string(),
198 arguments: "{}".to_string(),
199 result: "Result 2".to_string(),
200 })),
201 ChatMessage::ToolCallResult(Ok(ToolCallResult {
202 id: "3".to_string(),
203 name: "tool3".to_string(),
204 arguments: "{}".to_string(),
205 result: "Result 3".to_string(),
206 })),
207 ];
208 Context::new(messages, vec![])
209 }
210
211 #[test]
212 fn test_message_count() {
213 let ctx = create_test_context();
214 assert_eq!(ctx.message_count(), 6);
215 }
216
217 #[test]
218 fn test_with_compacted_summary_preserves_system_prompt() {
219 let ctx = create_test_context();
220 let compacted = ctx.with_compacted_summary("This is a summary of previous conversation.");
221
222 assert_eq!(compacted.message_count(), 2);
223 assert!(compacted.messages()[0].is_system());
224 assert!(compacted.messages()[1].is_summary());
225 }
226
227 #[test]
228 fn test_with_compacted_summary_empty_context() {
229 let ctx = Context::new(
230 vec![ChatMessage::System { content: "System".to_string(), timestamp: IsoString::now() }],
231 vec![],
232 );
233 let compacted = ctx.with_compacted_summary("Summary");
234
235 assert_eq!(compacted.message_count(), 1);
236 }
237
238 #[test]
239 fn test_messages_for_summary() {
240 let ctx = create_test_context();
241 let msgs = ctx.messages_for_summary();
242
243 assert_eq!(msgs.len(), 5);
244 assert!(msgs.iter().all(|m| !m.is_system()));
245 }
246
247 #[test]
248 fn test_prompt_cache_key_default_is_none() {
249 let ctx = create_test_context();
250 assert_eq!(ctx.prompt_cache_key(), None);
251 }
252
253 #[test]
254 fn test_prompt_cache_key_set_and_get() {
255 let mut ctx = create_test_context();
256 ctx.set_prompt_cache_key(Some("session-123".to_string()));
257 assert_eq!(ctx.prompt_cache_key(), Some("session-123"));
258
259 ctx.set_prompt_cache_key(None);
260 assert_eq!(ctx.prompt_cache_key(), None);
261 }
262
263 #[test]
264 fn test_prompt_cache_key_preserved_through_compaction() {
265 let mut ctx = create_test_context();
266 ctx.set_prompt_cache_key(Some("session-abc".to_string()));
267 let compacted = ctx.with_compacted_summary("Summary");
268 assert_eq!(compacted.prompt_cache_key(), Some("session-abc"));
269 }
270
271 #[test]
272 fn test_prompt_cache_key_preserved_through_projection() {
273 let model: LlmModel = "anthropic:claude-opus-4-6".parse().unwrap();
274 let mut ctx = Context::new(
275 vec![ChatMessage::User { content: vec![ContentBlock::text("Hello")], timestamp: IsoString::now() }],
276 vec![],
277 );
278 ctx.set_prompt_cache_key(Some("session-xyz".to_string()));
279 let projected = ctx.filter_encrypted_reasoning(&model);
280 assert_eq!(projected.prompt_cache_key(), Some("session-xyz"));
281 }
282
283 #[test]
284 fn test_reasoning_effort_default_is_none() {
285 let ctx = create_test_context();
286 assert_eq!(ctx.reasoning_effort(), None);
287 }
288
289 #[test]
290 fn test_reasoning_effort_set_and_get() {
291 let mut ctx = create_test_context();
292 ctx.set_reasoning_effort(Some(crate::ReasoningEffort::High));
293 assert_eq!(ctx.reasoning_effort(), Some(crate::ReasoningEffort::High));
294
295 ctx.set_reasoning_effort(None);
296 assert_eq!(ctx.reasoning_effort(), None);
297 }
298
299 #[test]
300 fn test_reasoning_effort_preserved_through_compaction() {
301 let mut ctx = create_test_context();
302 ctx.set_reasoning_effort(Some(crate::ReasoningEffort::Medium));
303 let compacted = ctx.with_compacted_summary("Summary");
304 assert_eq!(compacted.reasoning_effort(), Some(crate::ReasoningEffort::Medium));
305 }
306
307 #[test]
308 fn test_estimated_token_count() {
309 use crate::ToolDefinition;
310
311 let ctx = create_test_context();
317 let base_estimate = ctx.estimated_token_count();
318
319 assert_eq!(base_estimate, 87 / 4);
321
322 let tool = ToolDefinition {
324 name: "read_file".to_string(), description: "Reads a file".to_string(), parameters: "{}".to_string(), server: None,
328 };
329 let ctx_with_tools = Context::new(ctx.messages().clone(), vec![tool]);
330 let with_tools_estimate = ctx_with_tools.estimated_token_count();
331 assert_eq!(with_tools_estimate, (87 + 9 + 12 + 2) / 4);
332 assert!(with_tools_estimate > base_estimate);
333 }
334
335 #[test]
336 fn compaction_drops_encrypted_reasoning() {
337 let model: LlmModel = "anthropic:claude-opus-4-6".parse().unwrap();
338 let ctx = Context::new(
339 vec![
340 ChatMessage::User { content: vec![ContentBlock::text("Hello")], timestamp: IsoString::now() },
341 ChatMessage::Assistant {
342 content: "I see.".to_string(),
343 reasoning: AssistantReasoning {
344 summary_text: Some("thinking".to_string()),
345 encrypted_content: Some(crate::EncryptedReasoningContent {
346 id: "r_test".to_string(),
347 model,
348 content: "blob".to_string(),
349 }),
350 },
351 timestamp: IsoString::now(),
352 tool_calls: vec![],
353 },
354 ],
355 vec![],
356 );
357 let compacted = ctx.with_compacted_summary("Summary of conversation");
358
359 for msg in compacted.messages() {
360 if let ChatMessage::Assistant { reasoning, .. } = msg {
361 assert!(reasoning.encrypted_content.is_none(), "compaction should drop encrypted reasoning");
362 }
363 }
364 }
365
366 #[test]
367 fn projected_for_keeps_matching_model() {
368 let model: LlmModel = "anthropic:claude-opus-4-6".parse().unwrap();
369 let ctx = Context::new(
370 vec![ChatMessage::Assistant {
371 content: "reply".to_string(),
372 reasoning: AssistantReasoning {
373 summary_text: Some("think".to_string()),
374 encrypted_content: Some(crate::EncryptedReasoningContent {
375 id: "r_test".to_string(),
376 model: model.clone(),
377 content: "blob".to_string(),
378 }),
379 },
380 timestamp: IsoString::now(),
381 tool_calls: vec![],
382 }],
383 vec![],
384 );
385 let projected = ctx.filter_encrypted_reasoning(&model);
386 if let ChatMessage::Assistant { reasoning, .. } = &projected.messages()[0] {
387 assert!(reasoning.encrypted_content.is_some());
388 assert_eq!(reasoning.summary_text.as_deref(), Some("think"));
389 } else {
390 panic!("expected assistant message");
391 }
392 }
393
394 #[test]
395 fn projected_for_strips_non_matching_model() {
396 let model_a: LlmModel = "anthropic:claude-opus-4-6".parse().unwrap();
397 let model_b: LlmModel = "anthropic:claude-sonnet-4-5-20250929".parse().unwrap();
398 let ctx = Context::new(
399 vec![ChatMessage::Assistant {
400 content: "reply".to_string(),
401 reasoning: AssistantReasoning {
402 summary_text: Some("think".to_string()),
403 encrypted_content: Some(crate::EncryptedReasoningContent {
404 id: "r_test".to_string(),
405 model: model_a,
406 content: "blob".to_string(),
407 }),
408 },
409 timestamp: IsoString::now(),
410 tool_calls: vec![],
411 }],
412 vec![],
413 );
414 let projected = ctx.filter_encrypted_reasoning(&model_b);
415 if let ChatMessage::Assistant { reasoning, .. } = &projected.messages()[0] {
416 assert!(reasoning.encrypted_content.is_none());
417 assert_eq!(reasoning.summary_text.as_deref(), Some("think"));
418 } else {
419 panic!("expected assistant message");
420 }
421 }
422}