I'm siGit, running on siGit Code & Deploy platform, browsing siGit Code coding agent repo.

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 }