Skip to content

Commit 5297ec8

Browse files
feat(acp): AgentsMesh _loopal/* control-panel extension (#195)
Backend support for the AgentsMesh Loopal graphical control console over ACP. - translate/panel.rs: bgTask/cron/task/mcp/topology/goal/model/thinking → _loopal/* - adapter/control_command.rs + control.rs: session/control_request dispatch (18 loopal.* controls + set_model); unknown subtype → METHOD_NOT_FOUND - adapter/snapshot.rs: cold-start replay of view/snapshot as _loopal/* events - adapter/lifecycle.rs: advertise agentsmeshExtensions.controlRequest on initialize - adapter/events.rs: mode dual-emit (standard ACP CurrentModeUpdate + _loopal/mode) - translate/mod.rs: route non-ACP events through the panel translator - tests: translate golden-fixture contract + control round-trip e2e + unit
1 parent 1b479c3 commit 5297ec8

15 files changed

Lines changed: 781 additions & 59 deletions

File tree

crates/loopal-acp/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ rust_test(
2929
rust_test(
3030
name = "loopal-acp_test",
3131
srcs = glob(["tests/**/*.rs"]),
32+
compile_data = glob(["tests/fixtures/*.json"]),
3233
crate_root = "tests/suite.rs",
3334
edition = "2024",
3435
local = True,

crates/loopal-acp/src/adapter/control.rs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ use loopal_protocol::ControlCommand;
44
use serde_json::Value;
55

66
use crate::adapter::AcpAdapter;
7+
use crate::adapter::control_command::parse_loopal_control;
78
use crate::jsonrpc;
89

