Skip to main content

scv_protocol/
client.rs

1//! [`ClientMessage`]: everything a client sends.
2
3use serde::{Deserialize, Serialize};
4
5use crate::{Attachment, DaemonCommand, PeerInfo};
6#[cfg(doc)]
7use crate::{
8    CHAT_ATTACH_TOOL, MAX_CHANNEL_NAME_BYTES, MAX_SYSTEM_PROMPT_BYTES, MAX_TURN_ATTACHMENTS,
9};
10
11/// A conversation's chat log under the server's history directory,
12/// `<channel>/<account>/<conversation>`. Each part is at most 64 bytes of
13/// ASCII letters, digits, `-`, and `_`.
14#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
15pub struct ChatLog {
16    /// The channel, as SCV names it in paths (`wechat`, `feishu`).
17    pub channel: String,
18    /// The channel account.
19    pub account: String,
20    /// A digest of the conversation, so sender IDs never become paths.
21    pub conversation: String,
22}
23
24/// A message from client to server. Serialized as one JSON object per line,
25/// tagged by `type` (such as `turn.start`).
26#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
27#[serde(tag = "type")]
28pub enum ClientMessage {
29    /// Control the daemon itself (status, reload, channels, delegations,
30    /// restarts). Only the daemon socket accepts it.
31    #[serde(rename = "daemon.control")]
32    DaemonControl {
33        /// Chosen by the client; the events that answer this message carry it.
34        request_id: String,
35        /// What to do.
36        command: DaemonCommand,
37    },
38    /// The first message on every connection.
39    #[serde(rename = "initialize")]
40    Initialize {
41        /// Chosen by the client; the events that answer this message carry it.
42        request_id: String,
43        /// The [`PROTOCOL_VERSION`](crate::PROTOCOL_VERSION) the client speaks.
44        protocol_version: u32,
45        /// Who is connecting.
46        client: PeerInfo,
47    },
48    /// Start a session in a workspace. Each connection has at most one.
49    #[serde(rename = "session.start")]
50    SessionStart {
51        /// Chosen by the client; the events that answer this message carry it.
52        request_id: String,
53        /// The workspace directory.
54        cwd: String,
55        /// Provider profile to use instead of the configured default.
56        #[serde(default, skip_serializing_if = "Option::is_none")]
57        provider: Option<String>,
58        /// Model to use instead of the configured default.
59        #[serde(default, skip_serializing_if = "Option::is_none")]
60        model: Option<String>,
61        /// Provider endpoint to use instead of the configured one.
62        #[serde(default, skip_serializing_if = "Option::is_none")]
63        base_url: Option<String>,
64        /// Start the session without tools.
65        #[serde(default, skip_serializing_if = "Option::is_none")]
66        no_tools: Option<bool>,
67        /// Delegation depth of the client, when it is itself a delegated
68        /// agent (such as a nested SCV). Tools started from the session count
69        /// from it, so the depth limit holds across processes.
70        #[serde(default, skip_serializing_if = "Option::is_none")]
71        delegation_depth: Option<u32>,
72        /// The chat channel this session answers on, as its users name it
73        /// (such as `WeChat` or `Feishu`). The user reads short plain-text
74        /// replies there and never sees tool calls, so the server tells the
75        /// model; a chat client also delivers files the model attaches to
76        /// its reply, so a session with tools offers [`CHAT_ATTACH_TOOL`].
77        /// At most [`MAX_CHANNEL_NAME_BYTES`], without control characters.
78        #[serde(default, skip_serializing_if = "Option::is_none")]
79        channel: Option<String>,
80        /// The client approves every approval request of this session
81        /// without asking anyone. Background jobs, which outlive the turn
82        /// that could carry their requests, then get the same answer;
83        /// otherwise they get only what the approval policy grants unasked.
84        #[serde(default, skip_serializing_if = "Option::is_none")]
85        auto_approve: Option<bool>,
86        /// The chat log of the conversation this session answers: the server
87        /// starts the session with the log's open episode and, with tools,
88        /// lets the model search the rest. A chat client sends it for its
89        /// account owner's direct chat.
90        #[serde(default, skip_serializing_if = "Option::is_none")]
91        chat: Option<ChatLog>,
92        /// The whole system prompt, in place of the configured one and
93        /// everything the server adds to it (the working directory, project
94        /// instructions, skills, the chat channel). Accepted only with
95        /// `no_tools: true`, and at most [`MAX_SYSTEM_PROMPT_BYTES`]. Mail
96        /// triage sends a fixed frame this way, so no personal instruction
97        /// reaches a model reading untrusted mail.
98        #[serde(default, skip_serializing_if = "Option::is_none")]
99        system_prompt: Option<String>,
100        /// Keep the session's queued prompts on disk while it runs, so a
101        /// session started after a daemon restart can restore them
102        /// (`restore`). The TUI sets it.
103        #[serde(default, skip_serializing_if = "Option::is_none")]
104        persist_queue: Option<bool>,
105        /// The ID of a session that stopped with a daemon restart or crash
106        /// and kept its queue (`persist_queue`): its queued prompts join this
107        /// session's queue, paused, when the workspace is the same.
108        #[serde(default, skip_serializing_if = "Option::is_none")]
109        restore: Option<String>,
110    },
111    /// Attach to an existing session. Not supported: sessions belong to the
112    /// connection that started them.
113    #[serde(rename = "session.attach")]
114    SessionAttach {
115        /// Chosen by the client; the events that answer this message carry it.
116        request_id: String,
117        /// The session, as `session.started` named it.
118        session_id: String,
119        /// The workspace directory.
120        cwd: String,
121    },
122    /// Send a prompt. It starts a turn at once, or queues behind the running
123    /// one.
124    #[serde(rename = "turn.start")]
125    TurnStart {
126        /// Chosen by the client; the events that answer this message carry it.
127        request_id: String,
128        /// The session, as `session.started` named it.
129        session_id: String,
130        /// The user's text.
131        prompt: String,
132        /// Files that come with the prompt, at most [`MAX_TURN_ATTACHMENTS`].
133        /// The server lists them for the model and shows it images directly
134        /// when the model accepts image input.
135        #[serde(default, skip_serializing_if = "Vec::is_empty")]
136        attachments: Vec<Attachment>,
137    },
138    /// Replace the text of a queued prompt.
139    #[serde(rename = "queue.update")]
140    QueueUpdate {
141        /// Chosen by the client; the events that answer this message carry it.
142        request_id: String,
143        /// The session, as `session.started` named it.
144        session_id: String,
145        /// The queued prompt.
146        queue_id: String,
147        /// The entry's current revision; a stale one is refused.
148        revision: u64,
149        /// The user's text.
150        prompt: String,
151    },
152    /// Reorder a queued prompt.
153    #[serde(rename = "queue.move")]
154    QueueMove {
155        /// Chosen by the client; the events that answer this message carry it.
156        request_id: String,
157        /// The session, as `session.started` named it.
158        session_id: String,
159        /// The queued prompt.
160        queue_id: String,
161        /// The entry's current revision; a stale one is refused.
162        revision: u64,
163        /// Move before this entry; `None` moves it to the end.
164        before_queue_id: Option<String>,
165    },
166    /// Drop a queued prompt.
167    #[serde(rename = "queue.remove")]
168    QueueRemove {
169        /// Chosen by the client; the events that answer this message carry it.
170        request_id: String,
171        /// The session, as `session.started` named it.
172        session_id: String,
173        /// The queued prompt.
174        queue_id: String,
175        /// The entry's current revision; a stale one is refused.
176        revision: u64,
177    },
178    /// Hold or release the queue; a running turn is not affected.
179    #[serde(rename = "session.pause")]
180    SessionPause {
181        /// Chosen by the client; the events that answer this message carry it.
182        request_id: String,
183        /// The session, as `session.started` named it.
184        session_id: String,
185        /// Whether queued prompts wait instead of starting.
186        paused: bool,
187    },
188    /// Stop the running turn.
189    #[serde(rename = "turn.cancel")]
190    TurnCancel {
191        /// Chosen by the client; the events that answer this message carry it.
192        request_id: String,
193        /// The session, as `session.started` named it.
194        session_id: String,
195        /// The turn to cancel.
196        turn_id: String,
197    },
198    /// Answer an `approval.requested` event.
199    #[serde(rename = "approval.resolve")]
200    ApprovalResolve {
201        /// Chosen by the client; the events that answer this message carry it.
202        request_id: String,
203        /// The session, as `session.started` named it.
204        session_id: String,
205        /// The approval request being answered.
206        approval_id: String,
207        /// Whether the call may run.
208        approved: bool,
209    },
210    /// Forget the session's history and queue.
211    #[serde(rename = "session.clear")]
212    SessionClear {
213        /// Chosen by the client; the events that answer this message carry it.
214        request_id: String,
215        /// The session, as `session.started` named it.
216        session_id: String,
217    },
218}
219
220impl ClientMessage {
221    /// The `initialize` request every client sends first, declaring this
222    /// release's [`PROTOCOL_VERSION`](crate::PROTOCOL_VERSION).
223    pub fn initialize(request_id: impl Into<String>, client_name: impl Into<String>) -> Self {
224        Self::Initialize {
225            request_id: request_id.into(),
226            protocol_version: crate::PROTOCOL_VERSION,
227            client: PeerInfo {
228                name: client_name.into(),
229                version: env!("CARGO_PKG_VERSION").into(),
230            },
231        }
232    }
233
234    /// The client-chosen ID that answering events carry.
235    pub fn request_id(&self) -> &str {
236        match self {
237            Self::Initialize { request_id, .. }
238            | Self::DaemonControl { request_id, .. }
239            | Self::SessionStart { request_id, .. }
240            | Self::SessionAttach { request_id, .. }
241            | Self::TurnStart { request_id, .. }
242            | Self::QueueUpdate { request_id, .. }
243            | Self::QueueMove { request_id, .. }
244            | Self::QueueRemove { request_id, .. }
245            | Self::SessionPause { request_id, .. }
246            | Self::TurnCancel { request_id, .. }
247            | Self::ApprovalResolve { request_id, .. }
248            | Self::SessionClear { request_id, .. } => request_id,
249        }
250    }
251}