Skip to main content

hanzo_protocol/
mcp_protocol.rs

1use std::collections::HashMap;
2use std::fmt::Display;
3use std::path::PathBuf;
4
5use crate::config_types::ReasoningEffort;
6use crate::config_types::ReasoningSummary;
7use crate::config_types::SandboxMode;
8use crate::config_types::Verbosity;
9use crate::protocol::AskForApproval;
10use crate::protocol::EventMsg;
11use crate::protocol::FileChange;
12use crate::protocol::ReviewDecision;
13use crate::protocol::SandboxPolicy;
14use crate::protocol::TurnAbortReason;
15use mcp_types::JSONRPCNotification;
16use mcp_types::RequestId;
17use serde::Deserialize;
18use serde::Serialize;
19use strum_macros::Display;
20use ts_rs::TS;
21use uuid::Uuid;
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, TS, Hash)]
24#[ts(type = "string")]
25pub struct ConversationId {
26    uuid: Uuid,
27}
28
29impl ConversationId {
30    pub fn new() -> Self {
31        Self {
32            uuid: Uuid::new_v4(),
33        }
34    }
35
36    pub fn from_string(s: &str) -> Result<Self, uuid::Error> {
37        Ok(Self {
38            uuid: Uuid::parse_str(s)?,
39        })
40    }
41}
42
43impl From<Uuid> for ConversationId {
44    fn from(uuid: Uuid) -> Self {
45        Self { uuid }
46    }
47}
48
49impl From<ConversationId> for Uuid {
50    fn from(id: ConversationId) -> Self {
51        id.uuid
52    }
53}
54
55impl Default for ConversationId {
56    fn default() -> Self {
57        Self::new()
58    }
59}
60
61impl Display for ConversationId {
62    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63        write!(f, "{}", self.uuid)
64    }
65}
66
67impl Serialize for ConversationId {
68    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
69    where
70        S: serde::Serializer,
71    {
72        serializer.collect_str(&self.uuid)
73    }
74}
75
76impl<'de> Deserialize<'de> for ConversationId {
77    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
78    where
79        D: serde::Deserializer<'de>,
80    {
81        let value = String::deserialize(deserializer)?;
82        let uuid = Uuid::parse_str(&value).map_err(serde::de::Error::custom)?;
83        Ok(Self { uuid })
84    }
85}
86
87#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, TS)]
88#[ts(type = "string")]
89pub struct GitSha(pub String);
90
91impl GitSha {
92    pub fn new(sha: &str) -> Self {
93        Self(sha.to_string())
94    }
95}
96
97#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Display, TS)]
98#[serde(rename_all = "lowercase")]
99pub enum AuthMode {
100    ApiKey,
101    ChatGPT,
102}
103
104/// Request from the client to the server.
105#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
106#[serde(tag = "method", rename_all = "camelCase")]
107pub enum ClientRequest {
108    Initialize {
109        #[serde(rename = "id")]
110        #[ts(type = "string | number")]
111        request_id: RequestId,
112        params: InitializeParams,
113    },
114    NewConversation {
115        #[serde(rename = "id")]
116        #[ts(type = "string | number")]
117        request_id: RequestId,
118        params: NewConversationParams,
119    },
120    /// List recorded Codex conversations (rollouts) with optional pagination and search.
121    ListConversations {
122        #[serde(rename = "id")]
123        #[ts(type = "string | number")]
124        request_id: RequestId,
125        params: ListConversationsParams,
126    },
127    /// Resume a recorded Codex conversation from a rollout file.
128    ResumeConversation {
129        #[serde(rename = "id")]
130        #[ts(type = "string | number")]
131        request_id: RequestId,
132        params: ResumeConversationParams,
133    },
134    ArchiveConversation {
135        #[serde(rename = "id")]
136        #[ts(type = "string | number")]
137        request_id: RequestId,
138        params: ArchiveConversationParams,
139    },
140    SendUserMessage {
141        #[serde(rename = "id")]
142        #[ts(type = "string | number")]
143        request_id: RequestId,
144        params: SendUserMessageParams,
145    },
146    SendUserTurn {
147        #[serde(rename = "id")]
148        #[ts(type = "string | number")]
149        request_id: RequestId,
150        params: SendUserTurnParams,
151    },
152    InterruptConversation {
153        #[serde(rename = "id")]
154        #[ts(type = "string | number")]
155        request_id: RequestId,
156        params: InterruptConversationParams,
157    },
158    AddConversationListener {
159        #[serde(rename = "id")]
160        #[ts(type = "string | number")]
161        request_id: RequestId,
162        params: AddConversationListenerParams,
163    },
164    RemoveConversationListener {
165        #[serde(rename = "id")]
166        #[ts(type = "string | number")]
167        request_id: RequestId,
168        params: RemoveConversationListenerParams,
169    },
170    GitDiffToRemote {
171        #[serde(rename = "id")]
172        #[ts(type = "string | number")]
173        request_id: RequestId,
174        params: GitDiffToRemoteParams,
175    },
176    LoginApiKey {
177        #[serde(rename = "id")]
178        #[ts(type = "string | number")]
179        request_id: RequestId,
180        params: LoginApiKeyParams,
181    },
182    LoginChatGpt {
183        #[serde(rename = "id")]
184        #[ts(type = "string | number")]
185        request_id: RequestId,
186
187        #[ts(type = "undefined")]
188        #[serde(skip_serializing_if = "Option::is_none")]
189        params: Option<()>,
190    },
191    CancelLoginChatGpt {
192        #[serde(rename = "id")]
193        #[ts(type = "string | number")]
194        request_id: RequestId,
195        params: CancelLoginChatGptParams,
196    },
197    LogoutChatGpt {
198        #[serde(rename = "id")]
199        #[ts(type = "string | number")]
200        request_id: RequestId,
201
202        #[ts(type = "undefined")]
203        #[serde(skip_serializing_if = "Option::is_none")]
204        params: Option<()>,
205    },
206    GetAuthStatus {
207        #[serde(rename = "id")]
208        #[ts(type = "string | number")]
209        request_id: RequestId,
210        params: GetAuthStatusParams,
211    },
212    GetUserSavedConfig {
213        #[serde(rename = "id")]
214        #[ts(type = "string | number")]
215        request_id: RequestId,
216
217        #[ts(type = "undefined")]
218        #[serde(skip_serializing_if = "Option::is_none")]
219        params: Option<()>,
220    },
221    SetDefaultModel {
222        #[serde(rename = "id")]
223        #[ts(type = "string | number")]
224        request_id: RequestId,
225        params: SetDefaultModelParams,
226    },
227    GetUserAgent {
228        #[serde(rename = "id")]
229        #[ts(type = "string | number")]
230        request_id: RequestId,
231
232        #[ts(type = "undefined")]
233        #[serde(skip_serializing_if = "Option::is_none")]
234        params: Option<()>,
235    },
236    UserInfo {
237        #[serde(rename = "id")]
238        #[ts(type = "string | number")]
239        request_id: RequestId,
240
241        #[ts(type = "undefined")]
242        #[serde(skip_serializing_if = "Option::is_none")]
243        params: Option<()>,
244    },
245    FuzzyFileSearch {
246        #[serde(rename = "id")]
247        #[ts(type = "string | number")]
248        request_id: RequestId,
249        params: FuzzyFileSearchParams,
250    },
251    /// Execute a command (argv vector) under the server's sandbox.
252    ExecOneOffCommand {
253        #[serde(rename = "id")]
254        #[ts(type = "string | number")]
255        request_id: RequestId,
256        params: ExecOneOffCommandParams,
257    },
258}
259
260#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, TS)]
261#[serde(rename_all = "camelCase")]
262pub struct InitializeParams {
263    pub client_info: ClientInfo,
264}
265
266#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, TS)]
267#[serde(rename_all = "camelCase")]
268pub struct ClientInfo {
269    pub name: String,
270    #[serde(skip_serializing_if = "Option::is_none")]
271    pub title: Option<String>,
272    pub version: String,
273}
274
275#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
276#[serde(rename_all = "camelCase")]
277pub struct InitializeResponse {
278    pub user_agent: String,
279}
280
281#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, TS)]
282#[serde(rename_all = "camelCase")]
283pub struct NewConversationParams {
284    /// Optional override for the model name (e.g. "o3", "o4-mini").
285    #[serde(skip_serializing_if = "Option::is_none")]
286    pub model: Option<String>,
287
288    /// Configuration profile from config.toml to specify default options.
289    #[serde(skip_serializing_if = "Option::is_none")]
290    pub profile: Option<String>,
291
292    /// Working directory for the session. If relative, it is resolved against
293    /// the server process's current working directory.
294    #[serde(skip_serializing_if = "Option::is_none")]
295    pub cwd: Option<String>,
296
297    /// Approval policy for shell commands generated by the model:
298    /// `untrusted`, `on-failure`, `on-request`, `never`.
299    #[serde(skip_serializing_if = "Option::is_none")]
300    pub approval_policy: Option<AskForApproval>,
301
302    /// Sandbox mode: `read-only`, `workspace-write`, or `danger-full-access`.
303    #[serde(skip_serializing_if = "Option::is_none")]
304    pub sandbox: Option<SandboxMode>,
305
306    /// Individual config settings that will override what is in
307    /// HANZO_HOME/config.toml.
308    #[serde(skip_serializing_if = "Option::is_none")]
309    pub config: Option<HashMap<String, serde_json::Value>>,
310
311    /// The set of instructions to use instead of the default ones.
312    #[serde(skip_serializing_if = "Option::is_none")]
313    pub base_instructions: Option<String>,
314
315    /// Whether to include the plan tool in the conversation.
316    #[serde(skip_serializing_if = "Option::is_none")]
317    pub include_plan_tool: Option<bool>,
318
319    /// Whether to include the apply patch tool in the conversation.
320    #[serde(skip_serializing_if = "Option::is_none")]
321    pub include_apply_patch_tool: Option<bool>,
322}
323
324#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
325#[serde(rename_all = "camelCase")]
326pub struct NewConversationResponse {
327    pub conversation_id: ConversationId,
328    pub model: String,
329    /// Note this could be ignored by the model.
330    #[serde(skip_serializing_if = "Option::is_none")]
331    pub reasoning_effort: Option<ReasoningEffort>,
332    pub rollout_path: PathBuf,
333}
334
335#[derive(Serialize, Deserialize, Debug, Clone, TS)]
336#[serde(rename_all = "camelCase")]
337pub struct ResumeConversationResponse {
338    pub conversation_id: ConversationId,
339    pub model: String,
340    #[serde(skip_serializing_if = "Option::is_none")]
341    pub initial_messages: Option<Vec<EventMsg>>,
342}
343
344#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, TS)]
345#[serde(rename_all = "camelCase")]
346pub struct ListConversationsParams {
347    /// Optional page size; defaults to a reasonable server-side value.
348    #[serde(skip_serializing_if = "Option::is_none")]
349    pub page_size: Option<usize>,
350    /// Opaque pagination cursor returned by a previous call.
351    #[serde(skip_serializing_if = "Option::is_none")]
352    pub cursor: Option<String>,
353}
354
355#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
356#[serde(rename_all = "camelCase")]
357pub struct ConversationSummary {
358    pub conversation_id: ConversationId,
359    pub path: PathBuf,
360    pub preview: String,
361    /// RFC3339 timestamp string for the session start, if available.
362    #[serde(skip_serializing_if = "Option::is_none")]
363    pub timestamp: Option<String>,
364}
365
366#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
367#[serde(rename_all = "camelCase")]
368pub struct ListConversationsResponse {
369    pub items: Vec<ConversationSummary>,
370    /// Opaque cursor to pass to the next call to continue after the last item.
371    /// if None, there are no more items to return.
372    #[serde(skip_serializing_if = "Option::is_none")]
373    pub next_cursor: Option<String>,
374}
375
376#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
377#[serde(rename_all = "camelCase")]
378pub struct ResumeConversationParams {
379    /// Absolute path to the rollout JSONL file.
380    pub path: PathBuf,
381    /// Optional overrides to apply when spawning the resumed session.
382    #[serde(skip_serializing_if = "Option::is_none")]
383    pub overrides: Option<NewConversationParams>,
384}
385
386#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
387#[serde(rename_all = "camelCase")]
388pub struct AddConversationSubscriptionResponse {
389    pub subscription_id: Uuid,
390}
391
392/// The [`ConversationId`] must match the `rollout_path`.
393#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
394#[serde(rename_all = "camelCase")]
395pub struct ArchiveConversationParams {
396    pub conversation_id: ConversationId,
397    pub rollout_path: PathBuf,
398}
399
400#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
401#[serde(rename_all = "camelCase")]
402pub struct ArchiveConversationResponse {}
403
404#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
405#[serde(rename_all = "camelCase")]
406pub struct RemoveConversationSubscriptionResponse {}
407
408#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
409#[serde(rename_all = "camelCase")]
410pub struct LoginApiKeyParams {
411    pub api_key: String,
412}
413
414#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
415#[serde(rename_all = "camelCase")]
416pub struct LoginApiKeyResponse {}
417
418#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
419#[serde(rename_all = "camelCase")]
420pub struct LoginChatGptResponse {
421    pub login_id: Uuid,
422    /// URL the client should open in a browser to initiate the OAuth flow.
423    pub auth_url: String,
424}
425
426#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
427#[serde(rename_all = "camelCase")]
428pub struct GitDiffToRemoteResponse {
429    pub sha: GitSha,
430    pub diff: String,
431}
432
433#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
434#[serde(rename_all = "camelCase")]
435pub struct CancelLoginChatGptParams {
436    pub login_id: Uuid,
437}
438
439#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
440#[serde(rename_all = "camelCase")]
441pub struct GitDiffToRemoteParams {
442    pub cwd: PathBuf,
443}
444
445#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
446#[serde(rename_all = "camelCase")]
447pub struct CancelLoginChatGptResponse {}
448
449#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
450#[serde(rename_all = "camelCase")]
451pub struct LogoutChatGptParams {}
452
453#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
454#[serde(rename_all = "camelCase")]
455pub struct LogoutChatGptResponse {}
456
457#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
458#[serde(rename_all = "camelCase")]
459pub struct GetAuthStatusParams {
460    /// If true, include the current auth token (if available) in the response.
461    #[serde(skip_serializing_if = "Option::is_none")]
462    pub include_token: Option<bool>,
463    /// If true, attempt to refresh the token before returning status.
464    #[serde(skip_serializing_if = "Option::is_none")]
465    pub refresh_token: Option<bool>,
466}
467
468#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
469#[serde(rename_all = "camelCase")]
470pub struct ExecOneOffCommandParams {
471    /// Command argv to execute.
472    pub command: Vec<String>,
473    /// Timeout of the command in milliseconds.
474    /// If not specified, a sensible default is used server-side.
475    pub timeout_ms: Option<u64>,
476    /// Optional working directory for the process. Defaults to server config cwd.
477    #[serde(skip_serializing_if = "Option::is_none")]
478    pub cwd: Option<PathBuf>,
479    /// Optional explicit sandbox policy overriding the server default.
480    #[serde(skip_serializing_if = "Option::is_none")]
481    pub sandbox_policy: Option<SandboxPolicy>,
482}
483
484#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
485#[serde(rename_all = "camelCase")]
486pub struct ExecArbitraryCommandResponse {
487    pub exit_code: i32,
488    pub stdout: String,
489    pub stderr: String,
490}
491
492#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
493#[serde(rename_all = "camelCase")]
494pub struct GetAuthStatusResponse {
495    #[serde(skip_serializing_if = "Option::is_none")]
496    pub auth_method: Option<AuthMode>,
497    #[serde(skip_serializing_if = "Option::is_none")]
498    pub auth_token: Option<String>,
499
500    // Indicates that auth method must be valid to use the server.
501    // This can be false if using a custom provider that is configured
502    // with requires_openai_auth == false.
503    #[serde(skip_serializing_if = "Option::is_none")]
504    pub requires_openai_auth: Option<bool>,
505}
506
507#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
508#[serde(rename_all = "camelCase")]
509pub struct GetUserAgentResponse {
510    pub user_agent: String,
511}
512
513#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
514#[serde(rename_all = "camelCase")]
515pub struct UserInfoResponse {
516    /// Note: `alleged_user_email` is not currently verified. We read it from
517    /// the local auth.json, which the user could theoretically modify. In the
518    /// future, we may add logic to verify the email against the server before
519    /// returning it.
520    pub alleged_user_email: Option<String>,
521}
522
523#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
524#[serde(rename_all = "camelCase")]
525pub struct GetUserSavedConfigResponse {
526    pub config: UserSavedConfig,
527}
528
529#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
530#[serde(rename_all = "camelCase")]
531pub struct SetDefaultModelParams {
532    /// If set to None, this means `model` should be cleared in config.toml.
533    #[serde(skip_serializing_if = "Option::is_none")]
534    pub model: Option<String>,
535    /// If set to None, this means `model_reasoning_effort` should be cleared
536    /// in config.toml.
537    #[serde(skip_serializing_if = "Option::is_none")]
538    pub reasoning_effort: Option<ReasoningEffort>,
539}
540
541#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
542#[serde(rename_all = "camelCase")]
543pub struct SetDefaultModelResponse {}
544
545/// UserSavedConfig contains a subset of the config. It is meant to expose mcp
546/// client-configurable settings that can be specified in the NewConversation
547/// and SendUserTurn requests.
548#[derive(Deserialize, Debug, Clone, PartialEq, Serialize, TS)]
549#[serde(rename_all = "camelCase")]
550pub struct UserSavedConfig {
551    /// Approvals
552    #[serde(skip_serializing_if = "Option::is_none")]
553    pub approval_policy: Option<AskForApproval>,
554    #[serde(skip_serializing_if = "Option::is_none")]
555    pub sandbox_mode: Option<SandboxMode>,
556    #[serde(skip_serializing_if = "Option::is_none")]
557    pub sandbox_settings: Option<SandboxSettings>,
558
559    /// Model-specific configuration
560    #[serde(skip_serializing_if = "Option::is_none")]
561    pub model: Option<String>,
562    #[serde(skip_serializing_if = "Option::is_none")]
563    pub model_reasoning_effort: Option<ReasoningEffort>,
564    #[serde(skip_serializing_if = "Option::is_none")]
565    pub model_reasoning_summary: Option<ReasoningSummary>,
566    #[serde(skip_serializing_if = "Option::is_none")]
567    pub model_verbosity: Option<Verbosity>,
568
569    /// Tools
570    #[serde(skip_serializing_if = "Option::is_none")]
571    pub tools: Option<Tools>,
572
573    /// Profiles
574    #[serde(skip_serializing_if = "Option::is_none")]
575    pub profile: Option<String>,
576    #[serde(default)]
577    pub profiles: HashMap<String, Profile>,
578}
579
580/// MCP representation of a [`hanzo_core::config_profile::ConfigProfile`].
581#[derive(Deserialize, Debug, Clone, PartialEq, Serialize, TS)]
582#[serde(rename_all = "camelCase")]
583pub struct Profile {
584    pub model: Option<String>,
585    /// The key in the `model_providers` map identifying the
586    /// [`ModelProviderInfo`] to use.
587    pub model_provider: Option<String>,
588    pub approval_policy: Option<AskForApproval>,
589    pub model_reasoning_effort: Option<ReasoningEffort>,
590    pub model_reasoning_summary: Option<ReasoningSummary>,
591    pub model_verbosity: Option<Verbosity>,
592    pub chatgpt_base_url: Option<String>,
593}
594/// MCP representation of a [`hanzo_core::config::ToolsToml`].
595#[derive(Deserialize, Debug, Clone, PartialEq, Serialize, TS)]
596#[serde(rename_all = "camelCase")]
597pub struct Tools {
598    #[serde(skip_serializing_if = "Option::is_none")]
599    pub web_search: Option<bool>,
600    #[serde(skip_serializing_if = "Option::is_none")]
601    pub view_image: Option<bool>,
602}
603
604/// MCP representation of a [`hanzo_core::config_types::SandboxWorkspaceWrite`].
605#[derive(Deserialize, Debug, Clone, PartialEq, Serialize, TS)]
606#[serde(rename_all = "camelCase")]
607pub struct SandboxSettings {
608    #[serde(default)]
609    pub writable_roots: Vec<PathBuf>,
610    #[serde(skip_serializing_if = "Option::is_none")]
611    pub network_access: Option<bool>,
612    #[serde(skip_serializing_if = "Option::is_none")]
613    pub exclude_tmpdir_env_var: Option<bool>,
614    #[serde(skip_serializing_if = "Option::is_none")]
615    pub exclude_slash_tmp: Option<bool>,
616}
617
618#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
619#[serde(rename_all = "camelCase")]
620pub struct SendUserMessageParams {
621    pub conversation_id: ConversationId,
622    pub items: Vec<InputItem>,
623}
624
625#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
626#[serde(rename_all = "camelCase")]
627pub struct SendUserTurnParams {
628    pub conversation_id: ConversationId,
629    pub items: Vec<InputItem>,
630    pub cwd: PathBuf,
631    pub approval_policy: AskForApproval,
632    pub sandbox_policy: SandboxPolicy,
633    pub model: String,
634    #[serde(skip_serializing_if = "Option::is_none")]
635    pub effort: Option<ReasoningEffort>,
636    pub summary: ReasoningSummary,
637}
638
639#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
640#[serde(rename_all = "camelCase")]
641pub struct SendUserTurnResponse {}
642
643#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
644#[serde(rename_all = "camelCase")]
645pub struct InterruptConversationParams {
646    pub conversation_id: ConversationId,
647}
648
649#[derive(Serialize, Deserialize, Debug, Clone, TS)]
650#[serde(rename_all = "camelCase")]
651pub struct InterruptConversationResponse {
652    pub abort_reason: TurnAbortReason,
653}
654
655#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
656#[serde(rename_all = "camelCase")]
657pub struct SendUserMessageResponse {}
658
659#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
660#[serde(rename_all = "camelCase")]
661pub struct AddConversationListenerParams {
662    pub conversation_id: ConversationId,
663}
664
665#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
666#[serde(rename_all = "camelCase")]
667pub struct RemoveConversationListenerParams {
668    pub subscription_id: Uuid,
669}
670
671#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
672#[serde(rename_all = "camelCase")]
673#[serde(tag = "type", content = "data")]
674pub enum InputItem {
675    Text {
676        text: String,
677    },
678    /// Pre‑encoded data: URI image.
679    Image {
680        image_url: String,
681    },
682
683    /// Local image path provided by the user.  This will be converted to an
684    /// `Image` variant (base64 data URL) during request serialization.
685    LocalImage {
686        path: PathBuf,
687    },
688}
689
690// TODO(mbolin): Need test to ensure these constants match the enum variants.
691
692pub const APPLY_PATCH_APPROVAL_METHOD: &str = "applyPatchApproval";
693pub const EXEC_COMMAND_APPROVAL_METHOD: &str = "execCommandApproval";
694
695/// Request initiated from the server and sent to the client.
696#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
697#[serde(tag = "method", rename_all = "camelCase")]
698pub enum ServerRequest {
699    /// Request to approve a patch.
700    ApplyPatchApproval {
701        #[serde(rename = "id")]
702        #[ts(type = "string | number")]
703        request_id: RequestId,
704        params: ApplyPatchApprovalParams,
705    },
706    /// Request to exec a command.
707    ExecCommandApproval {
708        #[serde(rename = "id")]
709        #[ts(type = "string | number")]
710        request_id: RequestId,
711        params: ExecCommandApprovalParams,
712    },
713}
714
715#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
716pub struct ApplyPatchApprovalParams {
717    pub conversation_id: ConversationId,
718    /// Use to correlate this with [hanzo_core::protocol::PatchApplyBeginEvent]
719    /// and [hanzo_core::protocol::PatchApplyEndEvent].
720    pub call_id: String,
721    pub file_changes: HashMap<PathBuf, FileChange>,
722    /// Optional explanatory reason (e.g. request for extra write access).
723    #[serde(skip_serializing_if = "Option::is_none")]
724    pub reason: Option<String>,
725    /// When set, the agent is asking the user to allow writes under this root
726    /// for the remainder of the session (unclear if this is honored today).
727    #[serde(skip_serializing_if = "Option::is_none")]
728    pub grant_root: Option<PathBuf>,
729}
730
731#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
732pub struct ExecCommandApprovalParams {
733    pub conversation_id: ConversationId,
734    /// Use to correlate this with [hanzo_core::protocol::ExecCommandBeginEvent]
735    /// and [hanzo_core::protocol::ExecCommandEndEvent].
736    pub call_id: String,
737    pub command: Vec<String>,
738    pub cwd: PathBuf,
739    #[serde(skip_serializing_if = "Option::is_none")]
740    pub reason: Option<String>,
741}
742
743#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
744pub struct ExecCommandApprovalResponse {
745    pub decision: ReviewDecision,
746}
747
748#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
749pub struct ApplyPatchApprovalResponse {
750    pub decision: ReviewDecision,
751}
752
753#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
754#[serde(rename_all = "camelCase")]
755#[ts(rename_all = "camelCase")]
756pub struct FuzzyFileSearchParams {
757    pub query: String,
758    pub roots: Vec<String>,
759    // if provided, will cancel any previous request that used the same value
760    #[serde(skip_serializing_if = "Option::is_none")]
761    pub cancellation_token: Option<String>,
762}
763
764/// Superset of [`hanzo_file_search::FileMatch`]
765#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
766pub struct FuzzyFileSearchResult {
767    pub root: String,
768    pub path: String,
769    pub file_name: String,
770    pub score: u32,
771    #[serde(skip_serializing_if = "Option::is_none")]
772    pub indices: Option<Vec<u32>>,
773}
774
775#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
776pub struct FuzzyFileSearchResponse {
777    pub files: Vec<FuzzyFileSearchResult>,
778}
779
780#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
781#[serde(rename_all = "camelCase")]
782pub struct LoginChatGptCompleteNotification {
783    pub login_id: Uuid,
784    pub success: bool,
785    #[serde(skip_serializing_if = "Option::is_none")]
786    pub error: Option<String>,
787}
788
789#[derive(Serialize, Deserialize, Debug, Clone, TS)]
790#[serde(rename_all = "camelCase")]
791pub struct SessionConfiguredNotification {
792    /// Name left as session_id instead of conversation_id for backwards compatibility.
793    pub session_id: ConversationId,
794
795    /// Tell the client what model is being queried.
796    pub model: String,
797
798    /// The effort the model is putting into reasoning about the user's request.
799    #[serde(skip_serializing_if = "Option::is_none")]
800    pub reasoning_effort: Option<ReasoningEffort>,
801
802    /// Identifier of the history log file (inode on Unix, 0 otherwise).
803    pub history_log_id: u64,
804
805    /// Current number of entries in the history log.
806    pub history_entry_count: usize,
807
808    /// Optional initial messages (as events) for resumed sessions.
809    /// When present, UIs can use these to seed the history.
810    #[serde(skip_serializing_if = "Option::is_none")]
811    pub initial_messages: Option<Vec<EventMsg>>,
812
813    pub rollout_path: PathBuf,
814}
815
816#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
817#[serde(rename_all = "camelCase")]
818pub struct AuthStatusChangeNotification {
819    /// Current authentication method; omitted if signed out.
820    #[serde(skip_serializing_if = "Option::is_none")]
821    pub auth_method: Option<AuthMode>,
822}
823
824/// Notification sent from the server to the client.
825#[derive(Serialize, Deserialize, Debug, Clone, TS, Display)]
826#[serde(tag = "method", content = "params", rename_all = "camelCase")]
827#[strum(serialize_all = "camelCase")]
828pub enum ServerNotification {
829    /// Authentication status changed
830    AuthStatusChange(AuthStatusChangeNotification),
831
832    /// ChatGPT login flow completed
833    LoginChatGptComplete(LoginChatGptCompleteNotification),
834
835    /// The special session configured event for a new or resumed conversation.
836    SessionConfigured(SessionConfiguredNotification),
837}
838
839impl ServerNotification {
840    pub fn to_params(self) -> Result<serde_json::Value, serde_json::Error> {
841        match self {
842            ServerNotification::AuthStatusChange(params) => serde_json::to_value(params),
843            ServerNotification::LoginChatGptComplete(params) => serde_json::to_value(params),
844            ServerNotification::SessionConfigured(params) => serde_json::to_value(params),
845        }
846    }
847}
848
849impl TryFrom<JSONRPCNotification> for ServerNotification {
850    type Error = serde_json::Error;
851
852    fn try_from(value: JSONRPCNotification) -> Result<Self, Self::Error> {
853        serde_json::from_value(serde_json::to_value(value)?)
854    }
855}
856
857/// Notification sent from the client to the server.
858#[derive(Serialize, Deserialize, Debug, Clone, TS, Display)]
859#[serde(tag = "method", content = "params", rename_all = "camelCase")]
860#[strum(serialize_all = "camelCase")]
861pub enum ClientNotification {
862    Initialized,
863}
864
865#[cfg(test)]
866mod tests {
867    use super::*;
868    use anyhow::Result;
869    use pretty_assertions::assert_eq;
870    use serde_json::json;
871
872    #[test]
873    fn serialize_new_conversation() -> Result<()> {
874        let request = ClientRequest::NewConversation {
875            request_id: RequestId::Integer(42),
876            params: NewConversationParams {
877                model: Some("gpt-5.1-codex".to_string()),
878                profile: None,
879                cwd: None,
880                approval_policy: Some(AskForApproval::OnRequest),
881                sandbox: None,
882                config: None,
883                base_instructions: None,
884                include_plan_tool: None,
885                include_apply_patch_tool: None,
886            },
887        };
888        assert_eq!(
889            json!({
890                "method": "newConversation",
891                "id": 42,
892                "params": {
893                    "model": "gpt-5.1-codex",
894                    "approvalPolicy": "on-request"
895                }
896            }),
897            serde_json::to_value(&request)?,
898        );
899        Ok(())
900    }
901
902    #[test]
903    fn test_conversation_id_default_is_not_zeroes() {
904        let id = ConversationId::default();
905        assert_ne!(id.uuid, Uuid::nil());
906    }
907
908    #[test]
909    fn conversation_id_serializes_as_plain_string() -> Result<()> {
910        let id = ConversationId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c8")?;
911
912        assert_eq!(
913            json!("67e55044-10b1-426f-9247-bb680e5fe0c8"),
914            serde_json::to_value(id)?
915        );
916        Ok(())
917    }
918
919    #[test]
920    fn conversation_id_deserializes_from_plain_string() -> Result<()> {
921        let id: ConversationId =
922            serde_json::from_value(json!("67e55044-10b1-426f-9247-bb680e5fe0c8"))?;
923
924        assert_eq!(
925            ConversationId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c8")?,
926            id,
927        );
928        Ok(())
929    }
930
931    #[test]
932    fn serialize_client_notification() -> Result<()> {
933        let notification = ClientNotification::Initialized;
934        // Note there is no "params" field for this notification.
935        assert_eq!(
936            json!({
937                "method": "initialized",
938            }),
939            serde_json::to_value(&notification)?,
940        );
941        Ok(())
942    }
943}