Skip to content

Commit 8ab31b6

Browse files
brettchienclaude
andcommitted
feat(acp): ACP wire conformance, session/resume, streaming panic-safety
Align the ACP server to the official Agent Client Protocol (Schema v1.19.0) so standard ACP clients interoperate: - initialize: integer `protocolVersion: 1`, official `agentCapabilities` (`sessionCapabilities.resume`, `loadSession: false`, `promptCapabilities`), `authMethods: []`. - streaming: `session/update` notifications with `sessionUpdate: "agent_message_chunk"` and a `content` ContentBlock (was a custom `session/notification` + `chunk` wrapper). - stopReason: official snake_case (`end_turn` / `cancelled`); a backend timeout has no ACP stopReason and returns a JSON-RPC error instead. - session/cancel: one-way notification (no response) that ends the in-flight prompt with `stopReason: "cancelled"`. - session/resume: re-attach to a persisted session without replaying history (`session/load` is intentionally unsupported — the gateway keeps no replayable transcript; conversation state lives in the downstream agent). Also make delta streaming panic-safe: slice the reply snapshot with `str::get` instead of a byte index, so a boundary landing mid-codepoint (CJK / emoji) skips the frame instead of panicking. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent d66cf37 commit 8ab31b6

1 file changed

Lines changed: 172 additions & 64 deletions

File tree

crates/openab-gateway/src/adapters/acp_server.rs

Lines changed: 172 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ use tokio::sync::mpsc;
2626
use tracing::{debug, info, warn};
2727
use uuid::Uuid;
2828

29+
/// ACP wire protocol MAJOR version (an integer), returned from `initialize`.
30+
/// Tracks the official schema — see `docs/acp-official-methods.md`.
31+
const ACP_PROTOCOL_VERSION: u32 = 1;
32+
2933
// ---------------------------------------------------------------------------
3034
// ACP Configuration
3135
// ---------------------------------------------------------------------------
@@ -60,6 +64,11 @@ struct AcpSession {
6064
channel_id: String,
6165
/// Whether a prompt is currently in-flight for this session
6266
busy: bool,
67+
/// Cancel signal for the in-flight prompt, if any. `session/cancel` fires
68+
/// this so the streaming task stops gracefully and returns `stopReason:
69+
/// "cancelled"` to the prompt's own request id (rather than hard-aborting
70+
/// the task and orphaning that id).
71+
cancel: Option<Arc<tokio::sync::Notify>>,
6372
}
6473

