greentic_runner_host/http/
agent_chat.rs1use serde::{Deserialize, Serialize};
6
7use crate::activity::Activity;
8
9#[derive(Debug, Deserialize)]
11#[serde(rename_all = "camelCase")]
12pub struct AgentChatRequest {
13 pub text: String,
14 #[serde(default)]
15 pub tenant: Option<String>,
16 #[serde(default)]
17 pub conversation_id: Option<String>,
18 #[serde(default)]
19 pub user_id: Option<String>,
20 #[serde(default)]
21 pub flow_id: Option<String>,
22}
23
24#[derive(Debug, Serialize)]
26#[serde(rename_all = "camelCase")]
27pub struct ReplyView {
28 pub text: String,
29}
30
31#[derive(Debug, Serialize)]
33#[serde(rename_all = "camelCase")]
34pub struct AgentChatResponse {
35 pub replies: Vec<ReplyView>,
36}
37
38fn reply_text(activity: &Activity) -> String {
45 let payload = activity.payload();
46 if let Some(t) = payload.get("text").and_then(|v| v.as_str()) {
47 return t.to_string();
48 }
49 if let Some(t) = payload
50 .get("messages")
51 .and_then(|m| m.get(0))
52 .and_then(|m0| m0.get("text"))
53 .and_then(|v| v.as_str())
54 {
55 return t.to_string();
56 }
57 serde_json::to_string(payload).unwrap_or_default()
58}
59
60pub fn replies_to_response(activities: Vec<Activity>) -> AgentChatResponse {
62 let replies = activities
63 .iter()
64 .map(reply_text)
65 .filter(|t| !t.trim().is_empty())
66 .map(|text| ReplyView { text })
67 .collect();
68 AgentChatResponse { replies }
69}
70
71use axum::http::StatusCode;
72use axum::response::IntoResponse;
73use axum::{Json, extract::State};
74
75use crate::host::RunnerHost;
76use crate::http::auth::AdminGuard;
77use crate::runner::ServerState;
78
79const DEFAULT_CONVERSATION: &str = "test-chat";
82const DEFAULT_USER: &str = "test-chat-user";
83
84async fn execute_chat(
87 host: &RunnerHost,
88 default_tenant: &str,
89 req: AgentChatRequest,
90) -> Result<AgentChatResponse, (StatusCode, serde_json::Value)> {
91 let tenant = req
92 .tenant
93 .as_deref()
94 .map(str::to_string)
95 .unwrap_or_else(|| default_tenant.to_string());
96
97 let mut activity = Activity::text(req.text)
98 .in_conversation(
99 req.conversation_id
100 .unwrap_or_else(|| DEFAULT_CONVERSATION.to_string()),
101 )
102 .from_user(req.user_id.unwrap_or_else(|| DEFAULT_USER.to_string()));
103 if let Some(flow) = req.flow_id {
104 activity = activity.with_flow(flow);
105 }
106
107 match host.handle_activity(&tenant, activity).await {
108 Ok(activities) => Ok(replies_to_response(activities)),
109 Err(e) => {
110 let msg = format!("{e:#}");
111 let (code, error) = if msg.contains("not loaded") {
114 (StatusCode::NOT_FOUND, "tenant_not_loaded")
115 } else {
116 (StatusCode::INTERNAL_SERVER_ERROR, "agent_chat_failed")
117 };
118 Err((code, serde_json::json!({ "error": error, "message": msg })))
119 }
120 }
121}
122
123pub async fn agent_chat(
126 _guard: AdminGuard,
127 State(state): State<ServerState>,
128 Json(req): Json<AgentChatRequest>,
129) -> impl IntoResponse {
130 match execute_chat(&state.host, state.routing.default_tenant(), req).await {
131 Ok(response) => (StatusCode::OK, Json(response)).into_response(),
132 Err((code, body)) => (code, Json(body)).into_response(),
133 }
134}
135
136#[cfg(test)]
137mod tests {
138 use super::*;
139 use crate::activity::Activity;
140 use serde_json::json;
141
142 fn reply_with(payload: serde_json::Value) -> Activity {
143 Activity::from_output(payload, "demo")
147 }
148
149 #[test]
150 fn maps_text_payload_to_reply() {
151 let out = replies_to_response(vec![reply_with(json!({"text": "hello there"}))]);
152 assert_eq!(out.replies.len(), 1);
153 assert_eq!(out.replies[0].text, "hello there");
154 }
155
156 #[test]
157 fn maps_nested_messages_text() {
158 let out = replies_to_response(vec![reply_with(
159 json!({"messages": [{"text": "nested hi"}]}),
160 )]);
161 assert_eq!(out.replies[0].text, "nested hi");
162 }
163
164 #[test]
165 fn skips_empty_and_keeps_order() {
166 let out = replies_to_response(vec![
167 reply_with(json!({"text": ""})),
168 reply_with(json!({"text": "second"})),
169 ]);
170 assert_eq!(out.replies.len(), 1);
171 assert_eq!(out.replies[0].text, "second");
172 }
173
174 #[test]
175 fn request_deserializes_camel_case() {
176 let r: AgentChatRequest = serde_json::from_value(json!({
177 "text": "hi", "conversationId": "c1", "userId": "u1"
178 }))
179 .unwrap();
180 assert_eq!(r.text, "hi");
181 assert_eq!(r.conversation_id.as_deref(), Some("c1"));
182 assert_eq!(r.user_id.as_deref(), Some("u1"));
183 }
184
185 #[tokio::test]
190 async fn agent_chat_unknown_tenant_maps_to_not_found() {
191 let host = crate::host::RunnerHost::for_test();
192 let req = AgentChatRequest {
193 text: "hello".into(),
194 tenant: Some("nope".into()),
195 conversation_id: None,
196 user_id: None,
197 flow_id: None,
198 };
199 let err = execute_chat(&host, "test", req)
200 .await
201 .expect_err("unknown tenant should fail");
202 assert_eq!(err.0, StatusCode::NOT_FOUND);
203 assert_eq!(err.1["error"], "tenant_not_loaded");
204 }
205}