Skip to main content

yapi_core/agent_session/
extensions.rs

1//! Extension dispatch: pi events, input and command handlers, and the
2//! agent loop hooks that deliver loop events to extensions.
3
4use std::sync::Arc;
5use std::sync::atomic::Ordering;
6
7use futures_util::future::BoxFuture;
8use serde::Deserialize;
9use serde_json::Value;
10use tokio_util::sync::CancellationToken;
11use yapi_agent::Tool;
12use yapi_agent::hooks::AgentHooks;
13use yapi_ai::registry::Auth;
14use yapi_types::event::AgentEvent;
15use yapi_types::extension_event::{ExtensionEvent, RegisteredMcpServer, UiPrompt};
16use yapi_types::message::{
17    Content, ContentBlock, ImageContent, Message, StopReason, SystemMessage, ToolResultMessage,
18};
19use yapi_types::model::Model;
20use yapi_types::rpc::StreamingBehavior;
21use yapi_types::rpc::{CommandSource, SlashCommand, SourceInfo};
22use yapi_types::sync::{lock, write};
23use yapi_types::system_prompt::SystemPromptOptions;
24
25use crate::extensions::{
26    Context, Extension, ExtensionUi, Mode, SessionActions, ToolRenderers, receives, tool_granted,
27};
28use crate::mcp::config::check_registration;
29use crate::messages::convert_to_llm;
30use crate::time::now_ms;
31
32use super::{AgentSession, InputSource, Replacement, SessionChange, drain};
33
34/// What a handler result that changes the session's conversation needs.
35pub(super) const SESSION: &str = "the session grant";
36
37/// The fields of a `tool_result` handler's result that change the result.
38const TOOL_RESULT_KEYS: [&str; 5] = [
39    "content",
40    "details",
41    "structuredContent",
42    "isError",
43    "usage",
44];
45
46pub(super) struct Hooks {
47    pub(super) session: AgentSession,
48    pub(super) cancel: CancellationToken,
49    /// pi's turn index for extension events: turns since `agent_start`.
50    pub(super) turn_index: std::sync::atomic::AtomicU64,
51}
52
53fn ui_prompt<'a>(kind: &'a str, title: Option<&'a str>) -> UiPrompt<'a> {
54    UiPrompt {
55        reason: "ui_prompt",
56        kind,
57        title,
58    }
59}
60
61/// A loop event as pi's extension event (`_emitExtensionEvent` in pi's agent
62/// session), when `wanted` takes its type, or `None` for events extensions
63/// do not see.
64fn extension_event(
65    event: &AgentEvent,
66    turn_index: u64,
67    wanted: impl Fn(&str) -> bool,
68) -> Option<Value> {
69    let (kind, keys): (&str, &[&str]) = match event {
70        AgentEvent::AgentStart => ("agent_start", &[]),
71        AgentEvent::AgentEnd { .. } => ("agent_end", &["messages"]),
72        AgentEvent::TurnStart => ("turn_start", &[]),
73        // `turn_end` reaches extensions as a boundary, before the event.
74        AgentEvent::MessageStart { .. } => ("message_start", &["message"]),
75        AgentEvent::MessageUpdate { .. } => {
76            ("message_update", &["message", "assistantMessageEvent"])
77        }
78        // `message_end` reaches extensions as the message is finished.
79        AgentEvent::ToolExecutionStart { .. } => (
80            "tool_execution_start",
81            &["toolCallId", "toolName", "args", "parentToolCallId"],
82        ),
83        AgentEvent::ToolExecutionUpdate { .. } => (
84            "tool_execution_update",
85            &[
86                "toolCallId",
87                "toolName",
88                "args",
89                "partialResult",
90                "parentToolCallId",
91            ],
92        ),
93        AgentEvent::ToolExecutionEnd { .. } => (
94            "tool_execution_end",
95            &[
96                "toolCallId",
97                "toolName",
98                "result",
99                "isError",
100                "parentToolCallId",
101            ],
102        ),
103        _ => return None,
104    };
105    if !wanted(kind) {
106        return None;
107    }
108    let value = serde_json::to_value(event).ok()?;
109    let mut out = serde_json::Map::new();
110    out.insert("type".into(), Value::String(kind.to_owned()));
111    if kind == "turn_start" {
112        out.insert("turnIndex".into(), Value::from(turn_index));
113        out.insert("timestamp".into(), Value::from(now_ms()));
114    }
115    for key in keys {
116        if let Some(field) = value.get(*key) {
117            out.insert((*key).to_owned(), field.clone());
118        }
119    }
120    Some(Value::Object(out))
121}
122
123impl AgentHooks for Hooks {
124    fn on_event<'a>(&'a self, event: &'a AgentEvent) -> BoxFuture<'a, ()> {
125        Box::pin(async move {
126            let session = &self.session;
127            if let AgentEvent::MessageStart { message } = event {
128                session.dequeue_started(message);
129            }
130            // pi's interactive mode rewrites an aborted response's error before
131            // the session records it.
132            let rewritten = match event {
133                AgentEvent::MessageEnd {
134                    message: Message::Assistant(assistant),
135                } if assistant.stop_reason == StopReason::Aborted
136                    && lock(&session.inner.binding).1 == Mode::Tui =>
137                {
138                    let attempt = lock(&session.inner.recovery).retry_attempt;
139                    let mut assistant = assistant.clone();
140                    assistant.error_message = Some(if attempt > 0 {
141                        let plural = if attempt > 1 { "s" } else { "" };
142                        format!("Aborted after {attempt} retry attempt{plural}")
143                    } else {
144                        "Operation aborted".to_owned()
145                    });
146                    Some(AgentEvent::MessageEnd {
147                        message: Message::Assistant(assistant),
148                    })
149                }
150                _ => None,
151            };
152            let event = rewritten.as_ref().unwrap_or(event);
153            if matches!(event, AgentEvent::AgentStart) {
154                self.turn_index.store(0, Ordering::SeqCst);
155            }
156            let turn_index = self.turn_index.load(Ordering::SeqCst);
157            if matches!(event, AgentEvent::TurnEnd { .. }) {
158                self.turn_index.fetch_add(1, Ordering::SeqCst);
159            }
160            // As in pi, extensions see the event first, then listeners. The
161            // session records it last, so entries extensions append come before
162            // it.
163            if let Some(extension_event) =
164                extension_event(event, turn_index, |kind| session.has_handlers(kind))
165            {
166                session
167                    .emit_extension_event(&extension_event, self.cancel.clone())
168                    .await;
169            }
170            match event {
171                AgentEvent::AgentEnd { messages, .. } => {
172                    session.inner.nested.clear();
173                    session.emit(&AgentEvent::AgentEnd {
174                        messages: messages.clone(),
175                        will_retry: session.will_retry_after(messages),
176                    });
177                }
178                _ => session.emit(event),
179            }
180            match event {
181                AgentEvent::MessageEnd { message } => {
182                    let entry_id = session.with_session(|file| match message {
183                        Message::Custom(custom) => file
184                            .append_custom_message(
185                                &custom.custom_type,
186                                custom.content.clone(),
187                                custom.display,
188                                custom.details.clone(),
189                            )
190                            .ok(),
191                        Message::System(_)
192                        | Message::User(_)
193                        | Message::Assistant(_)
194                        | Message::ToolResult(_) => file.append_message(message.clone()).ok(),
195                        _ => None,
196                    });
197                    match message {
198                        Message::Assistant(assistant) => {
199                            let finished_retry = {
200                                let mut recovery = lock(&session.inner.recovery);
201                                recovery.last_assistant = Some(((**assistant).clone(), entry_id));
202                                recovery.turn_tool_results.clear();
203                                if !matches!(
204                                    assistant.stop_reason,
205                                    StopReason::Error | StopReason::Length
206                                ) {
207                                    recovery.overflow_recovery_attempted = false;
208                                }
209                                if assistant.stop_reason != StopReason::Error {
210                                    std::mem::take(&mut recovery.retry_attempt)
211                                } else {
212                                    0
213                                }
214                            };
215                            if finished_retry > 0 {
216                                session.emit(&AgentEvent::AutoRetryEnd {
217                                    success: true,
218                                    attempt: finished_retry,
219                                    final_error: None,
220                                });
221                            }
222                        }
223                        Message::ToolResult(_) => {
224                            if let Some(id) = entry_id {
225                                lock(&session.inner.recovery).turn_tool_results.push(id);
226                            }
227                        }
228                        _ => {}
229                    }
230                }
231                AgentEvent::TurnEnd { .. } => {
232                    let mut recovery = lock(&session.inner.recovery);
233                    recovery.last_tool_results = std::mem::take(&mut recovery.turn_tool_results);
234                }
235                _ => {}
236            }
237            // Custom messages sent during the turn are appended once its handlers
238            // ran, so those that turn_end handlers send join them, as in pi.
239            if matches!(event, AgentEvent::TurnEnd { .. }) {
240                session.flush_pending_custom();
241            }
242        })
243    }
244
245    fn transform_context(&self, messages: Vec<Message>) -> BoxFuture<'_, Vec<Message>> {
246        Box::pin(async move {
247            let session = &self.session;
248            let mut messages = session
249                .context_handlers(messages, self.cancel.clone())
250                .await;
251            // pi's hidden declarations projection: requests leave the tools
252            // the loadout hides out of every declaration change.
253            let hidden = lock(&session.inner.hidden_tools).clone();
254            if !hidden.is_empty() {
255                for message in &mut messages {
256                    if let Message::System(system) = message {
257                        system.tools_added = system.tools_added.take().and_then(|mut tools| {
258                            tools.retain(|tool| !hidden.contains(&tool.name));
259                            (!tools.is_empty()).then_some(tools)
260                        });
261                        system.tools_removed = system.tools_removed.take().and_then(|mut tools| {
262                            tools.retain(|tool| !hidden.contains(&tool.name));
263                            (!tools.is_empty()).then_some(tools)
264                        });
265                    }
266                }
267            }
268            let forced = lock(&session.inner.forced_prompt).clone();
269            if let Some(forced) = forced {
270                // pi's forced prompt projection: one system message with the
271                // forced text replaces the transcript's system messages.
272                let current = yapi_ai::transcript::current_system_message(&messages);
273                let head = Message::System(SystemMessage {
274                    content: Content::Text(forced),
275                    sections: None,
276                    timestamp: current
277                        .as_ref()
278                        .map_or_else(now_ms, |system| system.timestamp),
279                    tools_added: current.and_then(|system| system.tools_added),
280                    tools_removed: None,
281                });
282                messages.retain(|message| !matches!(message, Message::System(_)));
283                messages.insert(0, head);
284            }
285            messages
286        })
287    }
288
289    fn convert_to_llm(&self, messages: Vec<Message>) -> Vec<Message> {
290        let messages = convert_to_llm(messages);
291        // Read on every request, so a change applies mid-session.
292        let blocked = self
293            .session
294            .settings()
295            .images
296            .and_then(|images| images.block_images)
297            == Some(true);
298        if blocked {
299            crate::messages::block_images(messages)
300        } else {
301            messages
302        }
303    }
304
305    fn auth<'a>(&'a self, model: &'a Model) -> BoxFuture<'a, Auth> {
306        // The registry is read under a short lock. Credential commands run outside it.
307        let registry = self.session.registry();
308        Box::pin(async move { registry.auth(model).await })
309    }
310
311    fn current_tools(&self) -> BoxFuture<'_, Option<Vec<Arc<dyn Tool>>>> {
312        Box::pin(async move {
313            let declared = self
314                .session
315                .inner
316                .tools
317                .with(|registry| registry.declared());
318            self.session.refresh_loadouts(declared.clone()).await;
319            Some(self.session.loadout(declared))
320        })
321    }
322
323    fn before_tool_call<'a>(
324        &'a self,
325        call: yapi_agent::hooks::BeforeToolCall<'a>,
326    ) -> BoxFuture<'a, Option<yapi_agent::hooks::Block>> {
327        Box::pin(async move {
328            let ctx = self.session.extension_context(self.cancel.clone());
329            for extension in &self.session.inner.extensions {
330                if let Some(reason) = extension
331                    .tool_call(&ctx, &call.tool_call.name, &*call.args)
332                    .await
333                {
334                    let granted = extension.grants().session;
335                    return Some(yapi_agent::hooks::Block {
336                        reason: self
337                            .session
338                            .applies(extension, "tool_call", granted, SESSION)
339                            .then_some(reason),
340                        terminate: false,
341                    });
342                }
343            }
344            let mut event = ExtensionEvent::ToolCall {
345                tool_name: &call.tool_call.name,
346                tool_call_id: &call.tool_call.id,
347                parent_tool_call_id: call.parent_tool_call_id,
348                input: call.args,
349            }
350            .to_value();
351            // Handlers change the call's arguments by editing `event.input`,
352            // which later handlers see, as in pi. The arguments are what the
353            // model sees, and a handler changes them only when its grants
354            // would also let it run the tool itself.
355            let name = &call.tool_call.name;
356            let tool = self
357                .session
358                .inner
359                .tools
360                .with(|registry| registry.get(name).cloned());
361            for extension in self.session.handlers_of("tool_call") {
362                let Some(result) = extension.handle(&ctx, &event).await else {
363                    continue;
364                };
365                // The runtime reports the input whether or not a handler changed it.
366                if let Some(input) = result
367                    .get("input")
368                    .filter(|input| input.is_object() && **input != event["input"])
369                {
370                    let grants = extension.grants();
371                    let granted = grants.session
372                        && tool
373                            .as_ref()
374                            .map_or(grants.is_all(), |tool| tool_granted(&grants, tool));
375                    let missing =
376                        format!("the session grant and the grants to run the {name} tool");
377                    if self
378                        .session
379                        .applies(extension, "tool_call", granted, &missing)
380                    {
381                        event["input"] = input.clone();
382                    }
383                }
384                // A block always applies, but its reason is text the model
385                // reads, which needs the session grant.
386                if result["block"] == true {
387                    let granted = extension.grants().session;
388                    let reason = result["reason"].as_str().filter(|_| {
389                        self.session
390                            .applies(extension, "tool_call", granted, SESSION)
391                    });
392                    return Some(yapi_agent::hooks::Block {
393                        reason: reason.map(str::to_owned),
394                        terminate: result["terminate"] == true,
395                    });
396                }
397            }
398            *call.args = event["input"].take();
399            None
400        })
401    }
402
403    fn after_tool_call<'a>(
404        &'a self,
405        call: yapi_agent::hooks::AfterToolCall<'a>,
406    ) -> BoxFuture<'a, Option<yapi_agent::hooks::ResultPatch>> {
407        Box::pin(async move {
408            let mut patch = self.tool_result_handlers(&call).await;
409            // After the handlers, so images they add are normalized too.
410            let content = patch
411                .as_ref()
412                .and_then(|patch| patch.content.as_ref())
413                .unwrap_or(&call.result.content);
414            if content
415                .iter()
416                .any(|block| matches!(block, ContentBlock::Image(_)))
417            {
418                let content = content.clone();
419                let (auto_resize, limits) = self.session.image_options();
420                let normalized = tokio::task::spawn_blocking(move || {
421                    crate::images::normalize_tool_result(&content, auto_resize, limits.as_ref())
422                })
423                .await
424                .ok()
425                .flatten();
426                if let Some(content) = normalized {
427                    // New images alone keep the structured content, as in pi.
428                    patch
429                        .get_or_insert_with(|| yapi_agent::hooks::ResultPatch {
430                            structured_content: call.result.structured_content.clone(),
431                            ..Default::default()
432                        })
433                        .content = Some(content);
434                }
435            }
436            patch
437        })
438    }
439
440    fn finish_turn<'a>(
441        &'a self,
442        message: &'a yapi_types::message::AssistantMessage,
443        tool_results: &'a [ToolResultMessage],
444    ) -> BoxFuture<'a, yapi_agent::hooks::TurnDecision> {
445        Box::pin(async move {
446            let turn_index = self.turn_index.load(Ordering::SeqCst);
447            self.session
448                .turn_end_boundary(message, tool_results, turn_index, self.cancel.clone())
449                .await
450        })
451    }
452
453    fn finish_message(&self, message: Message) -> BoxFuture<'_, Message> {
454        Box::pin(async move {
455            self.session
456                .message_end_handlers(message, self.cancel.clone())
457                .await
458        })
459    }
460
461    fn complete_tool_result(&self, message: &mut ToolResultMessage) {
462        let Some(summary) = self.session.inner.nested.take(&message.tool_call_id) else {
463            return;
464        };
465        if summary.calls.is_some() {
466            message.nested_calls = summary.calls;
467        }
468        if let Some(usage) = summary.usage {
469            message.usage = Some(match &message.usage {
470                Some(own) => own.combine(&usage),
471                None => usage,
472            });
473        }
474    }
475
476    fn next_turn_messages(&self) -> BoxFuture<'_, Result<Option<Vec<Message>>, String>> {
477        // As in pi, each turn continues from the session's transcript, with
478        // what the session recorded since the last, such as custom messages
479        // held while it ran and entries turn_end handlers committed, once a
480        // context over the threshold is compacted.
481        Box::pin(async move {
482            self.session
483                .compact_before_next_response(&self.cancel)
484                .await?;
485            Ok(Some(self.session.messages()))
486        })
487    }
488
489    // As pi's agent, each drain reads the current mode, so a change applies
490    // to the running agent.
491    fn steering_messages(&self) -> BoxFuture<'_, Vec<Message>> {
492        let messages = drain(&self.session.inner.steering, self.session.drains_all().0);
493        Box::pin(async move { messages })
494    }
495
496    fn follow_up_messages(&self) -> BoxFuture<'_, Vec<Message>> {
497        let messages = drain(&self.session.inner.follow_up, self.session.drains_all().1);
498        Box::pin(async move { messages })
499    }
500}
501
502impl Hooks {
503    /// pi's `tool_result` event: the changes its handlers make, if any.
504    async fn tool_result_handlers(
505        &self,
506        call: &yapi_agent::hooks::AfterToolCall<'_>,
507    ) -> Option<yapi_agent::hooks::ResultPatch> {
508        let handlers = self.session.handlers_of("tool_result");
509        if handlers.is_empty() {
510            return None;
511        }
512        let ctx = self.session.extension_context(self.cancel.clone());
513        let mut event = ExtensionEvent::ToolResult {
514            tool_name: &call.tool_call.name,
515            tool_call_id: &call.tool_call.id,
516            parent_tool_call_id: call.parent_tool_call_id,
517            input: call.args,
518            content: &call.result.content,
519            details: call.result.details.as_ref(),
520            structured_content: call.result.structured_content.as_ref(),
521            is_error: call.is_error,
522            usage: call.result.usage.as_ref(),
523        }
524        .to_value();
525        let mut modified = false;
526        for extension in handlers {
527            let Some(result) = extension.handle(&ctx, &event).await else {
528                continue;
529            };
530            let given = |key: &str| result.get(key).filter(|value| !value.is_null());
531            let changes = TOOL_RESULT_KEYS.iter().any(|key| given(key).is_some());
532            if changes
533                && !self.session.applies(
534                    extension,
535                    "tool_result",
536                    extension.grants().session,
537                    SESSION,
538                )
539            {
540                continue;
541            }
542            // As in pi, structured content not replaced along with the
543            // content may no longer match it.
544            if given("content").is_some()
545                && given("structuredContent").is_none()
546                && let Some(event) = event.as_object_mut()
547            {
548                event.shift_remove("structuredContent");
549            }
550            for key in TOOL_RESULT_KEYS {
551                if let Some(value) = given(key)
552                    && let Some(event) = event.as_object_mut()
553                {
554                    set_tool_result_key(event, key, value.clone());
555                    modified = true;
556                }
557            }
558        }
559        modified.then(|| yapi_agent::hooks::ResultPatch {
560            content: serde_json::from_value(event["content"].clone()).ok(),
561            details: Some(event["details"].clone()).filter(|details| !details.is_null()),
562            structured_content: event.get("structuredContent").cloned(),
563            is_error: event["isError"].as_bool(),
564            usage: serde_json::from_value(event["usage"].clone()).ok(),
565            terminate: None,
566        })
567    }
568}
569
570/// Whether `extension` handles pi event `kind` and its grants let it hear it.
571fn hears(extension: &Arc<dyn Extension>, kind: &str) -> bool {
572    extension.handles(kind) && receives(kind, &extension.grants())
573}
574
575/// Sets `key` of a `tool_result` event where pi's event has it: pi's holds
576/// `details` and `usage` even when undefined, after `content` and `isError`.
577fn set_tool_result_key(event: &mut serde_json::Map<String, Value>, key: &str, value: Value) {
578    if let Some(slot) = event.get_mut(key) {
579        *slot = value;
580        return;
581    }
582    let after = match key {
583        "details" => "content",
584        "usage" => "isError",
585        _ => "",
586    };
587    let index = event
588        .keys()
589        .position(|name| name == after)
590        .map_or(event.len(), |index| index + 1);
591    event.shift_insert(index, key.to_owned(), value);
592}
593
594/// The servers in `registered` that runtimes other than `owner` registered.
595fn others(
596    registered: &indexmap::IndexMap<String, (u64, RegisteredMcpServer)>,
597    owner: u64,
598) -> impl Iterator<Item = &RegisteredMcpServer> + Clone {
599    registered
600        .values()
601        .filter(move |(runtime, _)| *runtime != owner)
602        .map(|(_, server)| server)
603}
604
605impl AgentSession {
606    /// The context extensions get, with `cancel` for the operation at hand.
607    pub(super) fn extension_context(&self, cancel: CancellationToken) -> Context {
608        let (ui, mode, _) = lock(&self.inner.binding).clone();
609        let (project_dir, project_trusted) = {
610            let settings = lock(&self.inner.settings);
611            (
612                settings.project_dir().to_path_buf(),
613                settings.project_trusted(),
614            )
615        };
616        Context {
617            cwd: self.inner.cwd.clone(),
618            agent_dir: self.inner.agent_dir.clone(),
619            project_dir,
620            project_trusted,
621            mode,
622            ui,
623            tools: self.inner.tools.clone(),
624            cancel,
625            session: self.downgrade(),
626        }
627    }
628
629    /// The UI and mode extensions are bound to.
630    pub fn extension_binding(&self) -> (Arc<dyn ExtensionUi>, Mode) {
631        let (ui, mode, _) = lock(&self.inner.binding).clone();
632        (ui, mode)
633    }
634
635    /// Whether a result of `extension`'s `event` handler applies: always
636    /// when `granted`, else never, and the extension is warned once per
637    /// event that the result needs `missing`, such as the session grant.
638    pub(super) fn applies(
639        &self,
640        extension: &Arc<dyn Extension>,
641        event: &str,
642        granted: bool,
643        missing: &str,
644    ) -> bool {
645        if !granted {
646            self.dropped(extension, event, missing);
647        }
648        granted
649    }
650
651    /// Warns `extension`, once per event, that the session ignored a result
652    /// of its `event` handler, which needs `missing`.
653    pub(super) fn dropped(&self, extension: &Arc<dyn Extension>, event: &str, missing: &str) {
654        let path = extension.source().path;
655        if lock(&self.inner.dropped_results).insert((path.clone(), event.to_owned())) {
656            extension.warn(&format!(
657                "Ignored a {event} result of extension {path}: it needs {missing}"
658            ));
659        }
660    }
661
662    pub(super) fn handlers_of(&self, kind: &str) -> Vec<&Arc<dyn Extension>> {
663        self.inner
664            .extensions
665            .iter()
666            .filter(|extension| hears(extension, kind))
667            .collect()
668    }
669
670    /// Whether any extension handles pi events of type `kind` and may hear them.
671    pub fn has_handlers(&self, kind: &str) -> bool {
672        self.inner
673            .extensions
674            .iter()
675            .any(|extension| hears(extension, kind))
676    }
677
678    /// Delivers pi event `event` to every extension that handles it, in
679    /// order, as pi's runner `emit` does. Only a `session_before_*` event has
680    /// a result: the last one a handler gave, or the first that cancels,
681    /// which stops delivery.
682    pub async fn emit_extension_event(
683        &self,
684        event: &Value,
685        cancel: CancellationToken,
686    ) -> Option<Value> {
687        let kind = event["type"].as_str().unwrap_or_default();
688        let handlers = self.handlers_of(kind);
689        if handlers.is_empty() {
690            return None;
691        }
692        let ctx = self.extension_context(cancel);
693        let before = kind.starts_with("session_before_");
694        let mut result = None;
695        for extension in handlers {
696            let Some(mut next) = extension.handle(&ctx, event).await else {
697                continue;
698            };
699            if !before {
700                continue;
701            }
702            let cancelled = next["cancel"] == true;
703            // Without the session grant, a handler may only cancel, such as
704            // a compaction it would otherwise summarize itself. Anything else
705            // counts as no result, so it never replaces a granted one.
706            if !extension.grants().session {
707                let changes = next
708                    .as_object()
709                    .is_some_and(|result| result.keys().any(|key| key != "cancel"));
710                if changes {
711                    self.dropped(extension, kind, SESSION);
712                }
713                if !cancelled {
714                    continue;
715                }
716                next = serde_json::json!({"cancel": true});
717            }
718            result = Some(next);
719            if cancelled {
720                break;
721            }
722        }
723        result
724    }
725
726    /// pi's `session_before_switch` or `session_before_fork`: whether an
727    /// extension cancels `change`.
728    pub async fn cancels(&self, change: &SessionChange) -> bool {
729        let reason = change.reason().as_str();
730        let event = match change {
731            SessionChange::New => ExtensionEvent::SessionBeforeSwitch {
732                reason,
733                target_session_file: None,
734            },
735            SessionChange::Resume(target) => ExtensionEvent::SessionBeforeSwitch {
736                reason,
737                target_session_file: Some(target),
738            },
739            SessionChange::Fork { entry_id, at } => ExtensionEvent::SessionBeforeFork {
740                entry_id,
741                position: if *at { "at" } else { "before" },
742            },
743        };
744        let result = self
745            .emit_extension_event(&event.to_value(), CancellationToken::new())
746            .await;
747        result.is_some_and(|result| result["cancel"] == true)
748    }
749
750    /// pi's `message_end`: the message as handlers replaced it, each seeing
751    /// the previous one's. A replacement of another role is reported and
752    /// skipped, and a missing content of a user, assistant, tool result or
753    /// custom message becomes empty.
754    async fn message_end_handlers(&self, message: Message, cancel: CancellationToken) -> Message {
755        let handlers = self.handlers_of("message_end");
756        if handlers.is_empty() {
757            return message;
758        }
759        let ctx = self.extension_context(cancel);
760        let report = |extension: &Arc<dyn Extension>, error: &str| {
761            ctx.ui
762                .extension_error(&extension.source().path, "message_end", error, None);
763        };
764        let mut current = serde_json::json!(message);
765        let mut replaced_by = None;
766        for extension in handlers {
767            let event = ExtensionEvent::MessageEnd { message: &current }.to_value();
768            let Some(next) = extension
769                .handle(&ctx, &event)
770                .await
771                .and_then(|mut result| result.get_mut("message").map(Value::take))
772                .filter(|next| !next.is_null())
773            else {
774                continue;
775            };
776            if !self.applies(
777                extension,
778                "message_end",
779                extension.grants().session,
780                SESSION,
781            ) {
782                continue;
783            }
784            if next["role"] != current["role"] {
785                report(
786                    extension,
787                    "message_end handlers must return a message with the same role",
788                );
789                continue;
790            }
791            current = next;
792            replaced_by = Some(extension);
793        }
794        let Some(extension) = replaced_by else {
795            return message;
796        };
797        if matches!(
798            current["role"].as_str(),
799            Some("user" | "assistant" | "toolResult" | "custom")
800        ) && current["content"].is_null()
801        {
802            current["content"] = Value::Array(Vec::new());
803        }
804        serde_json::from_value(current).unwrap_or_else(|error| {
805            report(extension, &error.to_string());
806            message
807        })
808    }
809
810    /// Passes `value` through the extensions handling `kind` as the event's
811    /// `field`, each seeing the `field` the previous one returned.
812    pub(super) async fn chain(
813        &self,
814        kind: &str,
815        field: &str,
816        value: Value,
817        cancel: CancellationToken,
818    ) -> Value {
819        let handlers = self.handlers_of(kind);
820        if handlers.is_empty() {
821            return value;
822        }
823        let ctx = self.extension_context(cancel);
824        let mut value = value;
825        for extension in handlers {
826            let event = serde_json::json!({"type": kind, field: value});
827            if let Some(result) = extension.handle(&ctx, &event).await
828                && let Some(next) = result.get(field)
829                && *next != value
830                && self.applies(extension, kind, extension.grants().session, SESSION)
831            {
832                value = next.clone();
833            }
834        }
835        value
836    }
837
838    /// pi's `emitContext`: `context` handlers see the conversation without
839    /// system messages, which come back as the current prompt when they
840    /// change it. `context_with_system` handlers then see and return the
841    /// whole transcript.
842    async fn context_handlers(
843        &self,
844        messages: Vec<Message>,
845        cancel: CancellationToken,
846    ) -> Vec<Message> {
847        let mut messages = messages;
848        let ctx = self.extension_context(cancel);
849        for extension in self.handlers_of("context") {
850            let visible: Vec<Message> = messages
851                .iter()
852                .filter(|message| !matches!(message, Message::System(_)))
853                .cloned()
854                .collect();
855            let event = ExtensionEvent::Context { messages: &visible }.to_value();
856            if let Some(result) = extension.handle(&ctx, &event).await
857                && let Ok(returned) =
858                    serde_json::from_value::<Vec<Message>>(result["messages"].clone())
859                && returned != visible
860                && self.applies(extension, "context", extension.grants().session, SESSION)
861            {
862                let head = yapi_ai::transcript::current_system_message(&messages);
863                messages = head
864                    .map(Message::System)
865                    .into_iter()
866                    .chain(returned)
867                    .collect();
868            }
869        }
870        for extension in self.handlers_of("context_with_system") {
871            let had_head = matches!(messages.first(), Some(Message::System(_)));
872            let event = ExtensionEvent::ContextWithSystem {
873                messages: &messages,
874            }
875            .to_value();
876            if let Some(result) = extension.handle(&ctx, &event).await
877                && let Ok(returned) =
878                    serde_json::from_value::<Vec<Message>>(result["messages"].clone())
879                && returned != messages
880                && self.applies(
881                    extension,
882                    "context_with_system",
883                    extension.grants().session,
884                    SESSION,
885                )
886            {
887                messages = returned;
888            }
889            if had_head && !matches!(messages.first(), Some(Message::System(_))) {
890                ctx.ui.extension_error(
891                    &extension.source().path,
892                    "context_with_system",
893                    "Handler removed the leading system message; the request has no prompt or initial tool declarations. Keep it at index 0 or replace a dropped prefix with getCurrentSystemMessage().",
894                    None,
895                );
896            }
897        }
898        messages
899    }
900
901    /// pi's `before_provider_headers`: the headers after handlers changed
902    /// them in place. A null value removes a header.
903    pub(super) async fn before_provider_headers(
904        &self,
905        headers: yapi_ai::stream::RequestHeaders,
906        cancel: CancellationToken,
907    ) -> yapi_ai::stream::RequestHeaders {
908        let value = serde_json::to_value(&headers).unwrap_or_default();
909        let value = self
910            .chain("before_provider_headers", "headers", value, cancel)
911            .await;
912        match value.as_object() {
913            Some(headers) => headers
914                .iter()
915                .map(|(name, value)| (name.clone(), value.as_str().map(str::to_owned)))
916                .collect(),
917            None => headers,
918        }
919    }
920
921    /// A blocking extension dialog of `kind` (`select`, `confirm`, `input`,
922    /// `editor` or `custom`) with `title` opens. Extensions hear of the
923    /// outermost as pi's `ui_prompt_start`.
924    pub fn ui_prompt_opened(&self, kind: &str, title: Option<&str>) {
925        let title = title.filter(|title| !title.is_empty()).map(str::to_owned);
926        {
927            let mut prompts = lock(&self.inner.ui_prompts);
928            prompts.0 += 1;
929            if prompts.0 > 1 {
930                return;
931            }
932            prompts.1 = Some((kind.to_owned(), title.clone()));
933        }
934        self.announce(ExtensionEvent::UiPromptStart(ui_prompt(
935            kind,
936            title.as_deref(),
937        )));
938    }
939
940    /// A blocking extension dialog closed. When it was the outermost,
941    /// extensions hear of it as pi's `ui_prompt_end`.
942    pub fn ui_prompt_closed(&self) {
943        let (kind, title) = {
944            let mut prompts = lock(&self.inner.ui_prompts);
945            prompts.0 = prompts.0.saturating_sub(1);
946            if prompts.0 > 0 {
947                return;
948            }
949            match prompts.1.take() {
950                Some(prompt) => prompt,
951                None => return,
952            }
953        };
954        self.announce(ExtensionEvent::UiPromptEnd(ui_prompt(
955            &kind,
956            title.as_deref(),
957        )));
958    }
959
960    /// Delivers `event` to extensions in the background, after the events
961    /// announced before it, as pi's runner delivers events it does not wait
962    /// for. Outside a tokio runtime it waits for the next
963    /// [`AgentSession::flush_announcements`].
964    pub(super) fn announce(&self, event: ExtensionEvent<'_>) {
965        let event = event.to_value();
966        let kind = event["type"].as_str().unwrap_or_default();
967        if !self.has_handlers(kind) {
968            return;
969        }
970        lock(&self.inner.announcements).push_back(event);
971        if let Ok(runtime) = tokio::runtime::Handle::try_current() {
972            let session = self.clone();
973            runtime.spawn(async move { session.flush_announcements().await });
974        }
975    }
976
977    /// Sets the MCP servers that extension runtime `owner` registered, and
978    /// announces `mcp_servers_change` when they changed after the extensions
979    /// started. Before then, as runtimes bind, the reports hold the servers
980    /// registered while extensions loaded, which the MCP extension reads
981    /// when the session starts. Nothing changes when a server's name, or its
982    /// tools' namespace, belongs to another runtime, and the error is pi's
983    /// for that registration.
984    pub fn set_mcp_servers(
985        &self,
986        owner: u64,
987        servers: Vec<RegisteredMcpServer>,
988    ) -> Result<(), String> {
989        let changed = {
990            let (registered, started) = &mut *lock(&self.inner.mcp_servers);
991            for server in &servers {
992                check_registration(others(registered, owner), &server.name)?;
993            }
994            let before = registered.clone();
995            // As in pi's registry, a server registered again keeps its place.
996            registered.retain(|name, (runtime, _)| {
997                *runtime != owner || servers.iter().any(|server| server.name == *name)
998            });
999            for server in servers {
1000                registered.insert(server.name.clone(), (owner, server));
1001            }
1002            *started && *registered != before
1003        };
1004        if changed {
1005            let servers = self.mcp_servers();
1006            self.announce(ExtensionEvent::McpServersChange { servers: &servers });
1007            self.report_unhandled_mcp_servers();
1008        }
1009        Ok(())
1010    }
1011
1012    /// pi's checks of server `name`, which an extension of runtime `owner`
1013    /// registers, against the servers of the other runtimes, without
1014    /// registering it.
1015    pub fn check_mcp_server(&self, owner: u64, name: &str) -> Result<(), String> {
1016        check_registration(others(&lock(&self.inner.mcp_servers).0, owner), name)
1017    }
1018
1019    /// pi's `reportUnhandledMcpServers`: when no extension handles
1020    /// `mcp_servers_change`, nothing connects registered servers, and each
1021    /// is reported once as its extension's error.
1022    fn report_unhandled_mcp_servers(&self) {
1023        if self.has_handlers("mcp_servers_change") {
1024            return;
1025        }
1026        let (ui, _) = self.extension_binding();
1027        for server in self.mcp_servers() {
1028            if !lock(&self.inner.unhandled_mcp_servers).insert(server.name.clone()) {
1029                continue;
1030            }
1031            let error = format!(
1032                "MCP server \"{}\" is registered, but no loaded extension connects MCP servers; another extension may have replaced the built-in MCP support",
1033                server.name
1034            );
1035            ui.extension_error(&server.extension_path, "register_mcp_server", &error, None);
1036        }
1037    }
1038
1039    /// pi's `getMcpServers`: the servers extensions registered, in
1040    /// registration order.
1041    pub fn mcp_servers(&self) -> Vec<RegisteredMcpServer> {
1042        lock(&self.inner.mcp_servers)
1043            .0
1044            .values()
1045            .map(|(_, server)| server.clone())
1046            .collect()
1047    }
1048
1049    /// Records the extension at `path`, whose factory failed with `error` as
1050    /// the extensions loaded for this session. The factories of every
1051    /// session after the first run again then, as pi loads the extensions
1052    /// for each runtime.
1053    pub fn add_extension_load_error(&self, path: String, error: String) {
1054        lock(&self.inner.extension_load_errors).push((path, error));
1055    }
1056
1057    /// The extensions that failed to load for this session, by path, with
1058    /// pi's message, which pi's interactive mode lists under
1059    /// `[Extension issues]`.
1060    pub fn extension_load_errors(&self) -> Vec<(String, String)> {
1061        lock(&self.inner.extension_load_errors).clone()
1062    }
1063
1064    /// Records the extension at `path`, which was not loaded for this
1065    /// session, with pi's `warning`.
1066    pub fn add_extension_warning(&self, path: String, warning: String) {
1067        lock(&self.inner.extension_warnings).push((path, warning));
1068    }
1069
1070    /// The extensions that were not loaded for this session, by path, with
1071    /// pi's warning, which pi's interactive mode lists under
1072    /// `[Extension issues]`.
1073    pub fn extension_warnings(&self) -> Vec<(String, String)> {
1074        lock(&self.inner.extension_warnings).clone()
1075    }
1076
1077    /// Waits until the events announced so far have reached extensions.
1078    pub async fn flush_announcements(&self) {
1079        let _turn = self.inner.announcing.lock().await;
1080        loop {
1081            let next = lock(&self.inner.announcements).pop_front();
1082            let Some(event) = next else {
1083                return;
1084            };
1085            self.emit_extension_event(&event, CancellationToken::new())
1086                .await;
1087        }
1088    }
1089
1090    /// pi's `input` event: `None` when a handler handled the input,
1091    /// otherwise the possibly transformed text and images.
1092    pub(super) async fn input_handlers(
1093        &self,
1094        text: &str,
1095        images: Vec<ImageContent>,
1096        source: InputSource,
1097        streaming: Option<StreamingBehavior>,
1098    ) -> Option<(String, Vec<ImageContent>)> {
1099        let handlers = self.handlers_of("input");
1100        if handlers.is_empty() {
1101            return Some((text.to_owned(), images));
1102        }
1103        let ctx = self.extension_context(CancellationToken::new());
1104        let mut text = text.to_owned();
1105        let mut images = images;
1106        for extension in handlers {
1107            let event = ExtensionEvent::Input {
1108                text: &text,
1109                images: &images,
1110                source: source.as_str(),
1111                streaming_behavior: streaming,
1112            }
1113            .to_value();
1114            let Some(result) = extension.handle(&ctx, &event).await else {
1115                continue;
1116            };
1117            match result["action"].as_str() {
1118                Some("handled") => return None,
1119                Some("transform")
1120                    if self.applies(extension, "input", extension.grants().session, SESSION) =>
1121                {
1122                    if let Some(next) = result["text"].as_str() {
1123                        next.clone_into(&mut text);
1124                    }
1125                    if let Ok(next) = serde_json::from_value(result["images"].clone()) {
1126                        images = next;
1127                    }
1128                }
1129                _ => {}
1130            }
1131        }
1132        Some((text, images))
1133    }
1134
1135    /// pi's `before_agent_start` event for extensions handling it, with the
1136    /// run's prompt `options`, which each handler may edit for the next: the
1137    /// custom messages to send with the prompt, and the system prompt a
1138    /// handler forced, if any. The edited options become the run's, and
1139    /// edited `selectedTools` the active tools.
1140    pub(super) async fn before_agent_start_handlers(
1141        &self,
1142        prompt: &str,
1143        images: &[ImageContent],
1144        mut options: SystemPromptOptions,
1145    ) -> (Vec<Message>, Option<String>) {
1146        let handlers = self.handlers_of("before_agent_start");
1147        let mut messages = Vec::new();
1148        let selected = options.selected_tools.clone();
1149        let ctx = self.extension_context(CancellationToken::new());
1150        for extension in handlers {
1151            let system_prompt = self.render_prompt(&options).unwrap_or_default();
1152            let event = ExtensionEvent::BeforeAgentStart {
1153                prompt,
1154                images,
1155                system_prompt: &system_prompt,
1156                system_prompt_options: &options,
1157            }
1158            .to_value();
1159            let Some(result) = extension.handle(&ctx, &event).await else {
1160                continue;
1161            };
1162            // Without the session grant, no part of the result applies. The
1163            // runtime reports the options and the forced prompt whether or not
1164            // a handler changed them, so only a change is warned of.
1165            if !extension.grants().session {
1166                let edited = result["systemPromptOptions"].is_object()
1167                    && SystemPromptOptions::deserialize(&result["systemPromptOptions"])
1168                        .is_ok_and(|edited| edited != options);
1169                let forced = result["systemPrompt"]
1170                    .as_str()
1171                    .is_some_and(|prompt| Some(prompt) != options.force_system_prompt.as_deref());
1172                let added = result["messages"]
1173                    .as_array()
1174                    .is_some_and(|messages| !messages.is_empty());
1175                if edited || forced || added {
1176                    self.dropped(extension, "before_agent_start", SESSION);
1177                }
1178                continue;
1179            }
1180            for message in result["messages"].as_array().into_iter().flatten() {
1181                let content = match &message["content"] {
1182                    Value::Null => Value::Array(Vec::new()),
1183                    other => other.clone(),
1184                };
1185                messages.push(Message::Custom(yapi_types::message::CustomMessage {
1186                    custom_type: message["customType"]
1187                        .as_str()
1188                        .unwrap_or_default()
1189                        .to_owned(),
1190                    content: serde_json::from_value(content).unwrap_or(Content::Blocks(Vec::new())),
1191                    display: message["display"].as_bool().unwrap_or(false),
1192                    details: (!message["details"].is_null()).then(|| message["details"].clone()),
1193                    timestamp: now_ms(),
1194                }));
1195            }
1196            // An edit that is not pi's shape leaves the options as they were.
1197            if result["systemPromptOptions"].is_object()
1198                && let Ok(edited) = SystemPromptOptions::deserialize(&result["systemPromptOptions"])
1199            {
1200                options = edited;
1201            }
1202            if let Some(prompt) = result["systemPrompt"].as_str() {
1203                options.force_system_prompt = Some(prompt.to_owned());
1204            }
1205        }
1206        if options.selected_tools != selected {
1207            self.set_active_tools(options.selected_tools.clone());
1208        }
1209        let forced = options.force_system_prompt.take();
1210        *lock(&self.inner.run_options) = Some(options);
1211        (messages, forced)
1212    }
1213
1214    /// Carries out a session change an extension command asks for: whether
1215    /// an extension cancelled it. Without a mode, nothing changes, as pi's
1216    /// default actions do.
1217    pub async fn session_action(
1218        &self,
1219        action: crate::extensions::SessionAction,
1220    ) -> Result<bool, String> {
1221        let actions = lock(&self.inner.binding).2.clone();
1222        match actions {
1223            Some(actions) => actions(action).await,
1224            None => Ok(false),
1225        }
1226    }
1227
1228    /// Gives extensions their UI, mode and the `actions` that carry out the
1229    /// session changes their commands ask for, and starts them: pi's
1230    /// `bindExtensions`, which emits `session_start` with the reason this
1231    /// session replaced another and that session's file, or `startup` when
1232    /// `replaced` is `None`.
1233    pub async fn bind_extensions(
1234        &self,
1235        ui: Arc<dyn ExtensionUi>,
1236        mode: Mode,
1237        actions: Option<SessionActions>,
1238        replaced: Option<(Replacement, Option<String>)>,
1239    ) {
1240        *lock(&self.inner.binding) = (ui, mode, actions);
1241        let ctx = self.extension_context(CancellationToken::new());
1242        for extension in &self.inner.extensions {
1243            extension.session_start(&ctx).await;
1244        }
1245        lock(&self.inner.mcp_servers).1 = true;
1246        let (reason, previous) = match replaced {
1247            Some((reason, previous)) => (Some(reason), reason.reported(previous)),
1248            None => (None, None),
1249        };
1250        let event = ExtensionEvent::SessionStart {
1251            reason: reason.map_or("startup", Replacement::as_str),
1252            previous_session_file: previous.as_deref(),
1253        };
1254        self.emit_extension_event(&event.to_value(), CancellationToken::new())
1255            .await;
1256        self.report_unhandled_mcp_servers();
1257        let reload = reason == Some(Replacement::Reload);
1258        self.discover_resources(if reload { "reload" } else { "startup" })
1259            .await;
1260    }
1261
1262    /// pi's `resources_discover`, after `session_start`: adds the skills,
1263    /// prompt templates and themes extensions name, each with its extension
1264    /// as source.
1265    async fn discover_resources(&self, reason: &str) {
1266        let handlers = self.handlers_of("resources_discover");
1267        if handlers.is_empty() {
1268            return;
1269        }
1270        let event = ExtensionEvent::ResourcesDiscover {
1271            cwd: &self.inner.cwd,
1272            reason,
1273        }
1274        .to_value();
1275        let ctx = self.extension_context(CancellationToken::new());
1276        let mut found: [Vec<SourceInfo>; 3] = Default::default();
1277        for extension in handlers {
1278            let Some(result) = extension.handle(&ctx, &event).await else {
1279                continue;
1280            };
1281            // The files it names load into the prompt and the commands.
1282            let grants = extension.grants();
1283            let names = ["skillPaths", "promptPaths", "themePaths"]
1284                .iter()
1285                .any(|kind| {
1286                    result[*kind]
1287                        .as_array()
1288                        .is_some_and(|paths| !paths.is_empty())
1289                });
1290            if names
1291                && !self.applies(
1292                    extension,
1293                    "resources_discover",
1294                    grants.session && grants.filesystem,
1295                    "the session and file grants",
1296                )
1297            {
1298                continue;
1299            }
1300            let origin = extension.source().path;
1301            let synthetic = origin.starts_with("builtin:") || origin.starts_with('<');
1302            let source = if synthetic {
1303                format!("extension:{}", origin.replace(['<', '>'], ""))
1304            } else {
1305                let name = std::path::Path::new(&origin)
1306                    .file_name()
1307                    .map(|name| name.to_string_lossy().into_owned())
1308                    .unwrap_or_default();
1309                let name = name
1310                    .strip_suffix(".ts")
1311                    .or_else(|| name.strip_suffix(".js"))
1312                    .unwrap_or(&name);
1313                format!("extension:{name}")
1314            };
1315            let base_dir = (!synthetic)
1316                .then(|| std::path::Path::new(&origin).parent())
1317                .flatten()
1318                .map(|dir| dir.display().to_string());
1319            for (kind, paths) in ["skillPaths", "promptPaths", "themePaths"]
1320                .iter()
1321                .zip(found.iter_mut())
1322            {
1323                for path in result[*kind].as_array().into_iter().flatten() {
1324                    let Some(path) = path.as_str() else { continue };
1325                    let path = crate::tools::path::resolve_to_cwd(path.trim(), &self.inner.cwd);
1326                    paths.push(SourceInfo {
1327                        path: path.display().to_string(),
1328                        source: source.clone(),
1329                        scope: "temporary".into(),
1330                        origin: "top-level".into(),
1331                        base_dir: base_dir.clone(),
1332                    });
1333                }
1334            }
1335        }
1336        // pi's `extendResources`: each kind extensions name paths for loads
1337        // again from its paths, merged with those it had.
1338        let [skills, prompts, themes] = found;
1339        let merged = |known: &mut Vec<SourceInfo>, found: Vec<SourceInfo>| {
1340            crate::resources::merge_sources(std::mem::take(known).into_iter().chain(found))
1341        };
1342        let mut resources = write(&self.inner.resources);
1343        let resources = &mut *resources;
1344        if !skills.is_empty() {
1345            resources.skill_sources = merged(&mut resources.skill_sources, skills);
1346            (resources.skills, resources.skill_diagnostics) =
1347                crate::resources::skills_from(&resources.skill_sources);
1348        }
1349        if !prompts.is_empty() {
1350            resources.template_sources = merged(&mut resources.template_sources, prompts);
1351            (resources.templates, resources.template_diagnostics) =
1352                crate::resources::templates_from(&resources.template_sources);
1353        }
1354        if !themes.is_empty() {
1355            resources.themes = merged(&mut resources.themes, themes);
1356        }
1357    }
1358
1359    /// Stops extensions before the session ends: pi's `session_shutdown`
1360    /// with the reason `quit`.
1361    pub async fn shutdown(&self) {
1362        self.stop_extensions(ExtensionEvent::SessionShutdown {
1363            reason: "quit",
1364            target_session_file: None,
1365        })
1366        .await;
1367        self.release_resources().await;
1368    }
1369
1370    /// Stops extensions before a session replaces this one for `reason`.
1371    /// `target` is the replacement's session file, which pi does not report
1372    /// on reload.
1373    pub async fn shutdown_for(&self, reason: Replacement, target: Option<String>) {
1374        let target = reason.reported(target);
1375        self.stop_extensions(ExtensionEvent::SessionShutdown {
1376            reason: reason.as_str(),
1377            target_session_file: target.as_deref(),
1378        })
1379        .await;
1380        if reason != Replacement::Reload {
1381            self.release_resources().await;
1382        }
1383    }
1384
1385    async fn stop_extensions(&self, event: ExtensionEvent<'_>) {
1386        self.cancel_cache_warming();
1387        self.emit_extension_event(&event.to_value(), CancellationToken::new())
1388            .await;
1389        let ctx = self.extension_context(CancellationToken::new());
1390        for extension in &self.inner.extensions {
1391            extension.session_shutdown(&ctx).await;
1392        }
1393    }
1394
1395    /// pi's `cleanupSessionResources`, run when a session ends or another
1396    /// replaces it, but not on reload: the session's cached sockets close
1397    /// and extensions release what they hold for it.
1398    async fn release_resources(&self) {
1399        let id = lock(&self.inner.state).session.id().to_owned();
1400        yapi_ai::api::close_codex_websockets(Some(&id));
1401        for extension in &self.inner.extensions {
1402            extension.end_session(&id).await;
1403        }
1404    }
1405
1406    /// The session's extensions.
1407    pub fn extensions(&self) -> &[Arc<dyn Extension>] {
1408        &self.inner.extensions
1409    }
1410
1411    /// The extension that draws tool `name`, and how.
1412    pub fn tool_renderer(&self, name: &str) -> Option<(Arc<dyn Extension>, ToolRenderers)> {
1413        self.inner.extensions.iter().find_map(|extension| {
1414            let renderers = extension.renderers().tools.get(name).copied()?;
1415            Some((extension.clone(), renderers))
1416        })
1417    }
1418
1419    /// The extension that draws custom entries of type `custom_type`.
1420    pub fn entry_renderer(&self, custom_type: &str) -> Option<Arc<dyn Extension>> {
1421        self.inner
1422            .extensions
1423            .iter()
1424            .find(|extension| {
1425                extension
1426                    .renderers()
1427                    .entries
1428                    .iter()
1429                    .any(|kind| kind == custom_type)
1430            })
1431            .cloned()
1432    }
1433
1434    /// The extensions that transform Markdown, in load order.
1435    pub fn markdown_transformers(&self) -> Vec<Arc<dyn Extension>> {
1436        self.inner
1437            .extensions
1438            .iter()
1439            .filter(|extension| extension.renderers().markdown)
1440            .cloned()
1441            .collect()
1442    }
1443
1444    /// The extensions that render code blocks, in load order.
1445    pub fn code_renderers(&self) -> Vec<Arc<dyn Extension>> {
1446        self.inner
1447            .extensions
1448            .iter()
1449            .filter(|extension| extension.renderers().code)
1450            .cloned()
1451            .collect()
1452    }
1453
1454    /// The extension that draws custom messages of type `custom_type`.
1455    pub fn message_renderer(&self, custom_type: &str) -> Option<Arc<dyn Extension>> {
1456        self.inner
1457            .extensions
1458            .iter()
1459            .find(|extension| {
1460                extension
1461                    .renderers()
1462                    .messages
1463                    .iter()
1464                    .any(|kind| kind == custom_type)
1465            })
1466            .cloned()
1467    }
1468
1469    /// The shortcuts the session's extensions bind, given the keys of each
1470    /// built-in action, and pi's warnings about conflicts.
1471    pub fn extension_shortcuts(
1472        &self,
1473        builtin: &[(String, Vec<String>)],
1474    ) -> (
1475        Vec<crate::extensions::ShortcutBinding>,
1476        Vec<(String, String)>,
1477    ) {
1478        crate::extensions::resolve_shortcuts(&self.inner.extensions, builtin)
1479    }
1480
1481    /// Runs an extension shortcut's handler and returns the handler's error.
1482    pub async fn run_shortcut(
1483        &self,
1484        binding: &crate::extensions::ShortcutBinding,
1485    ) -> Result<(), String> {
1486        let ctx = self.extension_context(CancellationToken::new());
1487        binding
1488            .extension
1489            .run_shortcut(&binding.registered, &ctx)
1490            .await
1491    }
1492
1493    /// pi's `resolveRegisteredCommands`: the extensions' commands in load
1494    /// order, each with the name it is invoked by. A name registered more
1495    /// than once is invoked as `name:1`, `name:2` and so on.
1496    pub fn extension_commands(&self) -> Vec<crate::extensions::ResolvedCommand> {
1497        let all: Vec<(Arc<dyn Extension>, crate::extensions::Command)> = self
1498            .inner
1499            .extensions
1500            .iter()
1501            .flat_map(|extension| {
1502                extension
1503                    .commands()
1504                    .into_iter()
1505                    .map(move |command| (Arc::clone(extension), command))
1506            })
1507            .collect();
1508        let count = |name: &str| {
1509            all.iter()
1510                .filter(|(_, command)| command.name == name)
1511                .count()
1512        };
1513        let mut seen: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
1514        let mut taken: std::collections::HashSet<String> = std::collections::HashSet::new();
1515        all.iter()
1516            .map(|(extension, command)| {
1517                let occurrence = {
1518                    let entry = seen.entry(command.name.clone()).or_default();
1519                    *entry += 1;
1520                    *entry
1521                };
1522                let mut invocation = if count(&command.name) > 1 {
1523                    format!("{}:{occurrence}", command.name)
1524                } else {
1525                    command.name.clone()
1526                };
1527                let mut suffix = occurrence;
1528                while taken.contains(&invocation) {
1529                    suffix += 1;
1530                    invocation = format!("{}:{suffix}", command.name);
1531                }
1532                taken.insert(invocation.clone());
1533                crate::extensions::ResolvedCommand {
1534                    invocation,
1535                    command: command.clone(),
1536                    extension: Arc::clone(extension),
1537                }
1538            })
1539            .collect()
1540    }
1541
1542    /// pi's slash commands, as RPC's `get_commands` and `pi.getCommands()`
1543    /// list them: the extensions' commands, then the prompt templates and
1544    /// the skills, each with where it comes from.
1545    pub fn slash_commands(&self) -> Vec<SlashCommand> {
1546        let resources = self.resources();
1547        let mut commands: Vec<SlashCommand> = self
1548            .extension_commands()
1549            .into_iter()
1550            .map(|resolved| SlashCommand {
1551                name: resolved.invocation,
1552                // pi omits a description the command did not give.
1553                description: Some(resolved.command.description)
1554                    .filter(|description| !description.is_empty()),
1555                source: CommandSource::Extension,
1556                source_info: resolved.extension.source(),
1557            })
1558            .collect();
1559        commands.extend(
1560            resources
1561                .templates
1562                .into_iter()
1563                .map(|template| SlashCommand {
1564                    name: template.name,
1565                    description: Some(template.description),
1566                    source: CommandSource::Prompt,
1567                    source_info: template.source,
1568                }),
1569        );
1570        commands.extend(resources.skills.into_iter().map(|skill| SlashCommand {
1571            name: format!("skill:{}", skill.name),
1572            description: Some(skill.description),
1573            source: CommandSource::Skill,
1574            source_info: skill.source,
1575        }));
1576        commands
1577    }
1578
1579    /// Whether `text` invokes an extension command.
1580    pub fn is_extension_command(&self, text: &str) -> bool {
1581        let Some(rest) = text.strip_prefix('/') else {
1582            return false;
1583        };
1584        let name = rest.split(' ').next().unwrap_or_default();
1585        self.extension_commands()
1586            .iter()
1587            .any(|command| command.invocation == name)
1588    }
1589
1590    /// Runs `/name args` when an extension command is invoked by `name`, and
1591    /// returns whether it did. Errors are the extension's to report.
1592    pub(super) async fn run_extension_command(&self, text: &str) -> bool {
1593        let Some(rest) = text.strip_prefix('/') else {
1594            return false;
1595        };
1596        let (name, args) = rest.split_once(' ').unwrap_or((rest, ""));
1597        let Some(resolved) = self
1598            .extension_commands()
1599            .into_iter()
1600            .find(|command| command.invocation == name)
1601        else {
1602            return false;
1603        };
1604        let ctx = self.extension_context(CancellationToken::new());
1605        resolved
1606            .extension
1607            .run_command(&resolved.command.name, args, &ctx)
1608            .await;
1609        true
1610    }
1611}
1612
1613#[cfg(test)]
1614mod tests {
1615    use serde_json::json;
1616
1617    use super::*;
1618
1619    /// A handler's `details` and `usage` take the places pi's event holds
1620    /// for them, as another extension runtime then sees the event.
1621    #[test]
1622    fn tool_result_key_placement() {
1623        let mut event = json!({"type": "tool_result", "content": [], "isError": false});
1624        let event = event.as_object_mut().unwrap();
1625        set_tool_result_key(event, "structuredContent", json!({}));
1626        set_tool_result_key(event, "usage", json!({}));
1627        set_tool_result_key(event, "details", json!({}));
1628        set_tool_result_key(event, "isError", json!(true));
1629        let keys: Vec<&String> = event.keys().collect();
1630        assert_eq!(
1631            keys,
1632            [
1633                "type",
1634                "content",
1635                "details",
1636                "isError",
1637                "usage",
1638                "structuredContent"
1639            ]
1640        );
1641        assert_eq!(event["isError"], true);
1642    }
1643}