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("assistant stream produced no content"));
327    }
328
329    Ok((
330        ConversationMessage::assistant_with_usage(blocks, usage),
331        usage,
332    ))
333}
334
335fn flush_text_block(text: &mut String, blocks: &mut Vec<ContentBlock>) {
336    if !text.is_empty() {
337        blocks.push(ContentBlock::Text {
338            text: std::mem::take(text),
339        });
340    }
341}
342
343fn format_hook_message(result: &HookRunResult, fallback: &str) -> String {
344    if result.messages().is_empty() {
345        fallback.to_string()
346    } else {
347        result.messages().join("\n")
348    }
349}
350
351fn merge_hook_feedback(messages: &[String], output: String, denied: bool) -> String {
352    if messages.is_empty() {
353        return output;
354    }
355
356    let mut sections = Vec::new();
357    if !output.trim().is_empty() {
358        sections.push(output);
359    }
360    let label = if denied {
361        "Hook feedback (denied)"
362    } else {
363        "Hook feedback"
364    };
365    sections.push(format!("{label}:\n{}", messages.join("\n")));
366    sections.join("\n\n")
367}
368
369type ToolHandler = Box<dyn FnMut(&str) -> Result<String, ToolError>>;
370
371#[derive(Default)]
372pub struct StaticToolExecutor {
373    handlers: BTreeMap<String, ToolHandler>,
374}
375
376impl StaticToolExecutor {
377    #[must_use]
378    pub fn new() -> Self {
379        Self::default()
380    }
381
382    #[must_use]
383    pub fn register(
384        mut self,
385        tool_name: impl Into<String>,
386        handler: impl FnMut(&str) -> Result<String, ToolError> + 'static,
387    ) -> Self {
388        self.handlers.insert(tool_name.into(), Box::new(handler));
389        self
390    }
391}
392
393impl ToolExecutor for StaticToolExecutor {
394    fn execute(&mut self, tool_name: &str, input: &str) -> Result<String, ToolError> {
395        self.handlers
396            .get_mut(tool_name)
397            .ok_or_else(|| ToolError::new(format!("unknown tool: {tool_name}")))?(input)
398    }
399}
400
401#[cfg(test)]
402mod tests {
403    use super::{
404        ApiClient, ApiRequest, AssistantEvent, ConversationRuntime, RuntimeError,
405        StaticToolExecutor,
406    };
407    use crate::compact::CompactionConfig;
408    use crate::permissions::{
409        PermissionMode, PermissionPolicy, PermissionPromptDecision, PermissionPrompter,
410        PermissionRequest,
411    };
412    use crate::prompt::{ProjectContext, SystemPromptBuilder};
413    use crate::session::{ContentBlock, MessageRole, Session};
414    use crate::usage::TokenUsage;
415    use std::path::PathBuf;
416
417    struct ScriptedApiClient {
418        call_count: usize,
419    }
420
421    impl ApiClient for ScriptedApiClient {
422        fn stream(&mut self, request: ApiRequest) -> Result<Vec<AssistantEvent>, RuntimeError> {
423            self.call_count += 1;
424            match self.call_count {
425                1 => {
426                    assert!(request
427                        .messages
428                        .iter()
429                        .any(|message| message.role == MessageRole::User));
430                    Ok(vec![
431                        AssistantEvent::TextDelta("Let me calculate that.".to_string()),
432                        AssistantEvent::ToolUse {
433                            id: "tool-1".to_string(),
434                            name: "add".to_string(),
435                            input: "2,2".to_string(),
436                        },
437                        AssistantEvent::Usage(TokenUsage {
438                            input_tokens: 20,
439                            output_tokens: 6,
440                            cache_creation_input_tokens: 1,
441                            cache_read_input_tokens: 2,
442                        }),
443                        AssistantEvent::MessageStop,
444                    ])
445                }
446                2 => {
447                    let last_message = request
448                        .messages
449                        .last()
450                        .expect("tool result should be present");
451                    assert_eq!(last_message.role, MessageRole::Tool);
452                    Ok(vec![
453                        AssistantEvent::TextDelta("The answer is 4.".to_string()),
454                        AssistantEvent::Usage(TokenUsage {
455                            input_tokens: 24,
456                            output_tokens: 4,
457                            cache_creation_input_tokens: 1,
458                            cache_read_input_tokens: 3,
459                        }),
460                        AssistantEvent::MessageStop,
461                    ])
462                }
463                _ => Err(RuntimeError::new("unexpected extra API call")),
464            }
465        }
466    }
467
468    struct PromptAllowOnce;
469
470    impl PermissionPrompter for PromptAllowOnce {
471        fn decide(&mut self, request: &PermissionRequest) -> PermissionPromptDecision {
472            assert_eq!(request.tool_name, "add");
473            PermissionPromptDecision::Allow
474        }
475    }
476
477    #[test]
478    fn runs_user_to_tool_to_result_loop_end_to_end_and_tracks_usage() {
479        let api_client = ScriptedApiClient { call_count: 0 };
480        let tool_executor = StaticToolExecutor::new().register("add", |input| {
481            let total = input
482                .split(',')
483                .map(|part| part.parse::<i32>().expect("input must be valid integer"))
484                .sum::<i32>();
485            Ok(total.to_string())
486        });
487        let permission_policy = PermissionPolicy::new(PermissionMode::WorkspaceWrite);
488        let system_prompt = SystemPromptBuilder::new()
489            .with_project_context(ProjectContext {
490                cwd: PathBuf::from("/tmp/project"),
491                current_date: "2026-03-31".to_string(),
492                git_status: None,
493                git_diff: None,
494                instruction_files: Vec::new(),
495            })
496            .with_os("linux", "6.8")
497            .build();
498        let mut runtime = ConversationRuntime::new(
499            Session::new(),
500            api_client,
501            tool_executor,
502            permission_policy,
503            system_prompt,
504        );
505
506        let summary = runtime
507            .run_turn("what is 2 + 2?", Some(&mut PromptAllowOnce))
508            .expect("conversation loop should succeed");
509
510        assert_eq!(summary.iterations, 2);
511        assert_eq!(summary.assistant_messages.len(), 2);
512        assert_eq!(summary.tool_results.len(), 1);
513        assert_eq!(runtime.session().messages.len(), 4);
514        assert_eq!(summary.usage.output_tokens, 10);
515        assert!(matches!(
516            runtime.session().messages[1].blocks[1],
517            ContentBlock::ToolUse { .. }
518        ));
519        assert!(matches!(
520            runtime.session().messages[2].blocks[0],
521            ContentBlock::ToolResult {
522                is_error: false,
523                ..
524            }
525        ));
526    }
527
528    #[test]
529    fn records_denied_tool_results_when_prompt_rejects() {
530        struct RejectPrompter;
531        impl PermissionPrompter for RejectPrompter {
532            fn decide(&mut self, _request: &PermissionRequest) -> PermissionPromptDecision {
533                PermissionPromptDecision::Deny {
534                    reason: "not now".to_string(),
535                }
536            }
537        }
538
539        struct SingleCallApiClient;
540        impl ApiClient for SingleCallApiClient {
541            fn stream(&mut self, request: ApiRequest) -> Result<Vec<AssistantEvent>, RuntimeError> {
542                if request
543                    .messages
544                    .iter()
545                    .any(|message| message.role == MessageRole::Tool)
546                {
547                    return Ok(vec![
548                        AssistantEvent::TextDelta("I could not use the tool.".to_string()),
549                        AssistantEvent::MessageStop,
550                    ]);
551                }
552                Ok(vec![
553                    AssistantEvent::ToolUse {
554                        id: "tool-1".to_string(),
555                        name: "blocked".to_string(),
556                        input: "secret".to_string(),
557                    },
558                    AssistantEvent::MessageStop,
559                ])
560            }
561        }
562
563        let mut runtime = ConversationRuntime::new(
564            Session::new(),
565            SingleCallApiClient,
566            StaticToolExecutor::new(),
567            PermissionPolicy::new(PermissionMode::WorkspaceWrite),
568            vec!["system".to_string()],
569        );
570
571        let summary = runtime
572            .run_turn("use the tool", Some(&mut RejectPrompter))
573            .expect("conversation should continue after denied tool");
574
575        assert_eq!(summary.tool_results.len(), 1);
576        assert!(matches!(
577            &summary.tool_results[0].blocks[0],
578            ContentBlock::ToolResult { is_error: true, output, .. } if output == "not now"
579        ));
580    }
581
582    #[test]
583    #[cfg(unix)]
584    fn denies_tool_use_when_pre_tool_hook_blocks() {
585        use crate::config::{RuntimeFeatureConfig, RuntimeHookConfig};
586        struct SingleCallApiClient;
587        impl ApiClient for SingleCallApiClient {
588            fn stream(&mut self, request: ApiRequest) -> Result<Vec<AssistantEvent>, RuntimeError> {
589                if request
590                    .messages
591                    .iter()
592                    .any(|message| message.role == MessageRole::Tool)
593                {
594                    return Ok(vec![
595                        AssistantEvent::TextDelta("blocked".to_string()),
596                        AssistantEvent::MessageStop,
597                    ]);
598                }
599                Ok(vec![
600                    AssistantEvent::ToolUse {
601                        id: "tool-1".to_string(),
602                        name: "blocked".to_string(),
603                        input: r#"{"path":"secret.txt"}"#.to_string(),
604                    },
605                    AssistantEvent::MessageStop,
606                ])
607            }
608        }
609
610        let deny_config = RuntimeFeatureConfig::default().with_hooks(RuntimeHookConfig::new(
611            vec!["printf 'blocked by hook'; exit 2".to_string()],
612            Vec::new(),
613        ));
614        let mut runtime = ConversationRuntime::new_with_features(
615            Session::new(),
616            SingleCallApiClient,
617            StaticToolExecutor::new().register("blocked", |_input| {
618                panic!("tool should not execute when hook denies")
619            }),
620            PermissionPolicy::new(PermissionMode::DangerFullAccess)
621                .with_tool_requirement("blocked", PermissionMode::WorkspaceWrite),
622            vec!["system".to_string()],
623            &deny_config,
624        );
625
626        let summary = runtime
627            .run_turn("use the tool", None)
628            .expect("conversation should continue after hook denial");
629
630        assert_eq!(summary.tool_results.len(), 1);
631        let ContentBlock::ToolResult {
632            is_error, output, ..
633        } = &summary.tool_results[0].blocks[0]
634        else {
635            panic!("expected tool result block");
636        };
637        assert!(
638            *is_error,
639            "hook denial should produce an error result: {output}"
640        );
641        assert!(
642            output.contains("denied tool") || output.contains("blocked by hook"),
643            "unexpected hook denial output: {output:?}"
644        );
645    }
646
647    #[test]
648    #[cfg(unix)]
649    fn appends_post_tool_hook_feedback_to_tool_result() {
650        use crate::config::{RuntimeFeatureConfig, RuntimeHookConfig};
651        struct TwoCallApiClient {
652            calls: usize,
653        }
654
655        impl ApiClient for TwoCallApiClient {
656            fn stream(&mut self, request: ApiRequest) -> Result<Vec<AssistantEvent>, RuntimeError> {
657                self.calls += 1;
658                match self.calls {
659                    1 => Ok(vec![
660                        AssistantEvent::ToolUse {
661                            id: "tool-1".to_string(),
662                            name: "add".to_string(),
663                            input: r#"{"lhs":2,"rhs":2}"#.to_string(),
664                        },
665                        AssistantEvent::MessageStop,
666                    ]),
667                    2 => {
668                        assert!(request
669                            .messages
670                            .iter()
671                            .any(|message| message.role == MessageRole::Tool));
672                        Ok(vec![
673                            AssistantEvent::TextDelta("done".to_string()),
674                            AssistantEvent::MessageStop,
675                        ])
676                    }
677                    _ => Err(RuntimeError::new("unexpected extra API call")),
678                }
679            }
680        }
681
682        let hook_config = RuntimeFeatureConfig::default().with_hooks(RuntimeHookConfig::new(
683            vec!["printf 'pre hook ran'".to_string()],
684            vec!["printf 'post hook ran'".to_string()],
685        ));
686        let mut runtime = ConversationRuntime::new_with_features(
687            Session::new(),
688            TwoCallApiClient { calls: 0 },
689            StaticToolExecutor::new().register("add", |_input| Ok("4".to_string())),
690            PermissionPolicy::new(PermissionMode::DangerFullAccess)
691                .with_tool_requirement("add", PermissionMode::WorkspaceWrite),
692            vec!["system".to_string()],
693            &hook_config,
694        );
695
696        let summary = runtime
697            .run_turn("use add", None)
698            .expect("tool loop succeeds");
699
700        assert_eq!(summary.tool_results.len(), 1);
701        let ContentBlock::ToolResult {
702            is_error, output, ..
703        } = &summary.tool_results[0].blocks[0]
704        else {
705            panic!("expected tool result block");
706        };
707        assert!(
708            !*is_error,
709            "post hook should preserve non-error result: {output:?}"
710        );
711        assert!(
712            output.contains('4'),
713            "tool output missing value: {output:?}"
714        );
715        assert!(
716            output.contains("pre hook ran"),
717            "tool output missing pre hook feedback: {output:?}"
718        );
719        assert!(
720            output.contains("post hook ran"),
721            "tool output missing post hook feedback: {output:?}"
722        );
723    }
724
725    #[test]
726    fn reconstructs_usage_tracker_from_restored_session() {
727        struct SimpleApi;
728        impl ApiClient for SimpleApi {
729            fn stream(
730                &mut self,
731                _request: ApiRequest,
732            ) -> Result<Vec<AssistantEvent>, RuntimeError> {
733                Ok(vec![
734                    AssistantEvent::TextDelta("done".to_string()),
735                    AssistantEvent::MessageStop,
736                ])
737            }
738        }
739
740        let mut session = Session::new();
741        session
742            .messages
743            .push(crate::session::ConversationMessage::assistant_with_usage(
744                vec![ContentBlock::Text {
745                    text: "earlier".to_string(),
746                }],
747                Some(TokenUsage {
748                    input_tokens: 11,
749                    output_tokens: 7,
750                    cache_creation_input_tokens: 2,
751                    cache_read_input_tokens: 1,
752                }),
753            ));
754
755        let runtime = ConversationRuntime::new(
756            session,
757            SimpleApi,
758            StaticToolExecutor::new(),
759            PermissionPolicy::new(PermissionMode::DangerFullAccess),
760            vec!["system".to_string()],
761        );
762
763        assert_eq!(runtime.usage().turns(), 1);
764        assert_eq!(runtime.usage().cumulative_usage().total_tokens(), 21);
765    }
766
767    #[test]
768    fn compacts_session_after_turns() {
769        struct SimpleApi;
770        impl ApiClient for SimpleApi {
771            fn stream(
772                &mut self,
773                _request: ApiRequest,
774            ) -> Result<Vec<AssistantEvent>, RuntimeError> {
775                Ok(vec![
776                    AssistantEvent::TextDelta("done".to_string()),
777                    AssistantEvent::MessageStop,
778                ])
779            }
780        }
781
782        let mut runtime = ConversationRuntime::new(
783            Session::new(),
784            SimpleApi,
785            StaticToolExecutor::new(),
786            PermissionPolicy::new(PermissionMode::DangerFullAccess),
787            vec!["system".to_string()],
788        );
789        runtime.run_turn("a", None).expect("turn a");
790        runtime.run_turn("b", None).expect("turn b");
791        runtime.run_turn("c", None).expect("turn c");
792
793        let result = runtime.compact(CompactionConfig {
794            preserve_recent_messages: 2,
795            max_estimated_tokens: 1,
796        });
797        assert!(result.summary.contains("Conversation summary"));
798        assert_eq!(
799            result.compacted_session.messages[0].role,
800            MessageRole::System
801        );
802    }
803}