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