Skip to main content

codineer_runtime/
conversation.rs

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}