6574
pub enum ReplyChunk {
@@ -238,7 +247,7 @@ async fn handle_acp_connection(state: Arc<crate::AppState>, socket: WebSocket) {
238247

239248
match req.method.as_str() {
240249
"initialize" => {
241-
let resp = handle_initialize(&connection_id, &req);
250+
let resp = handle_initialize(&req);
242251
let _ = out_tx.send(serde_json::to_string(&resp).unwrap());
243252
initialized = true;
244253
}
@@ -251,6 +260,15 @@ async fn handle_acp_connection(state: Arc<crate::AppState>, socket: WebSocket) {
251260
let resp = handle_session_new(&sessions, id.clone()).await;
252261
let _ = out_tx.send(serde_json::to_string(&resp).unwrap());
253262
}
263+
"session/resume" => {
264+
if !initialized {
265+
let resp = JsonRpcResponse::error(id, -32002, "Not initialized");
266+
let _ = out_tx.send(serde_json::to_string(&resp).unwrap());
267+
continue;
268+
}
269+
let resp = handle_session_resume(&sessions, id.clone(), req.params.as_ref()).await;
270+
let _ = out_tx.send(serde_json::to_string(&resp).unwrap());
271+
}
254272
"session/prompt" => {
255273
if !initialized {
256274
let resp = JsonRpcResponse::error(id, -32002, "Not initialized");
@@ -274,9 +292,21 @@ async fn handle_acp_connection(state: Arc<crate::AppState>, socket: WebSocket) {
274292
prompt_tasks.push(handle);
275293
}
276294
"session/cancel" => {
277-
// TODO: implement cancellation in Phase 2
278-
let resp = JsonRpcResponse::success(id, json!({}));
279-
let _ = out_tx.send(serde_json::to_string(&resp).unwrap());
295+
// Per ACP, session/cancel is a one-way NOTIFICATION — no response.
296+
// Fire the session's cancel signal; the in-flight prompt observes
297+
// it, cleans up, and returns stopReason:"cancelled" to the prompt's
298+
// own request id.
299+
let sess_key = req
300+
.params
301+
.as_ref()
302+
.and_then(|p| p.get("sessionId"))
303+
.and_then(|v| v.as_str());
304+
if let Some(k) = sess_key {
305+
let notify = sessions.lock().await.get(k).and_then(|s| s.cancel.clone());
306+
if let Some(n) = notify {
307+
n.notify_one();
308+
}
309+
}
280310
}
281311
_ => {
282312
let resp = JsonRpcResponse::error(
@@ -326,15 +356,21 @@ async fn handle_acp_connection(state: Arc<crate::AppState>, socket: WebSocket) {
326356
// Method handlers
327357
// ---------------------------------------------------------------------------
328358

329-
fn handle_initialize(connection_id: &str, _req: &JsonRpcRequest) -> JsonRpcResponse {
330-
let id = _req.id.clone().unwrap_or(Value::Null);
359+
fn handle_initialize(req: &JsonRpcRequest) -> JsonRpcResponse {
360+
let id = req.id.clone().unwrap_or(Value::Null);
361+
// ACP initialize response. protocolVersion is an integer MAJOR version.
362+
// We advertise `sessionCapabilities.resume` (we support session/resume) but
363+
// NOT `loadSession` — the gateway cannot replay conversation history to the
364+
// client (it lives inside the downstream agent CLI), so load is unsupported.
331365
JsonRpcResponse::success(
332366
id,
333367
json!({
334-
"protocolVersion": "v1",
335-
"connectionId": connection_id,
368+
"protocolVersion": ACP_PROTOCOL_VERSION,
336369
"agentCapabilities": {
337-
"streaming": true,
370+
"loadSession": false,
371+
"sessionCapabilities": {
372+
"resume": {}
373+
},
338374
"promptCapabilities": {
339375
"image": false,
340376
"audio": false,
@@ -343,8 +379,10 @@ fn handle_initialize(connection_id: &str, _req: &JsonRpcRequest) -> JsonRpcRespo
343379
},
344380
"agentInfo": {
345381
"name": "openab",
382+
"title": "OpenAB",
346383
"version": env!("CARGO_PKG_VERSION")
347-
}
384+
},
385+
"authMethods": []
348386
}),
349387
)
350388
}
@@ -353,32 +391,86 @@ async fn handle_session_new(
353391
sessions: &Arc<tokio::sync::Mutex<HashMap<String, AcpSession>>>,
354392
id: Value,
355393
) -> JsonRpcResponse {
356-
let session_id = format!("sess_{}", Uuid::new_v4());
357-
let channel_id = format!("acp_{}", Uuid::new_v4());
394+
// sessionId and channel_id share one uuid so channel_id is always
395+
// re-derivable from a persisted sessionId (see session/resume).
396+
let uuid = Uuid::new_v4();
397+
let session_id = format!("sess_{uuid}");
398+
let channel_id = format!("acp_{uuid}");
358399

359-
// Store session locally (reply channel is created lazily in session/prompt)
360400
sessions.lock().await.insert(
361401
session_id.clone(),
362402
AcpSession {
363403
channel_id,
364404
busy: false,
405+
cancel: None,
365406
},
366407
);
367408

368409
info!(session = %session_id, "ACP session created");
369410

370-
JsonRpcResponse::success(
371-
id,
372-
json!({
373-
"sessionId": session_id,
374-
"models": {
375-
"current": "openab",
376-
"available": [
377-
{"id": "openab", "name": "OpenAB Default Agent"}
378-
]
379-
}
380-
}),
381-
)
411+
// ACP session/new response is just { sessionId }.
412+
JsonRpcResponse::success(id, json!({ "sessionId": session_id }))
413+
}
414+
415+
/// `session/resume` — re-attach to a session the client persisted, WITHOUT
416+
/// replaying history (per ACP: the agent MUST NOT replay via session/update).
417+
///
418+
/// The client re-presents its `sessionId`; we derive the same deterministic
419+
/// `channel_id`, so the next prompt's GatewayEvent maps to the same core
420+
/// `session_key` (`acp:<channel_id>`) and the existing conversation continues.
421+
/// The core recovers the underlying agent session via its own persisted mapping
422+
/// plus a downstream `session/load` (survives process restart within the agent's
423+
/// retention / `session_ttl_hours`). Whether that succeeds is not observable
424+
/// here — an expired session simply starts fresh, and the core prefixes its
425+
/// first reply with a "Session expired" notice the client can surface.
426+
///
427+
/// Security: `sessionId` is a server-minted, high-entropy capability;
428+
/// `derive_channel_id` requires a well-formed `sess_<uuid>`, keeping the channel
429+
/// inside the `acp_` namespace and rejecting forged ids.
430+
async fn handle_session_resume(
431+
sessions: &Arc<tokio::sync::Mutex<HashMap<String, AcpSession>>>,
432+
id: Value,
433+
params: Option<&Value>,
434+
) -> JsonRpcResponse {
435+
let session_id = match params.and_then(|p| p.get("sessionId")).and_then(|v| v.as_str()) {
436+
Some(s) => s.to_string(),
437+
None => return JsonRpcResponse::error(id, -32602, "Missing sessionId"),
438+
};
439+
440+
let channel_id = match derive_channel_id(&session_id) {
441+
Some(cid) => cid,
442+
None => {
443+
return JsonRpcResponse::error(
444+
id,
445+
-32602,
446+
"Invalid sessionId: expected the form sess_<uuid>",
447+
);
448+
}
449+
};
450+
451+
sessions.lock().await.insert(
452+
session_id.clone(),
453+
AcpSession {
454+
channel_id,
455+
busy: false,
456+
cancel: None,
457+
},
458+
);
459+
460+
info!(session = %session_id, "ACP session resumed");
461+
462+
// ACP session/resume response is an empty object (no history replay).
463+
JsonRpcResponse::success(id, json!({}))
464+
}
465+
466+
/// Derive the deterministic `channel_id` (`acp_<uuid>`) from a client-supplied
467+
/// `sessionId` (`sess_<uuid>`). Returns `None` if malformed — the uuid must
468+
/// parse, which keeps a resumed channel inside the `acp_` namespace and rejects
469+
/// forged ids.
470+
fn derive_channel_id(session_id: &str) -> Option<String> {
471+
let uuid = session_id.strip_prefix("sess_")?;
472+
Uuid::parse_str(uuid).ok()?;
473+
Some(format!("acp_{uuid}"))
382474
}
383475

384476
async fn handle_session_prompt(
@@ -398,6 +490,10 @@ async fn handle_session_prompt(
398490
}
399491
};
400492

493+
// Cancel signal for this prompt; `session/cancel` fires it to stop the
494+
// stream gracefully.
495+
let cancel = Arc::new(tokio::sync::Notify::new());
496+
401497
// Look up session and acquire busy lock
402498
let channel_id = {
403499
let mut guard = sessions.lock().await;
@@ -414,6 +510,7 @@ async fn handle_session_prompt(
414510
return;
415511
}
416512
s.busy = true;
513+
s.cancel = Some(cancel.clone());
417514
s.channel_id.clone()
418515
}
419516
None => {
@@ -492,62 +589,73 @@ async fn handle_session_prompt(
492589

493590
info!(session = %session_id, channel = %channel_id, "ACP: prompt dispatched");
494591

495-
// Stream replies back as ACP notifications
592+
// Stream replies back as ACP `session/update` notifications.
496593
let mut sent_len = 0usize;
497594
let timeout = tokio::time::Duration::from_secs(180);
595+
let mut stop_reason = "end_turn";
596+
let mut timed_out = false;
498597

499598
loop {
500-
match tokio::time::timeout(timeout, reply_rx.recv()).await {
501-
Ok(Some(ReplyChunk::Text(full_text))) => {
502-
// Send delta as AgentMessageChunk notification
503-
let delta = if full_text.len() > sent_len {
504-
&full_text[sent_len..]
505-
} else {
506-
continue;
507-
};
508-
sent_len = full_text.len();
509-
510-
let notification = JsonRpcNotification {
511-
jsonrpc: "2.0",
512-
method: "session/notification".into(),
513-
params: json!({
514-
"sessionId": session_id,
515-
"update": {
516-
"sessionUpdate": "agentMessageChunk",
517-
"chunk": {
518-
"content": {"type": "text", "text": delta}
519-
}
520-
}
521-
}),
522-
};
523-
let _ = out_tx.send(serde_json::to_string(&notification).unwrap());
524-
}
525-
Ok(Some(ReplyChunk::Done)) | Ok(None) => {
599+
tokio::select! {
600+
// session/cancel fired — stop gracefully.
601+
_ = cancel.notified() => {
602+
stop_reason = "cancelled";
526603
break;
527604
}
528-
Err(_) => {
529-
warn!(session = %session_id, "ACP: prompt timed out waiting for reply");
530-
break;
605+
recv = tokio::time::timeout(timeout, reply_rx.recv()) => {
606+
match recv {
607+
Ok(Some(ReplyChunk::Text(full_text))) => {
608+
// Emit new text as an `agent_message_chunk` update.
609+
// Slice via `get` (not byte-index `[..]`) so a `sent_len`
610+
// that lands mid-codepoint — possible with CJK / 顏文字 /
611+
// emoji if a snapshot is ever non-append — skips this frame
612+
// instead of panicking; the next snapshot re-covers it.
613+
let delta = match full_text.get(sent_len..) {
614+
Some(d) if !d.is_empty() => d,
615+
_ => continue,
616+
};
617+
sent_len = full_text.len();
618+
619+
let notification = JsonRpcNotification {
620+
jsonrpc: "2.0",
621+
method: "session/update".into(),
622+
params: json!({
623+
"sessionId": session_id,
624+
"update": {
625+
"sessionUpdate": "agent_message_chunk",
626+
"content": {"type": "text", "text": delta}
627+
}
628+
}),
629+
};
630+
let _ = out_tx.send(serde_json::to_string(&notification).unwrap());
631+
}
632+
Ok(Some(ReplyChunk::Done)) | Ok(None) => break,
633+
Err(_) => {
634+
warn!(session = %session_id, "ACP: prompt timed out waiting for reply");
635+
timed_out = true;
636+
break;
637+
}
638+
}
531639
}
532640
}
533641
}
534642

535-
// Cleanup: remove from registry and release busy flag
643+
// Cleanup: remove from registry, release busy flag, clear cancel signal.
536644
if let Some(ref registry) = state.acp_reply_registry {
537645
registry.lock().unwrap_or_else(|e| e.into_inner()).remove(&channel_id);
538646
}
539647
if let Some(s) = sessions.lock().await.get_mut(&session_id) {
540648
s.busy = false;
649+
s.cancel = None;
541650
}
542651

543-
// Send the final response
544-
let resp = JsonRpcResponse::success(
545-
id,
546-
json!({
547-
"sessionId": session_id,
548-
"stopReason": "endTurn"
549-
}),
550-
);
652+
// Final response. A backend timeout has no ACP stopReason, so it is an error;
653+
// otherwise return the turn's PromptResponse { stopReason }.
654+
let resp = if timed_out {
655+
JsonRpcResponse::error(id, -32603, "Timed out waiting for agent backend")
656+
} else {
657+
JsonRpcResponse::success(id, json!({ "stopReason": stop_reason }))
658+
};
551659
let _ = out_tx.send(serde_json::to_string(&resp).unwrap());
552660
}
553661

0 commit comments

Comments
 (0)