Skip to main content

greentic_runner_host/http/
agent_chat.rs

1//! `POST /agent/chat` — a loopback HTTP ingress that wraps `RunnerHost::handle_activity`
2//! so an external caller (the designer's runner sidecar) can send a chat turn to a
3//! loaded agentic-worker pack and receive the reply. Blocking JSON response (v1).
4
5use serde::{Deserialize, Serialize};
6
7use crate::activity::Activity;
8
9/// One chat turn for a loaded worker pack.
10#[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/// One outbound reply line.
25#[derive(Debug, Serialize)]
26#[serde(rename_all = "camelCase")]
27pub struct ReplyView {
28    pub text: String,
29}
30
31/// The worker's reply turn.
32#[derive(Debug, Serialize)]
33#[serde(rename_all = "camelCase")]
34pub struct AgentChatResponse {
35    pub replies: Vec<ReplyView>,
36}
37
38/// Extract a human-readable reply line from an outbound activity.
39///
40/// Priority order:
41/// 1. `payload["text"]` as a string
42/// 2. `payload["messages"][0]["text"]` as a string
43/// 3. Compact JSON rendering of the whole payload
44fn 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
60/// Map the runtime's outbound activities into the chat response, dropping empties.
61pub 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
79/// Default conversation/user identifiers so a caller that omits them still
80/// threads a single in-memory conversation across turns.
81const DEFAULT_CONVERSATION: &str = "test-chat";
82const DEFAULT_USER: &str = "test-chat-user";
83
84/// Extracted core logic — separated so tests can exercise tenant resolution
85/// and error mapping without needing the full axum extractor stack.
86async 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            // handle_activity returns "tenant <name> not loaded" when the tenant
112            // isn't present in ActivePacks. Surface that as 404.
113            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
123/// `POST /agent/chat` — loopback-only (AdminGuard). Sends one chat turn to
124/// the loaded worker pack and returns its reply.
125pub 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        // Build an outbound-style activity carrying `payload`. Use the same
144        // constructor the runner uses for replies (Activity::from_output) —
145        // read activity.rs and match it; here we assert on the mapping only.
146        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    /// Route wiring smoke-test: `execute_chat` against a host with no loaded
186    /// packs returns 404 with `error = "tenant_not_loaded"`, proving that
187    /// `handle_activity`'s "not loaded" error is correctly mapped by the handler
188    /// core.
189    #[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}