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        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}