use std::future::Future;
use std::path::PathBuf;
use tools_kit::ToolsetBackend;
use providers_kit::ProviderBackend;
use storage_kit::{DatabaseBackend, StateStoreBackend};
pub use crate::file::FileRead;
use crate::backends::{codex, custom, opencode};
use crate::prompt::PromptStream;
use crate::{
ConversationId, ConversationNotificationReceiver, KitError, KnowledgeSource, NotificationId,
NotificationResolution, PromptRequest, PromptResponse, WorkspaceDefaults, WorkspaceId,
};
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ConversationInfo {
id: ConversationId,
workspace_id: WorkspaceId,
title: Option<String>,
}
impl ConversationInfo {
pub fn new(
id: ConversationId,
workspace_id: WorkspaceId,
title: Option<String>,
) -> Result<Self, KitError> {
Self::validate_title(title.as_deref())?;
Ok(Self {
id,
workspace_id,
title,
})
}
pub fn id(&self) -> &ConversationId {
&self.id
}
pub fn workspace_id(&self) -> &WorkspaceId {
&self.workspace_id
}
pub fn title(&self) -> Option<&str> {
self.title.as_deref()
}
fn validate_title(title: Option<&str>) -> Result<(), KitError> {
if title.is_some_and(|value| value.is_empty() || value.trim() != value) {
return Err(KitError::invalid_input(
"conversation title must be nonempty and unpadded when present",
));
}
Ok(())
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct NewConversation {
title: Option<String>,
defaults: Option<WorkspaceDefaults>,
knowledge_sources: Vec<KnowledgeSource>,
}
impl NewConversation {
pub fn new(
title: Option<String>,
defaults: Option<WorkspaceDefaults>,
knowledge_sources: Vec<KnowledgeSource>,
) -> Result<Self, KitError> {
ConversationInfo::validate_title(title.as_deref())?;
if let Some(defaults) = &defaults
&& let Some(policy) = defaults.knowledge_policy()
{
policy.validate()?;
}
let mut ids = std::collections::BTreeSet::new();
for source in &knowledge_sources {
source.validate()?;
if !ids.insert(source.source_id()) {
return Err(KitError::invalid_input(
"conversation knowledge source ids must be unique",
));
}
}
Ok(Self {
title,
defaults,
knowledge_sources,
})
}
pub fn title(&self) -> Option<&str> {
self.title.as_deref()
}
pub fn defaults(&self) -> Option<&WorkspaceDefaults> {
self.defaults.as_ref()
}
pub fn knowledge_sources(&self) -> &[KnowledgeSource] {
&self.knowledge_sources
}
}
pub trait ConversationBackend: Send + Sync + 'static {
fn id(&self) -> ConversationId;
fn info(&self) -> impl Future<Output = Result<ConversationInfo, KitError>> + Send + '_;
fn prompt(
&self,
request: PromptRequest,
) -> impl Future<Output = Result<PromptResponse, KitError>> + Send + '_;
fn prompt_stream(
&self,
_request: PromptRequest,
) -> impl Future<Output = Result<PromptStream, KitError>> + Send + '_ {
async {
Err(KitError::unsupported(
"incremental prompt output is unavailable",
))
}
}
fn abort(&self) -> impl Future<Output = Result<(), KitError>> + Send + '_;
fn read_file(
&self,
path: PathBuf,
) -> impl Future<Output = Result<FileRead, KitError>> + Send + '_;
fn notification_stream(&self) -> Result<ConversationNotificationReceiver, KitError>;
fn resolve_notification(
&self,
id: NotificationId,
resolution: NotificationResolution,
) -> impl Future<Output = Result<(), KitError>> + Send + '_;
fn close(self) -> impl Future<Output = Result<(), KitError>> + Send
where
Self: Sized;
}
pub struct Conversation<
S: StateStoreBackend = storage_kit::StateStore,
D: DatabaseBackend = storage_kit::Database,
P: ProviderBackend = providers_kit::Provider,
T: ToolsetBackend = tools_kit::Toolset,
> {
inner: ConversationInner<S, D, P, T>,
}
enum ConversationInner<
S: StateStoreBackend,
D: DatabaseBackend,
P: ProviderBackend,
T: ToolsetBackend,
> {
Codex(codex::conversations::CodexConversation<S, D, T>),
OpenCode(opencode::conversations::OpenCodeConversation<S, D>),
Custom(custom::CustomConversation<S, D, P, T>),
}
impl<S, D, P, T> Conversation<S, D, P, T>
where
S: StateStoreBackend,
D: DatabaseBackend,
P: ProviderBackend,
T: ToolsetBackend,
{
pub(crate) fn codex(backend: codex::conversations::CodexConversation<S, D, T>) -> Self {
Self {
inner: ConversationInner::Codex(backend),
}
}
pub(crate) fn opencode(backend: opencode::conversations::OpenCodeConversation<S, D>) -> Self {
Self {
inner: ConversationInner::OpenCode(backend),
}
}
pub(crate) fn custom(backend: custom::CustomConversation<S, D, P, T>) -> Self {
Self {
inner: ConversationInner::Custom(backend),
}
}
}
impl<S, D, P, T> ConversationBackend for ConversationInner<S, D, P, T>
where
S: StateStoreBackend,
D: DatabaseBackend,
P: ProviderBackend,
T: ToolsetBackend,
{
fn id(&self) -> ConversationId {
match self {
ConversationInner::Codex(backend) => backend.id(),
ConversationInner::OpenCode(backend) => backend.id(),
ConversationInner::Custom(backend) => backend.id(),
}
}
async fn info(&self) -> Result<ConversationInfo, KitError> {
match self {
ConversationInner::Codex(backend) => backend.info().await,
ConversationInner::OpenCode(backend) => backend.info().await,
ConversationInner::Custom(backend) => backend.info().await,
}
}
async fn prompt(&self, request: PromptRequest) -> Result<PromptResponse, KitError> {
match self {
ConversationInner::Codex(backend) => backend.prompt(request).await,
ConversationInner::OpenCode(backend) => backend.prompt(request).await,
ConversationInner::Custom(backend) => backend.prompt(request).await,
}
}
async fn prompt_stream(&self, request: PromptRequest) -> Result<PromptStream, KitError> {
match self {
ConversationInner::Codex(backend) => backend.prompt_stream(request).await,
ConversationInner::OpenCode(backend) => backend.prompt_stream(request).await,
ConversationInner::Custom(backend) => backend.prompt_stream(request).await,
}
}
async fn abort(&self) -> Result<(), KitError> {
match self {
ConversationInner::Codex(backend) => backend.abort().await,
ConversationInner::OpenCode(backend) => backend.abort().await,
ConversationInner::Custom(backend) => backend.abort().await,
}
}
async fn read_file(&self, path: PathBuf) -> Result<FileRead, KitError> {
match self {
ConversationInner::Codex(backend) => backend.read_file(path).await,
ConversationInner::OpenCode(backend) => backend.read_file(path).await,
ConversationInner::Custom(backend) => backend.read_file(path).await,
}
}
fn notification_stream(&self) -> Result<ConversationNotificationReceiver, KitError> {
match self {
ConversationInner::Codex(backend) => backend.notification_stream(),
ConversationInner::OpenCode(backend) => backend.notification_stream(),
ConversationInner::Custom(backend) => backend.notification_stream(),
}
}
async fn resolve_notification(
&self,
id: NotificationId,
resolution: NotificationResolution,
) -> Result<(), KitError> {
match self {
ConversationInner::Codex(backend) => backend.resolve_notification(id, resolution).await,
ConversationInner::OpenCode(backend) => {
backend.resolve_notification(id, resolution).await
}
ConversationInner::Custom(backend) => {
backend.resolve_notification(id, resolution).await
}
}
}
async fn close(self) -> Result<(), KitError> {
match self {
ConversationInner::Codex(backend) => backend.close().await,
ConversationInner::OpenCode(backend) => backend.close().await,
ConversationInner::Custom(backend) => backend.close().await,
}
}
}
impl<S, D, P, T> ConversationBackend for Conversation<S, D, P, T>
where
S: StateStoreBackend,
D: DatabaseBackend,
P: ProviderBackend,
T: ToolsetBackend,
{
fn id(&self) -> ConversationId {
self.inner.id()
}
async fn info(&self) -> Result<ConversationInfo, KitError> {
self.inner.info().await.map_err(|error| {
error
.at_operation(crate::error::Operation::ConversationInfo)
.in_phase(crate::error::ErrorPhase::StateRead)
})
}
async fn prompt(&self, request: PromptRequest) -> Result<PromptResponse, KitError> {
self.inner.prompt(request).await.map_err(|error| {
error
.at_operation(crate::error::Operation::Prompt)
.in_phase(crate::error::ErrorPhase::ModelGeneration)
})
}
async fn prompt_stream(&self, request: PromptRequest) -> Result<PromptStream, KitError> {
self.inner.prompt_stream(request).await.map_err(|error| {
error
.at_operation(crate::error::Operation::Prompt)
.in_phase(crate::error::ErrorPhase::ModelGeneration)
})
}
async fn abort(&self) -> Result<(), KitError> {
self.inner.abort().await.map_err(|error| {
error
.at_operation(crate::error::Operation::Abort)
.in_phase(crate::error::ErrorPhase::Cancellation)
})
}
async fn read_file(&self, path: PathBuf) -> Result<FileRead, KitError> {
self.inner.read_file(path).await.map_err(|error| {
error
.at_operation(crate::error::Operation::ReadFile)
.in_phase(crate::error::ErrorPhase::Admission)
})
}
fn notification_stream(&self) -> Result<ConversationNotificationReceiver, KitError> {
self.inner.notification_stream().map_err(|error| {
error
.at_operation(crate::error::Operation::NotificationStream)
.in_phase(crate::error::ErrorPhase::Admission)
})
}
async fn resolve_notification(
&self,
id: NotificationId,
resolution: NotificationResolution,
) -> Result<(), KitError> {
self.inner
.resolve_notification(id, resolution)
.await
.map_err(|error| {
error
.at_operation(crate::error::Operation::ResolveNotification)
.in_phase(crate::error::ErrorPhase::InteractionResolution)
})
}
async fn close(self) -> Result<(), KitError> {
self.inner.close().await.map_err(|error| {
error
.at_operation(crate::error::Operation::CloseConversation)
.in_phase(crate::error::ErrorPhase::Lifecycle)
})
}
}