910
impl AcpAdapter {
@@ -77,4 +78,31 @@ impl AcpAdapter {
7778
}
7879
}
7980
}
81+
82+
/// Handle `session/control_request` — AgentsMesh ACP extension carrying
83+
/// Loopal control-panel actions (bgTaskKill / cronDelete / compact / clear)
84+
/// and the runner's `set_model`. Unknown subtypes are rejected so the
85+
/// runner degrades gracefully.
86+
pub(crate) async fn handle_control_request(&self, id: i64, params: Value) {
87+
let subtype = params["subtype"].as_str().unwrap_or("");
88+
match parse_loopal_control(subtype, &params["params"]) {
89+
Some(c) => match self.client.send_control(&c).await {
90+
Ok(_) => self.acp_out.respond(id, serde_json::json!({})).await,
91+
Err(e) => {
92+
self.acp_out
93+
.respond_error(id, jsonrpc::INTERNAL_ERROR, &e.to_string())
94+
.await
95+
}
96+
},
97+
None => {
98+
self.acp_out
99+
.respond_error(
100+
id,
101+
jsonrpc::METHOD_NOT_FOUND,
102+
&format!("unsupported control subtype: {subtype}"),
103+
)
104+
.await
105+
}
106+
}
107+
}
80108
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
//! Pure `subtype` → `ControlCommand` mapping for `session/control_request`.
2+
//! Kept separate from the IO-bound handler so the full dispatch table is
3+
//! unit-testable and `handle_control_request` stays thin. Every Loopal
4+
//! ControlCommand the runtime implements (`input_control.rs`) is reachable here.
5+
6+
use loopal_protocol::{AgentMode, ControlCommand};
7+
use serde_json::Value;
8+
9+
pub(crate) fn parse_loopal_control(subtype: &str, p: &Value) -> Option<ControlCommand> {
10+
use ControlCommand as C;
11+
match subtype {
12+
"loopal.bgTaskKill" => p["id"]
13+
.as_str()
14+
.map(|s| C::BgTaskKill { id: s.to_string() }),
15+
"loopal.cronDelete" => p["id"]
16+
.as_str()
17+
.map(|s| C::CronDelete { id: s.to_string() }),
18+
"loopal.compact" => Some(C::Compact {
19+
instructions: p["instructions"].as_str().map(String::from),
20+
}),
21+
"loopal.clear" => Some(C::Clear),
22+
"loopal.suspend" => Some(C::Suspend),
23+
"loopal.unsuspend" => Some(C::Unsuspend),
24+
"loopal.mode" => match p["mode"].as_str()? {
25+
"plan" => Some(C::ModeSwitch(AgentMode::Plan)),
26+
"act" => Some(C::ModeSwitch(AgentMode::Act)),
27+
_ => None,
28+
},
29+
"loopal.mcpStatus" => Some(C::QueryMcpStatus),
30+
"loopal.mcpReconnect" => p["server"].as_str().map(|s| C::McpReconnect {
31+
server: s.to_string(),
32+
}),
33+
"loopal.mcpDisconnect" => p["server"].as_str().map(|s| C::McpDisconnect {
34+
server: s.to_string(),
35+
}),
36+
"loopal.rewind" => p["turn_index"].as_u64().map(|n| C::Rewind {
37+
turn_index: n as usize,
38+
}),
39+
"loopal.resumeSession" => p["session_id"]
40+
.as_str()
41+
.map(|s| C::ResumeSession(s.to_string())),
42+
"loopal.thinking" => p["config"]
43+
.as_str()
44+
.map(|s| C::ThinkingSwitch(s.to_string())),
45+
"loopal.goalCreate" => p["objective"].as_str().map(|s| C::GoalCreate {
46+
objective: s.to_string(),
47+
}),
48+
"loopal.goalPause" => Some(C::GoalUserPause),
49+
"loopal.goalResume" => Some(C::GoalUserResume),
50+
"loopal.goalComplete" => Some(C::GoalUserComplete),
51+
"loopal.goalReopen" => Some(C::GoalUserReopen),
52+
"loopal.goalClear" => Some(C::GoalClear),
53+
"set_model" => p["model"].as_str().map(|s| C::ModelSwitch(s.to_string())),
54+
_ => None,
55+
}
56+
}
57+
58+
#[cfg(test)]
59+
mod tests {
60+
use super::*;
61+
use serde_json::json;
62+
63+
#[test]
64+
fn maps_object_and_session_controls() {
65+
assert!(matches!(
66+
parse_loopal_control("loopal.bgTaskKill", &json!({"id":"bg1"})),
67+
Some(ControlCommand::BgTaskKill { id }) if id == "bg1"
68+
));
69+
assert!(matches!(
70+
parse_loopal_control("loopal.compact", &json!({})),
71+
Some(ControlCommand::Compact { instructions: None })
72+
));
73+
assert!(matches!(
74+
parse_loopal_control("loopal.clear", &json!({})),
75+
Some(ControlCommand::Clear)
76+
));
77+
assert!(matches!(
78+
parse_loopal_control("loopal.mode", &json!({"mode":"plan"})),
79+
Some(ControlCommand::ModeSwitch(AgentMode::Plan))
80+
));
81+
}
82+
83+
#[test]
84+
fn maps_mcp_suspend_goal() {
85+
assert!(matches!(
86+
parse_loopal_control("loopal.mcpReconnect", &json!({"server":"fs"})),
87+
Some(ControlCommand::McpReconnect { server }) if server == "fs"
88+
));
89+
assert!(matches!(
90+
parse_loopal_control("loopal.suspend", &json!({})),
91+
Some(ControlCommand::Suspend)
92+
));
93+
assert!(matches!(
94+
parse_loopal_control("loopal.goalCreate", &json!({"objective":"ship"})),
95+
Some(ControlCommand::GoalCreate { objective }) if objective == "ship"
96+
));
97+
assert!(matches!(
98+
parse_loopal_control("loopal.goalClear", &json!({})),
99+
Some(ControlCommand::GoalClear)
100+
));
101+
}
102+
103+
#[test]
104+
fn rejects_unknown_and_missing_fields() {
105+
assert!(parse_loopal_control("loopal.bogus", &json!({})).is_none());
106+
assert!(parse_loopal_control("loopal.bgTaskKill", &json!({})).is_none());
107+
assert!(parse_loopal_control("loopal.mode", &json!({"mode":"bogus"})).is_none());
108+
}
109+
}

