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}