use std::fmt;
use std::future::Future;
use std::path::PathBuf;
use std::pin::Pin;
use std::sync::{Arc, Mutex};
use tokio::sync::mpsc;
use crate::domain::agent::ReasoningLevel;
use crate::domain::composer;
use crate::infra::agent::AgentResponse;
pub type AgentFuture<T> = Pin<Box<dyn Future<Output = T> + Send>>;
#[derive(Debug, Clone, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
pub struct TurnPromptAttachment {
pub placeholder: String,
pub local_image_path: PathBuf,
}
#[derive(Debug, Clone, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
pub struct TurnPrompt {
pub attachments: Vec<TurnPromptAttachment>,
pub text: String,
#[serde(default)]
pub text_source: TurnPromptTextSource,
}
#[derive(Debug, Clone, Copy, Default, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
pub enum TurnPromptTextSource {
#[default]
UserPrompt,
AgentData,
}
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub(crate) enum TurnPromptContentPart<'prompt> {
Attachment(&'prompt TurnPromptAttachment),
OrphanAttachment(&'prompt TurnPromptAttachment),
Text(&'prompt str),
}
impl TurnPrompt {
#[must_use]
pub fn from_text(text: String) -> Self {
Self {
attachments: Vec::new(),
text,
text_source: TurnPromptTextSource::UserPrompt,
}
}
#[must_use]
pub fn from_agent_data(text: String) -> Self {
Self {
attachments: Vec::new(),
text,
text_source: TurnPromptTextSource::AgentData,
}
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.text.is_empty() && self.attachments.is_empty()
}
#[must_use]
pub fn has_attachments(&self) -> bool {
!self.attachments.is_empty()
}
pub fn local_image_paths(&self) -> impl Iterator<Item = &PathBuf> {
self.attachments
.iter()
.map(|attachment| &attachment.local_image_path)
}
#[must_use]
pub fn contains(&self, needle: &str) -> bool {
self.text.contains(needle)
}
#[must_use]
pub fn ends_with(&self, suffix: &str) -> bool {
self.text.ends_with(suffix)
}
#[must_use]
pub fn agent_text(&self) -> String {
match self.text_source {
TurnPromptTextSource::UserPrompt => composer::render_prompt_text_for_agent(&self.text),
TurnPromptTextSource::AgentData => self.text.clone(),
}
}
#[must_use]
pub(crate) fn content_parts(&self) -> Vec<TurnPromptContentPart<'_>> {
split_turn_prompt_content(&self.text, &self.attachments)
}
#[must_use]
pub fn transcript_text(&self) -> String {
let mut transcript_text = self.text.clone();
let missing_placeholders = self
.attachments
.iter()
.filter(|attachment| !self.text.contains(&attachment.placeholder))
.map(|attachment| attachment.placeholder.as_str())
.collect::<Vec<_>>();
if missing_placeholders.is_empty() {
return transcript_text;
}
if transcript_text
.chars()
.last()
.is_some_and(|character| !character.is_whitespace())
{
transcript_text.push(' ');
}
transcript_text.push_str(&missing_placeholders.join(" "));
transcript_text
}
}
#[must_use]
pub(crate) fn split_turn_prompt_content<'prompt>(
text: &'prompt str,
attachments: &'prompt [TurnPromptAttachment],
) -> Vec<TurnPromptContentPart<'prompt>> {
if attachments.is_empty() {
return vec![TurnPromptContentPart::Text(text)];
}
let mut ordered_attachments = attachments.iter().collect::<Vec<_>>();
ordered_attachments
.sort_by_key(|attachment| text.find(&attachment.placeholder).unwrap_or(usize::MAX));
let mut content_parts = Vec::new();
let mut orphan_attachments = Vec::new();
let mut remaining_text = text;
for attachment in ordered_attachments {
if let Some(placeholder_index) = remaining_text.find(&attachment.placeholder) {
let (before_placeholder, after_placeholder) =
remaining_text.split_at(placeholder_index);
if !before_placeholder.is_empty() {
content_parts.push(TurnPromptContentPart::Text(before_placeholder));
}
content_parts.push(TurnPromptContentPart::Attachment(attachment));
remaining_text = &after_placeholder[attachment.placeholder.len()..];
continue;
}
orphan_attachments.push(attachment);
}
if !remaining_text.is_empty() {
content_parts.push(TurnPromptContentPart::Text(remaining_text));
}
content_parts.extend(
orphan_attachments
.into_iter()
.map(TurnPromptContentPart::OrphanAttachment),
);
content_parts
}
impl From<String> for TurnPrompt {
fn from(text: String) -> Self {
Self::from_text(text)
}
}
impl From<&str> for TurnPrompt {
fn from(text: &str) -> Self {
Self::from_text(text.to_string())
}
}
impl From<&TurnPrompt> for TurnPrompt {
fn from(prompt: &TurnPrompt) -> Self {
prompt.clone()
}
}
impl fmt::Display for TurnPrompt {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.text)
}
}
impl PartialEq<&str> for TurnPrompt {
fn eq(&self, other: &&str) -> bool {
self.text == *other
}
}
impl PartialEq<TurnPrompt> for &str {
fn eq(&self, other: &TurnPrompt) -> bool {
*self == other.text
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AgentRequestKind {
SessionStart,
SessionResume {
session_output: Option<String>,
},
UtilityPrompt,
}
impl AgentRequestKind {
#[must_use]
pub fn protocol_profile(&self) -> crate::infra::agent::ProtocolRequestProfile {
match self {
Self::SessionStart | Self::SessionResume { .. } => {
crate::infra::agent::ProtocolRequestProfile::SessionTurn
}
Self::UtilityPrompt => crate::infra::agent::ProtocolRequestProfile::UtilityPrompt,
}
}
#[must_use]
pub fn is_resume(&self) -> bool {
matches!(self, Self::SessionResume { .. })
}
#[must_use]
pub fn session_output(&self) -> Option<&str> {
match self {
Self::SessionStart | Self::UtilityPrompt => None,
Self::SessionResume { session_output } => session_output.as_deref(),
}
}
}
#[derive(Debug, Clone)]
pub struct TurnRequest {
pub folder: PathBuf,
pub live_session_output: Option<Arc<Mutex<String>>>,
pub model: String,
pub request_kind: AgentRequestKind,
pub prompt: TurnPrompt,
pub provider_conversation_id: Option<String>,
pub persisted_instruction_conversation_id: Option<String>,
pub reasoning_level: ReasoningLevel,
}
#[derive(Clone, Debug, PartialEq)]
pub enum TurnEvent {
ThoughtDelta(String),
Completed {
context_reset: bool,
input_tokens: u64,
output_tokens: u64,
},
Failed(String),
PidUpdate(Option<u32>),
}
#[derive(Debug)]
pub struct TurnResult {
pub assistant_message: AgentResponse,
pub context_reset: bool,
pub input_tokens: u64,
pub output_tokens: u64,
pub provider_conversation_id: Option<String>,
}
pub struct SessionRef {
pub session_id: String,
}
pub struct StartSessionRequest {
pub folder: PathBuf,
pub session_id: String,
}
#[derive(Debug, thiserror::Error)]
pub enum AgentError {
#[error(transparent)]
AppServer(#[from] crate::infra::app_server::AppServerError),
#[error("{0}")]
Backend(String),
#[error("{0}")]
InterruptedByUser(String),
#[error("{0}")]
Io(String),
}
#[cfg_attr(test, mockall::automock)]
pub trait AgentChannel: Send + Sync {
fn start_session(
&self,
req: StartSessionRequest,
) -> AgentFuture<Result<SessionRef, AgentError>>;
fn run_turn(
&self,
session_id: String,
req: TurnRequest,
events: mpsc::UnboundedSender<TurnEvent>,
) -> AgentFuture<Result<TurnResult, AgentError>>;
fn shutdown_session(&self, session_id: String) -> AgentFuture<Result<(), AgentError>>;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_agent_request_kind_session_variants_use_session_protocol_profile() {
let start = AgentRequestKind::SessionStart;
let resume = AgentRequestKind::SessionResume {
session_output: Some("prior output".to_string()),
};
let start_profile = start.protocol_profile();
let resume_profile = resume.protocol_profile();
assert_eq!(
start_profile,
crate::infra::agent::ProtocolRequestProfile::SessionTurn
);
assert_eq!(
resume_profile,
crate::infra::agent::ProtocolRequestProfile::SessionTurn
);
}
#[test]
fn test_agent_request_kind_utility_prompt_uses_utility_protocol_profile() {
let request_kind = AgentRequestKind::UtilityPrompt;
let protocol_profile = request_kind.protocol_profile();
let session_output = request_kind.session_output();
assert_eq!(
protocol_profile,
crate::infra::agent::ProtocolRequestProfile::UtilityPrompt
);
assert_eq!(session_output, None);
}
#[test]
fn test_turn_prompt_transcript_text_keeps_inline_placeholders() {
let prompt = TurnPrompt {
attachments: vec![TurnPromptAttachment {
placeholder: "[Image #1]".to_string(),
local_image_path: PathBuf::from("/tmp/image-1.png"),
}],
text: "Review [Image #1] carefully".to_string(),
text_source: TurnPromptTextSource::UserPrompt,
};
let transcript_text = prompt.transcript_text();
assert_eq!(transcript_text, "Review [Image #1] carefully");
}
#[test]
fn test_turn_prompt_agent_text_rewrites_user_at_lookups() {
let prompt = TurnPrompt::from("Review @src/main.rs and person@example.com");
let agent_text = prompt.agent_text();
let transcript_text = prompt.transcript_text();
assert_eq!(agent_text, "Review \"src/main.rs\" and person@example.com");
assert_eq!(
transcript_text,
"Review @src/main.rs and person@example.com"
);
}
#[test]
fn test_turn_prompt_agent_text_preserves_agent_data_at_tokens() {
let prompt = TurnPrompt::from_agent_data(
"Diff:\n```diff\n+@dataclass\n+class Config:\n+ pass\n```".to_string(),
);
let agent_text = prompt.agent_text();
assert!(agent_text.contains("+@dataclass"));
assert!(!agent_text.contains("+\"dataclass\""));
}
#[test]
fn test_turn_prompt_transcript_text_appends_missing_placeholders() {
let prompt = TurnPrompt {
attachments: vec![
TurnPromptAttachment {
placeholder: "[Image #1]".to_string(),
local_image_path: PathBuf::from("/tmp/image-1.png"),
},
TurnPromptAttachment {
placeholder: "[Image #2]".to_string(),
local_image_path: PathBuf::from("/tmp/image-2.png"),
},
],
text: "Review".to_string(),
text_source: TurnPromptTextSource::UserPrompt,
};
let transcript_text = prompt.transcript_text();
assert_eq!(transcript_text, "Review [Image #1] [Image #2]");
}
#[test]
fn test_split_turn_prompt_content_orders_placeholders_and_appends_orphans() {
let attachments = vec![
TurnPromptAttachment {
placeholder: "[Image #1]".to_string(),
local_image_path: PathBuf::from("/tmp/image-1.png"),
},
TurnPromptAttachment {
placeholder: "[Image #2]".to_string(),
local_image_path: PathBuf::from("/tmp/image-2.png"),
},
TurnPromptAttachment {
placeholder: "[Image #3]".to_string(),
local_image_path: PathBuf::from("/tmp/image-3.png"),
},
];
let content_parts =
split_turn_prompt_content("Compare [Image #2] with [Image #1] now", &attachments);
assert_eq!(
content_parts,
vec![
TurnPromptContentPart::Text("Compare "),
TurnPromptContentPart::Attachment(&attachments[1]),
TurnPromptContentPart::Text(" with "),
TurnPromptContentPart::Attachment(&attachments[0]),
TurnPromptContentPart::Text(" now"),
TurnPromptContentPart::OrphanAttachment(&attachments[2]),
]
);
}
}