1#![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#[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 position: &'a str,
63 },
64 SessionBeforeCompact {
65 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 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 pub fn to_value(&self) -> Value {
186 serde_json::to_value(self).unwrap_or_default()
189 }
190}
191
192#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
195#[serde(rename_all = "camelCase")]
196pub struct RegisteredMcpServer {
197 pub name: String,
198 pub config: Value,
200 pub extension_path: String,
201}
202
203#[derive(Debug, Serialize)]
205pub struct UiPrompt<'a> {
206 pub reason: &'a str,
208 pub kind: &'a str,
210 #[serde(skip_serializing_if = "Option::is_none")]
211 pub title: Option<&'a str>,
212}
213
214#[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 #[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}