//! Data Transfer Objects crossing the IPC boundary. //! //! Convention (frozen here for L1, see L1-ipc-bridge.md "points d'attention"): //! **all IPC payloads are `camelCase`** via `#[serde(rename_all = "camelCase")]`. //! Rust uses `snake_case` fields; serde renames them on the wire so the //! TypeScript side sees idiomatic camelCase. This matches the persisted-domain //! JSON convention already used in the domain (`agents.json` etc.). use serde::{Deserialize, Serialize}; use application::{ AgentTicketState, AppError, AttachLiveAgentOutput, ConversationPreviewStatus, ConversationTurnWorkPreview, ConversationWorkSummary, CreateProjectInput, CreateProjectOutput, GitGraphOutput, HealthInput, HealthReport, LayoutKind, ListProjectsOutput, LiveSessionKind, LiveSessionSnapshot, OpenProjectOutput, ProjectWorkState, StopLiveAgentOutput, TicketWorkSource, TicketWorkStatus, TurnPage, TurnSource, TurnView, }; use domain::{AgentBusyState, PageCursor, PageDirection, Project, ProjectId, TurnRole}; /// Request DTO for the `health` command. #[derive(Debug, Clone, Default, Deserialize)] #[serde(rename_all = "camelCase")] pub struct HealthRequestDto { /// Optional note echoed back by the use case. #[serde(default)] pub note: Option, } impl From for HealthInput { fn from(dto: HealthRequestDto) -> Self { Self { note: dto.note } } } /// Response DTO for the `health` command. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct HealthResponseDto { /// Application version. pub version: String, /// Liveness flag. pub alive: bool, /// Server time in epoch milliseconds. pub time_millis: i64, /// Correlation id for this call. pub correlation_id: String, /// Echoed note, if any. pub note: Option, } impl From for HealthResponseDto { fn from(r: HealthReport) -> Self { Self { version: r.version, alive: r.alive, time_millis: r.time_millis, correlation_id: r.correlation_id, note: r.note, } } } /// Error DTO returned to the frontend in the `Err` arm of every command. /// /// `code` is a stable machine-readable string (see [`AppError::code`]); the /// frontend branches on it without parsing `message`. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ErrorDto { /// Stable error code, e.g. `NOT_FOUND`, `INVALID`. pub code: String, /// Human-readable message. pub message: String, } impl From for ErrorDto { fn from(e: AppError) -> Self { Self { code: e.code().to_owned(), message: e.to_string(), } } } // --------------------------------------------------------------------------- // Projects (L2) // --------------------------------------------------------------------------- /// A project as seen by the frontend (camelCase wire shape). /// /// `remote` is the domain [`domain::RemoteRef`], which already serialises /// camelCase + tagged (`kind`), so we embed it directly. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct ProjectDto { /// Stable project id (UUID string). pub id: String, /// Display name. pub name: String, /// Absolute project root. pub root: String, /// Where the project lives (`{ "kind": "local" }`, `ssh`, `wsl`). pub remote: domain::RemoteRef, /// Creation timestamp, epoch milliseconds. pub created_at: i64, } impl From for ProjectDto { fn from(p: Project) -> Self { Self { id: p.id.to_string(), name: p.name, root: p.root.as_str().to_owned(), remote: p.remote, created_at: p.created_at, } } } /// Request DTO for `create_project`. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CreateProjectRequestDto { /// Display name. pub name: String, /// Absolute project root. pub root: String, /// Optional remote reference; defaults to local when omitted. #[serde(default)] pub remote: Option, /// Optional default profile id. #[serde(default)] pub default_profile_id: Option, } impl From for CreateProjectInput { fn from(dto: CreateProjectRequestDto) -> Self { Self { name: dto.name, root: dto.root, remote: dto.remote, default_profile_id: dto.default_profile_id, } } } impl From for ProjectDto { fn from(out: CreateProjectOutput) -> Self { out.project.into() } } impl From for ProjectDto { fn from(out: OpenProjectOutput) -> Self { out.project.into() } } /// Parses a project-id string (UUID) coming from the frontend. /// /// # Errors /// Returns an [`ErrorDto`] with code `INVALID` if the string is not a UUID. pub fn parse_project_id(raw: &str) -> Result { uuid::Uuid::parse_str(raw) .map(ProjectId::from_uuid) .map_err(|_| ErrorDto { code: "INVALID".to_owned(), message: format!("invalid project id: {raw}"), }) } /// Response DTO for `list_projects`. #[derive(Debug, Clone, Serialize)] #[serde(transparent)] pub struct ProjectListDto(pub Vec); impl From for ProjectListDto { fn from(out: ListProjectsOutput) -> Self { Self(out.projects.into_iter().map(ProjectDto::from).collect()) } } // --------------------------------------------------------------------------- // Terminals (L3) // --------------------------------------------------------------------------- use application::{ CloseTerminalInput, CloseTerminalOutput, OpenTerminalInput, OpenTerminalOutput, ResizeTerminalInput, WriteToTerminalInput, }; use domain::SessionId; /// Request DTO for `open_terminal`. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct OpenTerminalRequestDto { /// Working directory (typically the project root). pub cwd: String, /// Initial terminal height in rows. pub rows: u16, /// Initial terminal width in columns. pub cols: u16, /// Optional explicit command; defaults to the platform shell when omitted. #[serde(default)] pub command: Option, /// Optional arguments for the command. #[serde(default)] pub args: Vec, } impl From for OpenTerminalInput { fn from(dto: OpenTerminalRequestDto) -> Self { Self { cwd: dto.cwd, rows: dto.rows, cols: dto.cols, command: dto.command, args: dto.args, node_id: None, } } } /// Response DTO for `open_terminal`: the freshly-opened session. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct TerminalSessionDto { /// Stable session id (UUID string) — used for write/resize/close + the /// output channel. pub session_id: String, /// Working directory the shell runs in. pub cwd: String, /// Current rows. pub rows: u16, /// Current cols. pub cols: u16, /// Conversation id **assigned** by this launch, when the agent's profile /// supports session assignment and the hosting cell had none yet (T4b). The /// front persists it on the leaf (`setCellConversation`) so the next open /// resumes. `None` for a plain terminal, a resume, or a degraded launch. #[serde(skip_serializing_if = "Option::is_none")] pub assigned_conversation_id: Option, /// **Id de session moteur** (resumable du provider courant) exposé par ce /// lancement, **distinct** de l'id de paire `assignedConversationId` (ARCHITECTURE /// §19.7, lot P8a). Le front le range dans le **cache** `engineSessionId` de la /// cellule (jamais sur `conversationId`). Absent pour un terminal nu, une reprise, /// ou quand le moteur n'expose encore aucun id. La source de vérité du resumable /// est `providers.json` (lot P8b). #[serde(skip_serializing_if = "Option::is_none")] pub engine_session_id: Option, /// How the frontend should render the cell hosting this session (§17.6): /// [`CellKind::Chat`] for a structured AI session (an `AgentChatView` driven by /// `agent_send`/`reattach_agent_chat`), [`CellKind::Pty`] for a raw terminal /// (xterm). **Derived**, not a layout field: it follows the presence of a /// structured session descriptor on the launch output — a single source of /// truth. Plain terminals and the non-launch construction paths default to /// [`CellKind::Pty`] (the historical, non-breaking shape: a PTY session DTO /// always serialises with `cellKind: "pty"`). pub cell_kind: CellKind, } /// Whether a session's hosting cell renders as a structured chat view or a raw /// terminal (ARCHITECTURE §17.6). Serialises as `"chat"` / `"pty"` on the wire; /// the frontend `LayoutGrid` switches `AgentChatView` vs `TerminalView` on it. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum CellKind { /// A raw PTY terminal cell (xterm) — the historical default. Pty, /// A structured AI chat cell (`AgentChatView`), backed by an `AgentSession`. Chat, } impl From for TerminalSessionDto { fn from(out: OpenTerminalOutput) -> Self { let s = out.session; Self { session_id: s.id.to_string(), cwd: s.cwd.as_str().to_owned(), rows: s.pty_size.rows, cols: s.pty_size.cols, assigned_conversation_id: None, engine_session_id: None, cell_kind: CellKind::Pty, } } } /// Response DTO for `reattach_terminal`: the retained scrollback of a still-live /// session, repainted into the re-mounting xterm before the new output stream is /// wired. Bytes are serialised as a number array, matching the PTY output channel. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct ReattachResultDto { /// The session that was re-attached (echoed back for the frontend). pub session_id: String, /// The most-recent retained output bytes (scrollback ring buffer). pub scrollback: Vec, } /// Request DTO for `write_terminal`. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct WriteTerminalRequestDto { /// Target session id. pub session_id: String, /// Bytes to write (xterm keystrokes). pub data: Vec, } impl WriteTerminalRequestDto { /// Converts to the use-case input, parsing the session id. /// /// # Errors /// [`ErrorDto`] with code `INVALID` if the id is malformed. pub fn into_input(self) -> Result { Ok(WriteToTerminalInput { session_id: parse_session_id(&self.session_id)?, data: self.data, }) } } /// Request DTO for `resize_terminal`. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ResizeTerminalRequestDto { /// Target session id. pub session_id: String, /// New rows. pub rows: u16, /// New cols. pub cols: u16, } impl ResizeTerminalRequestDto { /// Converts to the use-case input, parsing the session id. /// /// # Errors /// [`ErrorDto`] with code `INVALID` if the id is malformed. pub fn into_input(self) -> Result { Ok(ResizeTerminalInput { session_id: parse_session_id(&self.session_id)?, rows: self.rows, cols: self.cols, }) } } /// Response DTO for `close_terminal`. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct TerminalClosedDto { /// Exit code, if the process reported one. pub code: Option, } impl From for TerminalClosedDto { fn from(out: CloseTerminalOutput) -> Self { Self { code: out.code } } } /// Builds a [`CloseTerminalInput`] from a raw session-id string. /// /// # Errors /// [`ErrorDto`] with code `INVALID` if the id is malformed. pub fn parse_close_terminal(raw: &str) -> Result { Ok(CloseTerminalInput { session_id: parse_session_id(raw)?, }) } /// Parses a session-id string (UUID) coming from the frontend. /// /// # Errors /// Returns an [`ErrorDto`] with code `INVALID` if the string is not a UUID. pub fn parse_session_id(raw: &str) -> Result { uuid::Uuid::parse_str(raw) .map(SessionId::from_uuid) .map_err(|_| ErrorDto { code: "INVALID".to_owned(), message: format!("invalid session id: {raw}"), }) } // --------------------------------------------------------------------------- // Layout (L4 + #4 management + #3 per-cell agent) // --------------------------------------------------------------------------- use application::{ CreateLayoutOutput, DeleteLayoutOutput, LayoutInfo, LayoutOperation, ListLayoutsOutput, LoadLayoutOutput, MutateLayoutOutput, SetActiveLayoutOutput, }; use domain::{AgentId, Direction, LayoutId, LayoutTree, NodeId}; /// Response DTO carrying a layout tree. /// /// [`LayoutTree`] already serialises camelCase + tagged (its enum uses /// `#[serde(tag = "type", content = "node")]`), so we embed it directly; the /// TypeScript mirror in `@/domain` matches this shape. #[derive(Debug, Clone, Serialize)] #[serde(transparent)] pub struct LayoutDto(pub LayoutTree); impl From for LayoutDto { fn from(out: LoadLayoutOutput) -> Self { Self(out.layout) } } impl From for LayoutDto { fn from(out: MutateLayoutOutput) -> Self { Self(out.layout) } } /// A layout operation as sent by the frontend (tagged on `type`, camelCase). /// /// Mirrors [`LayoutOperation`]; node/session ids cross the wire as UUID strings /// and are parsed here. `direction` reuses the domain [`Direction`] (which /// already serialises `"row"`/`"column"`). #[derive(Debug, Clone, Deserialize)] #[serde(tag = "type", rename_all = "camelCase")] pub enum LayoutOperationDto { /// Split a leaf into a two-child split. #[serde(rename_all = "camelCase")] Split { /// Leaf to split. target: String, /// Row (columns) or Column (rows). direction: Direction, /// Id for the new sibling leaf. new_leaf: String, /// Id for the wrapping split container. container: String, }, /// Collapse a split container back to one child. #[serde(rename_all = "camelCase")] Merge { /// Split container to collapse. container: String, /// Index of the child to keep. keep_index: usize, }, /// Reassign a split's child weights. #[serde(rename_all = "camelCase")] Resize { /// Split container to resize. container: String, /// New weights (one per child). weights: Vec, }, /// Move a session from one leaf to another. #[serde(rename_all = "camelCase")] Move { /// Source leaf. from: String, /// Target (empty) leaf. to: String, }, /// Attach/detach a session to/from a leaf. #[serde(rename_all = "camelCase")] SetSession { /// Hosting leaf. target: String, /// Session id, or `null` to clear. #[serde(default)] session: Option, }, /// Attach/detach an agent to/from a leaf (#3 per-cell agent). #[serde(rename_all = "camelCase")] SetCellAgent { /// Hosting leaf. target: String, /// Agent id, or `null` to clear. #[serde(default)] agent: Option, }, /// Record/clear the persistent CLI conversation id on a leaf (T4b). #[serde(rename_all = "camelCase")] SetCellConversation { /// Hosting leaf. target: String, /// Conversation id, or `null` to clear. #[serde(default)] conversation_id: Option, }, } impl LayoutOperationDto { /// Converts to the use-case operation, parsing all ids. /// /// # Errors /// [`ErrorDto`] with code `INVALID` if any id is malformed. pub fn into_operation(self) -> Result { Ok(match self { Self::Split { target, direction, new_leaf, container, } => LayoutOperation::Split { target: parse_node_id(&target)?, direction, new_leaf: parse_node_id(&new_leaf)?, container: parse_node_id(&container)?, }, Self::Merge { container, keep_index, } => LayoutOperation::Merge { container: parse_node_id(&container)?, keep_index, }, Self::Resize { container, weights } => LayoutOperation::Resize { container: parse_node_id(&container)?, weights, }, Self::Move { from, to } => LayoutOperation::Move { from: parse_node_id(&from)?, to: parse_node_id(&to)?, }, Self::SetSession { target, session } => LayoutOperation::SetSession { target: parse_node_id(&target)?, session: session.as_deref().map(parse_session_id).transpose()?, }, Self::SetCellAgent { target, agent } => LayoutOperation::SetCellAgent { target: parse_node_id(&target)?, agent: agent.as_deref().map(parse_agent_id).transpose()?, }, Self::SetCellConversation { target, conversation_id, } => LayoutOperation::SetCellConversation { target: parse_node_id(&target)?, conversation_id, }, }) } } /// Parses a node-id string (UUID) coming from the frontend. /// /// # Errors /// Returns an [`ErrorDto`] with code `INVALID` if the string is not a UUID. pub fn parse_node_id(raw: &str) -> Result { uuid::Uuid::parse_str(raw) .map(NodeId::from_uuid) .map_err(|_| ErrorDto { code: "INVALID".to_owned(), message: format!("invalid node id: {raw}"), }) } /// Parses a layout-id string (UUID) coming from the frontend. /// /// # Errors /// Returns an [`ErrorDto`] with code `INVALID` if the string is not a UUID. pub fn parse_layout_id(raw: &str) -> Result { uuid::Uuid::parse_str(raw) .map(LayoutId::from_uuid) .map_err(|_| ErrorDto { code: "INVALID".to_owned(), message: format!("invalid layout id: {raw}"), }) } // --------------------------------------------------------------------------- // Layouts (#4) — management DTOs // --------------------------------------------------------------------------- /// Lightweight layout descriptor (id + name + kind), for the tab bar. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct LayoutInfoDto { /// Stable layout id (UUID string). pub id: String, /// Display name. pub name: String, /// Layout kind: `"terminal"` or `"gitGraph"`. pub kind: String, } impl From for LayoutInfoDto { fn from(info: LayoutInfo) -> Self { let kind = match info.kind { LayoutKind::Terminal => "terminal", LayoutKind::GitGraph => "gitGraph", } .to_owned(); Self { id: info.id.to_string(), name: info.name, kind, } } } /// Response DTO for `list_layouts`. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct ListLayoutsDto { /// All named layouts (id + name), in order. pub layouts: Vec, /// The id of the currently active layout. pub active_id: String, } impl From for ListLayoutsDto { fn from(out: ListLayoutsOutput) -> Self { Self { layouts: out.layouts.into_iter().map(LayoutInfoDto::from).collect(), active_id: out.active_id.to_string(), } } } /// Response DTO for `create_layout`. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct CreateLayoutResultDto { /// The id minted for the new layout. pub layout_id: String, } impl From for CreateLayoutResultDto { fn from(out: CreateLayoutOutput) -> Self { Self { layout_id: out.layout_id.to_string(), } } } /// Response DTO for `delete_layout`. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct DeleteLayoutResultDto { /// The active layout after the deletion. pub active_id: String, } impl From for DeleteLayoutResultDto { fn from(out: DeleteLayoutOutput) -> Self { Self { active_id: out.active_id.to_string(), } } } /// Request DTO for `create_layout`. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CreateLayoutRequestDto { /// Owning project id. pub project_id: String, /// Display name for the new layout. pub name: String, /// Optional layout kind: `"terminal"` (default) or `"gitGraph"`. #[serde(default)] pub kind: Option, } impl CreateLayoutRequestDto { /// Parses the optional `kind` string into a [`LayoutKind`]. /// /// # Errors /// [`ErrorDto`] with code `INVALID` if the value is not a known kind. pub fn parse_kind(&self) -> Result { match self.kind.as_deref() { None | Some("terminal") => Ok(LayoutKind::Terminal), Some("gitGraph") => Ok(LayoutKind::GitGraph), Some(other) => Err(ErrorDto { code: "INVALID".to_owned(), message: format!("unknown layout kind: {other}"), }), } } } /// Request DTO for `rename_layout`. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct RenameLayoutRequestDto { /// Owning project id. pub project_id: String, /// Layout to rename. pub layout_id: String, /// New display name. pub name: String, } /// Request DTO for `delete_layout`. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct DeleteLayoutRequestDto { /// Owning project id. pub project_id: String, /// Layout to delete. pub layout_id: String, } /// Response DTO for `set_active_layout`. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct SetActiveLayoutResultDto { /// The layout actually activated — equals the requested id when valid, else /// the unchanged current active id (self-healing fallback). Authoritative. pub active_id: String, } impl From for SetActiveLayoutResultDto { fn from(out: SetActiveLayoutOutput) -> Self { Self { active_id: out.active_id.to_string(), } } } /// Request DTO for `set_active_layout`. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SetActiveLayoutRequestDto { /// Owning project id. pub project_id: String, /// Layout to make active. pub layout_id: String, } // --------------------------------------------------------------------------- // Profiles & first-run (L5) // --------------------------------------------------------------------------- use application::{ ConfigureProfilesInput, ConfigureProfilesOutput, DeleteProfileInput, DetectProfilesInput, DetectProfilesOutput, FirstRunStateOutput, ListProfilesOutput, ProfileAvailability, ReferenceProfilesOutput, SaveProfileInput, SaveProfileOutput, }; use domain::profile::AgentProfile; use domain::ProfileId; /// A profile crossing the wire. [`AgentProfile`] already serialises camelCase /// (id, name, command, args, `contextInjection{strategy,…}`, detect, /// `cwdTemplate`), so we embed it directly — the TS mirror matches this shape. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(transparent)] pub struct ProfileDto(pub AgentProfile); /// A list of profiles (camelCase array on the wire). #[derive(Debug, Clone, Serialize)] #[serde(transparent)] pub struct ProfileListDto(pub Vec); impl From> for ProfileListDto { fn from(v: Vec) -> Self { Self(v.into_iter().map(ProfileDto).collect()) } } impl From for ProfileListDto { fn from(out: ListProfilesOutput) -> Self { out.profiles.into() } } impl From for ProfileListDto { fn from(out: ReferenceProfilesOutput) -> Self { out.profiles.into() } } impl From for ProfileDto { fn from(out: SaveProfileOutput) -> Self { Self(out.profile) } } /// Request DTO for `detect_profiles`: the candidate profiles to probe. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct DetectProfilesRequestDto { /// Candidate profiles whose `detect` command should be run. pub candidates: Vec, } impl From for DetectProfilesInput { fn from(dto: DetectProfilesRequestDto) -> Self { Self { candidates: dto.candidates, } } } /// One availability result (`profile` + whether its CLI is installed). #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct ProfileAvailabilityDto { /// The probed profile. pub profile: AgentProfile, /// Whether the CLI was detected (exit code 0). pub available: bool, } impl From for ProfileAvailabilityDto { fn from(a: ProfileAvailability) -> Self { Self { profile: a.profile, available: a.available, } } } /// Response DTO for `detect_profiles`. #[derive(Debug, Clone, Serialize)] #[serde(transparent)] pub struct DetectProfilesResponseDto(pub Vec); impl From for DetectProfilesResponseDto { fn from(out: DetectProfilesOutput) -> Self { Self(out.results.into_iter().map(Into::into).collect()) } } /// Request DTO for `save_profile`. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SaveProfileRequestDto { /// The profile to upsert. pub profile: AgentProfile, } impl From for SaveProfileInput { fn from(dto: SaveProfileRequestDto) -> Self { Self { profile: dto.profile, } } } /// Request DTO for `configure_profiles` (closes the first run). #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ConfigureProfilesRequestDto { /// All profiles the user chose to keep. pub profiles: Vec, } impl From for ConfigureProfilesInput { fn from(dto: ConfigureProfilesRequestDto) -> Self { Self { profiles: dto.profiles, } } } impl From for ProfileListDto { fn from(out: ConfigureProfilesOutput) -> Self { out.profiles.into() } } /// Response DTO for `first_run_state`. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct FirstRunStateDto { /// `true` when the first-run wizard should be shown. pub is_first_run: bool, /// Pre-filled reference catalogue to seed the wizard. pub reference_profiles: Vec, } impl From for FirstRunStateDto { fn from(out: FirstRunStateOutput) -> Self { Self { is_first_run: out.is_first_run, reference_profiles: out.reference_profiles, } } } // --------------------------------------------------------------------------- // Embedder profiles & engines (LOT C2 — §14.5.3) // --------------------------------------------------------------------------- use application::{ DismissChoice, DismissEmbedderSuggestionInput, EmbedderEnginesView, ListEmbedderProfilesOutput, OnnxModelView, SaveEmbedderProfileInput, SaveEmbedderProfileOutput, }; use domain::profile::EmbedderProfile; /// An embedder profile crossing the wire. [`EmbedderProfile`] already serialises /// camelCase (`id, name, strategy, model?, endpoint?, apiKeyEnv?, dimension`), so we /// embed it directly — the TS mirror matches this shape. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(transparent)] pub struct EmbedderProfileDto(pub EmbedderProfile); /// A list of embedder profiles (camelCase array on the wire). #[derive(Debug, Clone, Serialize)] #[serde(transparent)] pub struct EmbedderProfileListDto(pub Vec); impl From> for EmbedderProfileListDto { fn from(v: Vec) -> Self { Self(v.into_iter().map(EmbedderProfileDto).collect()) } } impl From for EmbedderProfileListDto { fn from(out: ListEmbedderProfilesOutput) -> Self { out.profiles.into() } } impl From for EmbedderProfileDto { fn from(out: SaveEmbedderProfileOutput) -> Self { Self(out.profile) } } /// Request DTO for `save_embedder_profile`: the embedder profile to upsert. /// /// Carries the [`EmbedderProfile`] fields directly (the entity validates them in the /// use case). Deserialised camelCase to match the persisted/domain shape. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SaveEmbedderProfileRequestDto { /// The embedder profile to upsert. pub profile: EmbedderProfile, } impl From for SaveEmbedderProfileInput { fn from(dto: SaveEmbedderProfileRequestDto) -> Self { let EmbedderProfile { id, name, strategy, model, endpoint, api_key_env, dimension, } = dto.profile; Self { id, name, strategy, model, endpoint, api_key_env, dimension, } } } /// One recommendable local ONNX model on the wire (camelCase). #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct OnnxModelInfoDto { /// Stable model id accepted by a `localOnnx` profile's `model` field. pub id: String, /// Human-readable name for the UI. pub display_name: String, /// Length of the vectors this model produces. pub dimension: usize, /// Approximate download/disk size in megabytes. pub approx_size_mb: u32, /// Whether this is the recommended default model. pub recommended: bool, } impl From for OnnxModelInfoDto { fn from(m: OnnxModelView) -> Self { Self { id: m.id, display_name: m.display_name, dimension: m.dimension, approx_size_mb: m.approx_size_mb, recommended: m.recommended, } } } /// Response DTO for `describe_embedder_engines` (drives the "configure an embedder?" /// UI). All-camelCase wire shape. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct EmbedderEnginesDto { /// The curated catalogue of recommendable local ONNX models. pub recommended_onnx: Vec, /// Whether an Ollama-style local embedding server was detected (best-effort). pub ollama_detected: bool, /// Ids of the recommended ONNX models already present in the local cache. pub onnx_cached_models: Vec, /// Whether the HTTP capability (`localServer`/`api`) is compiled into this binary. pub vector_http_enabled: bool, /// Whether the in-process ONNX capability (`localOnnx`) is compiled into this binary. pub vector_onnx_enabled: bool, } impl From for EmbedderEnginesDto { fn from(v: EmbedderEnginesView) -> Self { Self { recommended_onnx: v.recommended_onnx.into_iter().map(Into::into).collect(), ollama_detected: v.ollama_detected, onnx_cached_models: v.onnx_cached_models, vector_http_enabled: v.vector_http_enabled, vector_onnx_enabled: v.vector_onnx_enabled, } } } // --------------------------------------------------------------------------- // Embedder suggestion (LOT C3 — §14.5.5) // --------------------------------------------------------------------------- /// The user's response to the embedder suggestion, on the wire (camelCase: /// `"later"` | `"never"`). #[derive(Debug, Clone, Copy, Deserialize)] #[serde(rename_all = "camelCase")] pub enum DismissChoiceDto { /// "Plus tard" — re-proposable next session. Later, /// "Ne plus demander" — never again. Never, } impl From for DismissChoice { fn from(c: DismissChoiceDto) -> Self { match c { DismissChoiceDto::Later => Self::Later, DismissChoiceDto::Never => Self::Never, } } } /// Request DTO for `dismiss_embedder_suggestion`. The `project_id` is resolved to a /// project root by the command; `choice` is the user's dismissal. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct DismissEmbedderSuggestionRequestDto { /// The project the suggestion concerned (UUID string). pub project_id: String, /// The user's choice. pub choice: DismissChoiceDto, } impl DismissEmbedderSuggestionRequestDto { /// Builds the use-case input from a resolved project root + the DTO choice. #[must_use] pub fn into_input(self, project_root: domain::ProjectPath) -> DismissEmbedderSuggestionInput { DismissEmbedderSuggestionInput { project_root, choice: self.choice.into(), } } } /// Builds a [`DeleteProfileInput`] from a raw profile-id string. /// /// # Errors /// [`ErrorDto`] with code `INVALID` if the id is malformed. pub fn parse_delete_profile(raw: &str) -> Result { Ok(DeleteProfileInput { id: parse_profile_id(raw)?, }) } /// Parses a profile-id string (UUID) coming from the frontend. /// /// # Errors /// Returns an [`ErrorDto`] with code `INVALID` if the string is not a UUID. pub fn parse_profile_id(raw: &str) -> Result { uuid::Uuid::parse_str(raw) .map(ProfileId::from_uuid) .map_err(|_| ErrorDto { code: "INVALID".to_owned(), message: format!("invalid profile id: {raw}"), }) } // --------------------------------------------------------------------------- // Agents (L6) // --------------------------------------------------------------------------- use application::{ ChangeAgentProfileOutput, CreateAgentOutput, InspectConversationOutput, LaunchAgentOutput, ListAgentsOutput, ReadAgentContextOutput, }; use domain::{Agent, EffectivePermissions, PermissionSet, ProjectPermissions, TerminalSession}; /// An agent crossing the wire. [`Agent`] already serialises camelCase /// (`id`, `name`, `contextPath`, `profileId`, `origin` tagged, `synchronized`), /// so we embed it directly — the TS mirror matches this shape. #[derive(Debug, Clone, Serialize)] #[serde(transparent)] pub struct AgentDto(pub Agent); /// A list of agents (camelCase array on the wire). #[derive(Debug, Clone, Serialize)] #[serde(transparent)] pub struct AgentListDto(pub Vec); impl From for AgentListDto { fn from(out: ListAgentsOutput) -> Self { Self(out.agents.into_iter().map(AgentDto).collect()) } } impl From for AgentDto { fn from(out: CreateAgentOutput) -> Self { Self(out.agent) } } /// Request DTO for `create_agent`. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CreateAgentRequestDto { /// Id of the owning project. pub project_id: String, /// Display name for the new agent. pub name: String, /// Runtime profile id. pub profile_id: String, /// Initial Markdown content (empty when absent). #[serde(default)] pub initial_content: Option, } /// Response DTO for `read_agent_context`. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct ReadAgentContextResponseDto { /// The agent's Markdown context content. pub content: String, } impl From for ReadAgentContextResponseDto { fn from(out: ReadAgentContextOutput) -> Self { Self { content: out.content.into_string(), } } } /// Request DTO for `update_agent_context`. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct UpdateAgentContextRequestDto { /// Id of the owning project. pub project_id: String, /// Id of the agent to update. pub agent_id: String, /// New Markdown content. pub content: String, } // --------------------------------------------------------------------------- // Permissions (LP1) // --------------------------------------------------------------------------- /// Full project permission document crossing the wire. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(transparent)] pub struct ProjectPermissionsDto(pub ProjectPermissions); /// Effective permissions crossing the wire. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(transparent)] pub struct EffectivePermissionsDto(pub EffectivePermissions); /// Request DTO for updating project default permissions. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct UpdateProjectPermissionsRequestDto { /// Id of the owning project. pub project_id: String, /// New project defaults. `null` removes defaults. pub permissions: Option, } /// Request DTO for updating one agent permission override. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct UpdateAgentPermissionsRequestDto { /// Id of the owning project. pub project_id: String, /// Target agent id. pub agent_id: String, /// New override. `null` removes the override. pub permissions: Option, } /// Request DTO for resolving one agent's effective permissions. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ResolveAgentPermissionsRequestDto { /// Id of the owning project. pub project_id: String, /// Target agent id. pub agent_id: String, } /// Request DTO for `update_project_context`. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct UpdateProjectContextRequestDto { /// Id of the owning project. pub project_id: String, /// New project-level Markdown context, stored under `.ideai/CONTEXT.md`. pub content: String, } /// Request DTO for `launch_agent`. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct LaunchAgentRequestDto { /// Id of the owning project. pub project_id: String, /// Id of the agent to launch. pub agent_id: String, /// Initial terminal height in rows. pub rows: u16, /// Initial terminal width in columns. pub cols: u16, /// Persistent CLI conversation id currently recorded on the hosting cell, if /// any. `Some` ⇒ the launch resumes it; absent/`None` ⇒ a fresh cell (the /// launch may assign a new id when the profile supports it). #[serde(default)] pub conversation_id: Option, /// The layout leaf (node) hosting this launch. Enforces the "one live session /// per agent" invariant: a launch into a node different from where the agent /// is already running is refused (`AGENT_ALREADY_RUNNING`); the same node is /// idempotent. Absent ⇒ a fresh node is minted (and any already-live agent is /// refused). #[serde(default)] pub node_id: Option, } impl From for TerminalSessionDto { fn from(out: LaunchAgentOutput) -> Self { let assigned_conversation_id = out.assigned_conversation_id; let engine_session_id = out.engine_session_id; // §17.6: the cell kind is *derived* from the launch routing. A structured // descriptor (`structured: Some(..)`) means LaunchAgent routed to an // AgentSession ⇒ chat cell; its absence means the PTY/terminal path ⇒ // terminal cell. Single source of truth, no layout migration. let cell_kind = if out.structured.is_some() { CellKind::Chat } else { CellKind::Pty }; let s = out.session; Self { session_id: s.id.to_string(), cwd: s.cwd.as_str().to_owned(), rows: s.pty_size.rows, cols: s.pty_size.cols, assigned_conversation_id, engine_session_id, cell_kind, } } } /// Request DTO for `interrupt_agent` (cadrage C4 §4.2): the Interrompre path. The /// frontend sends `{ request: { projectId, agentId } }`. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct InterruptAgentRequestDto { /// Id of the owning project. pub project_id: String, /// Id of the agent whose running turn to preempt. pub agent_id: String, } /// Request DTO for `delegation_delivered` (ARCHITECTURE §20.3): the frontend write- /// portal acks that it **physically wrote** a delegation `ticket` into the agent's /// native PTY. Best-effort observability — it never changes correlation (the `ask` is /// still woken by `idea_reply`). The frontend sends `{ request: { projectId, agentId, /// ticket } }`. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct DeliveredDelegationRequestDto { /// Id of the owning project. pub project_id: String, /// Id of the agent whose terminal received the delegation. pub agent_id: String, /// Id of the delivered mailbox ticket. pub ticket: String, } /// Request DTO for `set_front_attached`: the write-portal of an agent cell reports /// whether a **frontend terminal cell is mounted** for `agentId` (`true` on mount, /// `false` on unmount). The mediator uses it to choose, at delivery time, between /// publishing `DelegationReady` (a cell will write it) and writing the turn into the /// PTY itself (headless/background-delegated agent with no cell). The frontend sends /// `{ request: { agentId, attached } }`. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct FrontAttachedRequestDto { /// Id of the agent whose terminal cell mounted/unmounted. pub agent_id: String, /// `true` when the cell's write-portal is now active, `false` on teardown. pub attached: bool, } /// Request DTO for `change_agent_profile` (§15.1): hot-swap an agent's runtime /// profile, optionally relaunching its live session in place. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ChangeAgentProfileRequestDto { /// Id of the owning project. pub project_id: String, /// Id of the agent whose runtime profile to change. pub agent_id: String, /// Id of the new runtime profile. pub profile_id: String, /// Terminal height in rows for a possible hot relaunch. pub rows: u16, /// Terminal width in columns for a possible hot relaunch. pub cols: u16, } /// Response DTO for `change_agent_profile`: the mutated agent plus the freshly /// relaunched session when a live session was hot-swapped (absent otherwise). #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct ChangeAgentProfileDto { /// The agent now carrying the new profile. pub agent: AgentDto, /// The relaunched session, present only when a live session was swapped. #[serde(skip_serializing_if = "Option::is_none")] pub relaunched_session: Option, } impl From for ChangeAgentProfileDto { fn from(out: ChangeAgentProfileOutput) -> Self { Self { agent: AgentDto(out.agent), relaunched_session: out.relaunched.map(TerminalSessionDto::from), } } } impl From for TerminalSessionDto { fn from(s: TerminalSession) -> Self { Self { session_id: s.id.to_string(), cwd: s.cwd.as_str().to_owned(), rows: s.pty_size.rows, cols: s.pty_size.cols, assigned_conversation_id: None, engine_session_id: None, cell_kind: CellKind::Pty, } } } // --------------------------------------------------------------------------- // Structured chat sessions (§17 — D4) // --------------------------------------------------------------------------- /// One incremental chunk of a structured agent reply, streamed over the chat /// session's [`tauri::ipc::Channel`] (ARCHITECTURE §17.7). The serialised wire /// twin of a [`domain::ports::ReplyEvent`]: the `agent_send` pump maps each turn /// event to one of these and pushes it to the frontend `AgentChatView`. /// /// Tagged on `kind` (camelCase: `"textDelta"` | `"toolActivity"` | `"final"`), so /// the front branches without positional parsing. `Final` is the **deterministic /// terminal** chunk of a turn — after it the turn is frozen and the stream ends. /// `Deserialize` is derived too so tests (and a mock gateway) can round-trip it. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "camelCase")] pub enum ReplyChunk { /// An assistant text fragment (incremental chat rendering). #[serde(rename_all = "camelCase")] TextDelta { /// The text fragment. text: String, }, /// A human-readable tool-activity badge (best-effort observability). #[serde(rename_all = "camelCase")] ToolActivity { /// The human-readable activity label. label: String, }, /// The deterministic end-of-turn chunk carrying the aggregated final content. #[serde(rename_all = "camelCase")] Final { /// The aggregated final content of the turn. content: String, }, } /// Response DTO for `reattach_agent_chat`: the retained **conversation /// scrollback** of a still-live structured session, replayed into the /// re-mounting `AgentChatView` before the new reply stream is wired (§17.6). The /// typed twin of [`ReattachResultDto`] (PTY scrollback bytes) — here the /// scrollback is the ordered list of [`ReplyChunk`]s already streamed. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct ReattachChatDto { /// The session that was re-attached (echoed back for the frontend). pub session_id: String, /// The chunks already streamed for this conversation, in order. The frontend /// replays them to rebuild the visible turns, then receives subsequent chunks /// over the freshly-registered channel. pub scrollback: Vec, } /// Request DTO for `inspect_conversation` (T7). #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct InspectConversationRequestDto { /// Id of the owning project. pub project_id: String, /// Id of the agent whose conversation is inspected. pub agent_id: String, /// The conversation id recorded on the hosting cell. pub conversation_id: String, } /// Response DTO for `inspect_conversation` (T7): the best-effort enriched /// details for a resume popup. Both fields are optional and **omitted from the /// wire when `None`** (`skip_serializing_if`), so the TypeScript side sees an /// absent key — not `null` — for a degraded inspection. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct ConversationDetailsDto { /// A short, best-effort label for the conversation (last user message, /// truncated). Absent when it could not be extracted. #[serde(skip_serializing_if = "Option::is_none")] pub last_topic: Option, /// A best-effort cumulative token count. Absent when no usage info exists. #[serde(skip_serializing_if = "Option::is_none")] pub token_count: Option, } impl From for ConversationDetailsDto { fn from(out: InspectConversationOutput) -> Self { Self { last_topic: out.details.last_topic, token_count: out.details.token_count, } } } /// One currently-live agent and the cell hosting it, for `list_live_agents`. /// /// Lets the UI disable an agent already running in another cell (it cannot be /// launched a second time — the "one live session per agent" invariant). #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct LiveAgentDto { /// The live agent's id (UUID string). pub agent_id: String, /// The hosting layout leaf's node id (UUID string). pub node_id: String, /// The live PTY session id, used to reattach a newly-opened cell without /// respawning the agent. pub session_id: String, /// Runtime family that owns the session (`pty`/`structured`). pub kind: LiveWorkSessionKindDto, } /// Response DTO for `list_live_agents` (transparent array on the wire). #[derive(Debug, Clone, Serialize)] #[serde(transparent)] pub struct LiveAgentListDto(pub Vec); impl LiveAgentListDto { /// Builds the wire list from the registry's live-session snapshots. /// /// De-duplicates by `agent_id`: the "one live session per agent" invariant /// guarantees an agent is live in at most one registry (PTY **or** /// structured), but the aggregated input could in theory carry the same agent /// twice; the UI contract is a dup-free set (each agent appears once), so we /// keep the first occurrence and drop any later one for the same agent. #[must_use] pub fn from_snapshots(pairs: Vec) -> Self { let mut seen = std::collections::HashSet::new(); Self( pairs .into_iter() .filter(|snapshot| seen.insert(snapshot.agent_id)) .map(|snapshot| LiveAgentDto { agent_id: snapshot.agent_id.to_string(), node_id: snapshot.node_id.to_string(), session_id: snapshot.session_id.to_string(), kind: snapshot.kind.into(), }) .collect(), ) } } /// Runtime family of a live work-state session. #[derive(Debug, Clone, Copy, Serialize)] #[serde(rename_all = "camelCase")] pub enum LiveWorkSessionKindDto { /// Raw PTY-backed CLI session. Pty, /// Structured agent-session backend. Structured, } impl From for LiveWorkSessionKindDto { fn from(kind: LiveSessionKind) -> Self { match kind { LiveSessionKind::Pty => Self::Pty, LiveSessionKind::Structured => Self::Structured, } } } /// Live session coordinates in the project work-state read model. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct LiveWorkSessionDto { /// The layout node currently hosting the session view. pub node_id: String, /// The live session id. pub session_id: String, /// Runtime family that owns the session. pub kind: LiveWorkSessionKindDto, } /// Derived processing status of a queued ticket. #[derive(Debug, Clone, Copy, Serialize)] #[serde(rename_all = "camelCase")] pub enum TicketWorkStatusDto { /// The agent's current busy turn is running this ticket. InProgress, /// Waiting behind the head / the agent is idle. Queued, } impl From for TicketWorkStatusDto { fn from(status: TicketWorkStatus) -> Self { match status { TicketWorkStatus::InProgress => Self::InProgress, TicketWorkStatus::Queued => Self::Queued, } } } /// Origin of a queued ticket (human operator or a delegating agent). #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase", tag = "kind")] pub enum TicketWorkSourceDto { /// The human operator. Human, /// Another agent delegating via `idea_ask_agent`. #[serde(rename_all = "camelCase")] Agent { /// The delegating agent id. agent_id: String, }, } impl From for TicketWorkSourceDto { fn from(source: TicketWorkSource) -> Self { match source { TicketWorkSource::Human => Self::Human, TicketWorkSource::Agent { agent_id } => Self::Agent { agent_id: agent_id.to_string(), }, } } } /// One queued/in-progress delegation ticket for an agent. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct AgentTicketStateDto { /// Stable id of the queued ticket. pub ticket_id: String, /// Conversation thread this task enters. pub conversation_id: String, /// FIFO position at snapshot time (`0` = head). pub position: u32, /// Derived status (in-progress vs queued). pub status: TicketWorkStatusDto, /// Origin of the ticket. pub source: TicketWorkSourceDto, /// Display label of the requester. pub requester_label: String, /// Bounded excerpt of the task. pub task_preview: String, /// Character length of the original (un-truncated) task. pub task_len: usize, } impl From for AgentTicketStateDto { fn from(ticket: AgentTicketState) -> Self { Self { ticket_id: ticket.ticket_id.to_string(), conversation_id: ticket.conversation_id.to_string(), position: ticket.position, status: ticket.status.into(), source: ticket.source.into(), requester_label: ticket.requester_label, task_preview: ticket.task_preview, task_len: ticket.task_len, } } } /// One manifest agent's current live/busy state. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct AgentWorkStateDto { /// Agent id. pub agent_id: String, /// Agent display name. pub name: String, /// Runtime profile id assigned to the agent. pub profile_id: String, /// Live session, if any. pub live: Option, /// Current mediated-input busy state. pub busy: AgentBusyState, /// Pending/in-progress delegation tickets, in FIFO order. pub tickets: Vec, } /// How much of a [`ConversationWorkSummaryDto`] could be derived, best-effort. #[derive(Debug, Clone, Copy, Serialize)] #[serde(rename_all = "camelCase")] pub enum ConversationPreviewStatusDto { /// A handoff was present and usable. Ready, /// No handoff yet; `recentTurns` may carry the log fallback. Missing, /// The handoff was unreadable but the log fallback was readable. Partial, /// Neither the handoff nor the log could be read. Unavailable, } impl From for ConversationPreviewStatusDto { fn from(status: ConversationPreviewStatus) -> Self { match status { ConversationPreviewStatus::Ready => Self::Ready, ConversationPreviewStatus::Missing => Self::Missing, ConversationPreviewStatus::Partial => Self::Partial, ConversationPreviewStatus::Unavailable => Self::Unavailable, } } } /// One recent turn surfaced in a conversation summary's log fallback. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct ConversationTurnWorkPreviewDto { /// Nature of the turn (`prompt`/`response`/`toolActivity`). pub role: TurnRole, /// Origin of the turn (human operator or a delegating agent). pub source: TicketWorkSourceDto, /// Timestamp (epoch milliseconds) of the turn. pub at_ms: u64, /// Bounded excerpt of the turn text. pub text_preview: String, /// Character length of the original (un-truncated) turn text. pub text_len: usize, } impl From for ConversationTurnWorkPreviewDto { fn from(turn: ConversationTurnWorkPreview) -> Self { Self { role: turn.role, source: turn.source.into(), at_ms: turn.at_ms, text_preview: turn.text_preview, text_len: turn.text_len, } } } /// Best-effort, read-only summary of one conversation visible through the tickets. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct ConversationWorkSummaryDto { /// The conversation (pair) this summary describes. pub conversation_id: String, /// How much could be derived. pub status: ConversationPreviewStatusDto, /// Bounded excerpt of the handoff objective, when present. pub objective_preview: Option, /// Bounded excerpt of the handoff summary, when present. pub summary_preview: Option, /// Character length of the original (un-truncated) handoff summary (`0` when none). pub summary_len: usize, /// Cursor (last turn id) covered by the handoff summary, when present. pub up_to: Option, /// Bounded, recent turns from the log fallback. pub recent_turns: Vec, } impl From for ConversationWorkSummaryDto { fn from(summary: ConversationWorkSummary) -> Self { Self { conversation_id: summary.conversation_id.to_string(), status: summary.status.into(), objective_preview: summary.objective_preview, summary_preview: summary.summary_preview, summary_len: summary.summary_len, up_to: summary.up_to.map(|cursor| cursor.to_string()), recent_turns: summary .recent_turns .into_iter() .map(ConversationTurnWorkPreviewDto::from) .collect(), } } } /// Project-level read model for conversation/delegation UX. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct ProjectWorkStateDto { /// Manifest agents in manifest order, enriched with live/busy state. pub agents: Vec, /// Best-effort summaries of the conversations referenced by the tickets, /// joined frontend-side via `tickets[].conversationId`. pub conversations: Vec, } impl From for ProjectWorkStateDto { fn from(state: ProjectWorkState) -> Self { Self { agents: state .agents .into_iter() .map(|agent| AgentWorkStateDto { agent_id: agent.agent_id.to_string(), name: agent.name, profile_id: agent.profile_id.to_string(), live: agent.live.map(|live| LiveWorkSessionDto { node_id: live.node_id.to_string(), session_id: live.session_id.to_string(), kind: live.kind.into(), }), busy: agent.busy, tickets: agent .tickets .into_iter() .map(AgentTicketStateDto::from) .collect(), }) .collect(), conversations: state .conversations .into_iter() .map(ConversationWorkSummaryDto::from) .collect(), } } } /// Request DTO for `attach_live_agent`: bind an already-running agent session to /// a visible layout cell without spawning a new process. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AttachLiveAgentRequestDto { /// Id of the owning project (validated for symmetry and future scoping). pub project_id: String, /// Id of the already-running agent. pub agent_id: String, /// Layout leaf that should display the live session. pub node_id: String, } /// Response DTO for `attach_live_agent`: the rebound live session's coordinates. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct AttachLiveAgentResponseDto { /// The rebound agent's id. pub agent_id: String, /// The node now hosting the session view. pub node_id: String, /// The (unchanged) live session id. pub session_id: String, /// Runtime family that owns the session (`pty`/`structured`). pub kind: LiveWorkSessionKindDto, } impl From for AttachLiveAgentResponseDto { fn from(out: AttachLiveAgentOutput) -> Self { Self { agent_id: out.agent_id.to_string(), node_id: out.node_id.to_string(), session_id: out.session_id.to_string(), kind: out.kind.into(), } } } /// Request DTO for `stop_live_agent`: tear down an already-running agent's live /// session by agent id (no spawn, agent entity preserved). #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct StopLiveAgentRequestDto { /// Id of the owning project (validated for symmetry and future scoping). pub project_id: String, /// Id of the running agent to stop. pub agent_id: String, } /// Response DTO for `stop_live_agent`: the torn-down session's coordinates. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct StopLiveAgentResponseDto { /// The stopped agent's id. pub agent_id: String, /// The id of the session that was torn down. pub session_id: String, /// Runtime family that owned the session (`pty`/`structured`). pub kind: LiveWorkSessionKindDto, } impl From for StopLiveAgentResponseDto { fn from(out: StopLiveAgentOutput) -> Self { Self { agent_id: out.agent_id.to_string(), session_id: out.session_id.to_string(), kind: out.kind.into(), } } } /// Parses an agent-id string (UUID) coming from the frontend. /// /// # Errors /// Returns an [`ErrorDto`] with code `INVALID` if the string is not a UUID. pub fn parse_agent_id(raw: &str) -> Result { uuid::Uuid::parse_str(raw) .map(AgentId::from_uuid) .map_err(|_| ErrorDto { code: "INVALID".to_owned(), message: format!("invalid agent id: {raw}"), }) } /// Parses a ticket-id string (UUID) coming from the frontend (`delegation_delivered`). /// /// # Errors /// Returns an [`ErrorDto`] with code `INVALID` if the string is not a UUID. pub fn parse_ticket_id(raw: &str) -> Result { uuid::Uuid::parse_str(raw) .map(domain::TicketId::from_uuid) .map_err(|_| ErrorDto { code: "INVALID".to_owned(), message: format!("invalid ticket id: {raw}"), }) } // --------------------------------------------------------------------------- // Resumable agents (§15.2 — Chantier B2) // --------------------------------------------------------------------------- use application::{ListResumableAgentsOutput, ResumableAgent}; /// One resumable agent cell, as seen by the frontend (§15.2). /// /// Mirrors [`ResumableAgent`]: the agent's identity + its host cell, the CLI /// conversation id to resume (absent ⇒ fresh relaunch), the `was_running` flag /// frozen at close, and whether the agent's profile can resume a CLI /// conversation. `conversationId` is **omitted from the wire when `None`** /// (`skip_serializing_if`), so the TypeScript side sees an absent key — not /// `null`. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct ResumableAgentDto { /// The resumable agent's id (UUID string). pub agent_id: String, /// The agent's display name (resolved from the manifest). pub name: String, /// The host layout leaf where the agent is relaunched/resumed (UUID string). pub node_id: String, /// Persistent CLI conversation id carried by the cell. Absent ⇒ fresh /// relaunch (no history to resume). #[serde(skip_serializing_if = "Option::is_none")] pub conversation_id: Option, /// The `agent_was_running` flag frozen at the cell's close. pub was_running: bool, /// `true` when the agent's profile carries a usable resume strategy. pub resume_supported: bool, } impl From for ResumableAgentDto { fn from(r: ResumableAgent) -> Self { Self { agent_id: r.agent_id.to_string(), name: r.name, node_id: r.node_id.to_string(), conversation_id: r.conversation_id, was_running: r.was_running, resume_supported: r.resume_supported, } } } /// Response DTO for `list_resumable_agents` (§15.2). #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct ResumableAgentListDto { /// The resumable agent cells, in layout-traversal order. pub resumable: Vec, } impl From for ResumableAgentListDto { fn from(out: ListResumableAgentsOutput) -> Self { Self { resumable: out .resumable .into_iter() .map(ResumableAgentDto::from) .collect(), } } } // --------------------------------------------------------------------------- // Templates & sync (L7) // --------------------------------------------------------------------------- use application::{ AgentDrift, CreateAgentFromTemplateInput, CreateTemplateInput, CreateTemplateOutput, DetectAgentDriftOutput, ListTemplatesOutput, SyncAgentWithTemplateOutput, UpdateTemplateInput, UpdateTemplateOutput, }; use domain::{AgentTemplate, TemplateId}; /// A template crossing the wire. [`AgentTemplate`] already serialises camelCase /// (`id`, `name`, `contentMd`, `version` as a number, `defaultProfileId`), /// so we embed it directly — the TS mirror matches this shape. #[derive(Debug, Clone, Serialize)] #[serde(transparent)] pub struct TemplateDto(pub AgentTemplate); /// A list of templates (transparent array on the wire). #[derive(Debug, Clone, Serialize)] #[serde(transparent)] pub struct TemplateListDto(pub Vec); impl From for TemplateListDto { fn from(out: ListTemplatesOutput) -> Self { Self(out.templates.into_iter().map(TemplateDto).collect()) } } impl From for TemplateDto { fn from(out: CreateTemplateOutput) -> Self { Self(out.template) } } impl From for TemplateDto { fn from(out: UpdateTemplateOutput) -> Self { Self(out.template) } } /// Request DTO for `create_template`. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CreateTemplateRequestDto { /// Display name. pub name: String, /// Initial Markdown content. pub content: String, /// Default runtime profile id for agents created from this template. pub default_profile_id: String, } impl CreateTemplateRequestDto { /// Converts to the use-case input, parsing the profile id. /// /// # Errors /// [`ErrorDto`] with code `INVALID` if the profile id is malformed. pub fn into_input(self) -> Result { Ok(CreateTemplateInput { name: self.name, content: self.content, default_profile_id: parse_profile_id(&self.default_profile_id)?, }) } } /// Request DTO for `update_template`. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct UpdateTemplateRequestDto { /// Id of the template to update. pub template_id: String, /// New Markdown content. pub content: String, } impl UpdateTemplateRequestDto { /// Converts to the use-case input, parsing the template id. /// /// # Errors /// [`ErrorDto`] with code `INVALID` if the template id is malformed. pub fn into_input(self) -> Result { Ok(UpdateTemplateInput { template_id: parse_template_id(&self.template_id)?, content: self.content, }) } } /// Parses a template-id string (UUID) coming from the frontend. /// /// # Errors /// Returns an [`ErrorDto`] with code `INVALID` if the string is not a UUID. pub fn parse_template_id(raw: &str) -> Result { uuid::Uuid::parse_str(raw) .map(TemplateId::from_uuid) .map_err(|_| ErrorDto { code: "INVALID".to_owned(), message: format!("invalid template id: {raw}"), }) } /// Request DTO for `create_agent_from_template`. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CreateAgentFromTemplateRequestDto { /// Id of the owning project. pub project_id: String, /// Source template id. pub template_id: String, /// Optional agent name; defaults to the template's name when absent. #[serde(default)] pub name: Option, /// Whether the agent tracks the template for future syncs. pub synchronized: bool, } impl CreateAgentFromTemplateRequestDto { /// Converts to the use-case input, given the resolved project. /// /// # Errors /// [`ErrorDto`] with code `INVALID` if the template id is malformed. pub fn into_input( self, project: domain::Project, ) -> Result { Ok(CreateAgentFromTemplateInput { project, template_id: parse_template_id(&self.template_id)?, name: self.name, synchronized: self.synchronized, }) } } /// One drifting agent, as seen by the frontend. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct AgentDriftDto { /// The drifting agent id (UUID string). pub agent_id: String, /// Version the agent is currently synced to. pub from: u64, /// Version available from the template. pub to: u64, } impl From for AgentDriftDto { fn from(d: AgentDrift) -> Self { Self { agent_id: d.agent_id.to_string(), from: d.from.get(), to: d.to.get(), } } } /// Response DTO for `detect_agent_drift` (transparent array on the wire). #[derive(Debug, Clone, Serialize)] #[serde(transparent)] pub struct AgentDriftListDto(pub Vec); impl From for AgentDriftListDto { fn from(out: DetectAgentDriftOutput) -> Self { Self(out.drifts.into_iter().map(AgentDriftDto::from).collect()) } } /// Response DTO for `sync_agent_with_template`. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct SyncResultDto { /// Whether a sync was actually applied. pub synced: bool, /// The version the agent is now at (`null` when no sync happened). pub version: Option, } impl From for SyncResultDto { fn from(out: SyncAgentWithTemplateOutput) -> Self { Self { synced: out.synced, version: out.version.map(|v| v.get()), } } } /// Request DTO for `sync_agent_with_template`. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SyncAgentWithTemplateRequestDto { /// Id of the owning project. pub project_id: String, /// Id of the agent to sync. pub agent_id: String, } // --------------------------------------------------------------------------- // Git (L8) // --------------------------------------------------------------------------- use application::{GitBranchesOutput, GitCommitOutput, GitLogOutput, GitStatusOutput}; use domain::ports::{GitCommitInfo, GitFileStatus, GraphCommit}; /// One changed path returned by `git_status`. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct GitFileStatusDto { /// Repo-relative path. pub path: String, /// Whether the change is staged. pub staged: bool, } impl From for GitFileStatusDto { fn from(s: GitFileStatus) -> Self { Self { path: s.path, staged: s.staged, } } } /// Response DTO for `git_status` (transparent array on the wire). #[derive(Debug, Clone, Serialize)] #[serde(transparent)] pub struct GitStatusListDto(pub Vec); impl From for GitStatusListDto { fn from(out: GitStatusOutput) -> Self { Self( out.entries .into_iter() .map(GitFileStatusDto::from) .collect(), ) } } /// A single commit summary crossing the wire. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct GitCommitDto { /// Commit hash. pub hash: String, /// Commit message summary. pub summary: String, } impl From for GitCommitDto { fn from(c: GitCommitInfo) -> Self { Self { hash: c.hash, summary: c.summary, } } } impl From for GitCommitDto { fn from(out: GitCommitOutput) -> Self { Self::from(out.commit) } } /// Response DTO for `git_log` (transparent array on the wire). #[derive(Debug, Clone, Serialize)] #[serde(transparent)] pub struct GitCommitListDto(pub Vec); impl From for GitCommitListDto { fn from(out: GitLogOutput) -> Self { Self(out.commits.into_iter().map(GitCommitDto::from).collect()) } } /// Response DTO for `git_branches`. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct GitBranchesDto { /// All local branches. pub branches: Vec, /// The current branch (`null` when detached or unborn). pub current: Option, } impl From for GitBranchesDto { fn from(out: GitBranchesOutput) -> Self { Self { branches: out.branches, current: out.current, } } } /// A single commit enriched for graph display. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct GraphCommitDto { /// Full commit hash. pub hash: String, /// First line of the commit message. pub summary: String, /// Parent commit hashes. pub parents: Vec, /// Ref labels pointing at this commit (e.g. `"main"`, `"tag: v1.0"`). pub refs: Vec, /// Author name. pub author: String, /// Author timestamp in Unix seconds. pub timestamp: i64, } impl From for GraphCommitDto { fn from(c: GraphCommit) -> Self { Self { hash: c.hash, summary: c.summary, parents: c.parents, refs: c.refs, author: c.author, timestamp: c.timestamp, } } } /// Response DTO for `git_graph` (transparent array on the wire). #[derive(Debug, Clone, Serialize)] #[serde(transparent)] pub struct GraphCommitListDto(pub Vec); impl From for GraphCommitListDto { fn from(out: GitGraphOutput) -> Self { Self(out.commits.into_iter().map(GraphCommitDto::from).collect()) } } /// Request DTO for `git_stage` / `git_unstage`. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct GitStageRequestDto { /// Id of the owning project. pub project_id: String, /// Repo-relative path to (un)stage. pub path: String, } /// Request DTO for `git_commit`. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct GitCommitRequestDto { /// Id of the owning project. pub project_id: String, /// Commit message. pub message: String, } /// Request DTO for `git_checkout`. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct GitCheckoutRequestDto { /// Id of the owning project. pub project_id: String, /// Branch to check out. pub branch: String, } // --------------------------------------------------------------------------- // Windows (L10) // --------------------------------------------------------------------------- use application::MoveTabToNewWindowOutput; use domain::ids::TabId; /// Response DTO for `move_tab_to_new_window`: the id minted for the new window /// (used as the new OS window's label). #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct MoveTabResultDto { /// The new window id (UUID string). pub new_window_id: String, } impl From for MoveTabResultDto { fn from(out: MoveTabToNewWindowOutput) -> Self { Self { new_window_id: out.new_window_id.to_string(), } } } /// Parses a tab-id string (UUID) coming from the frontend. /// /// # Errors /// Returns an [`ErrorDto`] with code `INVALID` if the string is not a UUID. pub fn parse_tab_id(raw: &str) -> Result { uuid::Uuid::parse_str(raw) .map(TabId::from_uuid) .map_err(|_| ErrorDto { code: "INVALID".to_owned(), message: format!("invalid tab id: {raw}"), }) } // --------------------------------------------------------------------------- // Skills (L12) // --------------------------------------------------------------------------- use application::{CreateSkillOutput, ListSkillsOutput, UpdateSkillOutput}; use domain::{Skill, SkillId, SkillScope}; /// A skill crossing the wire. [`Skill`] already serialises camelCase /// (`id`, `name`, `contentMd`, `scope` as `"global"`/`"project"`), so we embed /// it directly — the TS mirror matches this shape. #[derive(Debug, Clone, Serialize)] #[serde(transparent)] pub struct SkillDto(pub Skill); /// A list of skills (transparent array on the wire). #[derive(Debug, Clone, Serialize)] #[serde(transparent)] pub struct SkillListDto(pub Vec); impl From for SkillListDto { fn from(out: ListSkillsOutput) -> Self { Self(out.skills.into_iter().map(SkillDto).collect()) } } impl From for SkillDto { fn from(out: CreateSkillOutput) -> Self { Self(out.skill) } } impl From for SkillDto { fn from(out: UpdateSkillOutput) -> Self { Self(out.skill) } } /// Request DTO for `create_skill`. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CreateSkillRequestDto { /// Owning project (resolved to a root; ignored on disk for `Global`). pub project_id: String, /// Display name. pub name: String, /// Initial Markdown content. pub content: String, /// Scope the skill is created in. pub scope: SkillScope, } /// Request DTO for `update_skill`. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct UpdateSkillRequestDto { /// Owning project (resolved to a root; ignored on disk for `Global`). pub project_id: String, /// Id of the skill to update. pub skill_id: String, /// Scope the skill lives in. pub scope: SkillScope, /// New Markdown content. pub content: String, } /// Request DTO for `assign_skill_to_agent`. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AssignSkillRequestDto { /// Owning project. pub project_id: String, /// Agent receiving the skill. pub agent_id: String, /// Skill to assign. pub skill_id: String, /// Scope of the skill (recorded alongside the ref). pub scope: SkillScope, } /// Request DTO for `unassign_skill_from_agent`. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct UnassignSkillRequestDto { /// Owning project. pub project_id: String, /// Agent losing the skill. pub agent_id: String, /// Skill to unassign. pub skill_id: String, } /// Parses a skill-id string (UUID) coming from the frontend. /// /// # Errors /// Returns an [`ErrorDto`] with code `INVALID` if the string is not a UUID. pub fn parse_skill_id(raw: &str) -> Result { uuid::Uuid::parse_str(raw) .map(SkillId::from_uuid) .map_err(|_| ErrorDto { code: "INVALID".to_owned(), message: format!("invalid skill id: {raw}"), }) } // --------------------------------------------------------------------------- // Memory (LOT A — §14.5.1) // --------------------------------------------------------------------------- use application::{ CreateMemoryOutput, GetMemoryOutput, ListMemoriesOutput, ReadMemoryIndexOutput, RecallMemoryOutput, ResolveMemoryLinksOutput, UpdateMemoryOutput, }; use domain::{Memory, MemoryIndexEntry, MemorySlug, MemoryType}; /// A memory note crossing the wire. /// /// Built explicitly from [`Memory`] (its `body` is [`domain::MarkdownDoc`], not /// directly a JSON string) so the TS mirror gets a flat camelCase shape. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct MemoryDto { /// The note's slug (its identity, also the file stem). pub name: String, /// One-line description (the index hook). pub description: String, /// The note's kind. pub r#type: MemoryType, /// Markdown body of the note. pub content: String, } impl From for MemoryDto { fn from(memory: Memory) -> Self { Self { name: memory.frontmatter.name.as_str().to_owned(), description: memory.frontmatter.description, r#type: memory.frontmatter.r#type, content: memory.body.into_string(), } } } impl From for MemoryDto { fn from(out: CreateMemoryOutput) -> Self { Self::from(out.memory) } } impl From for MemoryDto { fn from(out: UpdateMemoryOutput) -> Self { Self::from(out.memory) } } impl From for MemoryDto { fn from(out: GetMemoryOutput) -> Self { Self::from(out.memory) } } /// A list of memory notes (transparent array on the wire). #[derive(Debug, Clone, Serialize)] #[serde(transparent)] pub struct MemoryListDto(pub Vec); impl From for MemoryListDto { fn from(out: ListMemoriesOutput) -> Self { Self(out.memories.into_iter().map(MemoryDto::from).collect()) } } /// One row of the structured `MEMORY.md` index crossing the wire. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct MemoryIndexEntryDto { /// The note's slug. pub slug: String, /// The note's display title. pub title: String, /// The one-line hook (the frontmatter description). pub hook: String, /// The note's kind. pub r#type: MemoryType, } impl From for MemoryIndexEntryDto { fn from(entry: MemoryIndexEntry) -> Self { Self { slug: entry.slug.as_str().to_owned(), title: entry.title, hook: entry.hook, r#type: entry.r#type, } } } /// The structured memory index (transparent array on the wire). #[derive(Debug, Clone, Serialize)] #[serde(transparent)] pub struct MemoryIndexDto(pub Vec); impl From for MemoryIndexDto { fn from(out: ReadMemoryIndexOutput) -> Self { Self( out.entries .into_iter() .map(MemoryIndexEntryDto::from) .collect(), ) } } impl From for MemoryIndexDto { fn from(out: RecallMemoryOutput) -> Self { Self( out.entries .into_iter() .map(MemoryIndexEntryDto::from) .collect(), ) } } /// A note's resolved outgoing links — the target slugs (transparent array). #[derive(Debug, Clone, Serialize)] #[serde(transparent)] pub struct MemoryLinksDto(pub Vec); impl From for MemoryLinksDto { fn from(out: ResolveMemoryLinksOutput) -> Self { Self( out.links .into_iter() .map(|link| link.target.as_str().to_owned()) .collect(), ) } } /// Request DTO for `create_memory`. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CreateMemoryRequestDto { /// Owning project (resolved to a root). pub project_id: String, /// Raw slug for the new note. pub name: String, /// One-line description (the index hook). pub description: String, /// The note's kind. pub r#type: MemoryType, /// Markdown body of the note. pub content: String, } /// Request DTO for `update_memory`. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct UpdateMemoryRequestDto { /// Owning project (resolved to a root). pub project_id: String, /// Slug of the note to replace. pub slug: String, /// New description (the index hook). pub description: String, /// New kind. pub r#type: MemoryType, /// New Markdown body. pub content: String, } /// Request DTO for `recall_memory` (LOT B — §14.5.2). #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct RecallMemoryRequestDto { /// Owning project (resolved to a root). pub project_id: String, /// The recall query (often the agent's current working context). pub text: String, /// Approximate token budget bounding the recalled entries (`0` ⇒ empty). pub token_budget: usize, } /// Parses a memory slug string coming from the frontend. /// /// # Errors /// Returns an [`ErrorDto`] with code `INVALID` if the string is not a valid /// kebab-case slug. pub fn parse_memory_slug(raw: &str) -> Result { MemorySlug::new(raw).map_err(|_| ErrorDto { code: "INVALID".to_owned(), message: format!("invalid memory slug: {raw}"), }) } // ── Conversation pagination (lot LS6) ──────────────────────────────────────── /// Request DTO for `read_conversation_page` — a human, paginated transcript read. /// /// `anchor` is a turn id to paginate around (omit to start from a thread end); /// `direction` is `"forward"` (towards newer) or `"backward"` (towards older, /// default — the human view opens on the latest page); `limit` is clamped by the /// domain to `[1, 200]` (omit/`0` ⇒ default 50). #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ReadConversationPageRequestDto { /// The owning project's id. pub project_id: String, /// The conversation (pair) id to read. pub conversation_id: String, /// Optional anchor turn id; omit to start from a thread end. #[serde(default)] pub anchor: Option, /// Pagination direction (`"forward"` or `"backward"`); defaults to backward. #[serde(default)] pub direction: Option, /// Requested page size (omit/`0` ⇒ default; clamped to `[1, 200]`). #[serde(default)] pub limit: Option, } impl ReadConversationPageRequestDto { /// Builds the domain [`PageCursor`] from the request (default direction backward, /// an unparseable anchor degrades to `None` = a thread-end page). #[must_use] pub fn cursor(&self) -> PageCursor { let direction = match self.direction.as_deref() { Some(d) if d.eq_ignore_ascii_case("forward") => PageDirection::Forward, _ => PageDirection::Backward, }; let anchor = self .anchor .as_deref() .and_then(|raw| uuid::Uuid::parse_str(raw).ok()) .map(domain::TurnId::from_uuid); PageCursor { anchor, direction } } } /// Origin of a turn in the human transcript view (mirrors [`TurnSource`]). #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase", tag = "kind")] pub enum TurnSourceDto { /// The human operator. Human, /// Another agent (delegation via `idea_ask_agent`). #[serde(rename_all = "camelCase")] Agent { /// The originating agent id. agent_id: String, }, } impl From for TurnSourceDto { fn from(source: TurnSource) -> Self { match source { TurnSource::Human => Self::Human, TurnSource::Agent { agent_id } => Self::Agent { agent_id: agent_id.to_string(), }, } } } /// One turn in the human transcript — **full text, never truncated**. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct TurnViewDto { /// Stable turn id (also the pagination anchor). pub id: String, /// Timestamp (epoch milliseconds). pub at_ms: u64, /// Turn nature (prompt, response, tool activity). pub role: TurnRole, /// Origin (human or delegating agent). pub source: TurnSourceDto, /// The turn's **complete** text (not truncated). pub text: String, /// Character length of the text. pub text_len: usize, } impl From for TurnViewDto { fn from(turn: TurnView) -> Self { Self { id: turn.id.to_string(), at_ms: turn.at_ms, role: turn.role, source: turn.source.into(), text: turn.text, text_len: turn.text_len, } } } /// A page of the human transcript, oldest-to-newest. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct TurnPageDto { /// The page's turns, oldest to newest. pub turns: Vec, /// Whether more turns exist beyond the page in the travel direction. pub has_more: bool, /// Last turn id of the page (anchor for the next request), or `None` if empty. pub next_anchor: Option, } impl From for TurnPageDto { fn from(page: TurnPage) -> Self { Self { turns: page.turns.into_iter().map(TurnViewDto::from).collect(), has_more: page.has_more, next_anchor: page.next_anchor.map(|id| id.to_string()), } } } #[cfg(test)] mod ls6_pagination_tests { //! LS6 — DTO de la lecture humaine paginée : parsing du curseur + mapping riche //! `TurnPage → TurnPageDto` (texte complet non borné, source Human/Agent, `has_more`, //! `next_anchor`). C'est le « round-trip d'une page » testable sans `State` Tauri. use super::*; use application::{TurnPage, TurnSource, TurnView}; fn tid(n: u128) -> domain::TurnId { domain::TurnId::from_uuid(uuid::Uuid::from_u128(n)) } #[test] fn request_cursor_defaults_to_backward_and_parses_anchor() { // Pas de direction ⇒ backward (la vue humaine ouvre sur la page la plus récente). let req = ReadConversationPageRequestDto { project_id: "p".into(), conversation_id: "c".into(), anchor: None, direction: None, limit: None, }; let cur = req.cursor(); assert_eq!(cur.direction, PageDirection::Backward); assert_eq!(cur.anchor, None); // Forward (insensible à la casse) + ancre UUID valide. let u = uuid::Uuid::from_u128(9); let req = ReadConversationPageRequestDto { project_id: "p".into(), conversation_id: "c".into(), anchor: Some(u.to_string()), direction: Some("Forward".into()), limit: Some(10), }; let cur = req.cursor(); assert_eq!(cur.direction, PageDirection::Forward); assert_eq!(cur.anchor, Some(domain::TurnId::from_uuid(u))); // Ancre non-UUID ⇒ dégrade en None (page depuis un bout du fil), jamais d'erreur. let req = ReadConversationPageRequestDto { project_id: "p".into(), conversation_id: "c".into(), anchor: Some("not-a-uuid".into()), direction: Some("backward".into()), limit: None, }; assert_eq!(req.cursor().anchor, None); } #[test] fn turn_page_dto_round_trips_full_text_and_maps_sources() { let big = "Z".repeat(40_000); let page = TurnPage { turns: vec![ TurnView { id: tid(1), at_ms: 1_700_000_000_000, role: TurnRole::Prompt, source: TurnSource::Human, text: big.clone(), text_len: big.chars().count(), }, TurnView { id: tid(2), at_ms: 1_700_000_000_001, role: TurnRole::Response, source: TurnSource::Agent { agent_id: domain::AgentId::from_uuid(uuid::Uuid::from_u128(7)), }, text: "réponse".to_owned(), text_len: 7, }, ], has_more: true, next_anchor: Some(tid(2)), }; let dto = TurnPageDto::from(page); // Texte complet préservé (non borné) + text_len. assert_eq!(dto.turns[0].text.chars().count(), 40_000); assert_eq!(dto.turns[0].text_len, 40_000); // next_anchor stringifié, has_more porté. assert!(dto.has_more); assert_eq!( dto.next_anchor.as_deref(), Some(tid(2).to_string().as_str()) ); // Round-trip JSON (la « page » telle qu'envoyée au front) : camelCase + source. let json = serde_json::to_value(&dto).unwrap(); assert_eq!(json["hasMore"], serde_json::json!(true)); assert_eq!(json["nextAnchor"], serde_json::json!(tid(2).to_string())); assert_eq!( json["turns"][0]["source"]["kind"], serde_json::json!("human") ); assert_eq!( json["turns"][1]["source"]["kind"], serde_json::json!("agent") ); assert_eq!( json["turns"][1]["source"]["agentId"], serde_json::json!(uuid::Uuid::from_u128(7).to_string()) ); // Le texte intégral survit au passage JSON. assert_eq!( json["turns"][0]["text"].as_str().unwrap().chars().count(), 40_000 ); } #[test] fn empty_turn_page_dto_has_no_next_anchor() { let dto = TurnPageDto::from(TurnPage { turns: Vec::new(), has_more: false, next_anchor: None, }); assert!(dto.turns.is_empty() && !dto.has_more && dto.next_anchor.is_none()); } }