1use std::collections::BTreeMap;
2use std::fmt::{Display, Formatter};
3
4use crate::compact::{
5 compact_session, estimate_session_tokens, CompactionConfig, CompactionResult,
6};
7use crate::config::RuntimeFeatureConfig;
8use crate::config::RuntimeHookConfig;
9use crate::hooks::{HookRunResult, HookRunner};
10use crate::permissions::{PermissionOutcome, PermissionPolicy, PermissionPrompter};
11use crate::session::{ContentBlock, ConversationMessage, Session};
12use crate::usage::{TokenUsage, UsageTracker};
13
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct ApiRequest {
16 pub system_prompt: Vec<String>,
17 pub messages: Vec<ConversationMessage>,
18}
19
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub enum AssistantEvent {
22 TextDelta(String),
23 ToolUse {
24 id: String,
25 name: String,
26 input: String,
27 },
28 Usage(TokenUsage),
29 MessageStop,
30}
31
32pub trait ApiClient {
33 fn stream(&mut self, request: ApiRequest) -> Result<Vec<AssistantEvent>, RuntimeError>;
34}
35
36pub trait ToolExecutor {
37 fn execute(&mut self, tool_name: &str, input: &str) -> Result<String, ToolError>;
38}
39
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct ToolError {
42 message: String,
43}
44
45impl ToolError {
46 #[must_use]
47 pub fn new(message: impl Into<String>) -> Self {
48 Self {
49 message: message.into(),
50 }
51 }
52}
53
54impl Display for ToolError {
55 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
56 write!(f, "{}", self.message)
57 }
58}
59
60impl std::error::Error for ToolError {}
61
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct RuntimeError {
64 message: String,
65}
66
67impl RuntimeError {
68 #[must_use]
69 pub fn new(message: impl Into<String>) -> Self {
70 Self {
71 message: message.into(),
72 }
73 }
74}
75
76impl Display for RuntimeError {
77 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
78 write!(f, "{}", self.message)
79 }
80}
81
82impl std::error::Error for RuntimeError {}
83
84#[derive(Debug, Clone, PartialEq, Eq)]
85pub struct TurnSummary {
86 pub assistant_messages: Vec<ConversationMessage>,
87 pub tool_results: Vec<ConversationMessage>,
88 pub iterations: usize,
89 pub usage: TokenUsage,
90}
91
92pub struct ConversationRuntime<C, T> {
93 session: Session,
94 api_client: C,
95 tool_executor: T,
96 permission_policy: PermissionPolicy,
97 system_prompt: Vec<String>,
98 max_iterations: usize,
99 usage_tracker: UsageTracker,
100 hook_runner: HookRunner<RuntimeHookConfig>,
101}
102
103impl<C, T> ConversationRuntime<C, T>
104where
105 C: ApiClient,
106 T: ToolExecutor,
107{
108 #[must_use]
109 pub fn new(
110 session: Session,
111 api_client: C,
112 tool_executor: T,
113 permission_policy: PermissionPolicy,
114 system_prompt: Vec<String>,
115 ) -> Self {
116 Self::new_with_features(
117 session,
118 api_client,
119 tool_executor,
120 permission_policy,
121 system_prompt,
122 &RuntimeFeatureConfig::default(),
123 )
124 }
125
126 #[must_use]
127 pub fn new_with_features(
128 session: Session,
129 api_client: C,
130 tool_executor: T,
131 permission_policy: PermissionPolicy,
132 system_prompt: Vec<String>,
133 feature_config: &RuntimeFeatureConfig,
134 ) -> Self {
135 let usage_tracker = UsageTracker::from_session(&session);
136 Self {
137 session,
138 api_client,
139 tool_executor,
140 permission_policy,
141 system_prompt,
142 max_iterations: 200,
143 usage_tracker,
144 hook_runner: HookRunner::from_feature_config(feature_config),
145 }
146 }
147
148 pub fn update_system_prompt(&mut self, system_prompt: Vec<String>) {
149 self.system_prompt = system_prompt;
150 }
151
152 #[must_use]
153 pub fn with_max_iterations(mut self, max_iterations: usize) -> Self {
154 self.max_iterations = max_iterations;
155 self
156 }
157
158 pub fn run_turn(
159 &mut self,
160 user_input: impl Into<String>,
161 mut prompter: Option<&mut dyn PermissionPrompter>,
162 ) -> Result<TurnSummary, RuntimeError> {
163 self.session
164 .messages
165 .push(ConversationMessage::user_text(user_input.into()));
166
167 let mut assistant_messages = Vec::new();
168 let mut tool_results = Vec::new();
169 let mut iterations = 0;
170
171 loop {
172 iterations += 1;
173 if iterations > self.max_iterations {
174 return Err(RuntimeError::new(
175 "conversation loop exceeded the maximum number of iterations",
176 ));
177 }
178
179 let request = ApiRequest {
180 system_prompt: self.system_prompt.clone(),
181 messages: self.session.messages.clone(),
182 };
183 let events = self.api_client.stream(request)?;
184 let (assistant_message, usage) = build_assistant_message(events)?;
185 if let Some(usage) = usage {
186 self.usage_tracker.record(usage);
187 }
188 let pending_tool_uses = assistant_message
189 .blocks
190 .iter()
191 .filter_map(|block| match block {
192 ContentBlock::ToolUse { id, name, input } => {
193 Some((id.clone(), name.clone(), input.clone()))
194 }
195 _ => None,
196 })
197 .collect::<Vec<_>>();
198
199 self.session.messages.push(assistant_message.clone());
200 assistant_messages.push(assistant_message);
201
202 if pending_tool_uses.is_empty() {
203 break;
204 }
205
206 for (tool_use_id, tool_name, input) in pending_tool_uses {
207 let permission_outcome = if let Some(prompt) = prompter.as_mut() {
208 self.permission_policy
209 .authorize(&tool_name, &input, Some(*prompt))
210 } else {
211 self.permission_policy.authorize(&tool_name, &input, None)
212 };
213
214 let result_message = match permission_outcome {
215 PermissionOutcome::Allow => {
216 let pre_hook_result = self.hook_runner.run_pre_tool_use(&tool_name, &input);
217 if pre_hook_result.is_denied() {
218 let deny_message = format!("PreToolUse hook denied tool `{tool_name}`");
219 ConversationMessage::tool_result(
220 tool_use_id,
221 tool_name,
222 format_hook_message(&pre_hook_result, &deny_message),
223 true,
224 )
225 } else {
226 let (mut output, mut is_error) =
227 match self.tool_executor.execute(&tool_name, &input) {
228 Ok(output) => (output, false),
229 Err(error) => (error.to_string(), true),
230 };
231 output = merge_hook_feedback(pre_hook_result.messages(), output, false);
232
233 let post_hook_result = self
234 .hook_runner
235 .run_post_tool_use(&tool_name, &input, &output, is_error);
236 if post_hook_result.is_denied() {
237 is_error = true;
238 }
239 output = merge_hook_feedback(
240 post_hook_result.messages(),
241 output,
242 post_hook_result.is_denied(),
243 );
244
245 ConversationMessage::tool_result(
246 tool_use_id,
247 tool_name,
248 output,
249 is_error,
250 )
251 }
252 }
253 PermissionOutcome::Deny { reason } => {
254 ConversationMessage::tool_result(tool_use_id, tool_name, reason, true)
255 }
256 };
257 self.session.messages.push(result_message.clone());
258 tool_results.push(result_message);
259 }
260 }
261
262 Ok(TurnSummary {
263 assistant_messages,
264 tool_results,
265 iterations,
266 usage: self.usage_tracker.cumulative_usage(),
267 })
268 }
269
270 #[must_use]
271 pub fn compact(&self, config: CompactionConfig) -> CompactionResult {
272 compact_session(&self.session, config)
273 }
274
275 #[must_use]
276 pub fn estimated_tokens(&self) -> usize {
277 estimate_session_tokens(&self.session)
278 }
279
280 #[must_use]
281 pub fn usage(&self) -> &UsageTracker {
282 &self.usage_tracker
283 }
284
285 #[must_use]
286 pub fn session(&self) -> &Session {
287 &self.session
288 }
289
290 #[must_use]
291 pub fn into_session(self) -> Session {
292 self.session
293 }
294}
295
296fn build_assistant_message(
297 events: Vec<AssistantEvent>,
298) -> Result<(ConversationMessage, Option<TokenUsage>), RuntimeError> {
299 let mut text = String::new();
300 let mut blocks = Vec::new();
301 let mut finished = false;
302 let mut usage = None;
303
304 for event in events {
305 match event {
306 AssistantEvent::TextDelta(delta) => text.push_str(&delta),
307 AssistantEvent::ToolUse { id, name, input } => {
308 flush_text_block(&mut text, &mut blocks);
309 blocks.push(ContentBlock::ToolUse { id, name, input });
310 }
311 AssistantEvent::Usage(value) => usage = Some(value),
312 AssistantEvent::MessageStop => {
313 finished = true;
314 }
315 }
316 }
317
318 flush_text_block(&mut text, &mut blocks);
319
320 if !finished {
321 return Err(RuntimeError::new(
322 "assistant stream ended without a message stop event",
323 ));
324 }
325 if blocks.is_empty() {
326 return Err(RuntimeError::new(
327 "assistant stream produced no content (empty reply from the model API; \
328 try upgrading codineer, check provider stream vs non-stream, or verify model id and API key)",
329 ));
330 }
331
332 Ok((
333 ConversationMessage::assistant_with_usage(blocks, usage),
334 usage,
335 ))
336}
337
338fn flush_text_block(text: &mut String, blocks: &mut Vec<ContentBlock>) {
339 if !text.is_empty() {
340 blocks.push(ContentBlock::Text {
341 text: std::mem::take(text),
342 });
343 }
344}
345
346fn format_hook_message(result: &HookRunResult, fallback: &str) -> String {
347 if result.messages().is_empty() {
348 fallback.to_string()
349 } else {
350 result.messages().join("\n")
351 }
352}
353
354fn merge_hook_feedback(messages: &[String], output: String, denied: bool) -> String {
355 if messages.is_empty() {
356 return output;
357 }
358
359 let mut sections = Vec::new();
360 if !output.trim().is_empty() {
361 sections.push(output);
362 }
363 let label = if denied {
364 "Hook feedback (denied)"
365 } else {
366 "Hook feedback"
367 };
368 sections.push(format!("{label}:\n{}", messages.join("\n")));
369 sections.join("\n\n")
370}
371
372type ToolHandler = Box<dyn FnMut(&str) -> Result<String, ToolError>>;
373
374#[derive(Default)]
375pub struct StaticToolExecutor {
376 handlers: BTreeMap<String, ToolHandler>,
377}
378
379impl StaticToolExecutor {
380 #[must_use]
381 pub fn new() -> Self {
382 Self::default()
383 }
384
385 #[must_use]
386 pub fn register(
387 mut self,
388 tool_name: impl Into<String>,
389 handler: impl FnMut(&str) -> Result<String, ToolError> + 'static,
390 ) -> Self {
391 self.handlers.insert(tool_name.into(), Box::new(handler));
392 self
393 }
394}
395
396impl ToolExecutor for StaticToolExecutor {
397 fn execute(&mut self, tool_name: &str, input: &str) -> Result<String, ToolError> {
398 self.handlers
399 .get_mut(tool_name)
400 .ok_or_else(|| ToolError::new(format!("unknown tool: {tool_name}")))?(input)
401 }
402}
403
404#[cfg(test)]
405mod tests {
406 use super::{
407 ApiClient, ApiRequest, AssistantEvent, ConversationRuntime, RuntimeError,
408 StaticToolExecutor,
409 };
410 use crate::compact::CompactionConfig;
411 use crate::permissions::{
412 PermissionMode, PermissionPolicy, PermissionPromptDecision, PermissionPrompter,
413 PermissionRequest,
414 };
415 use crate::prompt::{ProjectContext, SystemPromptBuilder};
416 use crate::session::{ContentBlock, MessageRole, Session};
417 use crate::usage::TokenUsage;
418 use std::path::PathBuf;
419
420 struct ScriptedApiClient {
421 call_count: usize,
422 }
423
424 impl ApiClient for ScriptedApiClient {
425 fn stream(&mut self, request: ApiRequest) -> Result<Vec<AssistantEvent>, RuntimeError> {
426 self.call_count += 1;
427 match self.call_count {
428 1 => {
429 assert!(request
430 .messages
431 .iter()
432 .any(|message| message.role == MessageRole::User));
433 Ok(vec![
434 AssistantEvent::TextDelta("Let me calculate that.".to_string()),
435 AssistantEvent::ToolUse {
436 id: "tool-1".to_string(),
437 name: "add".to_string(),
438 input: "2,2".to_string(),
439 },
440 AssistantEvent::Usage(TokenUsage {
441 input_tokens: 20,
442 output_tokens: 6,
443 cache_creation_input_tokens: 1,
444 cache_read_input_tokens: 2,
445 }),
446 AssistantEvent::MessageStop,
447 ])
448 }
449 2 => {
450 let last_message = request
451 .messages
452 .last()
453 .expect("tool result should be present");
454 assert_eq!(last_message.role, MessageRole::Tool);
455 Ok(vec![
456 AssistantEvent::TextDelta("The answer is 4.".to_string()),
457 AssistantEvent::Usage(TokenUsage {
458 input_tokens: 24,
459 output_tokens: 4,
460 cache_creation_input_tokens: 1,
461 cache_read_input_tokens: 3,
462 }),
463 AssistantEvent::MessageStop,
464 ])
465 }
466 _ => Err(RuntimeError::new("unexpected extra API call")),
467 }
468 }
469 }
470
471 struct PromptAllowOnce;
472
473 impl PermissionPrompter for PromptAllowOnce {
474 fn decide(&mut self, request: &PermissionRequest) -> PermissionPromptDecision {
475 assert_eq!(request.tool_name, "add");
476 PermissionPromptDecision::Allow
477 }
478 }
479
480 #[test]
481 fn runs_user_to_tool_to_result_loop_end_to_end_and_tracks_usage() {
482 let api_client = ScriptedApiClient { call_count: 0 };
483 let tool_executor = StaticToolExecutor::new().register("add", |input| {
484 let total = input
485 .split(',')
486 .map(|part| part.parse::<i32>().expect("input must be valid integer"))
487 .sum::<i32>();
488 Ok(total.to_string())
489 });
490 let permission_policy = PermissionPolicy::new(PermissionMode::WorkspaceWrite);
491 let system_prompt = SystemPromptBuilder::new()
492 .with_project_context(ProjectContext {
493 cwd: PathBuf::from("/tmp/project"),
494 current_date: "2026-03-31".to_string(),
495 git_status: None,
496 git_diff: None,
497 instruction_files: Vec::new(),
498 })
499 .with_os("linux", "6.8")
500 .build();
501 let mut runtime = ConversationRuntime::new(
502 Session::new(),
503 api_client,
504 tool_executor,
505 permission_policy,
506 system_prompt,
507 );
508
509 let summary = runtime
510 .run_turn("what is 2 + 2?", Some(&mut PromptAllowOnce))
511 .expect("conversation loop should succeed");
512
513 assert_eq!(summary.iterations, 2);
514 assert_eq!(summary.assistant_messages.len(), 2);
515 assert_eq!(summary.tool_results.len(), 1);
516 assert_eq!(runtime.session().messages.len(), 4);
517 assert_eq!(summary.usage.output_tokens, 10);
518 assert!(matches!(
519 runtime.session().messages[1].blocks[1],
520 ContentBlock::ToolUse { .. }
521 ));
522 assert!(matches!(
523 runtime.session().messages[2].blocks[0],
524 ContentBlock::ToolResult {
525 is_error: false,
526 ..
527 }
528 ));
529 }
530
531 #[test]
532 fn records_denied_tool_results_when_prompt_rejects() {
533 struct RejectPrompter;
534 impl PermissionPrompter for RejectPrompter {
535 fn decide(&mut self, _request: &PermissionRequest) -> PermissionPromptDecision {
536 PermissionPromptDecision::Deny {
537 reason: "not now".to_string(),
538 }
539 }
540 }
541
542 struct SingleCallApiClient;
543 impl ApiClient for SingleCallApiClient {
544 fn stream(&mut self, request: ApiRequest) -> Result<Vec<AssistantEvent>, RuntimeError> {
545 if request
546 .messages
547 .iter()
548 .any(|message| message.role == MessageRole::Tool)
549 {
550 return Ok(vec![
551 AssistantEvent::TextDelta("I could not use the tool.".to_string()),
552 AssistantEvent::MessageStop,
553 ]);
554 }
555 Ok(vec![
556 AssistantEvent::ToolUse {
557 id: "tool-1".to_string(),
558 name: "blocked".to_string(),
559 input: "secret".to_string(),
560 },
561 AssistantEvent::MessageStop,
562 ])
563 }
564 }
565
566 let mut runtime = ConversationRuntime::new(
567 Session::new(),
568 SingleCallApiClient,
569 StaticToolExecutor::new(),
570 PermissionPolicy::new(PermissionMode::WorkspaceWrite),
571 vec!["system".to_string()],
572 );
573
574 let summary = runtime
575 .run_turn("use the tool", Some(&mut RejectPrompter))
576 .expect("conversation should continue after denied tool");
577
578 assert_eq!(summary.tool_results.len(), 1);
579 assert!(matches!(
580 &summary.tool_results[0].blocks[0],
581 ContentBlock::ToolResult { is_error: true, output, .. } if output == "not now"
582 ));
583 }
584
585 #[test]
586 #[cfg(unix)]
587 fn denies_tool_use_when_pre_tool_hook_blocks() {
588 use crate::config::{RuntimeFeatureConfig, RuntimeHookConfig};
589 struct SingleCallApiClient;
590 impl ApiClient for SingleCallApiClient {
591 fn stream(&mut self, request: ApiRequest) -> Result<Vec<AssistantEvent>, RuntimeError> {
592 if request
593 .messages
594 .iter()
595 .any(|message| message.role == MessageRole::Tool)
596 {
597 return Ok(vec![
598 AssistantEvent::TextDelta("blocked".to_string()),
599 AssistantEvent::MessageStop,
600 ]);
601 }
602 Ok(vec![
603 AssistantEvent::ToolUse {
604 id: "tool-1".to_string(),
605 name: "blocked".to_string(),
606 input: r#"{"path":"secret.txt"}"#.to_string(),
607 },
608 AssistantEvent::MessageStop,
609 ])
610 }
611 }
612
613 let deny_config = RuntimeFeatureConfig::default().with_hooks(RuntimeHookConfig::new(
614 vec!["printf 'blocked by hook'; exit 2".to_string()],
615 Vec::new(),
616 ));
617 let mut runtime = ConversationRuntime::new_with_features(
618 Session::new(),
619 SingleCallApiClient,
620 StaticToolExecutor::new().register("blocked", |_input| {
621 panic!("tool should not execute when hook denies")
622 }),
623 PermissionPolicy::new(PermissionMode::DangerFullAccess)
624 .with_tool_requirement("blocked", PermissionMode::WorkspaceWrite),
625 vec!["system".to_string()],
626 &deny_config,
627 );
628
629 let summary = runtime
630 .run_turn("use the tool", None)
631 .expect("conversation should continue after hook denial");
632
633 assert_eq!(summary.tool_results.len(), 1);
634 let ContentBlock::ToolResult {
635 is_error, output, ..
636 } = &summary.tool_results[0].blocks[0]
637 else {
638 panic!("expected tool result block");
639 };
640 assert!(
641 *is_error,
642 "hook denial should produce an error result: {output}"
643 );
644 assert!(
645 output.contains("denied tool") || output.contains("blocked by hook"),
646 "unexpected hook denial output: {output:?}"
647 );
648 }
649
650 #[test]
651 #[cfg(unix)]
652 fn appends_post_tool_hook_feedback_to_tool_result() {
653 use crate::config::{RuntimeFeatureConfig, RuntimeHookConfig};
654 struct TwoCallApiClient {
655 calls: usize,
656 }
657
658 impl ApiClient for TwoCallApiClient {
659 fn stream(&mut self, request: ApiRequest) -> Result<Vec<AssistantEvent>, RuntimeError> {
660 self.calls += 1;
661 match self.calls {
662 1 => Ok(vec![
663 AssistantEvent::ToolUse {
664 id: "tool-1".to_string(),
665 name: "add".to_string(),
666 input: r#"{"lhs":2,"rhs":2}"#.to_string(),
667 },
668 AssistantEvent::MessageStop,
669 ]),
670 2 => {
671 assert!(request
672 .messages
673 .iter()
674 .any(|message| message.role == MessageRole::Tool));
675 Ok(vec![
676 AssistantEvent::TextDelta("done".to_string()),
677 AssistantEvent::MessageStop,
678 ])
679 }
680 _ => Err(RuntimeError::new("unexpected extra API call")),
681 }
682 }
683 }
684
685 let hook_config = RuntimeFeatureConfig::default().with_hooks(RuntimeHookConfig::new(
686 vec!["printf 'pre hook ran'".to_string()],
687 vec!["printf 'post hook ran'".to_string()],
688 ));
689 let mut runtime = ConversationRuntime::new_with_features(
690 Session::new(),
691 TwoCallApiClient { calls: 0 },
692 StaticToolExecutor::new().register("add", |_input| Ok("4".to_string())),
693 PermissionPolicy::new(PermissionMode::DangerFullAccess)
694 .with_tool_requirement("add", PermissionMode::WorkspaceWrite),
695 vec!["system".to_string()],
696 &hook_config,
697 );
698
699 let summary = runtime
700 .run_turn("use add", None)
701 .expect("tool loop succeeds");
702
703 assert_eq!(summary.tool_results.len(), 1);
704 let ContentBlock::ToolResult {
705 is_error, output, ..
706 } = &summary.tool_results[0].blocks[0]
707 else {
708 panic!("expected tool result block");
709 };
710 assert!(
711 !*is_error,
712 "post hook should preserve non-error result: {output:?}"
713 );
714 assert!(
715 output.contains('4'),
716 "tool output missing value: {output:?}"
717 );
718 assert!(
719 output.contains("pre hook ran"),
720 "tool output missing pre hook feedback: {output:?}"
721 );
722 assert!(
723 output.contains("post hook ran"),
724 "tool output missing post hook feedback: {output:?}"
725 );
726 }
727
728 #[test]
729 fn reconstructs_usage_tracker_from_restored_session() {
730 struct SimpleApi;
731 impl ApiClient for SimpleApi {
732 fn stream(
733 &mut self,
734 _request: ApiRequest,
735 ) -> Result<Vec<AssistantEvent>, RuntimeError> {
736 Ok(vec![
737 AssistantEvent::TextDelta("done".to_string()),
738 AssistantEvent::MessageStop,
739 ])
740 }
741 }
742
743 let mut session = Session::new();
744 session
745 .messages
746 .push(crate::session::ConversationMessage::assistant_with_usage(
747 vec![ContentBlock::Text {
748 text: "earlier".to_string(),
749 }],
750 Some(TokenUsage {
751 input_tokens: 11,
752 output_tokens: 7,
753 cache_creation_input_tokens: 2,
754 cache_read_input_tokens: 1,
755 }),
756 ));
757
758 let runtime = ConversationRuntime::new(
759 session,
760 SimpleApi,
761 StaticToolExecutor::new(),
762 PermissionPolicy::new(PermissionMode::DangerFullAccess),
763 vec!["system".to_string()],
764 );
765
766 assert_eq!(runtime.usage().turns(), 1);
767 assert_eq!(runtime.usage().cumulative_usage().total_tokens(), 21);
768 }
769
770 #[test]
771 fn compacts_session_after_turns() {
772 struct SimpleApi;
773 impl ApiClient for SimpleApi {
774 fn stream(
775 &mut self,
776 _request: ApiRequest,
777 ) -> Result<Vec<AssistantEvent>, RuntimeError> {
778 Ok(vec![
779 AssistantEvent::TextDelta("done".to_string()),
780 AssistantEvent::MessageStop,
781 ])
782 }
783 }
784
785 let mut runtime = ConversationRuntime::new(
786 Session::new(),
787 SimpleApi,
788 StaticToolExecutor::new(),
789 PermissionPolicy::new(PermissionMode::DangerFullAccess),
790 vec!["system".to_string()],
791 );
792 runtime.run_turn("a", None).expect("turn a");
793 runtime.run_turn("b", None).expect("turn b");
794 runtime.run_turn("c", None).expect("turn c");
795
796 let result = runtime.compact(CompactionConfig {
797 preserve_recent_messages: 2,
798 max_estimated_tokens: 1,
799 });
800 assert!(result.summary.contains("Conversation summary"));
801 assert_eq!(
802 result.compacted_session.messages[0].role,
803 MessageRole::System
804 );
805 }
806}