feat(acp): advertise the embeddedContext prompt capability

handle_prompt already inlines embedded text resources, but initialize only advertised image support, so a client following the spec never sent one. It fell back to resource_link and sigit read the file from disk, which misses unsaved edits in the editor buffer. The new test sends an embedded resource whose text differs from the file on disk and checks that the model gets the buffer's version. Fixes #141 Co-Authored-By: siGit Code v1.6.4 <noreply@sigit.si> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Seto Elkahfi committed Oct 8, 2026 at 07:04 UTC b5cb7da18c4cd73bb4d164cd788bfe59a9f891c2
3 files changed +290 -1
CHANGELOG.md
+8
@@ -2,6 +2,14 @@
2
3 ## Unreleased
4
5 +### Added
6 +
7 +- **An `@file` mention can carry unsaved edits.** siGit Code now advertises
8 + `promptCapabilities.embeddedContext`, so a client can put a resource's
9 + contents in the prompt instead of sending a link to it. Before, a client
10 + that follows the spec only sent `resource_link`, and siGit Code read the
11 + file from disk, missing whatever the user had not saved yet (#141).
12 +
13 ### Changed
14
15 - **A turn that cannot get its session back stops.** A turn on an HTTP backend
src/main.rs
+8 -1
@@ -1900,7 +1900,14 @@ impl SiGitAgent {
1900 // for the connection while the model is not, so a prompt that
1901 // brings an image to a model that cannot read one is answered with
1902 // a note instead (see `images_for_turn`).
1903 - .prompt_capabilities(PromptCapabilities::new().image(true))
1903 + //
1904 + // Embedded context lets the client inline a resource's
1905 + // contents, e.g. an `@file` mention with unsaved edits.
1906 + // Without it a client sends only a `resource_link`, and
1907 + // sigit reads the file from disk.
1908 + .prompt_capabilities(
1909 + PromptCapabilities::new().image(true).embedded_context(true),
1910 + )
1911 // Clients only pass HTTP MCP servers in `mcpServers` to an
1912 // agent that says it can reach them. SSE stays off: the
1913 // MCP spec deprecated that transport.
tests/acp_embedded_context.rs new
+274
@@ -0,0 +1,274 @@
1 +//! End-to-end check of embedded context over ACP (issue #141).
2 +//!
3 +//! A client only inlines a resource's contents into a prompt when the agent
4 +//! advertises `promptCapabilities.embeddedContext`. Otherwise it sends a
5 +//! `resource_link` and sigit reads the file from disk, which misses edits the
6 +//! user has not saved. This runs the real binary against a scripted
7 +//! OpenAI-compatible endpoint and checks that the capability is advertised
8 +//! and that an embedded resource reaches the model as sent, not as it is on
9 +//! disk.
10 +
11 +use std::collections::VecDeque;
12 +use std::io::{BufRead, BufReader, Read, Write};
13 +use std::net::TcpListener;
14 +use std::process::{Child, ChildStdin, Command, Stdio};
15 +use std::sync::mpsc::{Receiver, channel};
16 +use std::sync::{Arc, Mutex};
17 +use std::time::{Duration, Instant};
18 +
19 +use serde_json::{Value, json};
20 +
21 +const TIMEOUT: Duration = Duration::from_secs(60);
22 +
23 +// ── Scripted OpenAI-compatible endpoint ─────────────────────────────────────
24 +
25 +fn sse_body(events: &[Value]) -> String {
26 + let mut body = String::new();
27 + for event in events {
28 + body.push_str("data: ");
29 + body.push_str(&event.to_string());
30 + body.push_str("\n\n");
31 + }
32 + body.push_str("data: [DONE]\n\n");
33 + body
34 +}
35 +
36 +fn sse_text(text: &str) -> String {
37 + sse_body(&[json!({"choices": [{"delta": {"content": text}}]})])
38 +}
39 +
40 +/// Serves one scripted SSE response per request and records each request body.
41 +struct FakeEndpoint {
42 + port: u16,
43 + requests: Arc<Mutex<Vec<Value>>>,
44 +}
45 +
46 +fn start_fake_endpoint(responses: Vec<String>) -> FakeEndpoint {
47 + let listener = TcpListener::bind("127.0.0.1:0").expect("bind fake endpoint");
48 + let port = listener.local_addr().unwrap().port();
49 + let requests: Arc<Mutex<Vec<Value>>> = Arc::default();
50 + let recorded = Arc::clone(&requests);
51 + let queue = Mutex::new(VecDeque::from(responses));
52 +
53 + std::thread::spawn(move || {
54 + for stream in listener.incoming() {
55 + let Ok(mut stream) = stream else { continue };
56 + let mut reader = BufReader::new(match stream.try_clone() {
57 + Ok(clone) => clone,
58 + Err(_) => continue,
59 + });
60 + let mut content_length = 0usize;
61 + loop {
62 + let mut line = String::new();
63 + if reader.read_line(&mut line).unwrap_or(0) == 0 {
64 + break;
65 + }
66 + let line = line.trim();
67 + if line.is_empty() {
68 + break;
69 + }
70 + if let Some(length) = line.to_ascii_lowercase().strip_prefix("content-length:") {
71 + content_length = length.trim().parse().unwrap_or(0);
72 + }
73 + }
74 + let mut body = vec![0u8; content_length];
75 + if reader.read_exact(&mut body).is_err() {
76 + continue;
77 + }
78 + if let Ok(request) = serde_json::from_slice::<Value>(&body) {
79 + recorded.lock().unwrap().push(request);
80 + }
81 + let payload = queue
82 + .lock()
83 + .unwrap()
84 + .pop_front()
85 + .unwrap_or_else(|| sse_text("out of scripted responses"));
86 + let response = format!(
87 + "HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\n\
88 + content-length: {}\r\nconnection: close\r\n\r\n{}",
89 + payload.len(),
90 + payload
91 + );
92 + let _ = stream.write_all(response.as_bytes());
93 + }
94 + });
95 +
96 + FakeEndpoint { port, requests }
97 +}
98 +
99 +// ── ACP client over the binary's stdio ──────────────────────────────────────
100 +
101 +struct AgentUnderTest {
102 + child: Child,
103 + stdin: ChildStdin,
104 + incoming: Receiver<Value>,
105 + next_id: u64,
106 +}
107 +
108 +fn spawn_agent(port: u16, config_dir: &std::path::Path) -> AgentUnderTest {
109 + let mut child = Command::new(env!("CARGO_BIN_EXE_sigit"))
110 + .env("OPENAI_BASE_URL", format!("http://127.0.0.1:{port}"))
111 + .env("OPENAI_API_KEY", "test-key")
112 + .env("SIGIT_MODEL", "scripted-model")
113 + .env("SIGIT_CONFIG_DIR", config_dir)
114 + .env("SIGIT_MCP", "off")
115 + .env("SIGIT_PERMISSIONS", "allow")
116 + .env_remove("SIGIT_LOCAL_INFERENCE")
117 + .stdin(Stdio::piped())
118 + .stdout(Stdio::piped())
119 + .stderr(Stdio::null())
120 + .spawn()
121 + .expect("spawn sigit in ACP mode");
122 +
123 + let stdout = child.stdout.take().unwrap();
124 + let (message_tx, incoming) = channel();
125 + std::thread::spawn(move || {
126 + for line in BufReader::new(stdout).lines() {
127 + let Ok(line) = line else { break };
128 + if let Ok(message) = serde_json::from_str::<Value>(&line)
129 + && message_tx.send(message).is_err()
130 + {
131 + break;
132 + }
133 + }
134 + });
135 +
136 + let stdin = child.stdin.take().unwrap();
137 + AgentUnderTest {
138 + child,
139 + stdin,
140 + incoming,
141 + next_id: 0,
142 + }
143 +}
144 +
145 +impl AgentUnderTest {
146 + fn send(&mut self, message: Value) {
147 + let mut line = message.to_string();
148 + line.push('\n');
149 + self.stdin
150 + .write_all(line.as_bytes())
151 + .expect("write to agent stdin");
152 + self.stdin.flush().expect("flush agent stdin");
153 + }
154 +
155 + fn request(&mut self, method: &str, params: Value) -> u64 {
156 + self.next_id += 1;
157 + let id = self.next_id;
158 + self.send(json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}));
159 + id
160 + }
161 +
162 + /// Wait for the response to our request `id`, collecting the raw JSON of
163 + /// every `session/update` notification that arrives before it.
164 + fn wait_for_response_collecting_updates(&mut self, id: u64) -> (Value, String) {
165 + let deadline = Instant::now() + TIMEOUT;
166 + let mut updates = String::new();
167 + loop {
168 + let remaining = deadline.saturating_duration_since(Instant::now());
169 + match self.incoming.recv_timeout(remaining) {
170 + Ok(message) if message["id"] == id && message.get("method").is_none() => {
171 + assert!(
172 + message.get("error").is_none(),
173 + "request {id} failed: {message}"
174 + );
175 + return (message, updates);
176 + }
177 + Ok(message) => {
178 + if message["method"] == "session/update" {
179 + updates.push_str(&message["params"].to_string());
180 + updates.push('\n');
181 + }
182 + }
183 + Err(_) => panic!("timed out waiting for response to request {id}"),
184 + }
185 + }
186 + }
187 +
188 + fn wait_for_response(&mut self, id: u64) -> Value {
189 + self.wait_for_response_collecting_updates(id).0
190 + }
191 +}
192 +
193 +impl Drop for AgentUnderTest {
194 + fn drop(&mut self) {
195 + let _ = self.child.kill();
196 + let _ = self.child.wait();
197 + }
198 +}
199 +
200 +#[test]
201 +fn an_embedded_resource_reaches_the_model_as_the_client_sent_it() {
202 + let endpoint = start_fake_endpoint(vec![sse_text("done")]);
203 + let scratch = std::env::temp_dir().join(format!("sigit_acp_embedded_{}", std::process::id()));
204 + let config_dir = scratch.join("config");
205 + let project = scratch.join("project");
206 + std::fs::create_dir_all(&config_dir).unwrap();
207 + std::fs::create_dir_all(&project).unwrap();
208 + let notes = project.join("notes.txt");
209 + std::fs::write(&notes, "saved on disk").unwrap();
210 +
211 + let mut agent = spawn_agent(endpoint.port, &config_dir);
212 +
213 + let id = agent.request(
214 + "initialize",
215 + json!({"protocolVersion": 1, "clientCapabilities": {}}),
216 + );
217 + let initialize = agent.wait_for_response(id);
218 + assert_eq!(
219 + initialize["result"]["agentCapabilities"]["promptCapabilities"]["embeddedContext"], true,
220 + "a client only embeds resources for an agent that advertises it: {initialize}"
221 + );
222 +
223 + let id = agent.request("session/new", json!({"cwd": project, "mcpServers": []}));
224 + let session_id = agent.wait_for_response(id)["result"]["sessionId"]
225 + .as_str()
226 + .expect("session id")
227 + .to_string();
228 +
229 + // The editor's buffer has an edit the file on disk does not.
230 + let uri = format!("file://{}", notes.display());
231 + let id = agent.request(
232 + "session/prompt",
233 + json!({
234 + "sessionId": session_id,
235 + "prompt": [
236 + {"type": "text", "text": "summarize this"},
237 + {"type": "resource", "resource": {
238 + "uri": uri,
239 + "mimeType": "text/plain",
240 + "text": "unsaved buffer edit",
241 + }},
242 + ],
243 + }),
244 + );
245 + let response = agent.wait_for_response(id);
246 + assert_eq!(response["result"]["stopReason"], "end_turn");
247 +
248 + let request = endpoint
249 + .requests
250 + .lock()
251 + .unwrap()
252 + .first()
253 + .cloned()
254 + .expect("the endpoint received a completion request");
255 + let user = request["messages"]
256 + .as_array()
257 + .expect("messages")
258 + .iter()
259 + .rev()
260 + .find(|message| message["role"] == "user")
261 + .expect("a user message")
262 + .to_string();
263 + assert!(
264 + user.contains("unsaved buffer edit"),
265 + "the embedded contents must reach the model: {user}"
266 + );
267 + assert!(
268 + !user.contains("saved on disk"),
269 + "an embedded resource must not be re-read from disk: {user}"
270 + );
271 +
272 + drop(agent);
273 + let _ = std::fs::remove_dir_all(&scratch);
274 +}