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 },
101 /// Attach to an existing session. Not supported: sessions belong to the
102 /// connection that started them.
103 #[serde(rename = "session.attach")]
104 SessionAttach {
105 /// Chosen by the client; the events that answer this message carry it.
106 request_id: String,
107 /// The session, as `session.started` named it.
108 session_id: String,
109 /// The workspace directory.
110 cwd: String,
111 },
112 /// Send a prompt. It starts a turn at once, or queues behind the running
113 /// one.
114 #[serde(rename = "turn.start")]
115 TurnStart {
116 /// Chosen by the client; the events that answer this message carry it.
117 request_id: String,
118 /// The session, as `session.started` named it.
119 session_id: String,
120 /// The user's text.
121 prompt: String,
122 /// Files that come with the prompt, at most [`MAX_TURN_ATTACHMENTS`].
123 /// The server lists them for the model and shows it images directly
124 /// when the model accepts image input.
125 #[serde(default, skip_serializing_if = "Vec::is_empty")]
126 attachments: Vec<Attachment>,
127 },
128 /// Replace the text of a queued prompt.
129 #[serde(rename = "queue.update")]
130 QueueUpdate {
131 /// Chosen by the client; the events that answer this message carry it.
132 request_id: String,
133 /// The session, as `session.started` named it.
134 session_id: String,
135 /// The queued prompt.
136 queue_id: String,
137 /// The entry's current revision; a stale one is refused.
138 revision: u64,
139 /// The user's text.
140 prompt: String,
141 },
142 /// Reorder a queued prompt.
143 #[serde(rename = "queue.move")]
144 QueueMove {
145 /// Chosen by the client; the events that answer this message carry it.
146 request_id: String,
147 /// The session, as `session.started` named it.
148 session_id: String,
149 /// The queued prompt.
150 queue_id: String,
151 /// The entry's current revision; a stale one is refused.
152 revision: u64,
153 /// Move before this entry; `None` moves it to the end.
154 before_queue_id: Option<String>,
155 },
156 /// Drop a queued prompt.
157 #[serde(rename = "queue.remove")]
158 QueueRemove {
159 /// Chosen by the client; the events that answer this message carry it.
160 request_id: String,
161 /// The session, as `session.started` named it.
162 session_id: String,
163 /// The queued prompt.
164 queue_id: String,
165 /// The entry's current revision; a stale one is refused.
166 revision: u64,
167 },
168 /// Hold or release the queue; a running turn is not affected.
169 #[serde(rename = "session.pause")]
170 SessionPause {
171 /// Chosen by the client; the events that answer this message carry it.
172 request_id: String,
173 /// The session, as `session.started` named it.
174 session_id: String,
175 /// Whether queued prompts wait instead of starting.
176 paused: bool,
177 },
178 /// Stop the running turn.
179 #[serde(rename = "turn.cancel")]
180 TurnCancel {
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 /// The turn to cancel.
186 turn_id: String,
187 },
188 /// Answer an `approval.requested` event.
189 #[serde(rename = "approval.resolve")]
190 ApprovalResolve {
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 approval request being answered.
196 approval_id: String,
197 /// Whether the call may run.
198 approved: bool,
199 },
200 /// Forget the session's history and queue.
201 #[serde(rename = "session.clear")]
202 SessionClear {
203 /// Chosen by the client; the events that answer this message carry it.
204 request_id: String,
205 /// The session, as `session.started` named it.
206 session_id: String,
207 },
208}
209
210impl ClientMessage {
211 /// The `initialize` request every client sends first, declaring this
212 /// release's [`PROTOCOL_VERSION`](crate::PROTOCOL_VERSION).
213 pub fn initialize(request_id: impl Into<String>, client_name: impl Into<String>) -> Self {
214 Self::Initialize {
215 request_id: request_id.into(),
216 protocol_version: crate::PROTOCOL_VERSION,
217 client: PeerInfo {
218 name: client_name.into(),
219 version: env!("CARGO_PKG_VERSION").into(),
220 },
221 }
222 }
223
224 /// The client-chosen ID that answering events carry.
225 pub fn request_id(&self) -> &str {
226 match self {
227 Self::Initialize { request_id, .. }
228 | Self::DaemonControl { request_id, .. }
229 | Self::SessionStart { request_id, .. }
230 | Self::SessionAttach { request_id, .. }
231 | Self::TurnStart { request_id, .. }
232 | Self::QueueUpdate { request_id, .. }
233 | Self::QueueMove { request_id, .. }
234 | Self::QueueRemove { request_id, .. }
235 | Self::SessionPause { request_id, .. }
236 | Self::TurnCancel { request_id, .. }
237 | Self::ApprovalResolve { request_id, .. }
238 | Self::SessionClear { request_id, .. } => request_id,
239 }
240 }
241}