1
+//! Skills are slash commands: `/<skill-name> [task]` activates the skill and
2
+//! runs a turn, and the name shows up in the client's command list.
3
+
4
+use std::io::{BufRead, BufReader, Read, Write};
5
+use std::net::TcpListener;
6
+use std::process::{Child, ChildStdin, Command, Stdio};
7
+use std::sync::mpsc::{Receiver, Sender, channel};
8
+use std::time::{Duration, Instant};
9
+
10
+use serde_json::{Value, json};
11
+
12
+const TIMEOUT: Duration = Duration::from_secs(60);
13
+
14
+/// Answers every chat completion with a short reply and reports each request
15
+/// body on the returned channel.
16
+fn start_fake_endpoint() -> (u16, Receiver<String>) {
17
+ let listener = TcpListener::bind("127.0.0.1:0").expect("bind fake endpoint");
18
+ let port = listener.local_addr().unwrap().port();
19
+ let (bodies, received): (Sender<String>, Receiver<String>) = channel();
20
+ std::thread::spawn(move || {
21
+ for stream in listener.incoming() {
22
+ let Ok(mut stream) = stream else { continue };
23
+ let Ok(clone) = stream.try_clone() else {
24
+ continue;
25
+ };
26
+ let mut reader = BufReader::new(clone);
27
+ let mut content_length = 0usize;
28
+ loop {
29
+ let mut line = String::new();
30
+ if reader.read_line(&mut line).unwrap_or(0) == 0 {
31
+ break;
32
+ }
33
+ let line = line.trim();
34
+ if line.is_empty() {
35
+ break;
36
+ }
37
+ if let Some(length) = line.to_ascii_lowercase().strip_prefix("content-length:") {
38
+ content_length = length.trim().parse().unwrap_or(0);
39
+ }
40
+ }
41
+ let mut body = vec![0u8; content_length];
42
+ if reader.read_exact(&mut body).is_err() {
43
+ continue;
44
+ }
45
+ let _ = bodies.send(String::from_utf8_lossy(&body).into_owned());
46
+ let payload = format!(
47
+ "data: {}\n\ndata: [DONE]\n\n",
48
+ json!({"choices": [{"delta": {"content": "Done."}}]})
49
+ );
50
+ let response = format!(
51
+ "HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\n\
52
+ content-length: {}\r\nconnection: close\r\n\r\n{}",
53
+ payload.len(),
54
+ payload
55
+ );
56
+ let _ = stream.write_all(response.as_bytes());
57
+ }
58
+ });
59
+ (port, received)
60
+}
61
+
62
+struct Wire {
63
+ child: Child,
64
+ stdin: ChildStdin,
65
+ incoming: Receiver<Value>,
66
+ next_id: u64,
67
+}
68
+
69
+fn spawn(port: u16, config_dir: &std::path::Path) -> Wire {
70
+ let mut child = Command::new(env!("CARGO_BIN_EXE_sigit"))
71
+ .env("OPENAI_BASE_URL", format!("http://127.0.0.1:{port}"))
72
+ .env("OPENAI_API_KEY", "test-key")
73
+ .env("SIGIT_MODEL", "scripted-model")
74
+ .env("SIGIT_CONFIG_DIR", config_dir)
75
+ .env("SIGIT_MCP", "off")
76
+ .env("SIGIT_PERMISSIONS", "allow")
77
+ .env_remove("SIGIT_LOCAL_INFERENCE")
78
+ .stdin(Stdio::piped())
79
+ .stdout(Stdio::piped())
80
+ .stderr(Stdio::null())
81
+ .spawn()
82
+ .expect("spawn sigit in ACP mode");
83
+ let stdout = child.stdout.take().unwrap();
84
+ let (tx, incoming) = channel();
85
+ std::thread::spawn(move || {
86
+ for line in BufReader::new(stdout).lines() {
87
+ let Ok(line) = line else { break };
88
+ if let Ok(message) = serde_json::from_str::<Value>(&line)
89
+ && tx.send(message).is_err()
90
+ {
91
+ break;
92
+ }
93
+ }
94
+ });
95
+ let stdin = child.stdin.take().unwrap();
96
+ Wire {
97
+ child,
98
+ stdin,
99
+ incoming,
100
+ next_id: 0,
101
+ }
102
+}
103
+
104
+impl Wire {
105
+ fn request(&mut self, method: &str, params: Value) -> u64 {
106
+ self.next_id += 1;
107
+ let id = self.next_id;
108
+ let mut line =
109
+ json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}).to_string();
110
+ line.push('\n');
111
+ self.stdin.write_all(line.as_bytes()).unwrap();
112
+ self.stdin.flush().unwrap();
113
+ id
114
+ }
115
+
116
+ /// The response to `id` and every `session/update` seen on the way.
117
+ fn finish(&mut self, id: u64) -> (Value, Vec<Value>) {
118
+ let mut updates = Vec::new();
119
+ let deadline = Instant::now() + TIMEOUT;
120
+ loop {
121
+ let remaining = deadline.saturating_duration_since(Instant::now());
122
+ let Ok(message) = self.incoming.recv_timeout(remaining) else {
123
+ panic!("timed out waiting for the response to request {id}");
124
+ };
125
+ if message["method"] == "session/update" {
126
+ updates.push(message["params"]["update"].clone());
127
+ }
128
+ if message["id"] == id && message.get("method").is_none() {
129
+ assert!(
130
+ message.get("error").is_none(),
131
+ "request {id} failed: {message}"
132
+ );
133
+ return (message, updates);
134
+ }
135
+ }
136
+ }
137
+
138
+ fn next_commands_update(&mut self) -> Value {
139
+ let deadline = Instant::now() + TIMEOUT;
140
+ loop {
141
+ let remaining = deadline.saturating_duration_since(Instant::now());
142
+ let message = self
143
+ .incoming
144
+ .recv_timeout(remaining)
145
+ .expect("timed out waiting for available_commands_update");
146
+ let update = &message["params"]["update"];
147
+ if message["method"] == "session/update"
148
+ && update["sessionUpdate"] == "available_commands_update"
149
+ {
150
+ return update.clone();
151
+ }
152
+ }
153
+ }
154
+}
155
+
156
+impl Drop for Wire {
157
+ fn drop(&mut self) {
158
+ let _ = self.child.kill();
159
+ let _ = self.child.wait();
160
+ }
161
+}
162
+
163
+#[test]
164
+fn a_skill_is_advertised_and_runs_as_a_slash_command() {
165
+ let scratch = std::env::temp_dir().join(format!("sigit_acp_skill_cmd_{}", std::process::id()));
166
+ let config_dir = scratch.join("config");
167
+ let project = scratch.join("project");
168
+ let skill_dir = config_dir.join("skills").join("zz-shelf-sorter");
169
+ std::fs::create_dir_all(&skill_dir).unwrap();
170
+ std::fs::create_dir_all(&project).unwrap();
171
+ // A description past 100 characters, over two lines.
172
+ let description = format!("Sort shelves.\n {}", "Use it when asked. ".repeat(10));
173
+ std::fs::write(
174
+ skill_dir.join("SKILL.md"),
175
+ format!(
176
+ "---\nname: zz-shelf-sorter\ndescription: \"{}\"\n---\n\nAlphabetise the shelves first.\n",
177
+ description.replace('\n', " ")
178
+ ),
179
+ )
180
+ .unwrap();
181
+
182
+ let (port, requests) = start_fake_endpoint();
183
+ let mut wire = spawn(port, &config_dir);
184
+ let id = wire.request(
185
+ "initialize",
186
+ json!({"protocolVersion": 1, "clientCapabilities": {}}),
187
+ );
188
+ wire.finish(id);
189
+
190
+ let id = wire.request("session/new", json!({"cwd": project, "mcpServers": []}));
191
+ let (response, _) = wire.finish(id);
192
+ let session_id = response["result"]["sessionId"]
193
+ .as_str()
194
+ .unwrap()
195
+ .to_string();
196
+
197
+ let update = wire.next_commands_update();
198
+ let commands = update["availableCommands"].as_array().unwrap();
199
+ let skill = commands
200
+ .iter()
201
+ .find(|c| c["name"] == "zz-shelf-sorter")
202
+ .unwrap_or_else(|| panic!("the skill must be advertised: {update}"));
203
+ assert_eq!(skill["input"]["hint"], "task (optional)", "{skill}");
204
+ let shown = skill["description"].as_str().unwrap();
205
+ assert!(
206
+ shown.chars().count() <= 100 && !shown.contains('\n'),
207
+ "description must be one short line: {shown:?}"
208
+ );
209
+
210
+ let id = wire.request(
211
+ "session/prompt",
212
+ json!({
213
+ "sessionId": session_id,
214
+ "prompt": [{"type": "text", "text": "/zz-shelf-sorter do the garage"}],
215
+ }),
216
+ );
217
+ wire.finish(id);
218
+
219
+ let body = requests
220
+ .recv_timeout(TIMEOUT)
221
+ .expect("the skill command should run a turn against the model endpoint");
222
+ assert!(body.contains("Alphabetise the shelves first."), "{body}");
223
+ assert!(body.contains("Task: do the garage"), "{body}");
224
+
225
+ drop(wire);
226
+ let _ = std::fs::remove_dir_all(&scratch);
227
+}