Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions crates/buzz-acp/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,11 @@ clap = { version = "4", features = ["derive", "env"] }
# Config file
toml = "1.0"

# Durable ACP session binding store location
dirs = "6"
# Cross-process flock for shared session bindings
fs2 = "0.4"

# Filter expressions
evalexpr = { workspace = true }

Expand All @@ -78,4 +83,5 @@ nix = { version = "0.31", default-features = false, features = ["signal"] }

[dev-dependencies]
tokio = { workspace = true, features = ["test-util"] }
tempfile = "3"
httparse = "1"
127 changes: 121 additions & 6 deletions crates/buzz-acp/src/acp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -672,6 +672,50 @@ impl AcpClient {
.session_id)
}

/// Send `session/load` for an existing ACP session id.
///
/// Used after harness restart when a durable channel→session binding is
/// known and the agent advertised `agentCapabilities.loadSession`.
/// History-replay `session/update` notifications are consumed by the
/// request loop without entering the observer feed, so relay observers do
/// not republish the loaded transcript.
pub async fn session_load_full(
&mut self,
cwd: &str,
session_id: &str,
mcp_servers: Vec<McpServer>,
) -> Result<SessionNewResponse, AcpError> {
let params = serde_json::json!({
"cwd": cwd,
"sessionId": session_id,
"mcpServers": mcp_servers,
});
let result = self
.send_request_with_session_update_observer("session/load", params, false)
.await?;
// Spec-compliant agents may omit sessionId on load (it is implied).
// Prefer the request id so callers always have a concrete binding.
let resolved_id = result
.get("sessionId")
.and_then(|v| v.as_str())
.unwrap_or(session_id)
.to_owned();
tracing::info!(target: "acp::session", "session loaded: {resolved_id}");
Ok(SessionNewResponse {
session_id: resolved_id,
raw: result,
})
}

/// Returns true when an initialize result advertises `loadSession`.
pub fn agent_supports_load_session(init_result: &serde_json::Value) -> bool {
init_result
.get("agentCapabilities")
.and_then(|caps| caps.get("loadSession"))
.and_then(|v| v.as_bool())
.unwrap_or(false)
}

/// Send Goose's custom system-prompt request after `session/new`.
pub async fn session_set_goose_system_prompt(
&mut self,
Expand Down Expand Up @@ -1050,7 +1094,7 @@ impl AcpClient {
/// Send a JSON-RPC request and wait for the matching response.
///
/// Assigns the next available id, writes the NDJSON line to stdin,
/// then calls [`read_until_response`](Self::read_until_response).
/// then reads until the matching response arrives.
///
/// The write phase is bounded by `WRITE_TIMEOUT` (30s) and the read phase
/// by `REQUEST_TIMEOUT` (60s), so worst-case wall clock is ~90s. Non-prompt
Expand All @@ -1060,6 +1104,16 @@ impl AcpClient {
&mut self,
method: &str,
params: serde_json::Value,
) -> Result<serde_json::Value, AcpError> {
self.send_request_with_session_update_observer(method, params, true)
.await
}

async fn send_request_with_session_update_observer(
&mut self,
method: &str,
params: serde_json::Value,
observe_session_updates: bool,
) -> Result<serde_json::Value, AcpError> {
let id = self.next_id;
self.next_id += 1;
Expand All @@ -1082,7 +1136,12 @@ impl AcpClient {
Err(_) => return Err(AcpError::Timeout(timeout)),
}

match tokio::time::timeout(timeout, self.read_until_response(id)).await {
match tokio::time::timeout(
timeout,
self.read_until_response_with_session_update_observer(id, observe_session_updates),
)
.await
{
Ok(result) => result,
Err(_) => Err(AcpError::Timeout(timeout)),
}
Expand All @@ -1092,7 +1151,7 @@ impl AcpClient {
///
/// After a [`AcpError::Timeout`] from [`send_request`], the agent may
/// eventually send the late response. That stale message will sit in the
/// `BufReader` buffer and be silently skipped by the next `read_until_response`
/// `BufReader` buffer and be silently skipped by the next response-read
/// call (ID mismatch). However, if the caller wants a clean slate — e.g.
/// before retrying the same method — they can call this to consume any
/// buffered data with a short deadline.
Expand Down Expand Up @@ -1151,9 +1210,10 @@ impl AcpClient {
///
/// Compares the incoming `id` field as a `serde_json::Value` against
/// `json!(expected_id)` so that both numeric and string IDs work correctly.
async fn read_until_response(
async fn read_until_response_with_session_update_observer(
&mut self,
expected_id: u64,
observe_session_updates: bool,
) -> Result<serde_json::Value, AcpError> {
loop {
// LinesCodec::new_with_max_length enforces MAX_LINE_SIZE at the
Expand Down Expand Up @@ -1197,7 +1257,11 @@ impl AcpClient {
continue;
}
};
self.observe("acp_read", msg.clone());
let is_session_update =
msg.get("method").and_then(|v| v.as_str()) == Some("session/update");
if observe_session_updates || !is_session_update {
self.observe("acp_read", msg.clone());
}

// Check if this is a response to our expected request (has matching id
// AND no `method` field — a `method` field means it's an agent-initiated
Expand Down Expand Up @@ -1244,7 +1308,7 @@ impl AcpClient {
}
}

/// Idle-aware message loop: like [`read_until_response`] but resets an idle
/// Idle-aware message loop: like the regular response-read path but resets an idle
/// deadline on every stdout line. Fires [`AcpError::IdleTimeout`] on silence
/// or [`AcpError::HardTimeout`] on absolute wall-clock cap.
///
Expand Down Expand Up @@ -3183,6 +3247,57 @@ mod tests {
assert_eq!(result.unwrap()["worked"], serde_json::json!(true));
}

#[tokio::test]
async fn session_load_suppresses_replayed_updates_from_observer_only() {
let script = r#"
read -t 2 _load
echo '{"jsonrpc":"2.0","method":"session/update","params":{"marker":"replayed"}}'
echo '{"jsonrpc":"2.0","id":0,"result":{}}'
read -t 2 _next
echo '{"jsonrpc":"2.0","method":"session/update","params":{"marker":"live"}}'
echo '{"jsonrpc":"2.0","id":1,"result":{"worked":true}}'
sleep 1
"#;
let mut client = spawn_script(script).await;
let observer = crate::observer::ObserverHandle::in_process();
client.set_observer(Some(observer.clone()), 0);

let loaded = client
.session_load_full("/", "sess-existing", Vec::new())
.await
.expect("session/load should succeed");
assert_eq!(loaded.session_id, "sess-existing");

let next = client
.send_request("test/echo", serde_json::json!({}))
.await
.expect("follow-up request should succeed");
assert_eq!(next["worked"], serde_json::json!(true));

let observed_reads: Vec<_> = observer
.snapshot()
.into_iter()
.filter(|event| event.kind == "acp_read")
.map(|event| event.payload)
.collect();
assert!(
!observed_reads
.iter()
.any(|payload| payload["params"]["marker"] == "replayed"),
"session/load replay updates must not enter the observer feed"
);
assert!(
observed_reads
.iter()
.any(|payload| payload["params"]["marker"] == "live"),
"normal session updates must remain observable after load"
);
assert!(
observed_reads.iter().any(|payload| payload["id"] == 0),
"the session/load response itself must remain observable"
);
}

#[tokio::test]
async fn keepalive_resets_idle_past_deadline() {
// Keepalive session/update lines every 50ms against a 100ms idle deadline.
Expand Down
Loading