@@ -26,6 +26,10 @@ use tokio::sync::mpsc;
2626use tracing:: { debug, info, warn} ;
2727use 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
6574pub 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
384476async 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