Skip to main content

yapi_types/
extension_event.rs

1//! Events pi delivers to extension handlers, in the shape and key order of
2//! pi's emit sites.
3//!
4//! Mirrors the event interfaces in `packages/coding-agent/src/core/extensions/types.ts`
5//! in pi `v1.0.0`. Loop events (`agent_start`, `message_update`,
6//! `tool_execution_*` and the like) reach extensions as the fields of their
7//! [`AgentEvent`](crate::event::AgentEvent). Three events are built where they
8//! are emitted: `before_provider_request` and `before_provider_headers` pass
9//! one field through a chain of handlers, and pi writes the `data` of
10//! `provider_stream_event` before its `type`.
11#![allow(
12    missing_docs,
13    reason = "variants mirror pi's TypeScript types; contracts are noted where they differ from the name"
14)]
15
16use std::path::Path;
17
18use indexmap::IndexMap;
19use serde::{Deserialize, Serialize};
20use serde_json::Value;
21
22use crate::event::CompactionReason;
23use crate::message::{ContentBlock, ImageContent, Message, ThinkingLevel, Usage};
24use crate::model::Model;
25use crate::rpc::StreamingBehavior;
26use crate::session::FileEntry;
27use crate::system_prompt::SystemPromptOptions;
28
29/// A pi event for extension handlers. Absent optional fields are left out,
30/// as pi leaves them undefined.
31#[derive(Debug, Serialize)]
32#[serde(
33    tag = "type",
34    rename_all = "snake_case",
35    rename_all_fields = "camelCase"
36)]
37pub enum ExtensionEvent<'a> {
38    ResourcesDiscover {
39        cwd: &'a Path,
40        reason: &'a str,
41    },
42    McpServersChange {
43        servers: &'a [RegisteredMcpServer],
44    },
45    SessionStart {
46        reason: &'a str,
47        #[serde(skip_serializing_if = "Option::is_none")]
48        previous_session_file: Option<&'a str>,
49    },
50    SessionInfoChanged {
51        #[serde(skip_serializing_if = "Option::is_none")]
52        name: Option<&'a str>,
53    },
54    SessionBeforeSwitch {
55        reason: &'a str,
56        #[serde(skip_serializing_if = "Option::is_none")]
57        target_session_file: Option<&'a str>,
58    },
59    SessionBeforeFork {
60        entry_id: &'a str,
61        /// `at` or `before`.
62        position: &'a str,
63    },
64    SessionBeforeCompact {
65        /// pi's `CompactionPreparation`, which yapi-core defines.
66        preparation: Value,
67        branch_entries: &'a [&'a FileEntry],
68        #[serde(skip_serializing_if = "Option::is_none")]
69        custom_instructions: Option<&'a str>,
70        reason: CompactionReason,
71        will_retry: bool,
72    },
73    SessionCompact {
74        compaction_entry: &'a FileEntry,
75        from_extension: bool,
76        reason: CompactionReason,
77        will_retry: bool,
78    },
79    SessionCompactFailed {
80        reason: CompactionReason,
81        #[serde(skip_serializing_if = "Option::is_none")]
82        error_message: Option<&'a str>,
83        aborted: bool,
84        will_retry: bool,
85        from_extension: bool,
86    },
87    SessionShutdown {
88        reason: &'a str,
89        #[serde(skip_serializing_if = "Option::is_none")]
90        target_session_file: Option<&'a str>,
91    },
92    SessionBeforeTree {
93        preparation: TreePreparation<'a>,
94    },
95    SessionTree {
96        new_leaf_id: Option<&'a str>,
97        old_leaf_id: Option<&'a str>,
98        #[serde(skip_serializing_if = "Option::is_none")]
99        summary_entry: Option<&'a FileEntry>,
100        #[serde(skip_serializing_if = "Option::is_none")]
101        from_extension: Option<bool>,
102    },
103    Context {
104        messages: &'a [Message],
105    },
106    ContextWithSystem {
107        messages: &'a [Message],
108    },
109    AfterProviderResponse {
110        status: u16,
111        headers: &'a IndexMap<String, String>,
112    },
113    BeforeAgentStart {
114        prompt: &'a str,
115        images: &'a [ImageContent],
116        system_prompt: &'a str,
117        system_prompt_options: &'a SystemPromptOptions,
118    },
119    AgentBeforeSettle {
120        outcome: &'a str,
121    },
122    AgentSettled,
123    /// pi's decision before a cache warming refresh, which handlers may
124    /// override with `{ action }`.
125    CacheWarmingDecision {
126        warm_cost: f64,
127        miss_cost: f64,
128        continuation_probability: f64,
129        /// `warm` or `stop`.
130        action: &'a str,
131    },
132    UiPromptStart(UiPrompt<'a>),
133    UiPromptEnd(UiPrompt<'a>),
134    TurnEnd {
135        turn_index: u64,
136        message: &'a Message,
137        tool_results: &'a [Message],
138        message_entry_id: &'a str,
139        tool_result_entry_ids: &'a [String],
140        outcome: &'a str,
141    },
142    MessageEnd {
143        /// The message as the previous handler left it.
144        message: &'a Value,
145    },
146    ModelSelect {
147        model: &'a Model,
148        #[serde(skip_serializing_if = "Option::is_none")]
149        previous_model: Option<&'a Model>,
150        source: &'a str,
151    },
152    ThinkingLevelSelect {
153        level: ThinkingLevel,
154        previous_level: ThinkingLevel,
155    },
156    UserBash {
157        command: &'a str,
158        exclude_from_context: bool,
159        cwd: &'a Path,
160    },
161    Input {
162        text: &'a str,
163        images: &'a [ImageContent],
164        source: &'a str,
165        #[serde(skip_serializing_if = "Option::is_none")]
166        streaming_behavior: Option<StreamingBehavior>,
167    },
168    ToolCall {
169        tool_name: &'a str,
170        tool_call_id: &'a str,
171        #[serde(skip_serializing_if = "Option::is_none")]
172        parent_tool_call_id: Option<&'a str>,
173        input: &'a Value,
174    },
175    ToolResult {
176        tool_name: &'a str,
177        tool_call_id: &'a str,
178        #[serde(skip_serializing_if = "Option::is_none")]
179        parent_tool_call_id: Option<&'a str>,
180        input: &'a Value,
181        content: &'a [ContentBlock],
182        #[serde(skip_serializing_if = "Option::is_none")]
183        details: Option<&'a Value>,
184        #[serde(skip_serializing_if = "Option::is_none")]
185        structured_content: Option<&'a Value>,
186        is_error: bool,
187        #[serde(skip_serializing_if = "Option::is_none")]
188        usage: Option<&'a Usage>,
189    },
190}
191
192impl ExtensionEvent<'_> {
193    /// The event as the JSON document handlers receive.
194    pub fn to_value(&self) -> Value {
195        // Only a path that is not UTF-8 fails to serialize; such an event is
196        // `null` and reaches no handler.
197        serde_json::to_value(self).unwrap_or_default()
198    }
199}
200
201/// pi's `RegisteredMcpServer`: a server an extension registered with
202/// `pi.registerMcpServer`.
203#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
204#[serde(rename_all = "camelCase")]
205pub struct RegisteredMcpServer {
206    pub name: String,
207    /// The `mcpServers` entry.
208    pub config: Value,
209    pub extension_path: String,
210}
211
212/// pi's `UIPromptEvent`: a blocking extension dialog opened or closed.
213#[derive(Debug, Serialize)]
214pub struct UiPrompt<'a> {
215    /// Always `ui_prompt`.
216    pub reason: &'a str,
217    /// `select`, `confirm`, `input`, `editor` or `custom`.
218    pub kind: &'a str,
219    #[serde(skip_serializing_if = "Option::is_none")]
220    pub title: Option<&'a str>,
221}
222
223/// pi's `TreePreparation`: what navigating the session tree would
224/// summarize.
225#[derive(Debug, Serialize)]
226#[serde(rename_all = "camelCase")]
227pub struct TreePreparation<'a> {
228    pub target_id: &'a str,
229    pub old_leaf_id: Option<&'a str>,
230    pub common_ancestor_id: Option<&'a str>,
231    pub entries_to_summarize: &'a [FileEntry],
232    pub user_wants_summary: bool,
233    #[serde(skip_serializing_if = "Option::is_none")]
234    pub custom_instructions: Option<&'a str>,
235    /// Left out unless set.
236    #[serde(skip_serializing_if = "std::ops::Not::not")]
237    pub replace_instructions: bool,
238    #[serde(skip_serializing_if = "Option::is_none")]
239    pub label: Option<&'a str>,
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245    use crate::json;
246
247    #[test]
248    fn optional_fields_are_left_out_in_pi_order() {
249        let failed = |error_message| {
250            json::stringify(
251                &ExtensionEvent::SessionCompactFailed {
252                    reason: CompactionReason::Overflow,
253                    error_message,
254                    aborted: false,
255                    will_retry: false,
256                    from_extension: true,
257                }
258                .to_value(),
259            )
260        };
261        assert_eq!(
262            failed(Some("boom")),
263            r#"{"type":"session_compact_failed","reason":"overflow","errorMessage":"boom","aborted":false,"willRetry":false,"fromExtension":true}"#
264        );
265        assert_eq!(
266            failed(None),
267            r#"{"type":"session_compact_failed","reason":"overflow","aborted":false,"willRetry":false,"fromExtension":true}"#
268        );
269    }
270
271    #[test]
272    fn tree_preparation_sets_only_given_options() {
273        let tree = |custom_instructions, replace_instructions, label| {
274            json::stringify(
275                &ExtensionEvent::SessionBeforeTree {
276                    preparation: TreePreparation {
277                        target_id: "b",
278                        old_leaf_id: Some("a"),
279                        common_ancestor_id: None,
280                        entries_to_summarize: &[],
281                        user_wants_summary: true,
282                        custom_instructions,
283                        replace_instructions,
284                        label,
285                    },
286                }
287                .to_value(),
288            )
289        };
290        assert_eq!(
291            tree(None, false, None),
292            r#"{"type":"session_before_tree","preparation":{"targetId":"b","oldLeafId":"a","commonAncestorId":null,"entriesToSummarize":[],"userWantsSummary":true}}"#
293        );
294        assert_eq!(
295            tree(Some("focus"), true, Some("l")),
296            r#"{"type":"session_before_tree","preparation":{"targetId":"b","oldLeafId":"a","commonAncestorId":null,"entriesToSummarize":[],"userWantsSummary":true,"customInstructions":"focus","replaceInstructions":true,"label":"l"}}"#
297        );
298    }
299
300    #[test]
301    fn ui_prompt_follows_the_tag() {
302        let prompt = UiPrompt {
303            reason: "ui_prompt",
304            kind: "select",
305            title: None,
306        };
307        assert_eq!(
308            json::stringify(&ExtensionEvent::UiPromptEnd(prompt).to_value()),
309            r#"{"type":"ui_prompt_end","reason":"ui_prompt","kind":"select"}"#
310        );
311    }
312}