use anyhow::Result;
use async_trait::async_trait;
use greentic_types::{ReplyScope, TenantCtx};
use serde_json::Value;
use std::sync::Arc;
use super::dispatch_listener::SessionResumer;
use crate::engine::runtime::{IngressEnvelope, StateMachineRuntime};
const PACK_HINT_MARKER: &str = "::pack=";
const FLOW_HINT_MARKER: &str = "::flow=";
const THREAD_HINT_MARKER: &str = "::thread=";
const REPLY_HINT_MARKER: &str = "::reply=";
pub struct RuntimeSessionResumer {
runtime: Arc<StateMachineRuntime>,
}
impl RuntimeSessionResumer {
pub fn new(runtime: Arc<StateMachineRuntime>) -> Self {
Self { runtime }
}
pub(crate) fn build_resume_envelope(
tenant: &TenantCtx,
correlation_id: &str,
output: Value,
) -> IngressEnvelope {
let (without_reply, reply_to) = split_marker(correlation_id, REPLY_HINT_MARKER);
let (without_thread, thread) = split_marker(&without_reply, THREAD_HINT_MARKER);
let (without_flow, flow_marker) = split_marker(&without_thread, FLOW_HINT_MARKER);
let (bare_hint, pack_id) = split_pack_suffix(&without_flow);
let parts: Vec<&str> = bare_hint.splitn(5, ':').collect();
let provider = parts.get(1).map(|segment| segment.to_string());
let channel = parts.get(2).map(|segment| segment.to_string());
let conversation = parts.get(3).map(|segment| segment.to_string());
let user = parts.get(4).map(|segment| segment.to_string());
let flow_id = flow_marker
.or_else(|| pack_id.clone())
.unwrap_or_else(|| "resume.flow".to_string());
IngressEnvelope {
tenant: tenant.tenant_id.to_string(),
env: Some(tenant.env.to_string()),
pack_id,
flow_id,
flow_type: None,
action: None,
session_hint: Some(bare_hint.clone()),
provider,
channel,
conversation: conversation.clone(),
user,
activity_id: None,
timestamp: None,
payload: output,
metadata: None,
reply_scope: conversation.map(|conversation| ReplyScope {
conversation,
thread,
reply_to,
correlation: None,
}),
}
}
}
#[async_trait]
impl SessionResumer for RuntimeSessionResumer {
async fn resume(&self, tenant: TenantCtx, correlation_id: &str, output: Value) -> Result<()> {
let envelope = Self::build_resume_envelope(&tenant, correlation_id, output);
self.runtime.handle(envelope).await.map(|_| ())
}
}
fn split_pack_suffix(hint: &str) -> (String, Option<String>) {
split_marker(hint, PACK_HINT_MARKER)
}
fn split_marker(value: &str, marker: &str) -> (String, Option<String>) {
match value.rsplit_once(marker) {
Some((prefix, captured)) if !captured.is_empty() => {
(prefix.to_string(), Some(captured.to_string()))
}
_ => (value.to_string(), None),
}
}
#[cfg(test)]
mod tests {
use super::*;
use greentic_types::{EnvId, TenantId};
use serde_json::json;
use std::str::FromStr;
fn tenant_ctx() -> TenantCtx {
TenantCtx::new(
EnvId::from_str("local").unwrap(),
TenantId::from_str("demo").unwrap(),
)
}
#[test]
fn build_resume_envelope_sets_session_hint_to_correlation_id() {
let envelope = RuntimeSessionResumer::build_resume_envelope(
&tenant_ctx(),
"demo:provider:chan:conv:user",
json!({ "ok": true }),
);
assert_eq!(
envelope.session_hint.as_deref(),
Some("demo:provider:chan:conv:user")
);
assert_eq!(envelope.payload, json!({ "ok": true }));
assert_eq!(envelope.tenant, "demo");
assert_eq!(envelope.env.as_deref(), Some("local"));
}
#[test]
fn build_resume_envelope_parses_conversation_into_reply_scope() {
let envelope = RuntimeSessionResumer::build_resume_envelope(
&tenant_ctx(),
"demo:provider:chan:conv:user",
json!(null),
);
let scope = envelope.reply_scope.expect("reply scope from conversation");
assert_eq!(scope.conversation, "conv");
assert!(scope.thread.is_none());
assert!(scope.correlation.is_none());
assert_eq!(envelope.conversation.as_deref(), Some("conv"));
assert_eq!(envelope.provider.as_deref(), Some("provider"));
assert_eq!(envelope.channel.as_deref(), Some("chan"));
assert_eq!(envelope.user.as_deref(), Some("user"));
}
#[test]
fn build_resume_envelope_recovers_pack_id_from_suffix() {
let envelope = RuntimeSessionResumer::build_resume_envelope(
&tenant_ctx(),
"demo:provider:chan:conv:user::pack=greentic.demo",
json!(null),
);
assert_eq!(envelope.pack_id.as_deref(), Some("greentic.demo"));
assert_eq!(
envelope.session_hint.as_deref(),
Some("demo:provider:chan:conv:user")
);
assert_eq!(envelope.conversation.as_deref(), Some("conv"));
}
#[test]
fn build_resume_envelope_recovers_pack_and_flow_for_routing() {
let envelope = RuntimeSessionResumer::build_resume_envelope(
&tenant_ctx(),
"demo:provider:chan:conv:user::pack=greentic.demo::flow=wait.flow",
json!(null),
);
assert_eq!(envelope.flow_id, "wait.flow");
assert_eq!(envelope.pack_id.as_deref(), Some("greentic.demo"));
assert_eq!(
envelope.session_hint.as_deref(),
Some("demo:provider:chan:conv:user")
);
assert_eq!(envelope.conversation.as_deref(), Some("conv"));
}
#[test]
fn build_resume_envelope_recovers_thread_and_reply_into_scope() {
let envelope = RuntimeSessionResumer::build_resume_envelope(
&tenant_ctx(),
"demo:provider:chan:conv:user::pack=greentic.demo::flow=wait.flow::thread=topic-7::reply=msg-42",
json!(null),
);
assert_eq!(envelope.flow_id, "wait.flow");
assert_eq!(envelope.pack_id.as_deref(), Some("greentic.demo"));
assert_eq!(
envelope.session_hint.as_deref(),
Some("demo:provider:chan:conv:user")
);
let scope = envelope.reply_scope.expect("reply scope with thread/reply");
assert_eq!(scope.conversation, "conv");
assert_eq!(scope.thread.as_deref(), Some("topic-7"));
assert_eq!(scope.reply_to.as_deref(), Some("msg-42"));
}
#[test]
fn build_resume_envelope_recovers_thread_without_reply() {
let envelope = RuntimeSessionResumer::build_resume_envelope(
&tenant_ctx(),
"demo:provider:chan:conv:user::pack=greentic.demo::flow=wait.flow::thread=topic-7",
json!(null),
);
let scope = envelope.reply_scope.expect("reply scope with thread");
assert_eq!(scope.thread.as_deref(), Some("topic-7"));
assert!(scope.reply_to.is_none());
assert_eq!(
envelope.session_hint.as_deref(),
Some("demo:provider:chan:conv:user")
);
}
#[test]
fn build_resume_envelope_without_markers_leaves_thread_and_reply_none() {
let envelope = RuntimeSessionResumer::build_resume_envelope(
&tenant_ctx(),
"demo:provider:chan:conv:user::pack=greentic.demo::flow=wait.flow",
json!(null),
);
let scope = envelope.reply_scope.expect("reply scope from conversation");
assert!(scope.thread.is_none());
assert!(scope.reply_to.is_none());
}
#[test]
fn split_pack_suffix_handles_missing_and_empty_marker() {
assert_eq!(
split_pack_suffix("a:b:c:d:e"),
("a:b:c:d:e".to_string(), None)
);
assert_eq!(
split_pack_suffix("a:b:c:d:e::pack=p"),
("a:b:c:d:e".to_string(), Some("p".to_string()))
);
assert_eq!(
split_pack_suffix("a:b:c:d:e::pack="),
("a:b:c:d:e::pack=".to_string(), None)
);
}
}