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 CacheWarmingDecision {
126 warm_cost: f64,
127 miss_cost: f64,
128 continuation_probability: f64,
129 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 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 pub fn to_value(&self) -> Value {
195 serde_json::to_value(self).unwrap_or_default()
198 }
199}
200
201#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
204#[serde(rename_all = "camelCase")]
205pub struct RegisteredMcpServer {
206 pub name: String,
207 pub config: Value,
209 pub extension_path: String,
210}
211
212#[derive(Debug, Serialize)]
214pub struct UiPrompt<'a> {
215 pub reason: &'a str,
217 pub kind: &'a str,
219 #[serde(skip_serializing_if = "Option::is_none")]
220 pub title: Option<&'a str>,
221}
222
223#[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 #[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}