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