1
+//! End-to-end check that reopening a saved session redraws it in the client.
2
+//!
3
+//! ACP's `session/load` is a replay: the client renders the reopened thread
4
+//! from the `session/update` notifications the agent streams while the request
5
+//! is in flight, and keeps nothing of its own. Restoring the history into the
6
+//! backend is what makes the *model* remember, but it puts nothing on screen —
7
+//! before this, clicking a thread in Zed's history opened an empty one that
8
+//! looked brand new (issue #77).
9
+//!
10
+//! This drives the real binary against a scripted OpenAI-compatible endpoint:
11
+//! run one prompt that talks and calls a tool, then load the same session id
12
+//! and assert the conversation comes back over the wire.
13
+
14
+use std::collections::VecDeque;
15
+use std::io::{BufRead, BufReader, Read, Write};
16
+use std::net::TcpListener;
17
+use std::process::{Child, ChildStdin, Command, Stdio};
18
+use std::sync::Mutex;
19
+use std::sync::mpsc::{Receiver, channel};
20
+use std::time::{Duration, Instant};
21
+
22
+use serde_json::{Value, json};
23
+
24
+const TIMEOUT: Duration = Duration::from_secs(60);
25
+
26
+// ── Scripted OpenAI-compatible endpoint ─────────────────────────────────────
27
+
28
+fn sse_body(events: &[Value]) -> String {
29
+ let mut body = String::new();
30
+ for event in events {
31
+ body.push_str("data: ");
32
+ body.push_str(&event.to_string());
33
+ body.push_str("\n\n");
34
+ }
35
+ body.push_str("data: [DONE]\n\n");
36
+ body
37
+}
38
+
39
+fn sse_text(text: &str) -> String {
40
+ sse_body(&[json!({"choices": [{"delta": {"content": text}}]})])
41
+}
42
+
43
+fn sse_text_then_tool_call(text: &str, id: &str, name: &str, arguments: &str) -> String {
44
+ sse_body(&[
45
+ json!({"choices": [{"delta": {"content": text}}]}),
46
+ json!({
47
+ "choices": [{"delta": {"tool_calls": [{
48
+ "index": 0,
49
+ "id": id,
50
+ "function": {"name": name, "arguments": arguments},
51
+ }]}}]
52
+ }),
53
+ ])
54
+}
55
+
56
+/// Serves one scripted SSE response per request.
57
+fn start_fake_endpoint(responses: Vec<String>) -> u16 {
58
+ let listener = TcpListener::bind("127.0.0.1:0").expect("bind fake endpoint");
59
+ let port = listener.local_addr().unwrap().port();
60
+ let queue = Mutex::new(VecDeque::from(responses));
61
+
62
+ std::thread::spawn(move || {
63
+ for stream in listener.incoming() {
64
+ let Ok(mut stream) = stream else { continue };
65
+ let mut reader = BufReader::new(match stream.try_clone() {
66
+ Ok(clone) => clone,
67
+ Err(_) => continue,
68
+ });
69
+ let mut content_length = 0usize;
70
+ loop {
71
+ let mut line = String::new();
72
+ if reader.read_line(&mut line).unwrap_or(0) == 0 {
73
+ break;
74
+ }
75
+ let line = line.trim();
76
+ if line.is_empty() {
77
+ break;
78
+ }
79
+ if let Some(length) = line.to_ascii_lowercase().strip_prefix("content-length:") {
80
+ content_length = length.trim().parse().unwrap_or(0);
81
+ }
82
+ }
83
+ let mut body = vec![0u8; content_length];
84
+ if reader.read_exact(&mut body).is_err() {
85
+ continue;
86
+ }
87
+ let payload = queue
88
+ .lock()
89
+ .unwrap()
90
+ .pop_front()
91
+ .unwrap_or_else(|| "data: [DONE]\n\n".to_string());
92
+ let response = format!(
93
+ "HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\n\
94
+ content-length: {}\r\nconnection: close\r\n\r\n{}",
95
+ payload.len(),
96
+ payload
97
+ );
98
+ let _ = stream.write_all(response.as_bytes());
99
+ }
100
+ });
101
+
102
+ port
103
+}
104
+
105
+// ── The agent under test ────────────────────────────────────────────────────
106
+
107
+struct AgentUnderTest {
108
+ child: Child,
109
+ stdin: ChildStdin,
110
+ incoming: Receiver<Value>,
111
+ next_id: u64,
112
+}
113
+
114
+fn spawn_agent(port: u16, config_dir: &std::path::Path) -> AgentUnderTest {
115
+ let mut child = Command::new(env!("CARGO_BIN_EXE_sigit"))
116
+ .env("OPENAI_BASE_URL", format!("http://127.0.0.1:{port}"))
117
+ .env("OPENAI_API_KEY", "test-key")
118
+ .env("SIGIT_MODEL", "scripted-model")
119
+ .env("SIGIT_CONFIG_DIR", config_dir)
120
+ .env("SIGIT_MCP", "off")
121
+ .env("SIGIT_PERMISSIONS", "allow")
122
+ .env_remove("SIGIT_LOCAL_INFERENCE")
123
+ .stdin(Stdio::piped())
124
+ .stdout(Stdio::piped())
125
+ .stderr(Stdio::null())
126
+ .spawn()
127
+ .expect("spawn sigit in ACP mode");
128
+
129
+ let stdout = child.stdout.take().unwrap();
130
+ let (message_tx, incoming) = channel();
131
+ std::thread::spawn(move || {
132
+ for line in BufReader::new(stdout).lines() {
133
+ let Ok(line) = line else { break };
134
+ if let Ok(message) = serde_json::from_str::<Value>(&line)
135
+ && message_tx.send(message).is_err()
136
+ {
137
+ break;
138
+ }
139
+ }
140
+ });
141
+
142
+ let stdin = child.stdin.take().unwrap();
143
+ AgentUnderTest {
144
+ child,
145
+ stdin,
146
+ incoming,
147
+ next_id: 0,
148
+ }
149
+}
150
+
151
+impl AgentUnderTest {
152
+ fn request(&mut self, method: &str, params: Value) -> u64 {
153
+ self.next_id += 1;
154
+ let id = self.next_id;
155
+ let mut line =
156
+ json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}).to_string();
157
+ line.push('\n');
158
+ self.stdin
159
+ .write_all(line.as_bytes())
160
+ .expect("write to agent stdin");
161
+ self.stdin.flush().expect("flush agent stdin");
162
+ id
163
+ }
164
+
165
+ /// The response to one of our requests, plus every `session/update` that
166
+ /// arrived before it — which for `session/load` is the whole replay.
167
+ fn wait_for_response_with_updates(&mut self, id: u64) -> (Value, Vec<Value>) {
168
+ let mut updates = Vec::new();
169
+ let deadline = Instant::now() + TIMEOUT;
170
+ loop {
171
+ let remaining = deadline.saturating_duration_since(Instant::now());
172
+ let Ok(message) = self.incoming.recv_timeout(remaining) else {
173
+ panic!("timed out waiting for the response to request {id}");
174
+ };
175
+ if message["method"] == "session/update" {
176
+ updates.push(message["params"]["update"].clone());
177
+ }
178
+ if message["id"] == id && message.get("method").is_none() {
179
+ assert!(
180
+ message.get("error").is_none(),
181
+ "request {id} failed: {message}"
182
+ );
183
+ return (message, updates);
184
+ }
185
+ }
186
+ }
187
+}
188
+
189
+impl Drop for AgentUnderTest {
190
+ fn drop(&mut self) {
191
+ let _ = self.child.kill();
192
+ let _ = self.child.wait();
193
+ }
194
+}
195
+
196
+fn text_of(update: &Value) -> &str {
197
+ update["content"]["text"].as_str().unwrap_or_default()
198
+}
199
+
200
+#[test]
201
+fn loading_a_saved_session_replays_it_to_the_client() {
202
+ let scratch = std::env::temp_dir().join(format!("sigit_acp_load_{}", std::process::id()));
203
+ let config_dir = scratch.join("config");
204
+ let project = scratch.join("project");
205
+ std::fs::create_dir_all(&config_dir).unwrap();
206
+ std::fs::create_dir_all(&project).unwrap();
207
+ let notes = project.join("notes.md");
208
+ std::fs::write(¬es, "hello from the notes\n").unwrap();
209
+
210
+ // Round 1 talks and reads that file; round 2 answers.
211
+ let port = start_fake_endpoint(vec![
212
+ sse_text_then_tool_call(
213
+ "Let me look at the notes.",
214
+ "call_1",
215
+ "read_file",
216
+ &json!({"path": notes.to_string_lossy()}).to_string(),
217
+ ),
218
+ sse_text("The notes say hello."),
219
+ ]);
220
+
221
+ let mut agent = spawn_agent(port, &config_dir);
222
+
223
+ let id = agent.request(
224
+ "initialize",
225
+ json!({"protocolVersion": 1, "clientCapabilities": {}}),
226
+ );
227
+ let (initialize, _) = agent.wait_for_response_with_updates(id);
228
+ assert_eq!(
229
+ initialize["result"]["agentCapabilities"]["loadSession"], true,
230
+ "a client only offers to reopen threads for an agent that advertises loadSession: \
231
+ {initialize}"
232
+ );
233
+
234
+ let id = agent.request("session/new", json!({"cwd": project, "mcpServers": []}));
235
+ let (new_session, _) = agent.wait_for_response_with_updates(id);
236
+ let session_id = new_session["result"]["sessionId"]
237
+ .as_str()
238
+ .expect("session id")
239
+ .to_string();
240
+
241
+ let id = agent.request(
242
+ "session/prompt",
243
+ json!({
244
+ "sessionId": session_id,
245
+ "prompt": [{"type": "text", "text": "what do the notes say?"}],
246
+ }),
247
+ );
248
+ agent.wait_for_response_with_updates(id);
249
+
250
+ // Reopen it, the way clicking the thread in the editor's history does.
251
+ let id = agent.request(
252
+ "session/load",
253
+ json!({"sessionId": session_id, "cwd": project, "mcpServers": []}),
254
+ );
255
+ let (_, replay) = agent.wait_for_response_with_updates(id);
256
+
257
+ let user: Vec<&str> = replay
258
+ .iter()
259
+ .filter(|update| update["sessionUpdate"] == "user_message_chunk")
260
+ .map(text_of)
261
+ .collect();
262
+ assert_eq!(
263
+ user,
264
+ vec!["what do the notes say?"],
265
+ "the user's turn must come back: {replay:#?}"
266
+ );
267
+
268
+ let agent_text: String = replay
269
+ .iter()
270
+ .filter(|update| update["sessionUpdate"] == "agent_message_chunk")
271
+ .map(text_of)
272
+ .collect();
273
+ assert!(
274
+ agent_text.contains("Let me look at the notes.")
275
+ && agent_text.contains("The notes say hello."),
276
+ "both rounds of the reply must come back, got {agent_text:?}"
277
+ );
278
+
279
+ let tool_call = replay
280
+ .iter()
281
+ .find(|update| update["sessionUpdate"] == "tool_call")
282
+ .unwrap_or_else(|| panic!("the tool call must come back: {replay:#?}"));
283
+ assert_eq!(tool_call["title"], "read_file");
284
+ // Nothing is still running in a saved session, and the result is folded
285
+ // back into its call rather than replayed as a loose message.
286
+ assert_eq!(tool_call["status"], "completed");
287
+ assert!(
288
+ tool_call["rawOutput"]
289
+ .as_str()
290
+ .unwrap_or_default()
291
+ .contains("hello from the notes"),
292
+ "the tool result must ride along with its call: {tool_call}"
293
+ );
294
+
295
+ // The seeded system context is the model's, not the user's — it must never
296
+ // surface as a message in the thread.
297
+ for update in &replay {
298
+ assert!(
299
+ !text_of(update).contains("project working directory"),
300
+ "system context leaked into the replay: {update}"
301
+ );
302
+ }
303
+
304
+ drop(agent);
305
+ let _ = std::fs::remove_dir_all(&scratch);
306
+}