13
//! would reject the whole session.
14
//! 2. `selected: allow_once` — the tool must actually execute and its output
15
//! travel back to the endpoint as a tool result.
16
+//!
17
+//! A second test covers live progress: a slow command's `in_progress` tool call
18
+//! must reach the client while the command is still running, which is what the
19
+//! editor's spinner is driven from.
20
21
use std::collections::VecDeque;
22
use std::io::{BufRead, BufReader, Read, Write};
128
}
129
130
fn spawn_agent(port: u16, config_dir: &std::path::Path) -> AgentUnderTest {
127
- let mut child = Command::new(env!("CARGO_BIN_EXE_sigit"))
131
+ spawn_agent_with_permissions(port, config_dir, None)
132
+}
133
+
134
+/// `permissions` sets `SIGIT_PERMISSIONS`; `None` leaves the default `ask` mode
135
+/// in place, which is what the permission round-trip needs.
136
+fn spawn_agent_with_permissions(
137
+ port: u16,
138
+ config_dir: &std::path::Path,
139
+ permissions: Option<&str>,
140
+) -> AgentUnderTest {
141
+ let mut command = Command::new(env!("CARGO_BIN_EXE_sigit"));
142
+ command
143
.env("OPENAI_BASE_URL", format!("http://127.0.0.1:{port}"))
144
.env("OPENAI_API_KEY", "test-key")
145
.env("SIGIT_MODEL", "scripted-model")
146
.env("SIGIT_CONFIG_DIR", config_dir)
147
.env("SIGIT_MCP", "off")
133
- // A fresh config dir means the default permission mode, `ask` — make
134
- // sure the environment can't turn the gate off underneath the test.
135
- .env_remove("SIGIT_PERMISSIONS")
148
.env_remove("SIGIT_LOCAL_INFERENCE")
149
.stdin(Stdio::piped())
150
.stdout(Stdio::piped())
139
- .stderr(Stdio::null())
140
- .spawn()
141
- .expect("spawn sigit in ACP mode");
151
+ .stderr(Stdio::null());
152
+ match permissions {
153
+ // A fresh config dir means the default permission mode, `ask` — make
154
+ // sure the environment can't turn the gate off underneath the test.
155
+ None => command.env_remove("SIGIT_PERMISSIONS"),
156
+ Some(mode) => command.env("SIGIT_PERMISSIONS", mode),
157
+ };
158
+ let mut child = command.spawn().expect("spawn sigit in ACP mode");
159
160
let stdout = child.stdout.take().unwrap();
161
let (message_tx, incoming) = channel();
225
response
226
}
227
228
+ /// The first `session/update` whose payload satisfies `matches`.
229
+ fn wait_for_update(&mut self, what: &str, matches: impl Fn(&Value) -> bool) -> Value {
230
+ let message = self.wait_for(what, |message| {
231
+ message["method"] == "session/update" && matches(&message["params"]["update"])
232
+ });
233
+ message["params"]["update"].clone()
234
+ }
235
+
236
/// A request *from* the agent (has a `method` and its own id).
237
fn wait_for_agent_request(&mut self, method: &str) -> Value {
238
self.wait_for(&format!("agent request {method}"), |message| {
382
drop(agent);
383
let _ = std::fs::remove_dir_all(&scratch);
384
}
385
+
386
+#[test]
387
+fn a_slow_commands_progress_reaches_the_client_while_it_runs() {
388
+ // `SIGIT_PERMISSIONS=allow` skips the gate, so the only thing between the
389
+ // two updates below is the command itself.
390
+ let endpoint = start_fake_endpoint(vec![
391
+ sse_tool_call("call_1", "run_command", r#"{"command":"sleep 2"}"#),
392
+ sse_text("done"),
393
+ ]);
394
+
395
+ let scratch = std::env::temp_dir().join(format!("sigit_acp_progress_{}", std::process::id()));
396
+ let config_dir = scratch.join("config");
397
+ let cwd = scratch.join("cwd");
398
+ std::fs::create_dir_all(&config_dir).unwrap();
399
+ std::fs::create_dir_all(&cwd).unwrap();
400
+
401
+ let mut agent = spawn_agent_with_permissions(endpoint.port, &config_dir, Some("allow"));
402
+
403
+ let id = agent.request(
404
+ "initialize",
405
+ json!({"protocolVersion": 1, "clientCapabilities": {}}),
406
+ );
407
+ agent.wait_for_response(id);
408
+
409
+ let id = agent.request("session/new", json!({"cwd": cwd, "mcpServers": []}));
410
+ let session_id = agent.wait_for_response(id)["result"]["sessionId"]
411
+ .as_str()
412
+ .expect("session id")
413
+ .to_string();
414
+
415
+ let prompt_id = agent.request(
416
+ "session/prompt",
417
+ json!({
418
+ "sessionId": session_id,
419
+ "prompt": [{"type": "text", "text": "run the slow command"}],
420
+ }),
421
+ );
422
+
423
+ let started = agent.wait_for_update("the tool call starting", |update| {
424
+ update["sessionUpdate"] == "tool_call" && update["toolCallId"] == "call_1"
425
+ });
426
+ let in_progress_at = Instant::now();
427
+ assert_eq!(
428
+ started["status"], "in_progress",
429
+ "a running tool call must be announced as in_progress: {started}"
430
+ );
431
+
432
+ agent.wait_for_update("the tool call finishing", |update| {
433
+ update["sessionUpdate"] == "tool_call_update"
434
+ && update["toolCallId"] == "call_1"
435
+ && update["status"] == "completed"
436
+ });
437
+
438
+ // `sleep 2` runs between the two updates. If the turn blocked the
439
+ // connection's actors, both would only reach the client once the command
440
+ // had already finished, and the editor would never draw a spinner.
441
+ assert!(
442
+ in_progress_at.elapsed() >= Duration::from_millis(1_000),
443
+ "in_progress arrived only {:?} before completed — the client had no \
444
+ window to show progress in",
445
+ in_progress_at.elapsed()
446
+ );
447
+
448
+ let response = agent.wait_for_response(prompt_id);
449
+ assert_eq!(response["result"]["stopReason"], "end_turn");
450
+
451
+ drop(agent);
452
+ let _ = std::fs::remove_dir_all(&scratch);
453
+}