feat(chat): persistance bornee du brouillon custom chat par projet/layout (#182)
This commit is contained in:
@ -14,20 +14,20 @@ use crate::dto::DismissEmbedderSuggestionRequestDto;
|
||||
use application::plugin_slash_commands_from_runtime_catalog;
|
||||
use application::{
|
||||
AppError, AssignSkillToAgentInput, AttachLiveAgentInput, ChangeAgentProfileInput,
|
||||
CloseProjectInput, CreateAgentInput, CreateLayoutInput, CreateMemoryInput, CreateSkillInput,
|
||||
DeleteAgentInput, DeleteEmbedderProfileInput, DeleteLayoutInput, DeleteMemoryInput,
|
||||
DeleteSkillInput, DeleteTemplateInput, DetectAgentDriftInput, ExecuteSlashCommandInput,
|
||||
GetMemoryInput, GetProjectSystemPermissionsInput, GetProjectWorkStateInput, GitBranchesInput,
|
||||
GitCheckoutInput, GitCommitInput, GitGraphInput, GitInitInput, GitLogInput, GitStagePathInput,
|
||||
GitStatusInput, InspectConversationInput, LaunchAgentInput, ListAgentsInput, ListDevicesInput,
|
||||
ListLayoutsInput, ListMemoriesInput, ListResumableAgentsInput, ListSkillsInput,
|
||||
ListSlashCommandsInput, LiveSessions, LoadLayoutInput, McpRuntime, MutateLayoutInput,
|
||||
OpenPluginLayoutWindowInput, OpenProjectInput, ReadAgentContextInput,
|
||||
ReadConversationPageInput, ReadMcpToolPermissionsInput, ReadMemoryIndexInput,
|
||||
ReadProjectContextInput, RecallMemoryInput, ReconcileLayoutsInput, ReconcileLiveStateInput,
|
||||
RenameDeviceInput, RenameLayoutInput, ResolveAgentPermissionsInput,
|
||||
ChatDraftScopeInput, CloseProjectInput, CreateAgentInput, CreateLayoutInput, CreateMemoryInput,
|
||||
CreateSkillInput, DeleteAgentInput, DeleteEmbedderProfileInput, DeleteLayoutInput,
|
||||
DeleteMemoryInput, DeleteSkillInput, DeleteTemplateInput, DetectAgentDriftInput,
|
||||
ExecuteSlashCommandInput, GetMemoryInput, GetProjectSystemPermissionsInput,
|
||||
GetProjectWorkStateInput, GitBranchesInput, GitCheckoutInput, GitCommitInput, GitGraphInput,
|
||||
GitInitInput, GitLogInput, GitStagePathInput, GitStatusInput, InspectConversationInput,
|
||||
LaunchAgentInput, ListAgentsInput, ListDevicesInput, ListLayoutsInput, ListMemoriesInput,
|
||||
ListResumableAgentsInput, ListSkillsInput, ListSlashCommandsInput, LiveSessions,
|
||||
LoadLayoutInput, McpRuntime, MutateLayoutInput, OpenPluginLayoutWindowInput, OpenProjectInput,
|
||||
ReadAgentContextInput, ReadConversationPageInput, ReadMcpToolPermissionsInput,
|
||||
ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput, ReconcileLayoutsInput,
|
||||
ReconcileLiveStateInput, RenameDeviceInput, RenameLayoutInput, ResolveAgentPermissionsInput,
|
||||
ResolveAgentSystemPermissionsInput, ResolveMemoryLinksInput, RevokeDeviceInput,
|
||||
RotateConversationLogInput, SetActiveLayoutInput, SlashCommandEffect,
|
||||
RotateConversationLogInput, SaveChatDraftInput, SetActiveLayoutInput, SlashCommandEffect,
|
||||
SnapshotRunningAgentsInput, StopLiveAgentInput, SyncAgentWithTemplateInput,
|
||||
UnassignSkillFromAgentInput, UpdateAgentContextInput, UpdateAgentEffortInput,
|
||||
UpdateAgentMcpToolPermissionsInput, UpdateAgentPermissionsInput,
|
||||
@ -48,28 +48,29 @@ use crate::dto::{
|
||||
AppExitWorkGuardStateDto, AssignSkillRequestDto, AttachBackgroundTaskResultDto,
|
||||
AttachLiveAgentRequestDto, AttachLiveAgentResponseDto, BackgroundTaskDto, CellKind,
|
||||
ChangeAgentProfileDto, ChangeAgentProfileRequestDto, ChatAttachmentDto, ChatAttachmentInputDto,
|
||||
CloneOpenCodeProfileFromSeedRequestDto, CloneProfileFromSeedRequestDto,
|
||||
ConfigureProfilesRequestDto, ConversationDetailsDto, CreateAgentFromTemplateRequestDto,
|
||||
CreateAgentRequestDto, CreateLayoutRequestDto, CreateLayoutResultDto, CreateMemoryRequestDto,
|
||||
CreateProjectRequestDto, CreateSkillRequestDto, CreateTemplateRequestDto,
|
||||
DeleteLayoutRequestDto, DeleteLayoutResultDto, DeliveredDelegationRequestDto,
|
||||
DetectProfilesRequestDto, DetectProfilesResponseDto, EmbedderEnginesDto, EmbedderProfileDto,
|
||||
EmbedderProfileListDto, ErrorDto, ExecuteSlashCommandRequestDto,
|
||||
ExecuteSlashCommandResponseDto, FirstRunStateDto, FrontAttachedRequestDto, GitBranchesDto,
|
||||
GitCheckoutRequestDto, GitCommitDto, GitCommitListDto, GitCommitRequestDto, GitStageRequestDto,
|
||||
GitStatusListDto, GraphCommitListDto, HealthRequestDto, HealthResponseDto,
|
||||
ImportChatAttachmentsRequestDto, ImportChatAttachmentsResponseDto,
|
||||
InspectConversationRequestDto, InterruptAgentRequestDto, LaunchAgentRequestDto, LayoutDto,
|
||||
LayoutOperationDto, ListLayoutsDto, LiveAgentListDto, MemoryDto, MemoryIndexDto,
|
||||
MemoryLinksDto, MemoryListDto, ModelServerConfigDto, ModelServerConfigListDto,
|
||||
OpenCodeProviderListDto, OpenTerminalRequestDto, PreviewModelServerCommandDto, ProfileDto,
|
||||
ProfileListDto, ProfileModelCatalogDto, ProjectDto, ProjectListDto,
|
||||
ProjectMcpToolPermissionsDto, ProjectPermissionsDto, ProjectSystemPermissionsDto,
|
||||
ProjectWorkStateDto, ReadAgentContextResponseDto, ReadConversationPageRequestDto,
|
||||
ReattachChatDto, ReattachResultDto, RecallMemoryRequestDto, RenameLayoutRequestDto, ReplyChunk,
|
||||
ResizeTerminalRequestDto, ResolveAgentPermissionsRequestDto,
|
||||
ResolveAgentPermissionsResponseDto, ResolveAgentSystemPermissionsRequestDto,
|
||||
ResolvedAgentSystemPermissionsDto, ResumableAgentListDto, SaveEmbedderProfileRequestDto,
|
||||
ChatDraftScopeRequestDto, CloneOpenCodeProfileFromSeedRequestDto,
|
||||
CloneProfileFromSeedRequestDto, ConfigureProfilesRequestDto, ConversationDetailsDto,
|
||||
CreateAgentFromTemplateRequestDto, CreateAgentRequestDto, CreateLayoutRequestDto,
|
||||
CreateLayoutResultDto, CreateMemoryRequestDto, CreateProjectRequestDto, CreateSkillRequestDto,
|
||||
CreateTemplateRequestDto, DeleteLayoutRequestDto, DeleteLayoutResultDto,
|
||||
DeliveredDelegationRequestDto, DetectProfilesRequestDto, DetectProfilesResponseDto,
|
||||
EmbedderEnginesDto, EmbedderProfileDto, EmbedderProfileListDto, ErrorDto,
|
||||
ExecuteSlashCommandRequestDto, ExecuteSlashCommandResponseDto, FirstRunStateDto,
|
||||
FrontAttachedRequestDto, GitBranchesDto, GitCheckoutRequestDto, GitCommitDto, GitCommitListDto,
|
||||
GitCommitRequestDto, GitStageRequestDto, GitStatusListDto, GraphCommitListDto,
|
||||
HealthRequestDto, HealthResponseDto, ImportChatAttachmentsRequestDto,
|
||||
ImportChatAttachmentsResponseDto, InspectConversationRequestDto, InterruptAgentRequestDto,
|
||||
LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto, LiveAgentListDto,
|
||||
MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto, ModelServerConfigDto,
|
||||
ModelServerConfigListDto, OpenCodeProviderListDto, OpenTerminalRequestDto,
|
||||
PreviewModelServerCommandDto, ProfileDto, ProfileListDto, ProfileModelCatalogDto, ProjectDto,
|
||||
ProjectListDto, ProjectMcpToolPermissionsDto, ProjectPermissionsDto,
|
||||
ProjectSystemPermissionsDto, ProjectWorkStateDto, ReadAgentContextResponseDto,
|
||||
ReadChatDraftResponseDto, ReadConversationPageRequestDto, ReattachChatDto, ReattachResultDto,
|
||||
RecallMemoryRequestDto, RenameLayoutRequestDto, ReplyChunk, ResizeTerminalRequestDto,
|
||||
ResolveAgentPermissionsRequestDto, ResolveAgentPermissionsResponseDto,
|
||||
ResolveAgentSystemPermissionsRequestDto, ResolvedAgentSystemPermissionsDto,
|
||||
ResumableAgentListDto, SaveChatDraftRequestDto, SaveEmbedderProfileRequestDto,
|
||||
SaveModelServerRequestDto, SaveOpenCodeProviderProfileRequestDto, SaveProfileRequestDto,
|
||||
SetActiveLayoutRequestDto, SetActiveLayoutResultDto, SkillDto, SkillListDto,
|
||||
SlashCommandEffectDto, SlashCommandListDto, StopLiveAgentRequestDto, StopLiveAgentResponseDto,
|
||||
@ -2237,6 +2238,77 @@ pub async fn change_agent_profile(
|
||||
.map_err(ErrorDto::from)
|
||||
}
|
||||
|
||||
fn chat_draft_scope_from_dto(
|
||||
request: ChatDraftScopeRequestDto,
|
||||
) -> Result<ChatDraftScopeInput, ErrorDto> {
|
||||
Ok(ChatDraftScopeInput {
|
||||
project_id: parse_project_id(&request.project_id)?,
|
||||
layout_id: parse_layout_id(&request.layout_id)?,
|
||||
node_id: parse_node_id(&request.node_id)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// `read_chat_draft` — read persisted custom chat draft text for a project/layout/node.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an [`ErrorDto`] for malformed ids, unknown project, or store failure.
|
||||
#[tauri::command]
|
||||
pub async fn read_chat_draft(
|
||||
request: ChatDraftScopeRequestDto,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<ReadChatDraftResponseDto, ErrorDto> {
|
||||
let output = state
|
||||
.read_chat_draft
|
||||
.execute(chat_draft_scope_from_dto(request)?)
|
||||
.await
|
||||
.map_err(ErrorDto::from)?;
|
||||
Ok(ReadChatDraftResponseDto {
|
||||
text: output.text,
|
||||
updated_at_ms: output.updated_at_ms,
|
||||
})
|
||||
}
|
||||
|
||||
/// `save_chat_draft` — persist custom chat draft text for a project/layout/node.
|
||||
///
|
||||
/// Empty text clears the draft. Attachments are not part of this contract.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an [`ErrorDto`] for malformed ids, unknown project, invalid text, or store failure.
|
||||
#[tauri::command]
|
||||
pub async fn save_chat_draft(
|
||||
request: SaveChatDraftRequestDto,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), ErrorDto> {
|
||||
state
|
||||
.save_chat_draft
|
||||
.execute(SaveChatDraftInput {
|
||||
scope: ChatDraftScopeInput {
|
||||
project_id: parse_project_id(&request.project_id)?,
|
||||
layout_id: parse_layout_id(&request.layout_id)?,
|
||||
node_id: parse_node_id(&request.node_id)?,
|
||||
},
|
||||
text: request.text,
|
||||
})
|
||||
.await
|
||||
.map_err(ErrorDto::from)
|
||||
}
|
||||
|
||||
/// `clear_chat_draft` — clear persisted custom chat draft text for a project/layout/node.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an [`ErrorDto`] for malformed ids, unknown project, or store failure.
|
||||
#[tauri::command]
|
||||
pub async fn clear_chat_draft(
|
||||
request: ChatDraftScopeRequestDto,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), ErrorDto> {
|
||||
state
|
||||
.clear_chat_draft
|
||||
.execute(chat_draft_scope_from_dto(request)?)
|
||||
.await
|
||||
.map_err(ErrorDto::from)
|
||||
}
|
||||
|
||||
/// `agent_send` — send a prompt to a live **structured** (chat) session and pump
|
||||
/// the turn's reply events to the frontend over `on_reply` (ARCHITECTURE §17.7).
|
||||
///
|
||||
|
||||
@ -342,6 +342,9 @@ pub fn run() {
|
||||
commands::delete_agent,
|
||||
commands::launch_agent,
|
||||
commands::change_agent_profile,
|
||||
commands::read_chat_draft,
|
||||
commands::save_chat_draft,
|
||||
commands::clear_chat_draft,
|
||||
commands::agent_send,
|
||||
commands::import_chat_attachments,
|
||||
commands::cancel_resume,
|
||||
|
||||
@ -7,9 +7,10 @@
|
||||
//! `cellKind` and PTY paths keep `"pty"`.
|
||||
|
||||
use app_tauri_lib::dto::{
|
||||
CellKind, ChatAttachmentDto, ChatAttachmentInputDto, ImportChatAttachmentsRequestDto,
|
||||
ImportChatAttachmentsResponseDto, ReattachChatDto, ReplyChunk, ReplyProgressDto,
|
||||
ReplyProgressKindDto, ReplyProgressSourceDto, ReplyProgressStageDto, TerminalSessionDto,
|
||||
CellKind, ChatAttachmentDto, ChatAttachmentInputDto, ChatDraftScopeRequestDto,
|
||||
ImportChatAttachmentsRequestDto, ImportChatAttachmentsResponseDto, ReadChatDraftResponseDto,
|
||||
ReattachChatDto, ReplyChunk, ReplyProgressDto, ReplyProgressKindDto, ReplyProgressSourceDto,
|
||||
ReplyProgressStageDto, SaveChatDraftRequestDto, TerminalSessionDto,
|
||||
};
|
||||
use application::{LaunchAgentOutput, StructuredSessionDescriptor};
|
||||
use domain::project::ProjectPath;
|
||||
@ -154,6 +155,50 @@ fn reply_chunk_rejects_snake_case_tag() {
|
||||
// Chat attachments — structured input + durable metadata, camelCase
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn chat_draft_scope_request_deserialises_camel_case() {
|
||||
let dto: ChatDraftScopeRequestDto = serde_json::from_value(json!({
|
||||
"projectId": Uuid::from_u128(1).to_string(),
|
||||
"layoutId": Uuid::from_u128(2).to_string(),
|
||||
"nodeId": Uuid::from_u128(3).to_string()
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(dto.project_id, Uuid::from_u128(1).to_string());
|
||||
assert_eq!(dto.layout_id, Uuid::from_u128(2).to_string());
|
||||
assert_eq!(dto.node_id, Uuid::from_u128(3).to_string());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_chat_draft_request_deserialises_text_only_camel_case() {
|
||||
let dto: SaveChatDraftRequestDto = serde_json::from_value(json!({
|
||||
"projectId": Uuid::from_u128(1).to_string(),
|
||||
"layoutId": Uuid::from_u128(2).to_string(),
|
||||
"nodeId": Uuid::from_u128(3).to_string(),
|
||||
"text": "draft text"
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(dto.text, "draft text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_chat_draft_response_serialises_camel_case() {
|
||||
let dto = ReadChatDraftResponseDto {
|
||||
text: Some("draft text".to_owned()),
|
||||
updated_at_ms: Some(42),
|
||||
};
|
||||
let v = serde_json::to_value(dto).unwrap();
|
||||
|
||||
assert_eq!(v["text"], "draft text");
|
||||
assert_eq!(v["updatedAtMs"], 42);
|
||||
assert!(v.get("updated_at_ms").is_none(), "no snake_case leak");
|
||||
assert!(
|
||||
v.get("attachments").is_none(),
|
||||
"drafts never persist attachments"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_attachment_input_dto_deserialises_camel_case() {
|
||||
let dto: ChatAttachmentInputDto = serde_json::from_value(json!({
|
||||
|
||||
157
crates/application/src/chat_drafts.rs
Normal file
157
crates/application/src/chat_drafts.rs
Normal file
@ -0,0 +1,157 @@
|
||||
//! Custom chat draft use cases.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::{ChatDraftStore, Clock, ProjectStore};
|
||||
use domain::{ChatDraft, ChatDraftKey, LayoutId, NodeId, Project, ProjectId};
|
||||
|
||||
use crate::AppError;
|
||||
|
||||
/// Input identifying one draft scope.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ChatDraftScopeInput {
|
||||
/// Project owning the layout.
|
||||
pub project_id: ProjectId,
|
||||
/// Named layout containing the chat cell.
|
||||
pub layout_id: LayoutId,
|
||||
/// Layout node hosting the chat cell.
|
||||
pub node_id: NodeId,
|
||||
}
|
||||
|
||||
impl ChatDraftScopeInput {
|
||||
fn key(self) -> ChatDraftKey {
|
||||
ChatDraftKey::new(self.project_id, self.layout_id, self.node_id)
|
||||
}
|
||||
}
|
||||
|
||||
/// Output of [`ReadChatDraft`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ReadChatDraftOutput {
|
||||
/// Restored draft text, or `None` when no draft exists.
|
||||
pub text: Option<String>,
|
||||
/// Last update time when a draft exists.
|
||||
pub updated_at_ms: Option<u64>,
|
||||
}
|
||||
|
||||
/// Input for [`SaveChatDraft`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SaveChatDraftInput {
|
||||
/// Draft scope.
|
||||
pub scope: ChatDraftScopeInput,
|
||||
/// Text to persist. Empty text clears the draft.
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
/// Reads a persisted custom chat draft.
|
||||
pub struct ReadChatDraft {
|
||||
projects: Arc<dyn ProjectStore>,
|
||||
drafts: Arc<dyn ChatDraftStore>,
|
||||
}
|
||||
|
||||
impl ReadChatDraft {
|
||||
/// Builds the use case.
|
||||
#[must_use]
|
||||
pub fn new(projects: Arc<dyn ProjectStore>, drafts: Arc<dyn ChatDraftStore>) -> Self {
|
||||
Self { projects, drafts }
|
||||
}
|
||||
|
||||
/// Executes the read.
|
||||
pub async fn execute(
|
||||
&self,
|
||||
input: ChatDraftScopeInput,
|
||||
) -> Result<ReadChatDraftOutput, AppError> {
|
||||
let project = self.load_project(input.project_id).await?;
|
||||
let draft = self.drafts.read(&project.root, input.key()).await?;
|
||||
Ok(ReadChatDraftOutput {
|
||||
text: draft.as_ref().map(|draft| draft.text.clone()),
|
||||
updated_at_ms: draft.map(|draft| draft.updated_at_ms),
|
||||
})
|
||||
}
|
||||
|
||||
async fn load_project(&self, project_id: ProjectId) -> Result<Project, AppError> {
|
||||
self.projects
|
||||
.load_project(project_id)
|
||||
.await
|
||||
.map_err(AppError::from)
|
||||
}
|
||||
}
|
||||
|
||||
/// Saves a persisted custom chat draft.
|
||||
pub struct SaveChatDraft {
|
||||
projects: Arc<dyn ProjectStore>,
|
||||
drafts: Arc<dyn ChatDraftStore>,
|
||||
clock: Arc<dyn Clock>,
|
||||
}
|
||||
|
||||
impl SaveChatDraft {
|
||||
/// Builds the use case.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
projects: Arc<dyn ProjectStore>,
|
||||
drafts: Arc<dyn ChatDraftStore>,
|
||||
clock: Arc<dyn Clock>,
|
||||
) -> Self {
|
||||
Self {
|
||||
projects,
|
||||
drafts,
|
||||
clock,
|
||||
}
|
||||
}
|
||||
|
||||
/// Executes the save. Empty text clears the draft.
|
||||
pub async fn execute(&self, input: SaveChatDraftInput) -> Result<(), AppError> {
|
||||
let project = self.load_project(input.scope.project_id).await?;
|
||||
let key = input.scope.key();
|
||||
if input.text.is_empty() {
|
||||
return self
|
||||
.drafts
|
||||
.clear(&project.root, key)
|
||||
.await
|
||||
.map_err(AppError::from);
|
||||
}
|
||||
let updated_at_ms = u64::try_from(self.clock.now_millis()).unwrap_or(0);
|
||||
let draft = ChatDraft::new(key, input.text, updated_at_ms)
|
||||
.map_err(|err| AppError::Invalid(err.to_string()))?;
|
||||
self.drafts
|
||||
.save(&project.root, draft)
|
||||
.await
|
||||
.map_err(AppError::from)
|
||||
}
|
||||
|
||||
async fn load_project(&self, project_id: ProjectId) -> Result<Project, AppError> {
|
||||
self.projects
|
||||
.load_project(project_id)
|
||||
.await
|
||||
.map_err(AppError::from)
|
||||
}
|
||||
}
|
||||
|
||||
/// Clears a persisted custom chat draft.
|
||||
pub struct ClearChatDraft {
|
||||
projects: Arc<dyn ProjectStore>,
|
||||
drafts: Arc<dyn ChatDraftStore>,
|
||||
}
|
||||
|
||||
impl ClearChatDraft {
|
||||
/// Builds the use case.
|
||||
#[must_use]
|
||||
pub fn new(projects: Arc<dyn ProjectStore>, drafts: Arc<dyn ChatDraftStore>) -> Self {
|
||||
Self { projects, drafts }
|
||||
}
|
||||
|
||||
/// Executes the clear.
|
||||
pub async fn execute(&self, input: ChatDraftScopeInput) -> Result<(), AppError> {
|
||||
let project = self.load_project(input.project_id).await?;
|
||||
self.drafts
|
||||
.clear(&project.root, input.key())
|
||||
.await
|
||||
.map_err(AppError::from)
|
||||
}
|
||||
|
||||
async fn load_project(&self, project_id: ProjectId) -> Result<Project, AppError> {
|
||||
self.projects
|
||||
.load_project(project_id)
|
||||
.await
|
||||
.map_err(AppError::from)
|
||||
}
|
||||
}
|
||||
@ -14,6 +14,7 @@
|
||||
pub mod agent;
|
||||
pub mod background;
|
||||
pub mod chat_attachments;
|
||||
pub mod chat_drafts;
|
||||
pub mod conversation;
|
||||
pub mod device;
|
||||
pub mod diag;
|
||||
@ -76,6 +77,10 @@ pub use chat_attachments::{
|
||||
ImportChatAttachmentItem, ImportChatAttachmentSource, ImportChatAttachments,
|
||||
ImportChatAttachmentsInput, ImportChatAttachmentsOutput, CHAT_ATTACHMENT_MAX_BYTES,
|
||||
};
|
||||
pub use chat_drafts::{
|
||||
ChatDraftScopeInput, ClearChatDraft, ReadChatDraft, ReadChatDraftOutput, SaveChatDraft,
|
||||
SaveChatDraftInput,
|
||||
};
|
||||
pub use conversation::{
|
||||
ConversationArchiveProvider, ReadConversationPage, ReadConversationPageInput, RecordTurn,
|
||||
RotateConversationLog, RotateConversationLogInput, TurnPage, TurnSource, TurnView,
|
||||
|
||||
230
crates/application/tests/chat_drafts.rs
Normal file
230
crates/application/tests/chat_drafts.rs
Normal file
@ -0,0 +1,230 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use domain::ports::{ChatDraftStore, Clock, ProjectStore, StoreError};
|
||||
use domain::{
|
||||
ChatDraft, ChatDraftKey, LayoutId, NodeId, Project, ProjectId, ProjectPath, RemoteRef,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use application::{
|
||||
ChatDraftScopeInput, ClearChatDraft, ReadChatDraft, SaveChatDraft, SaveChatDraftInput,
|
||||
};
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakeProjects {
|
||||
projects: Mutex<HashMap<ProjectId, Project>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ProjectStore for FakeProjects {
|
||||
async fn list_projects(&self) -> Result<Vec<Project>, StoreError> {
|
||||
Ok(self.projects.lock().unwrap().values().cloned().collect())
|
||||
}
|
||||
|
||||
async fn load_project(&self, id: ProjectId) -> Result<Project, StoreError> {
|
||||
self.projects
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(&id)
|
||||
.cloned()
|
||||
.ok_or(StoreError::NotFound)
|
||||
}
|
||||
|
||||
async fn save_project(&self, project: &Project) -> Result<(), StoreError> {
|
||||
self.projects
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(project.id, project.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn save_workspace(
|
||||
&self,
|
||||
_workspace: &domain::layout::Workspace,
|
||||
) -> Result<(), StoreError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn load_workspace(&self) -> Result<domain::layout::Workspace, StoreError> {
|
||||
Err(StoreError::NotFound)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakeDrafts {
|
||||
drafts: Mutex<HashMap<ChatDraftKey, ChatDraft>>,
|
||||
saves: Mutex<Vec<ChatDraft>>,
|
||||
clears: Mutex<Vec<ChatDraftKey>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ChatDraftStore for FakeDrafts {
|
||||
async fn read(
|
||||
&self,
|
||||
_root: &ProjectPath,
|
||||
key: ChatDraftKey,
|
||||
) -> Result<Option<ChatDraft>, StoreError> {
|
||||
Ok(self.drafts.lock().unwrap().get(&key).cloned())
|
||||
}
|
||||
|
||||
async fn save(&self, _root: &ProjectPath, draft: ChatDraft) -> Result<(), StoreError> {
|
||||
self.saves.lock().unwrap().push(draft.clone());
|
||||
self.drafts.lock().unwrap().insert(draft.key, draft);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn clear(&self, _root: &ProjectPath, key: ChatDraftKey) -> Result<(), StoreError> {
|
||||
self.clears.lock().unwrap().push(key);
|
||||
self.drafts.lock().unwrap().remove(&key);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
struct FixedClock;
|
||||
|
||||
impl Clock for FixedClock {
|
||||
fn now_millis(&self) -> i64 {
|
||||
1234
|
||||
}
|
||||
}
|
||||
|
||||
fn pid() -> ProjectId {
|
||||
ProjectId::from_uuid(Uuid::from_u128(1))
|
||||
}
|
||||
|
||||
fn lid(n: u128) -> LayoutId {
|
||||
LayoutId::from_uuid(Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
fn nid(n: u128) -> NodeId {
|
||||
NodeId::from_uuid(Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
fn project() -> Project {
|
||||
Project::new(
|
||||
pid(),
|
||||
"P",
|
||||
ProjectPath::new("/tmp/idea-chat-drafts-test").unwrap(),
|
||||
RemoteRef::Local,
|
||||
0,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn scope(layout_id: LayoutId, node_id: NodeId) -> ChatDraftScopeInput {
|
||||
ChatDraftScopeInput {
|
||||
project_id: pid(),
|
||||
layout_id,
|
||||
node_id,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn save_and_read_chat_draft_round_trip_text_only() {
|
||||
let projects = Arc::new(FakeProjects::default());
|
||||
projects.save_project(&project()).await.unwrap();
|
||||
let drafts = Arc::new(FakeDrafts::default());
|
||||
let save = SaveChatDraft::new(
|
||||
Arc::clone(&projects) as Arc<dyn ProjectStore>,
|
||||
Arc::clone(&drafts) as Arc<dyn ChatDraftStore>,
|
||||
Arc::new(FixedClock),
|
||||
);
|
||||
let read = ReadChatDraft::new(
|
||||
Arc::clone(&projects) as Arc<dyn ProjectStore>,
|
||||
Arc::clone(&drafts) as Arc<dyn ChatDraftStore>,
|
||||
);
|
||||
|
||||
save.execute(SaveChatDraftInput {
|
||||
scope: scope(lid(2), nid(3)),
|
||||
text: "hello draft".to_owned(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let output = read.execute(scope(lid(2), nid(3))).await.unwrap();
|
||||
assert_eq!(output.text.as_deref(), Some("hello draft"));
|
||||
assert_eq!(output.updated_at_ms, Some(1234));
|
||||
assert_eq!(drafts.saves.lock().unwrap()[0].text, "hello draft");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn save_empty_text_clears_draft() {
|
||||
let projects = Arc::new(FakeProjects::default());
|
||||
projects.save_project(&project()).await.unwrap();
|
||||
let drafts = Arc::new(FakeDrafts::default());
|
||||
let key = ChatDraftKey::new(pid(), lid(2), nid(3));
|
||||
drafts
|
||||
.drafts
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(key, ChatDraft::new(key, "old", 1).unwrap());
|
||||
let save = SaveChatDraft::new(
|
||||
Arc::clone(&projects) as Arc<dyn ProjectStore>,
|
||||
Arc::clone(&drafts) as Arc<dyn ChatDraftStore>,
|
||||
Arc::new(FixedClock),
|
||||
);
|
||||
|
||||
save.execute(SaveChatDraftInput {
|
||||
scope: scope(lid(2), nid(3)),
|
||||
text: String::new(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(drafts.drafts.lock().unwrap().is_empty());
|
||||
assert_eq!(drafts.clears.lock().unwrap().as_slice(), &[key]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn clear_chat_draft_is_scoped_by_layout_and_node() {
|
||||
let projects = Arc::new(FakeProjects::default());
|
||||
projects.save_project(&project()).await.unwrap();
|
||||
let drafts = Arc::new(FakeDrafts::default());
|
||||
let keep = ChatDraftKey::new(pid(), lid(2), nid(4));
|
||||
let clear = ChatDraftKey::new(pid(), lid(2), nid(3));
|
||||
drafts
|
||||
.drafts
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(keep, ChatDraft::new(keep, "keep", 1).unwrap());
|
||||
drafts
|
||||
.drafts
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(clear, ChatDraft::new(clear, "clear", 1).unwrap());
|
||||
let usecase = ClearChatDraft::new(
|
||||
Arc::clone(&projects) as Arc<dyn ProjectStore>,
|
||||
Arc::clone(&drafts) as Arc<dyn ChatDraftStore>,
|
||||
);
|
||||
|
||||
usecase.execute(scope(lid(2), nid(3))).await.unwrap();
|
||||
|
||||
let drafts = drafts.drafts.lock().unwrap();
|
||||
assert!(drafts.contains_key(&keep));
|
||||
assert!(!drafts.contains_key(&clear));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn oversized_chat_draft_is_rejected_before_store_call() {
|
||||
let projects = Arc::new(FakeProjects::default());
|
||||
projects.save_project(&project()).await.unwrap();
|
||||
let drafts = Arc::new(FakeDrafts::default());
|
||||
let save = SaveChatDraft::new(
|
||||
Arc::clone(&projects) as Arc<dyn ProjectStore>,
|
||||
Arc::clone(&drafts) as Arc<dyn ChatDraftStore>,
|
||||
Arc::new(FixedClock),
|
||||
);
|
||||
|
||||
let err = save
|
||||
.execute(SaveChatDraftInput {
|
||||
scope: scope(lid(2), nid(3)),
|
||||
text: "x".repeat(domain::CHAT_DRAFT_MAX_BYTES + 1),
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(err.code(), "INVALID");
|
||||
assert!(drafts.saves.lock().unwrap().is_empty());
|
||||
}
|
||||
@ -3100,6 +3100,42 @@ impl From<TerminalSession> for TerminalSessionDto {
|
||||
// Structured chat sessions (§17 — D4)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Request scoped to one persisted custom chat draft.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ChatDraftScopeRequestDto {
|
||||
/// Project id.
|
||||
pub project_id: String,
|
||||
/// Named layout id.
|
||||
pub layout_id: String,
|
||||
/// Layout node id.
|
||||
pub node_id: String,
|
||||
}
|
||||
|
||||
/// Request for saving one custom chat draft.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SaveChatDraftRequestDto {
|
||||
/// Project id.
|
||||
pub project_id: String,
|
||||
/// Named layout id.
|
||||
pub layout_id: String,
|
||||
/// Layout node id.
|
||||
pub node_id: String,
|
||||
/// Draft text. Empty text clears the draft.
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
/// Response for reading one custom chat draft.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ReadChatDraftResponseDto {
|
||||
/// Draft text, or `null` when absent.
|
||||
pub text: Option<String>,
|
||||
/// Last update time, epoch milliseconds, or `null` when absent.
|
||||
pub updated_at_ms: Option<u64>,
|
||||
}
|
||||
|
||||
/// Structured attachment intent sent by the chat frontend.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
|
||||
@ -16,7 +16,7 @@ use application::{
|
||||
AddIssueAttachment, AgentResumer, AgentWakeService, AppError, AssignIssueAgent,
|
||||
AssignSkillToAgent, AssignTicketToSprint, AttachLiveAgent, AuthenticateSession,
|
||||
BackgroundCommandArchive, BulkDeleteIssues, BulkUpdateIssuePriority, BulkUpdateIssueStatus,
|
||||
CancelBackgroundTask, ChangeAgentProfile, CheckEmbedderSuggestion,
|
||||
CancelBackgroundTask, ChangeAgentProfile, CheckEmbedderSuggestion, ClearChatDraft,
|
||||
CloneOpenCodeProfileFromSeed, CloneProfileFromSeed, CloseProject, CloseTab, CloseTerminal,
|
||||
CloseTicketAssistant, ConfigureProfiles, ContextGuardUseCases, CreateAgentFromScratch,
|
||||
CreateAgentFromTemplate, CreateIssue, CreateLayout, CreateMemory, CreateProject, CreateSkill,
|
||||
@ -40,30 +40,31 @@ use application::{
|
||||
PairAttemptLimiter, PairDevice, PermissionProjectorRegistry, PluginCommandTasks,
|
||||
PluginConfigDocuments, PluginEventSubscriptions, PluginStorageAccess,
|
||||
PluginToolchainDiagnostics, PluginWorkspaceAccess, ProposeContext, QueryProjectStructure,
|
||||
ReadAgentContext, ReadContext, ReadConversationPage, ReadIssue, ReadIssueAttachment,
|
||||
ReadIssueCarnet, ReadMcpToolPermissions, ReadMemory, ReadMemoryIndex, ReadProjectContext,
|
||||
ReadSkill, ReadTemplate, RecallMemory, ReconcileLayouts, ReconcileLiveState,
|
||||
ReconcileLiveStateInput, ReconcilePluginMcpServers, RecordTurn, RecordTurnProvider,
|
||||
ReferenceProfiles, RenameDevice, RenameLayout, RenameSprint, ReorderSprints, ResizeTerminal,
|
||||
ResolveAgentCapabilities, ResolveAgentPermissions, ResolveAgentSystemPermissions,
|
||||
ResolveMemoryLinks, RestoreOpenWindows, RetryBackgroundTask, ReviewPluginPackage,
|
||||
RevokeAllDevices, RevokeDevice, RotateConversationLog, SaveEmbedderProfile, SaveModelServer,
|
||||
SaveOpenCodeProviderProfile, SaveProfile, SessionLimitService, SetActiveLayout,
|
||||
SetPluginEnabled, SlashCommandRegistry, SnapshotOpenWindows, SnapshotRunningAgents,
|
||||
SpawnBackgroundCommand, StopLiveAgent, StructuredRoutingMode, StructuredSessions,
|
||||
SuggestedThisSession, SyncAgentWithTemplate, TerminalSessions, TouchDevice,
|
||||
UnassignSkillFromAgent, UnassignTicketFromSprint, UninstallPlugin, UnlinkIssues,
|
||||
UpdateAgentContext, UpdateAgentEffort, UpdateAgentMcpToolPermissions, UpdateAgentPermissions,
|
||||
UpdateAgentSystemPermissions, UpdateIssue, UpdateIssueCarnet, UpdateLiveState, UpdateMemory,
|
||||
UpdateProjectContext, UpdateProjectMcpToolPermissions, UpdateProjectPermissions,
|
||||
UpdateProjectSystemPermissions, UpdateSkill, UpdateTemplate, WakeSessionProvider, WriteMemory,
|
||||
WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET,
|
||||
ReadAgentContext, ReadChatDraft, ReadContext, ReadConversationPage, ReadIssue,
|
||||
ReadIssueAttachment, ReadIssueCarnet, ReadMcpToolPermissions, ReadMemory, ReadMemoryIndex,
|
||||
ReadProjectContext, ReadSkill, ReadTemplate, RecallMemory, ReconcileLayouts,
|
||||
ReconcileLiveState, ReconcileLiveStateInput, ReconcilePluginMcpServers, RecordTurn,
|
||||
RecordTurnProvider, ReferenceProfiles, RenameDevice, RenameLayout, RenameSprint,
|
||||
ReorderSprints, ResizeTerminal, ResolveAgentCapabilities, ResolveAgentPermissions,
|
||||
ResolveAgentSystemPermissions, ResolveMemoryLinks, RestoreOpenWindows, RetryBackgroundTask,
|
||||
ReviewPluginPackage, RevokeAllDevices, RevokeDevice, RotateConversationLog, SaveChatDraft,
|
||||
SaveEmbedderProfile, SaveModelServer, SaveOpenCodeProviderProfile, SaveProfile,
|
||||
SessionLimitService, SetActiveLayout, SetPluginEnabled, SlashCommandRegistry,
|
||||
SnapshotOpenWindows, SnapshotRunningAgents, SpawnBackgroundCommand, StopLiveAgent,
|
||||
StructuredRoutingMode, StructuredSessions, SuggestedThisSession, SyncAgentWithTemplate,
|
||||
TerminalSessions, TouchDevice, UnassignSkillFromAgent, UnassignTicketFromSprint,
|
||||
UninstallPlugin, UnlinkIssues, UpdateAgentContext, UpdateAgentEffort,
|
||||
UpdateAgentMcpToolPermissions, UpdateAgentPermissions, UpdateAgentSystemPermissions,
|
||||
UpdateIssue, UpdateIssueCarnet, UpdateLiveState, UpdateMemory, UpdateProjectContext,
|
||||
UpdateProjectMcpToolPermissions, UpdateProjectPermissions, UpdateProjectSystemPermissions,
|
||||
UpdateSkill, UpdateTemplate, WakeSessionProvider, WriteMemory, WriteToTerminal,
|
||||
AGENT_MEMORY_RECALL_BUDGET,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use domain::ports::{
|
||||
AgentContextStore, AgentRuntime, AgentSession, AgentSessionFactory, AgentToolPolicyStore,
|
||||
AgentWakePort, AssistantContextProvider, BackgroundTaskPortError, BackgroundTaskRunner,
|
||||
BackgroundTaskStore, ChatAttachmentStore, Clock, DeviceSessionStore, Embedder,
|
||||
BackgroundTaskStore, ChatAttachmentStore, ChatDraftStore, Clock, DeviceSessionStore, Embedder,
|
||||
EmbedderEnvInspector, EmbedderProfileStore, EmbedderPromptStore, EnvironmentReader, EventBus,
|
||||
FileSystem, GitPort, IdGenerator, IssueNumberAllocator, IssueStore, McpToolPermissionStore,
|
||||
MemoryRecall, MemoryStore, ModelArtifactDownloader, PermissionStore, PluginManifestValidator,
|
||||
@ -91,13 +92,13 @@ use infrastructure::{
|
||||
BackgroundTaskReadyToDeliver, ClaudePermissionProjector, ClaudeTranscriptInspector,
|
||||
CliAgentRuntime, CodexPermissionProjector, CommandBackgroundRunner,
|
||||
EmbeddedCompatibilityMatrix, EmbedderEnvProbe, ExternalMcpPluginSupervisor,
|
||||
FsAssistantContextStore, FsBackgroundTaskStore, FsChatAttachmentStore, FsConversationLog,
|
||||
FsDeviceSessionStore, FsEmbedderProfileStore, FsEmbedderPromptStore, FsHandoffStore,
|
||||
FsIssueNumberAllocator, FsIssueStore, FsLiveStateStore, FsMcpToolPermissionStore,
|
||||
FsMemoryStore, FsModelServerRegistry, FsOrchestratorWatcher, FsPermissionStore,
|
||||
FsPluginPackageStore, FsPluginRegistryStore, FsPluginStorageStore, FsProfileStore,
|
||||
FsProjectStore, FsProviderSessionStore, FsSecretStore, FsSkillStore, FsSprintStore,
|
||||
FsSystemPermissionStore, FsTemplateStore, FsWindowStateStore, Git2Repository,
|
||||
FsAssistantContextStore, FsBackgroundTaskStore, FsChatAttachmentStore, FsChatDraftStore,
|
||||
FsConversationLog, FsDeviceSessionStore, FsEmbedderProfileStore, FsEmbedderPromptStore,
|
||||
FsHandoffStore, FsIssueNumberAllocator, FsIssueStore, FsLiveStateStore,
|
||||
FsMcpToolPermissionStore, FsMemoryStore, FsModelServerRegistry, FsOrchestratorWatcher,
|
||||
FsPermissionStore, FsPluginPackageStore, FsPluginRegistryStore, FsPluginStorageStore,
|
||||
FsProfileStore, FsProjectStore, FsProviderSessionStore, FsSecretStore, FsSkillStore,
|
||||
FsSprintStore, FsSystemPermissionStore, FsTemplateStore, FsWindowStateStore, Git2Repository,
|
||||
HeuristicHandoffSummarizer, HfModelArtifactDownloader, HttpOpenAiCompatibleProbe,
|
||||
HttpProviderModelCatalogue, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox,
|
||||
InMemoryPairAttemptLimiter, LlamaCppRuntime, LocalEnvironmentReader, LocalFileSystem,
|
||||
@ -1023,6 +1024,12 @@ pub struct BackendCore {
|
||||
pub mark_issue_attachment_summarized: Arc<MarkIssueAttachmentSummarized>,
|
||||
/// Import durable agent/chat attachments.
|
||||
pub import_chat_attachments: Arc<ImportChatAttachments>,
|
||||
/// Read one persisted custom chat draft.
|
||||
pub read_chat_draft: Arc<ReadChatDraft>,
|
||||
/// Save one persisted custom chat draft.
|
||||
pub save_chat_draft: Arc<SaveChatDraft>,
|
||||
/// Clear one persisted custom chat draft.
|
||||
pub clear_chat_draft: Arc<ClearChatDraft>,
|
||||
/// Link two public tickets.
|
||||
pub link_issues: Arc<LinkIssues>,
|
||||
/// Unlink public tickets.
|
||||
@ -1711,6 +1718,8 @@ impl BackendCore {
|
||||
let chat_attachment_store = Arc::new(FsChatAttachmentStore::new());
|
||||
let chat_attachment_store_port =
|
||||
Arc::clone(&chat_attachment_store) as Arc<dyn ChatAttachmentStore>;
|
||||
let chat_draft_store = Arc::new(FsChatDraftStore::new());
|
||||
let chat_draft_store_port = Arc::clone(&chat_draft_store) as Arc<dyn ChatDraftStore>;
|
||||
let create_issue = Arc::new(CreateIssue::new(
|
||||
Arc::clone(&issue_store_port),
|
||||
Arc::clone(&issue_allocator_port),
|
||||
@ -1769,6 +1778,19 @@ impl BackendCore {
|
||||
Arc::clone(&ids) as Arc<dyn IdGenerator>,
|
||||
Arc::clone(&clock) as Arc<dyn Clock>,
|
||||
));
|
||||
let read_chat_draft = Arc::new(ReadChatDraft::new(
|
||||
Arc::clone(&store_port),
|
||||
Arc::clone(&chat_draft_store_port),
|
||||
));
|
||||
let save_chat_draft = Arc::new(SaveChatDraft::new(
|
||||
Arc::clone(&store_port),
|
||||
Arc::clone(&chat_draft_store_port),
|
||||
Arc::clone(&clock) as Arc<dyn Clock>,
|
||||
));
|
||||
let clear_chat_draft = Arc::new(ClearChatDraft::new(
|
||||
Arc::clone(&store_port),
|
||||
Arc::clone(&chat_draft_store_port),
|
||||
));
|
||||
let link_issues = Arc::new(LinkIssues::new(
|
||||
Arc::clone(&issue_store_port),
|
||||
Arc::clone(&clock) as Arc<dyn Clock>,
|
||||
@ -3001,6 +3023,9 @@ impl BackendCore {
|
||||
read_issue_attachment,
|
||||
mark_issue_attachment_summarized,
|
||||
import_chat_attachments,
|
||||
read_chat_draft,
|
||||
save_chat_draft,
|
||||
clear_chat_draft,
|
||||
link_issues,
|
||||
unlink_issues,
|
||||
assign_issue_agent,
|
||||
|
||||
84
crates/domain/src/chat_draft.rs
Normal file
84
crates/domain/src/chat_draft.rs
Normal file
@ -0,0 +1,84 @@
|
||||
//! Persisted custom chat draft model.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::ids::{LayoutId, NodeId, ProjectId};
|
||||
|
||||
/// Maximum draft text size persisted by IdeA.
|
||||
pub const CHAT_DRAFT_MAX_BYTES: usize = 100 * 1024;
|
||||
|
||||
/// Stable scope for one custom chat draft.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ChatDraftKey {
|
||||
/// Project owning the layout.
|
||||
pub project_id: ProjectId,
|
||||
/// Named layout containing the chat cell.
|
||||
pub layout_id: LayoutId,
|
||||
/// Layout node hosting the chat cell.
|
||||
pub node_id: NodeId,
|
||||
}
|
||||
|
||||
impl ChatDraftKey {
|
||||
/// Builds a draft key.
|
||||
#[must_use]
|
||||
pub const fn new(project_id: ProjectId, layout_id: LayoutId, node_id: NodeId) -> Self {
|
||||
Self {
|
||||
project_id,
|
||||
layout_id,
|
||||
node_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Durable text draft for one custom chat composer.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ChatDraft {
|
||||
/// Stable draft scope.
|
||||
pub key: ChatDraftKey,
|
||||
/// Draft text. Attachments are deliberately excluded.
|
||||
pub text: String,
|
||||
/// Last update time, epoch milliseconds.
|
||||
pub updated_at_ms: u64,
|
||||
}
|
||||
|
||||
impl ChatDraft {
|
||||
/// Builds validated draft metadata.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`ChatDraftError::TooLarge`] when `text` exceeds the maximum
|
||||
/// persisted draft size.
|
||||
pub fn new(
|
||||
key: ChatDraftKey,
|
||||
text: impl Into<String>,
|
||||
updated_at_ms: u64,
|
||||
) -> Result<Self, ChatDraftError> {
|
||||
let text = text.into();
|
||||
if text.len() > CHAT_DRAFT_MAX_BYTES {
|
||||
return Err(ChatDraftError::TooLarge {
|
||||
max: CHAT_DRAFT_MAX_BYTES,
|
||||
actual: text.len(),
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
key,
|
||||
text,
|
||||
updated_at_ms,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Chat draft validation errors.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
||||
pub enum ChatDraftError {
|
||||
/// Draft content exceeds the persisted size limit.
|
||||
#[error("chat draft exceeds {max} bytes: {actual}")]
|
||||
TooLarge {
|
||||
/// Maximum allowed bytes.
|
||||
max: usize,
|
||||
/// Actual text bytes.
|
||||
actual: usize,
|
||||
},
|
||||
}
|
||||
@ -34,6 +34,7 @@ pub mod agent;
|
||||
pub mod agent_tool_policy;
|
||||
pub mod background_task;
|
||||
pub mod chat_attachment;
|
||||
pub mod chat_draft;
|
||||
pub mod conversation;
|
||||
pub mod conversation_log;
|
||||
pub mod device;
|
||||
@ -106,6 +107,7 @@ pub use background_task::{
|
||||
pub use chat_attachment::{
|
||||
ChatAttachment, ChatAttachmentError, ChatAttachmentId, ChatAttachmentSourceKind,
|
||||
};
|
||||
pub use chat_draft::{ChatDraft, ChatDraftError, ChatDraftKey, CHAT_DRAFT_MAX_BYTES};
|
||||
|
||||
pub use skill::{Skill, SkillKind, SkillRef, SkillScope};
|
||||
|
||||
|
||||
@ -35,6 +35,7 @@ use crate::background_task::{
|
||||
BackgroundTask, BackgroundTaskKind, BackgroundTaskResult, BackgroundTaskWakePolicy,
|
||||
};
|
||||
use crate::chat_attachment::{ChatAttachment, ChatAttachmentId, ChatAttachmentSourceKind};
|
||||
use crate::chat_draft::{ChatDraft, ChatDraftKey};
|
||||
use crate::device::{AuthenticatedDevice, DeviceId, PairedDevice};
|
||||
use crate::events::DomainEvent;
|
||||
use crate::ids::{
|
||||
@ -1251,6 +1252,23 @@ pub trait ChatAttachmentStore: Send + Sync {
|
||||
) -> Result<Vec<ChatAttachment>, ChatAttachmentStoreError>;
|
||||
}
|
||||
|
||||
/// Persistence port for project-scoped custom chat drafts.
|
||||
#[async_trait]
|
||||
pub trait ChatDraftStore: Send + Sync {
|
||||
/// Reads one draft. Missing entries return `Ok(None)`.
|
||||
async fn read(
|
||||
&self,
|
||||
root: &ProjectPath,
|
||||
key: ChatDraftKey,
|
||||
) -> Result<Option<ChatDraft>, StoreError>;
|
||||
|
||||
/// Saves or replaces one draft.
|
||||
async fn save(&self, root: &ProjectPath, draft: ChatDraft) -> Result<(), StoreError>;
|
||||
|
||||
/// Clears one draft. Missing entries are a no-op.
|
||||
async fn clear(&self, root: &ProjectPath, key: ChatDraftKey) -> Result<(), StoreError>;
|
||||
}
|
||||
|
||||
/// Errors from the sprint store.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
||||
pub enum SprintStoreError {
|
||||
|
||||
26
crates/domain/tests/chat_draft.rs
Normal file
26
crates/domain/tests/chat_draft.rs
Normal file
@ -0,0 +1,26 @@
|
||||
use domain::{ChatDraft, ChatDraftError, ChatDraftKey, LayoutId, NodeId, ProjectId};
|
||||
use uuid::Uuid;
|
||||
|
||||
fn key() -> ChatDraftKey {
|
||||
ChatDraftKey::new(
|
||||
ProjectId::from_uuid(Uuid::from_u128(1)),
|
||||
LayoutId::from_uuid(Uuid::from_u128(2)),
|
||||
NodeId::from_uuid(Uuid::from_u128(3)),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_draft_accepts_text_and_scope() {
|
||||
let draft = ChatDraft::new(key(), "hello", 42).unwrap();
|
||||
|
||||
assert_eq!(draft.key, key());
|
||||
assert_eq!(draft.text, "hello");
|
||||
assert_eq!(draft.updated_at_ms, 42);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_draft_rejects_oversized_text() {
|
||||
let err = ChatDraft::new(key(), "x".repeat(domain::CHAT_DRAFT_MAX_BYTES + 1), 42).unwrap_err();
|
||||
|
||||
assert!(matches!(err, ChatDraftError::TooLarge { .. }));
|
||||
}
|
||||
125
crates/infrastructure/src/chat_drafts.rs
Normal file
125
crates/infrastructure/src/chat_drafts.rs
Normal file
@ -0,0 +1,125 @@
|
||||
//! Filesystem custom chat draft store.
|
||||
//!
|
||||
//! Drafts are persisted per project under `<root>/.ideai/chat-drafts.json`.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use domain::ports::{ChatDraftStore, StoreError};
|
||||
use domain::{ChatDraft, ChatDraftKey, ProjectPath};
|
||||
|
||||
const IDEAI_DIR: &str = ".ideai";
|
||||
const CHAT_DRAFTS_FILE: &str = "chat-drafts.json";
|
||||
const CHAT_DRAFTS_TMP_FILE: &str = "chat-drafts.json.tmp";
|
||||
const CHAT_DRAFTS_VERSION: u32 = 1;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ChatDraftsDoc {
|
||||
version: u32,
|
||||
drafts: Vec<ChatDraft>,
|
||||
}
|
||||
|
||||
impl Default for ChatDraftsDoc {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
version: CHAT_DRAFTS_VERSION,
|
||||
drafts: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Filesystem-backed [`ChatDraftStore`].
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct FsChatDraftStore;
|
||||
|
||||
impl FsChatDraftStore {
|
||||
/// Builds a store.
|
||||
#[must_use]
|
||||
pub const fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
fn dir(root: &ProjectPath) -> PathBuf {
|
||||
PathBuf::from(root.as_str()).join(IDEAI_DIR)
|
||||
}
|
||||
|
||||
fn path(root: &ProjectPath) -> PathBuf {
|
||||
dir(root).join(CHAT_DRAFTS_FILE)
|
||||
}
|
||||
|
||||
fn tmp_path(root: &ProjectPath) -> PathBuf {
|
||||
dir(root).join(CHAT_DRAFTS_TMP_FILE)
|
||||
}
|
||||
|
||||
fn io_error(err: std::io::Error) -> StoreError {
|
||||
StoreError::Io(err.to_string())
|
||||
}
|
||||
|
||||
async fn read_doc(root: &ProjectPath) -> Result<ChatDraftsDoc, StoreError> {
|
||||
match tokio::fs::read(path(root)).await {
|
||||
Ok(bytes) => {
|
||||
let doc: ChatDraftsDoc = serde_json::from_slice(&bytes)
|
||||
.map_err(|err| StoreError::Serialization(err.to_string()))?;
|
||||
if doc.version != CHAT_DRAFTS_VERSION {
|
||||
return Err(StoreError::Invalid(format!(
|
||||
"unsupported chat drafts version {}",
|
||||
doc.version
|
||||
)));
|
||||
}
|
||||
Ok(doc)
|
||||
}
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(ChatDraftsDoc::default()),
|
||||
Err(err) => Err(io_error(err)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn write_doc(root: &ProjectPath, doc: &ChatDraftsDoc) -> Result<(), StoreError> {
|
||||
let dir = dir(root);
|
||||
tokio::fs::create_dir_all(&dir).await.map_err(io_error)?;
|
||||
let bytes =
|
||||
serde_json::to_vec_pretty(doc).map_err(|err| StoreError::Serialization(err.to_string()))?;
|
||||
let tmp = tmp_path(root);
|
||||
tokio::fs::write(&tmp, bytes).await.map_err(io_error)?;
|
||||
tokio::fs::rename(tmp, path(root)).await.map_err(io_error)
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ChatDraftStore for FsChatDraftStore {
|
||||
async fn read(
|
||||
&self,
|
||||
root: &ProjectPath,
|
||||
key: ChatDraftKey,
|
||||
) -> Result<Option<ChatDraft>, StoreError> {
|
||||
Ok(read_doc(root)
|
||||
.await?
|
||||
.drafts
|
||||
.into_iter()
|
||||
.find(|draft| draft.key == key))
|
||||
}
|
||||
|
||||
async fn save(&self, root: &ProjectPath, draft: ChatDraft) -> Result<(), StoreError> {
|
||||
let mut doc = read_doc(root).await?;
|
||||
if let Some(slot) = doc
|
||||
.drafts
|
||||
.iter_mut()
|
||||
.find(|existing| existing.key == draft.key)
|
||||
{
|
||||
*slot = draft;
|
||||
} else {
|
||||
doc.drafts.push(draft);
|
||||
}
|
||||
doc.drafts
|
||||
.sort_by_key(|draft| (draft.key.project_id, draft.key.layout_id, draft.key.node_id));
|
||||
write_doc(root, &doc).await
|
||||
}
|
||||
|
||||
async fn clear(&self, root: &ProjectPath, key: ChatDraftKey) -> Result<(), StoreError> {
|
||||
let mut doc = read_doc(root).await?;
|
||||
doc.drafts.retain(|draft| draft.key != key);
|
||||
write_doc(root, &doc).await
|
||||
}
|
||||
}
|
||||
@ -15,6 +15,7 @@
|
||||
pub mod assistant;
|
||||
pub mod background_task;
|
||||
pub mod chat_attachments;
|
||||
pub mod chat_drafts;
|
||||
pub mod clock;
|
||||
pub mod conversation;
|
||||
pub mod conversation_log;
|
||||
@ -55,6 +56,7 @@ pub use background_task::{
|
||||
CommandBackgroundRunner,
|
||||
};
|
||||
pub use chat_attachments::FsChatAttachmentStore;
|
||||
pub use chat_drafts::FsChatDraftStore;
|
||||
pub use clock::SystemClock;
|
||||
pub use conversation::InMemoryConversationRegistry;
|
||||
pub use conversation_log::{
|
||||
|
||||
110
crates/infrastructure/tests/chat_drafts.rs
Normal file
110
crates/infrastructure/tests/chat_drafts.rs
Normal file
@ -0,0 +1,110 @@
|
||||
use domain::ports::{ChatDraftStore, IdGenerator};
|
||||
use domain::{ChatDraft, ChatDraftKey, LayoutId, NodeId, ProjectId, ProjectPath};
|
||||
use infrastructure::{FsChatDraftStore, UuidGenerator};
|
||||
use uuid::Uuid;
|
||||
|
||||
fn temp_dir(tag: &str) -> std::path::PathBuf {
|
||||
std::env::temp_dir().join(format!(
|
||||
"idea-chat-draft-{tag}-{}",
|
||||
UuidGenerator::new().new_uuid()
|
||||
))
|
||||
}
|
||||
|
||||
fn pid(n: u128) -> ProjectId {
|
||||
ProjectId::from_uuid(Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
fn lid(n: u128) -> LayoutId {
|
||||
LayoutId::from_uuid(Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
fn nid(n: u128) -> NodeId {
|
||||
NodeId::from_uuid(Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
fn key(layout: u128, node: u128) -> ChatDraftKey {
|
||||
ChatDraftKey::new(pid(1), lid(layout), nid(node))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fs_chat_draft_store_saves_reads_and_replaces_one_scope() {
|
||||
let project_root = temp_dir("roundtrip");
|
||||
tokio::fs::create_dir_all(&project_root).await.unwrap();
|
||||
let root = ProjectPath::new(project_root.to_string_lossy().into_owned()).unwrap();
|
||||
let store = FsChatDraftStore::new();
|
||||
|
||||
store
|
||||
.save(&root, ChatDraft::new(key(2, 3), "first", 10).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
store
|
||||
.save(&root, ChatDraft::new(key(2, 3), "second", 11).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let draft = store.read(&root, key(2, 3)).await.unwrap().unwrap();
|
||||
assert_eq!(draft.text, "second");
|
||||
assert_eq!(draft.updated_at_ms, 11);
|
||||
assert!(
|
||||
tokio::fs::metadata(project_root.join(".ideai/chat-drafts.json"))
|
||||
.await
|
||||
.is_ok()
|
||||
);
|
||||
|
||||
let _ = tokio::fs::remove_dir_all(project_root).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fs_chat_draft_store_scopes_by_layout_and_node() {
|
||||
let project_root = temp_dir("scoped");
|
||||
tokio::fs::create_dir_all(&project_root).await.unwrap();
|
||||
let root = ProjectPath::new(project_root.to_string_lossy().into_owned()).unwrap();
|
||||
let store = FsChatDraftStore::new();
|
||||
|
||||
store
|
||||
.save(&root, ChatDraft::new(key(2, 2), "node two", 10).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
store
|
||||
.save(&root, ChatDraft::new(key(2, 3), "node three", 11).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
store.read(&root, key(2, 2)).await.unwrap().unwrap().text,
|
||||
"node two"
|
||||
);
|
||||
assert_eq!(
|
||||
store.read(&root, key(2, 3)).await.unwrap().unwrap().text,
|
||||
"node three"
|
||||
);
|
||||
|
||||
let _ = tokio::fs::remove_dir_all(project_root).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fs_chat_draft_store_clear_removes_only_target_scope() {
|
||||
let project_root = temp_dir("clear");
|
||||
tokio::fs::create_dir_all(&project_root).await.unwrap();
|
||||
let root = ProjectPath::new(project_root.to_string_lossy().into_owned()).unwrap();
|
||||
let store = FsChatDraftStore::new();
|
||||
|
||||
store
|
||||
.save(&root, ChatDraft::new(key(2, 2), "clear", 10).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
store
|
||||
.save(&root, ChatDraft::new(key(2, 3), "keep", 11).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
store.clear(&root, key(2, 2)).await.unwrap();
|
||||
|
||||
assert!(store.read(&root, key(2, 2)).await.unwrap().is_none());
|
||||
assert_eq!(
|
||||
store.read(&root, key(2, 3)).await.unwrap().unwrap().text,
|
||||
"keep"
|
||||
);
|
||||
|
||||
let _ = tokio::fs::remove_dir_all(project_root).await;
|
||||
}
|
||||
Reference in New Issue
Block a user