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    UiPromptStart(UiPrompt<'a>),
124    UiPromptEnd(UiPrompt<'a>),
125    TurnEnd {
126        turn_index: u64,
127        message: &'a Message,
128        tool_results: &'a [Message],
129        message_entry_id: &'a str,
130        tool_result_entry_ids: &'a [String],
131        outcome: &'a str,
132    },
133    MessageEnd {
134        /// The message as the previous handler left it.
135        message: &'a Value,
136    },
137    ModelSelect {
138        model: &'a Model,
139        #[serde(skip_serializing_if = "Option::is_none")]
140        previous_model: Option<&'a Model>,
141        source: &'a str,
142    },
143    ThinkingLevelSelect {
144        level: ThinkingLevel,
145        previous_level: ThinkingLevel,
146    },
147    UserBash {
148        command: &'a str,
149        exclude_from_context: bool,
150        cwd: &'a Path,
151    },
152    Input {
153        text: &'a str,
154        images: &'a [ImageContent],
155        source: &'a str,
156        #[serde(skip_serializing_if = "Option::is_none")]
157        streaming_behavior: Option<StreamingBehavior>,
158    },
159    ToolCall {
160        tool_name: &'a str,
161        tool_call_id: &'a str,
162        #[serde(skip_serializing_if = "Option::is_none")]
163        parent_tool_call_id: Option<&'a str>,
164        input: &'a Value,
165    },
166    ToolResult {
167        tool_name: &'a str,
168        tool_call_id: &'a str,
169        #[serde(skip_serializing_if = "Option::is_none")]
170        parent_tool_call_id: Option<&'a str>,
171        input: &'a Value,
172        content: &'a [ContentBlock],
173        #[serde(skip_serializing_if = "Option::is_none")]
174        details: Option<&'a Value>,
175        #[serde(skip_serializing_if = "Option::is_none")]
176        structured_content: Option<&'a Value>,
177        is_error: bool,
178        #[serde(skip_serializing_if = "Option::is_none")]
179        usage: Option<&'a Usage>,
180    },
181}
182
183impl ExtensionEvent<'_> {
184    /// The event as the JSON document handlers receive.
185    pub fn to_value(&self) -> Value {
186        // Only a path that is not UTF-8 fails to serialize; such an event is
187        // `null` and reaches no handler.
188        serde_json::to_value(self).unwrap_or_default()
189    }
190}
191
192/// pi's `RegisteredMcpServer`: a server an extension registered with
193/// `pi.registerMcpServer`.
194#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
195#[serde(rename_all = "camelCase")]
196pub struct RegisteredMcpServer {
197    pub name: String,
198    /// The `mcpServers` entry.
199    pub config: Value,
200    pub extension_path: String,
201}
202
203/// pi's `UIPromptEvent`: a blocking extension dialog opened or closed.
204#[derive(Debug, Serialize)]
205pub struct UiPrompt<'a> {
206    /// Always `ui_prompt`.
207    pub reason: &'a str,
208    /// `select`, `confirm`, `input`, `editor` or `custom`.
209    pub kind: &'a str,
210    #[serde(skip_serializing_if = "Option::is_none")]
211    pub title: Option<&'a str>,
212}
213
214/// pi's `TreePreparation`: what navigating the session tree would
215/// summarize.
216#[derive(Debug, Serialize)]
217#[serde(rename_all = "camelCase")]
218pub struct TreePreparation<'a> {
219    pub target_id: &'a str,
220    pub old_leaf_id: Option<&'a str>,
221    pub common_ancestor_id: Option<&'a str>,
222    pub entries_to_summarize: &'a [FileEntry],
223    pub user_wants_summary: bool,
224    #[serde(skip_serializing_if = "Option::is_none")]
225    pub custom_instructions: Option<&'a str>,
226    /// Left out unless set.
227    #[serde(skip_serializing_if = "std::ops::Not::not")]
228    pub replace_instructions: bool,
229    #[serde(skip_serializing_if = "Option::is_none")]
230    pub label: Option<&'a str>,
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236    use crate::json;
237
238    #[test]
239    fn optional_fields_are_left_out_in_pi_order() {
240        let failed = |error_message| {
241            json::stringify(
242                &ExtensionEvent::SessionCompactFailed {
243                    reason: CompactionReason::Overflow,
244                    error_message,
245                    aborted: false,
246                    will_retry: false,
247                    from_extension: true,
248                }
249                .to_value(),
250            )
251        };
252        assert_eq!(
253            failed(Some("boom")),
254            r#"{"type":"session_compact_failed","reason":"overflow","errorMessage":"boom","aborted":false,"willRetry":false,"fromExtension":true}"#
255        );
256        assert_eq!(
257            failed(None),
258            r#"{"type":"session_compact_failed","reason":"overflow","aborted":false,"willRetry":false,"fromExtension":true}"#
259        );
260    }
261
262    #[test]
263    fn tree_preparation_sets_only_given_options() {
264        let tree = |custom_instructions, replace_instructions, label| {
265            json::stringify(
266                &ExtensionEvent::SessionBeforeTree {
267                    preparation: TreePreparation {
268                        target_id: "b",
269                        old_leaf_id: Some("a"),
270                        common_ancestor_id: None,
271                        entries_to_summarize: &[],
272                        user_wants_summary: true,
273                        custom_instructions,
274                        replace_instructions,
275                        label,
276                    },
277                }
278                .to_value(),
279            )
280        };
281        assert_eq!(
282            tree(None, false, None),
283            r#"{"type":"session_before_tree","preparation":{"targetId":"b","oldLeafId":"a","commonAncestorId":null,"entriesToSummarize":[],"userWantsSummary":true}}"#
284        );
285        assert_eq!(
286            tree(Some("focus"), true, Some("l")),
287            r#"{"type":"session_before_tree","preparation":{"targetId":"b","oldLeafId":"a","commonAncestorId":null,"entriesToSummarize":[],"userWantsSummary":true,"customInstructions":"focus","replaceInstructions":true,"label":"l"}}"#
288        );
289    }
290
291    #[test]
292    fn ui_prompt_follows_the_tag() {
293        let prompt = UiPrompt {
294            reason: "ui_prompt",
295            kind: "select",
296            title: None,
297        };
298        assert_eq!(
299            json::stringify(&ExtensionEvent::UiPromptEnd(prompt).to_value()),
300            r#"{"type":"ui_prompt_end","reason":"ui_prompt","kind":"select"}"#
301        );
302    }
303}