crates/loopal-acp/src/adapter/events.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,19 @@ impl AcpAdapter {
7575
}
7676
}
7777
}
78+
// Mode dual-emit: translate_event already sent the standard ACP
79+
// CurrentModeUpdate (for generic clients). Additionally surface
80+
// `_loopal/mode` so the Loopal console status bar reads mode from the
81+
// same loopalSession mirror as thinking/model — AgentsMesh does not
82+
// consume the ACP session-mode channel.
83+
if let AgentEventPayload::ModeChanged { mode } = &event.payload {
84+
let (method, params) = crate::translate::ext::ext_notification(
85+
session_id,
86+
"mode",
87+
serde_json::json!({ "mode": mode }),
88+
);
89+
self.acp_out.notify(&method, params).await;
90+
}
7891
None
7992
}
8093

crates/loopal-acp/src/adapter/lifecycle.rs

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,19 @@ use crate::adapter::AcpAdapter;
77
use crate::types::make_init_response;
88

99
impl AcpAdapter {
10-
/// Handle `initialize` — return agent capabilities and info.
10+
/// Handle `initialize` — return agent capabilities and info. Advertises
11+
/// the AgentsMesh `controlRequest` extension so the runner routes Loopal
12+
/// control-panel actions (bg-task kill / cron delete) via
13+
/// `session/control_request`.
1114
pub(crate) async fn handle_initialize(&self, id: i64, _params: Value) {
12-
let result = make_init_response();
13-
self.acp_out
14-
.respond(id, serde_json::to_value(result).unwrap_or_default())
15-
.await;
15+
let mut result = serde_json::to_value(make_init_response()).unwrap_or_default();
16+
if let Some(obj) = result.as_object_mut() {
17+
obj.insert(
18+
"agentsmeshExtensions".into(),
19+
serde_json::json!({ "controlRequest": true }),
20+
);
21+
}
22+
self.acp_out.respond(id, result).await;
1623
info!("ACP initialized");
1724
}
1825

crates/loopal-acp/src/adapter/mod.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
//! ACP adapter — bridges ACP (session/*) with Hub via UiSession.
22
33
mod control;
4+
mod control_command;
45
mod events;
56
mod lifecycle;
67
mod permission;
78
mod prompt;
89
mod session;
10+
mod snapshot;
911

1012
use std::sync::Arc;
1113

@@ -74,6 +76,7 @@ impl AcpAdapter {
7476
"session/close" => self.handle_close(id, params).await,
7577
"session/set_mode" => self.handle_set_mode(id, params).await,
7678
"session/set_config_option" => self.handle_set_config_option(id, params).await,
79+
"session/control_request" => self.handle_control_request(id, params).await,
7780
"session/load" => {
7881
self.acp_out
7982
.respond_error(

crates/loopal-acp/src/adapter/session.rs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,21 @@ use crate::jsonrpc;
88
use crate::types::make_new_session_response;
99

1010
impl AcpAdapter {
11-
/// Handle `session/new` — assign session ID, drain bootstrap events.
11+
/// Handle `session/new` — assign session ID, drain bootstrap events, then
12+
/// replay the agent's current view/snapshot as `_loopal/*` events so the
13+
/// control panel cold-starts with pre-existing cron / task / bg-shell state.
1214
pub(crate) async fn handle_new_session(&self, id: i64, _params: Value) {
1315
let sid = uuid::Uuid::new_v4().to_string();
1416
*self.session_id.lock().await = Some(sid.clone());
1517
self.acp_out
1618
.respond(
1719
id,
18-
serde_json::to_value(make_new_session_response(sid)).unwrap_or_default(),
20+
serde_json::to_value(make_new_session_response(sid.clone())).unwrap_or_default(),
1921
)
2022
.await;
2123
info!("ACP session created");
2224
self.drain_bootstrap_events().await;
25+
self.replay_loopal_snapshot(&sid).await;
2326
}
2427

2528
/// Handle `session/list` → HubClient.list_agents().

0 commit comments

Comments
 (0)