113
}
114
115
fn spawn_agent(port: u16, config_dir: &std::path::Path) -> AgentUnderTest {
116
+ spawn_agent_with_env(port, config_dir, &[])
117
+}
118
+
119
+fn spawn_agent_with_env(
120
+ port: u16,
121
+ config_dir: &std::path::Path,
122
+ env: &[(&str, &str)],
123
+) -> AgentUnderTest {
124
let mut child = Command::new(env!("CARGO_BIN_EXE_sigit"))
125
.env("OPENAI_BASE_URL", format!("http://127.0.0.1:{port}"))
126
.env("OPENAI_API_KEY", "test-key")
129
.env("SIGIT_MCP", "off")
130
.env("SIGIT_PERMISSIONS", "allow")
131
.env_remove("SIGIT_LOCAL_INFERENCE")
132
+ .env_remove("SIGIT_MAX_TOOL_ROUNDS")
133
+ .envs(env.iter().copied())
134
.stdin(Stdio::piped())
135
.stdout(Stdio::piped())
136
.stderr(Stdio::null())
204
205
/// Send one text prompt and return the stop reason the turn ended with.
206
fn prompt(&mut self, session_id: &str, text: &str) -> String {
207
+ self.prompt_with_reply(session_id, text).0
208
+ }
209
+
210
+ /// Like [`Self::prompt`], also returning the assistant text the client
211
+ /// was sent on the way, joined the way a client renders it.
212
+ fn prompt_with_reply(&mut self, session_id: &str, text: &str) -> (String, String) {
213
let id = self.request(
214
"session/prompt",
215
json!({"sessionId": session_id, "prompt": [{"type": "text", "text": text}]}),
216
);
201
- self.wait_for_response(id)["result"]["stopReason"]
202
- .as_str()
203
- .expect("stop reason")
204
- .to_string()
217
+ let mut rendered = String::new();
218
+ let deadline = Instant::now() + TIMEOUT;
219
+ loop {
220
+ let remaining = deadline.saturating_duration_since(Instant::now());
221
+ let Ok(message) = self.incoming.recv_timeout(remaining) else {
222
+ panic!("timed out waiting for the response to request {id}");
223
+ };
224
+ let update = &message["params"]["update"];
225
+ if message["method"] == "session/update"
226
+ && update["sessionUpdate"] == "agent_message_chunk"
227
+ && let Some(chunk) = update["content"]["text"].as_str()
228
+ {
229
+ rendered.push_str(chunk);
230
+ }
231
+ if message["id"] == id && message.get("method").is_none() {
232
+ assert!(message.get("error").is_none(), "prompt failed: {message}");
233
+ let stop_reason = message["result"]["stopReason"]
234
+ .as_str()
235
+ .expect("stop reason")
236
+ .to_string();
237
+ return (stop_reason, rendered);
238
+ }
239
+ }
240
}
241
}
242
352
353
std::fs::remove_dir_all(&dir).ok();
354
}
355
+
356
+/// What issue #120 looked like from the editor: the turn reached the cap, the
357
+/// model used its last reply to announce the next step, the call behind it was
358
+/// dropped, and the thread stopped on "Now commit:" with nothing after it.
359
+#[test]
360
+fn a_turn_stopped_at_the_cap_says_so_to_the_user_and_the_model() {
361
+ let dir = scratch("cap_notice");
362
+ let endpoint = start_fake_endpoint(vec![
363
+ sse_tool_call("call_1", "run_command", r#"{"command":"echo round-1"}"#),
364
+ sse_tool_call("call_2", "run_command", r#"{"command":"echo round-2"}"#),
365
+ sse_text("Now commit:", "stop"),
366
+ ]);
367
+ let mut agent = spawn_agent_with_env(
368
+ endpoint.port,
369
+ &dir.join("config"),
370
+ &[("SIGIT_MAX_TOOL_ROUNDS", "2")],
371
+ );
372
+ let session_id = agent.open_session(&dir.join("work"));
373
+
374
+ let (stop_reason, reply) = agent.prompt_with_reply(&session_id, "ship it");
375
+
376
+ assert_eq!(stop_reason, "max_turn_requests");
377
+ assert!(
378
+ reply.contains("Now commit:") && reply.contains("all 2 of its tool rounds"),
379
+ "the user must be told why the turn stopped: {reply:?}"
380
+ );
381
+
382
+ // The model is told before its last reply, on the result of the last
383
+ // call, and only there.
384
+ let requests = endpoint.requests.lock().unwrap();
385
+ assert_eq!(requests.len(), 3);
386
+ let tool_results = |request: &Value| -> Vec<String> {
387
+ request["messages"]
388
+ .as_array()
389
+ .expect("messages")
390
+ .iter()
391
+ .filter(|message| message["role"] == "tool")
392
+ .map(|message| message["content"].as_str().unwrap_or_default().to_string())
393
+ .collect()
394
+ };
395
+ let before_cap = tool_results(&requests[1]);
396
+ assert!(
397
+ before_cap
398
+ .iter()
399
+ .all(|content| !content.contains("[siGit Code]")),
400
+ "no note while rounds remain: {before_cap:?}"
401
+ );
402
+ let at_cap = tool_results(&requests[2]);
403
+ assert!(
404
+ at_cap.last().is_some_and(|content| {
405
+ content.contains("round-2") && content.contains("no more tools can run")
406
+ }),
407
+ "the last tool result carries the note: {at_cap:?}"
408
+ );
409
+ assert!(
410
+ requests[2].get("tools").is_none(),
411
+ "the closing round offers no tools: {}",
412
+ requests[2]
413
+ );
414
+
415
+ std::fs::remove_dir_all(&dir).ok();
416
+}
417
+
418
+/// A turn that finishes inside its rounds gets no notice.
419
+#[test]
420
+fn a_turn_that_finishes_inside_the_cap_gets_no_notice() {
421
+ let dir = scratch("under_cap");
422
+ let endpoint = start_fake_endpoint(vec![
423
+ sse_tool_call("call_1", "run_command", r#"{"command":"echo round-1"}"#),
424
+ sse_text("Committed.", "stop"),
425
+ ]);
426
+ let mut agent = spawn_agent_with_env(
427
+ endpoint.port,
428
+ &dir.join("config"),
429
+ &[("SIGIT_MAX_TOOL_ROUNDS", "2")],
430
+ );
431
+ let session_id = agent.open_session(&dir.join("work"));
432
+
433
+ let (stop_reason, reply) = agent.prompt_with_reply(&session_id, "ship it");
434
+
435
+ assert_eq!(stop_reason, "end_turn");
436
+ assert_eq!(reply.trim(), "Committed.");
437
+
438
+ std::fs::remove_dir_all(&dir).ok();
439
+}