Skip to main content

oino_core/session/
prompt.rs

1//! Prompt pipeline and run lifecycle (`prompt`, `steer`, `followUp`, `sendCustomMessage`,
2//! `sendUserMessage`, `_startRun`, `_prepareRun`, `_handlePostAgentRun`, `_emitAgentSettled`).
3
4use futures::future::BoxFuture;
5use oino_ai::{
6    AgentMessage, CustomMessage, ImageContent, JsonValue, UserContent, UserContentField,
7    UserMessage,
8};
9use serde::{Deserialize, Serialize};
10use serde_json::json;
11use tokio_util::sync::CancellationToken;
12
13use super::{AgentSession, IsolatedTask, SessionOpError, lock};
14use crate::events::AgentSessionEvent;
15use crate::extensions::api::CallKind;
16use crate::extensions::host::{CustomMessageDraft, Delivery};
17use crate::extensions::runner::InputOutcome;
18
19/// How an accepted prompt was dispatched.
20#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(rename_all = "lowercase")]
22pub enum PromptDisposition {
23    /// An extension command or input handler consumed it.
24    Handled,
25    /// Queued (steering / follow-up).
26    Queued,
27    /// A run started.
28    Started,
29}
30
31/// Queueing while streaming.
32#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(rename_all = "camelCase")]
34pub enum StreamingBehavior {
35    /// Steering queue.
36    Steer,
37    /// Follow-up queue.
38    FollowUp,
39}
40
41impl StreamingBehavior {
42    const fn as_str(self) -> &'static str {
43        match self {
44            Self::Steer => "steer",
45            Self::FollowUp => "followUp",
46        }
47    }
48}
49
50/// The input source record.
51#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
52#[serde(rename_all = "lowercase")]
53pub enum InputSource {
54    /// A frontend user.
55    #[default]
56    Interactive,
57    /// RPC client.
58    Rpc,
59    /// An extension.
60    Extension,
61}
62
63impl InputSource {
64    const fn as_str(self) -> &'static str {
65        match self {
66            Self::Interactive => "interactive",
67            Self::Rpc => "rpc",
68            Self::Extension => "extension",
69        }
70    }
71}
72
73/// Options of [`AgentSession::prompt`].
74#[derive(Clone, Debug, PartialEq)]
75pub struct PromptOptions {
76    /// Dispatch extension commands, expand skills and templates (default `true`).
77    pub expand_prompt_templates: bool,
78    /// Image attachments.
79    pub images: Vec<ImageContent>,
80    /// Queueing while streaming.
81    pub streaming_behavior: Option<StreamingBehavior>,
82    /// Input source.
83    pub source: InputSource,
84}
85
86impl Default for PromptOptions {
87    fn default() -> Self {
88        Self {
89            expand_prompt_templates: true,
90            images: Vec::new(),
91            streaming_behavior: None,
92            source: InputSource::Interactive,
93        }
94    }
95}
96
97/// What starts a run.
98#[derive(Clone, Debug)]
99pub(crate) enum Trigger {
100    User {
101        prompt: String,
102        images: Vec<ImageContent>,
103    },
104    Custom(CustomMessage),
105}
106
107fn user_message(text: String, images: Vec<ImageContent>) -> AgentMessage {
108    let mut content = vec![UserContent::text(text)];
109    content.extend(images.into_iter().map(UserContent::Image));
110    AgentMessage::User(UserMessage {
111        content: UserContentField::Blocks(content),
112        timestamp: oino_agent::now_ms(),
113    })
114}
115
116fn images_json(images: &[ImageContent]) -> JsonValue {
117    JsonValue::Array(
118        images
119            .iter()
120            .map(|i| json!({"type": "image", "data": i.data, "mimeType": i.mime_type}))
121            .collect(),
122    )
123}
124
125type Deferred = BoxFuture<'static, ()>;
126
127impl AgentSession {
128    /// Sends a prompt: extension commands run immediately, `input` handlers
129    /// may transform or handle it, skills and templates expand, and while streaming it is
130    /// queued per `streaming_behavior`. Returns once the run started (await
131    /// [`AgentSession::wait_for_idle`] for completion).
132    ///
133    /// # Errors
134    ///
135    /// Fixed errors: streaming without behaviour, no model, no API key, compaction running.
136    pub fn prompt(
137        &self,
138        text: String,
139        options: PromptOptions,
140    ) -> BoxFuture<'_, Result<PromptDisposition, SessionOpError>> {
141        Box::pin(self.prompt_inner(text, options))
142    }
143
144    async fn prompt_inner(
145        &self,
146        text: String,
147        options: PromptOptions,
148    ) -> Result<PromptDisposition, SessionOpError> {
149        if lock(&self.inner.book).emitting_settled {
150            let session = self.clone();
151            self.defer(Box::pin(async move {
152                if let Err(error) = session.prompt(text, options).await {
153                    session
154                        .runner()
155                        .emit_error("<runtime>", "prompt", error.to_string());
156                }
157            }));
158            return Ok(PromptDisposition::Queued);
159        }
160        if options.expand_prompt_templates
161            && text.starts_with('/')
162            && self.try_execute_extension_command(&text).await
163        {
164            return Ok(PromptDisposition::Handled);
165        }
166        if lock(&self.inner.cancels).compaction.is_some() {
167            return Err(SessionOpError::CompactionInProgress);
168        }
169        let streaming = self.is_streaming();
170        let Some((text, images)) = self
171            .run_input_handlers(
172                text,
173                options.images,
174                options.source,
175                options.streaming_behavior.filter(|_| streaming),
176            )
177            .await
178        else {
179            return Ok(PromptDisposition::Handled);
180        };
181        let expanded = if options.expand_prompt_templates {
182            self.expand_templates(&text)
183        } else {
184            text
185        };
186        if self.is_streaming() {
187            let behavior = options
188                .streaming_behavior
189                .ok_or(SessionOpError::StreamingWithoutBehavior)?;
190            self.queue_input(behavior, expanded, images);
191            return Ok(PromptDisposition::Queued);
192        }
193        self.validate_model_and_auth()?;
194        self.start_run(Trigger::User {
195            prompt: expanded,
196            images,
197        })
198        .await?;
199        Ok(PromptDisposition::Started)
200    }
201
202    /// Queues a steering message.
203    ///
204    /// # Errors
205    ///
206    /// [`SessionOpError::CommandNotAllowed`] for extension commands.
207    pub async fn steer(
208        &self,
209        text: String,
210        images: Vec<ImageContent>,
211        source: InputSource,
212    ) -> Result<PromptDisposition, SessionOpError> {
213        self.queue_user_input(text, images, StreamingBehavior::Steer, source)
214            .await
215    }
216
217    /// Queues a follow-up message.
218    ///
219    /// # Errors
220    ///
221    /// [`SessionOpError::CommandNotAllowed`] for extension commands.
222    pub async fn follow_up(
223        &self,
224        text: String,
225        images: Vec<ImageContent>,
226        source: InputSource,
227    ) -> Result<PromptDisposition, SessionOpError> {
228        self.queue_user_input(text, images, StreamingBehavior::FollowUp, source)
229            .await
230    }
231
232    async fn queue_user_input(
233        &self,
234        text: String,
235        images: Vec<ImageContent>,
236        behavior: StreamingBehavior,
237        source: InputSource,
238    ) -> Result<PromptDisposition, SessionOpError> {
239        if text.starts_with('/') {
240            let name = command_name(&text);
241            if self.runner().command(name).is_some() {
242                return Err(SessionOpError::CommandNotAllowed(name.to_owned()));
243            }
244        }
245        let streaming = self.is_streaming();
246        let Some((text, images)) = self
247            .run_input_handlers(text, images, source, streaming.then_some(behavior))
248            .await
249        else {
250            return Ok(PromptDisposition::Handled);
251        };
252        let expanded = self.expand_templates(&text);
253        self.queue_input(behavior, expanded, images);
254        Ok(PromptDisposition::Queued)
255    }
256
257    fn queue_input(&self, behavior: StreamingBehavior, text: String, images: Vec<ImageContent>) {
258        {
259            let mut book = lock(&self.inner.book);
260            match behavior {
261                StreamingBehavior::Steer => book.steering.push(text.clone()),
262                StreamingBehavior::FollowUp => book.follow_up.push(text.clone()),
263            }
264        }
265        self.emit_queue_update();
266        let message = user_message(text, images);
267        match behavior {
268            StreamingBehavior::Steer => self.inner.agent.steer(message),
269            StreamingBehavior::FollowUp => self.inner.agent.follow_up(message),
270        }
271    }
272
273    fn expand_templates(&self, text: &str) -> String {
274        let expanded = self.expand_skill_command(text);
275        let resources = self
276            .inner
277            .resources
278            .read()
279            .unwrap_or_else(std::sync::PoisonError::into_inner);
280        oino_resources::templates::expand_prompt_template(&expanded, resources.prompts())
281            .unwrap_or(expanded)
282    }
283
284    /// E x p a n d   S k i l l   C o m m a n d.
285    fn expand_skill_command(&self, text: &str) -> String {
286        let resources = self
287            .inner
288            .resources
289            .read()
290            .unwrap_or_else(std::sync::PoisonError::into_inner);
291        let path = oino_resources::skills::parse_skill_command(text).and_then(|command| {
292            resources
293                .skills()
294                .iter()
295                .find(|skill| skill.name == command.name)
296                .map(|skill| skill.file_path.display().to_string())
297        });
298        match oino_resources::skills::expand_skill_command(text, resources.skills()) {
299            Ok(Some(block)) => block,
300            Ok(None) => text.to_owned(),
301            Err(error) => {
302                drop(resources);
303                self.runner().emit_error(
304                    path.as_deref().unwrap_or("<skill>"),
305                    "skill_expansion",
306                    error.to_string(),
307                );
308                text.to_owned()
309            }
310        }
311    }
312
313    async fn run_input_handlers(
314        &self,
315        text: String,
316        images: Vec<ImageContent>,
317        source: InputSource,
318        streaming: Option<StreamingBehavior>,
319    ) -> Option<(String, Vec<ImageContent>)> {
320        let runner = self.runner();
321        if !runner.has_handlers("input") {
322            return Some((text, images));
323        }
324        let images_opt = (!images.is_empty()).then_some(images.as_slice());
325        match runner
326            .emit_input(
327                &text,
328                images_opt,
329                source.as_str(),
330                streaming.map(StreamingBehavior::as_str),
331            )
332            .await
333        {
334            InputOutcome::Handled => None,
335            InputOutcome::Transform { text, images: next } => Some((text, next.unwrap_or(images))),
336            InputOutcome::Continue => Some((text, images)),
337        }
338    }
339
340    fn validate_model_and_auth(&self) -> Result<(), SessionOpError> {
341        let docs = self.inner.docs_root.join("docs");
342        let Some(model) = self.model() else {
343            return Err(SessionOpError::NoModel(
344                oino_models::auth::guidance::no_model_selected_message(&docs),
345            ));
346        };
347        let models = &self.inner.models;
348        if models.has_configured_auth(&model.provider)
349            || models.check_auth(&model.provider).is_some()
350        {
351            return Ok(());
352        }
353        let message = if models.is_using_oauth(&model.provider) {
354            super::hooks::oauth_failed_message(&model.provider)
355        } else {
356            oino_models::auth::guidance::no_api_key_found_message(&model.provider, &docs)
357        };
358        Err(SessionOpError::NoApiKey {
359            provider: model.provider,
360            message,
361        })
362    }
363
364    /// Runs an extension command if `text` names one.
365    async fn try_execute_extension_command(&self, text: &str) -> bool {
366        let name = command_name(text);
367        let args = text.split_once(' ').map_or("", |(_, rest)| rest);
368        let runner = self.runner();
369        let Some(command) = runner.command(name) else {
370            return false;
371        };
372        self.record_command_echo(&command.spec.name, args);
373        let cx = runner.call_context().with_kind(CallKind::Command);
374        let result = command
375            .extension
376            .instance
377            .run_command(&command.spec.name, args, CancellationToken::new(), cx)
378            .await;
379        if let Err(error) = result {
380            runner.emit_error(&format!("command:{name}"), "command", error.message);
381        }
382        true
383    }
384
385    /// Oino command echo: every extension command run is recorded as a custom entry.
386    fn record_command_echo(&self, name: &str, args: &str) {
387        let appended = lock(&self.inner.manager).append_custom_entry(
388            "oino.command-echo",
389            Some(json!({"name": name, "args": args})),
390        );
391        if let Ok(id) = appended {
392            self.publish_entry(&id);
393        }
394    }
395
396    /// `sendCustomMessage` (four cases, see runtime ยง2).
397    ///
398    /// # Errors
399    ///
400    /// Run start failures.
401    pub fn send_custom_message(
402        &self,
403        draft: CustomMessageDraft,
404        trigger_turn: bool,
405        deliver_as: Option<Delivery>,
406    ) -> BoxFuture<'_, Result<(), SessionOpError>> {
407        Box::pin(self.send_custom_message_inner(draft, trigger_turn, deliver_as))
408    }
409
410    async fn send_custom_message_inner(
411        &self,
412        draft: CustomMessageDraft,
413        trigger_turn: bool,
414        deliver_as: Option<Delivery>,
415    ) -> Result<(), SessionOpError> {
416        let message = CustomMessage {
417            custom_type: draft.custom_type,
418            content: draft.content,
419            display: draft.display,
420            details: draft.details,
421            timestamp: oino_agent::now_ms(),
422        };
423        let streaming = self.is_streaming();
424        if deliver_as == Some(Delivery::NextTurn) {
425            lock(&self.inner.book).pending_next_turn.push(message);
426        } else if streaming && trigger_turn {
427            if deliver_as == Some(Delivery::FollowUp) {
428                self.inner.agent.follow_up(AgentMessage::Custom(message));
429            } else {
430                self.inner.agent.steer(AgentMessage::Custom(message));
431            }
432        } else if trigger_turn {
433            if lock(&self.inner.book).emitting_settled {
434                let session = self.clone();
435                let draft = CustomMessageDraft {
436                    custom_type: message.custom_type,
437                    content: message.content,
438                    display: message.display,
439                    details: message.details,
440                };
441                self.defer(Box::pin(async move {
442                    if let Err(error) = session
443                        .send_custom_message(draft, trigger_turn, deliver_as)
444                        .await
445                    {
446                        session
447                            .runner()
448                            .emit_error("<runtime>", "send_message", error.to_string());
449                    }
450                }));
451                return Ok(());
452            }
453            self.start_run(Trigger::Custom(message)).await?;
454        } else if streaming {
455            lock(&self.inner.book).pending_custom.push(message);
456        } else {
457            self.append_custom_message(message);
458        }
459        Ok(())
460    }
461
462    /// Always triggers a turn (queued while streaming).
463    ///
464    /// # Errors
465    ///
466    /// As [`AgentSession::prompt`].
467    pub async fn send_user_message(
468        &self,
469        content: UserContentField,
470        deliver_as: Option<Delivery>,
471        expand_prompt_templates: bool,
472    ) -> Result<PromptDisposition, SessionOpError> {
473        let (text, images) = match content {
474            UserContentField::Text(text) => (text, Vec::new()),
475            UserContentField::Blocks(blocks) => {
476                let mut texts = Vec::new();
477                let mut images = Vec::new();
478                for block in blocks {
479                    match block {
480                        UserContent::Text(text) => texts.push(text.text),
481                        UserContent::Image(image) => images.push(image),
482                    }
483                }
484                (texts.join("\n"), images)
485            }
486        };
487        let streaming_behavior = match deliver_as {
488            Some(Delivery::Steer) => Some(StreamingBehavior::Steer),
489            Some(Delivery::FollowUp) => Some(StreamingBehavior::FollowUp),
490            _ => None,
491        };
492        self.prompt(
493            text,
494            PromptOptions {
495                expand_prompt_templates,
496                images,
497                streaming_behavior,
498                source: InputSource::Extension,
499            },
500        )
501        .await
502    }
503
504    pub(crate) fn append_custom_message(&self, message: CustomMessage) {
505        let appended = lock(&self.inner.manager).append_custom_message_entry(
506            &message.custom_type,
507            message.content.clone(),
508            message.display,
509            message.details.clone(),
510        );
511        if let Err(error) = appended {
512            tracing::error!("persisting custom message failed: {error}");
513        }
514        self.refresh_context();
515        let message = AgentMessage::Custom(message);
516        self.emit(AgentSessionEvent::MessageStart {
517            message: message.clone(),
518        });
519        self.emit(AgentSessionEvent::MessageEnd { message });
520    }
521
522    pub(crate) fn flush_pending_custom_messages(&self) {
523        let pending = std::mem::take(&mut lock(&self.inner.book).pending_custom);
524        if pending.is_empty() {
525            return;
526        }
527        for message in pending {
528            self.append_custom_message(message);
529        }
530        // D-FLUSH barrier 3: flushed custom messages are durable before the run continues.
531        self.boundary_sync("flush pending custom messages");
532    }
533
534    fn defer(&self, action: Deferred) {
535        let session = self.clone();
536        self.spawn_isolated(IsolatedTask::Drive, async move {
537            // Runs once the current agent_settled emission finished (deferred actions).
538            while lock(&session.inner.book).emitting_settled {
539                tokio::task::yield_now().await;
540            }
541            action.await;
542        });
543    }
544
545    /// Prepares the run inline, then drives it in the background.
546    pub(crate) async fn start_run(&self, trigger: Trigger) -> Result<(), SessionOpError> {
547        {
548            let mut book = lock(&self.inner.book);
549            book.run_active = true;
550            book.abort_requested = false;
551        }
552        self.clear_waiting(crate::events::RunState::Running);
553        self.mark_busy();
554        let messages = match self.prepare_run(trigger).await {
555            Ok(messages) => messages,
556            Err(error) => {
557                lock(&self.inner.book).run_active = false;
558                self.flush_pending_bash_messages();
559                self.flush_pending_custom_messages();
560                self.set_idle_if_idle();
561                return Err(error);
562            }
563        };
564        self.flush_pending_bash_messages();
565        self.flush_pending_custom_messages();
566        lock(&self.inner.book).failed_response = None;
567        self.record_selection();
568        self.clear_pending_tools();
569        let session = self.clone();
570        self.spawn_isolated(IsolatedTask::Drive, async move {
571            session.drive_run(messages).await;
572        });
573        Ok(())
574    }
575
576    async fn drive_run(self, messages: Vec<AgentMessage>) {
577        if let Err(error) = self.inner.agent.prompt(messages).await {
578            tracing::error!("agent prompt failed: {error}");
579        }
580        loop {
581            if self.abort_requested() {
582                break;
583            }
584            if self.handle_post_agent_run().await {
585                if self.abort_requested() {
586                    break;
587                }
588                if self.inner.agent.continue_run().await.is_err() {
589                    break;
590                }
591                continue;
592            }
593            if self.abort_requested() || !self.run_before_settle_boundary().await {
594                break;
595            }
596            if self.abort_requested() || self.inner.agent.continue_run().await.is_err() {
597                break;
598            }
599        }
600        if self.abort_requested() {
601            self.finish_cancelled_retry();
602        }
603        {
604            let mut book = lock(&self.inner.book);
605            book.failed_response = None;
606            book.run_prompt_options = None;
607        }
608        self.flush_pending_bash_messages();
609        self.flush_pending_custom_messages();
610        self.emit_agent_settled().await;
611    }
612
613    pub(crate) fn abort_requested(&self) -> bool {
614        lock(&self.inner.book).abort_requested
615    }
616
617    /// P r e p a r e   R u n.
618    async fn prepare_run(&self, trigger: Trigger) -> Result<Vec<AgentMessage>, SessionOpError> {
619        if let Some(last) = self.last_assistant_message() {
620            self.check_compaction(&last, false).await;
621        }
622        let mut base = self.system_prompt_options();
623        self.apply_skill_opt_in(&mut base.skills);
624        let runner = self.runner();
625        let mut trigger_json = serde_json::Map::new();
626        match &trigger {
627            Trigger::User { prompt, images } => {
628                trigger_json.insert("messageType".into(), json!("user"));
629                trigger_json.insert("prompt".into(), json!(prompt));
630                if !images.is_empty() {
631                    trigger_json.insert("images".into(), images_json(images));
632                }
633            }
634            Trigger::Custom(message) => {
635                trigger_json.insert("messageType".into(), json!("custom"));
636                trigger_json.insert(
637                    "message".into(),
638                    json!(AgentMessage::Custom(message.clone())),
639                );
640            }
641        }
642        let mut options = base.clone();
643        let mut injected = Vec::new();
644        if runner.has_handlers("before_agent_start") {
645            let docs_root = self.inner.docs_root.clone();
646            let render = move |options: &JsonValue| -> String {
647                serde_json::from_value::<oino_resources::BuildSystemPromptOptions>(options.clone())
648                    .ok()
649                    .and_then(|o| {
650                        oino_resources::system_prompt::build_system_prompt(&o, &docs_root).ok()
651                    })
652                    .unwrap_or_default()
653            };
654            let outcome = runner
655                .emit_before_agent_start(trigger_json, json!(base), &render)
656                .await;
657            if let Ok(next) = serde_json::from_value(outcome.system_prompt_options) {
658                options = next;
659            }
660            if outcome.system_prompt.is_some() {
661                options.force_system_prompt = outcome.system_prompt;
662            }
663            injected = outcome.messages;
664        }
665        if options.selected_tools == base.selected_tools {
666            options.selected_tools = self.active_tool_names();
667        }
668        let mut messages = Vec::new();
669        match trigger {
670            Trigger::Custom(message) => messages.push(AgentMessage::Custom(message)),
671            Trigger::User { prompt, images } => messages.push(user_message(prompt, images)),
672        }
673        let next_turn = std::mem::take(&mut lock(&self.inner.book).pending_next_turn);
674        messages.extend(next_turn.into_iter().map(AgentMessage::Custom));
675        for draft in injected {
676            let content = draft
677                .get("content")
678                .filter(|c| !c.is_null())
679                .and_then(|c| serde_json::from_value(c.clone()).ok())
680                .unwrap_or(UserContentField::Blocks(Vec::new()));
681            messages.push(AgentMessage::Custom(CustomMessage {
682                custom_type: draft
683                    .get("customType")
684                    .and_then(JsonValue::as_str)
685                    .unwrap_or_default()
686                    .to_owned(),
687                content,
688                display: draft
689                    .get("display")
690                    .and_then(JsonValue::as_bool)
691                    .unwrap_or(false),
692                details: draft.get("details").cloned(),
693                timestamp: oino_agent::now_ms(),
694            }));
695        }
696        let current = self.messages();
697        let update = self.prepare_prompt_and_tool_loadout(&mut options, &current);
698        lock(&self.inner.book).run_prompt_options = Some(options);
699        if let Some(update) = update {
700            messages.insert(0, AgentMessage::System(update));
701        }
702        Ok(messages)
703    }
704
705    /// The last assistant message of the transcript.
706    #[must_use]
707    pub fn last_assistant_message(&self) -> Option<oino_ai::AssistantMessage> {
708        self.messages().into_iter().rev().find_map(|m| match m {
709            AgentMessage::Assistant(a) => Some(a),
710            _ => None,
711        })
712    }
713
714    /// Retry, overflow recovery, threshold compaction, queued work.
715    async fn handle_post_agent_run(&self) -> bool {
716        let message = lock(&self.inner.book).last_assistant.take();
717        if self.abort_requested() {
718            self.finish_cancelled_retry();
719            return false;
720        }
721        let Some(message) = message else {
722            return self.inner.agent.has_queued_messages();
723        };
724        if self.is_retryable_error(&message) && self.prepare_retry(&message).await {
725            if self.abort_requested() {
726                self.finish_cancelled_retry();
727            }
728            lock(&self.inner.book).failed_response = Some(message);
729            return !self.abort_requested();
730        }
731        if self.abort_requested() {
732            self.finish_cancelled_retry();
733            return false;
734        }
735        let final_failure = {
736            let mut book = lock(&self.inner.book);
737            if message.stop_reason == oino_ai::StopReason::Error && book.retry_attempt > 0 {
738                let attempt = book.retry_attempt;
739                book.retry_attempt = 0;
740                Some(attempt)
741            } else {
742                None
743            }
744        };
745        if let Some(attempt) = final_failure {
746            self.emit(AgentSessionEvent::AutoRetryEnd {
747                success: false,
748                attempt,
749                final_error: message.error_message.clone(),
750            });
751        }
752        if self.check_compaction(&message, true).await {
753            return !self.abort_requested();
754        }
755        !self.abort_requested() && self.inner.agent.has_queued_messages()
756    }
757
758    /// E m i t   A g e n t   S e t t l e d.
759    async fn emit_agent_settled(&self) {
760        let aborted = {
761            let mut book = lock(&self.inner.book);
762            book.run_active = false;
763            book.emitting_settled = true;
764            book.abort_requested
765        };
766        let runner = self.runner();
767        if runner.has_handlers("agent_settled") {
768            runner
769                .emit(json!({"type": "agent_settled", "aborted": aborted}))
770                .await;
771        }
772        self.emit(AgentSessionEvent::AgentSettled { aborted });
773        lock(&self.inner.book).emitting_settled = false;
774        self.set_idle_if_idle();
775        self.refresh_git_topic_if_followed();
776    }
777}
778
779/// Command name of `/name args`.
780pub(crate) fn command_name(text: &str) -> &str {
781    let without_slash = text.strip_prefix('/').unwrap_or(text);
782    without_slash
783        .split_once(' ')
784        .map_or(without_slash, |(name, _)| name)
785}