1use 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
34pub(super) const SESSION: &str = "the session grant";
36
37const 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 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
61fn 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 AgentEvent::MessageStart { .. } => ("message_start", &["message"]),
75 AgentEvent::MessageUpdate { .. } => {
76 ("message_update", &["message", "assistantMessageEvent"])
77 }
78 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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
570fn hears(extension: &Arc<dyn Extension>, kind: &str) -> bool {
572 extension.handles(kind) && receives(kind, &extension.grants())
573}
574
575fn 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
594fn 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 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 pub fn extension_binding(&self) -> (Arc<dyn ExtensionUi>, Mode) {
631 let (ui, mode, _) = lock(&self.inner.binding).clone();
632 (ui, mode)
633 }
634
635 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 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 pub fn has_handlers(&self, kind: &str) -> bool {
672 self.inner
673 .extensions
674 .iter()
675 .any(|extension| hears(extension, kind))
676 }
677
678 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 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 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 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: ¤t }.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 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 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 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 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 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 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 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 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 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 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 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 pub fn add_extension_load_error(&self, path: String, error: String) {
1054 lock(&self.inner.extension_load_errors).push((path, error));
1055 }
1056
1057 pub fn extension_load_errors(&self) -> Vec<(String, String)> {
1061 lock(&self.inner.extension_load_errors).clone()
1062 }
1063
1064 pub fn add_extension_warning(&self, path: String, warning: String) {
1067 lock(&self.inner.extension_warnings).push((path, warning));
1068 }
1069
1070 pub fn extension_warnings(&self) -> Vec<(String, String)> {
1074 lock(&self.inner.extension_warnings).clone()
1075 }
1076
1077 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 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 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 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 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 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 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 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 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 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 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 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 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 pub fn extensions(&self) -> &[Arc<dyn Extension>] {
1408 &self.inner.extensions
1409 }
1410
1411 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 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 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 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 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 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 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 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 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 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 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 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 #[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}