Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions crates/command-contract/src/facets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -111,3 +111,97 @@ pub trait CommandMediaContext {
/// Validate and insert a resolved media path atomically.
fn attach_media(&mut self, resolved_path: &Path) -> Result<MediaAttachmentReceipt, String>;
}

// Project (FEAT-021 D1/D2/D3/D4)
// ---------------------------------------------------------------------------

/// Portable goal status for the project facet (FEAT-021 D1).
///
/// Mirrors the four TUI-owned `tools::goal::GoalStatus` variants without
/// naming the TUI type. The adapter maps host state onto this enum; handlers
/// compare and render it directly.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ProjectGoalStatus {
#[default]
Active,
Paused,
Complete,
Blocked,
}

/// Portable session-share projection (FEAT-021 D1).
///
/// Carries only the emptiness/length and the model/mode labels the live
/// `/share` handler consumes. The session history itself, exporter I/O, and
/// all `App` state stay host-side.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProjectShareProjection {
/// Whether the session history is empty (drives the empty-share error).
pub history_is_empty: bool,
/// Session history length used in the export message and action.
pub history_len: usize,
/// Current model label.
pub model: String,
/// Current operating-mode label.
pub mode_label: String,
}

/// Portable goal projection (FEAT-021 D1).
///
/// Carries the visible goal state, the effective pending-control view, and the
/// session-derived token fallback the live `/goal` handler consumes. Concrete
/// goal-service, session-manager, and `App` types never cross the boundary.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProjectGoalState {
/// Visible goal objective.
pub objective: Option<String>,
/// Visible goal status.
pub status: ProjectGoalStatus,
/// Pause reason label when the goal is paused (already rendered).
pub pause_reason: Option<String>,
/// Elapsed seconds from `started_at` when present (host-computed).
pub started_at_elapsed_seconds: Option<u64>,
/// Seconds of goal time used (stable budget/elapsed source).
pub time_used_seconds: u64,
/// Optional token budget.
pub token_budget: Option<u32>,
/// Tokens used by the goal engine.
pub tokens_used: u64,
/// Session conversation-token total (fallback when tokens_used == 0).
pub session_total_tokens: u32,
/// Goal continuation count.
pub continuation_count: u32,
/// Whether pending goal controls are queued (effective-state gate).
pub pending_controls: bool,
/// Last-known durable objective (session-derived effective source).
pub last_known_objective: Option<String>,
/// Last-known durable status (session-derived effective source).
pub last_known_status: Option<ProjectGoalStatus>,
/// Whether the conversation has API messages (bare `/goal` context gate).
pub conversation_present: bool,
/// Whether the host is currently loading (idle-hint gate).
pub is_loading: bool,
/// Whether the goal continuation loop is waiting (idle-hint gate).
pub goal_continuation_waiting: bool,
}

/// Host project data for the project command group (FEAT-021 D1).
///
/// Exposes the typed, exact-minimum operations the live project handlers
/// consume: `/lsp` status/set state, `/share` session payload data, and
/// `/goal` goal state including the session-derived effective values.
/// `/init` host data flows through the existing `WORKSPACE` facet (D2), so
/// `/init` destructures exactly `WORKSPACE` (D4) and consumes no
/// project-facet method. All results are contract-owned portable values; implementation
/// errors cross as safe text. The TUI adapter is the only place that touches
/// `App`, `config::config`, the goal service, or the session manager.
pub trait CommandProjectContext {
/// `/lsp` status: whether LSP diagnostics are enabled.
fn lsp_enabled(&self) -> bool;
/// `/lsp` set: enable or disable LSP diagnostics.
fn lsp_set(&mut self, enabled: bool) -> Result<(), String>;
/// `/share` projection: session emptiness, length, model, and mode label.
fn share_projection(&self) -> ProjectShareProjection;
/// `/goal` projection: visible and effective goal state.
fn goal_state(&self) -> ProjectGoalState;
}
15 changes: 13 additions & 2 deletions crates/command-contract/src/handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,9 @@

use crate::facets::{
CommandCostContext, CommandMediaContext, CommandModePolicyContext, CommandModelContext,
CommandPresentationContext, CommandSessionContext, CommandSkillsContext,
CommandPresentationContext, CommandProjectContext, CommandSessionContext, CommandSkillsContext,
CommandSystemPromptContext, CommandWorkspaceContext,
};

/// A command handler that is either argument-only or capability-scoped.
#[derive(Clone, Copy)]
pub enum CommandHandler<R> {
Expand All @@ -28,6 +27,7 @@ pub struct CommandContexts<'a> {
workspace: Option<&'a mut dyn CommandWorkspaceContext>,
presentation: Option<&'a mut dyn CommandPresentationContext>,
media: Option<&'a mut dyn CommandMediaContext>,
project: Option<&'a mut dyn CommandProjectContext>,
}

/// Consumed envelope used when one handler needs several independent facets.
Expand All @@ -41,6 +41,7 @@ pub struct ContextParts<'a> {
pub workspace: Option<&'a mut dyn CommandWorkspaceContext>,
pub presentation: Option<&'a mut dyn CommandPresentationContext>,
pub media: Option<&'a mut dyn CommandMediaContext>,
pub project: Option<&'a mut dyn CommandProjectContext>,
}

impl<'a> CommandContexts<'a> {
Expand All @@ -55,6 +56,7 @@ impl<'a> CommandContexts<'a> {
workspace: None,
presentation: None,
media: None,
project: None,
}
}

Expand All @@ -69,6 +71,7 @@ impl<'a> CommandContexts<'a> {
workspace: self.workspace,
presentation: self.presentation,
media: self.media,
project: self.project,
}
}

Expand Down Expand Up @@ -140,6 +143,14 @@ impl<'a> CommandContexts<'a> {
);
self
}

pub fn with_project(mut self, value: &'a mut dyn CommandProjectContext) -> Self {
assert!(
self.project.replace(value).is_none(),
"project facet already set"
);
self
}
}

impl Default for CommandContexts<'_> {
Expand Down
154 changes: 154 additions & 0 deletions crates/command-contract/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -344,3 +344,157 @@ fn envelope_rejects_duplicate_new_slots_deterministically() {
}));
assert!(result.is_err(), "duplicate media slot must assert");
}

// Project facet (FEAT-021 D1/D4)
// ---------------------------------------------------------------------------

/// Deterministic fake project facet over portable values only.
struct FakeProject {
lsp_enabled: bool,
share: ProjectShareProjection,
goal: ProjectGoalState,
}

impl FakeProject {
fn new() -> Self {
Self {
lsp_enabled: false,
share: ProjectShareProjection {
history_is_empty: true,
history_len: 0,
model: "deepseek-chat".to_string(),
mode_label: "ACT".to_string(),
},
goal: ProjectGoalState {
objective: Some("Ship FEAT-021".to_string()),
status: ProjectGoalStatus::Active,
pause_reason: None,
started_at_elapsed_seconds: Some(42),
time_used_seconds: 42,
token_budget: Some(50_000),
tokens_used: 1_000,
session_total_tokens: 2_000,
continuation_count: 3,
pending_controls: false,
last_known_objective: None,
last_known_status: None,
conversation_present: true,
is_loading: false,
goal_continuation_waiting: false,
},
}
}
}

impl CommandProjectContext for FakeProject {
fn lsp_enabled(&self) -> bool {
self.lsp_enabled
}

fn lsp_set(&mut self, enabled: bool) -> Result<(), String> {
self.lsp_enabled = enabled;
Ok(())
}

fn share_projection(&self) -> ProjectShareProjection {
self.share.clone()
}

fn goal_state(&self) -> ProjectGoalState {
self.goal.clone()
}
}

#[test]
fn project_facet_is_object_safe_and_typed() {
fn project(_: &dyn CommandProjectContext) {}
project(&FakeProject::new());

let mut project = FakeProject::new();
assert!(!project.lsp_enabled());
project.lsp_set(true).unwrap();
assert!(project.lsp_enabled());
project.lsp_set(false).unwrap();
assert!(!project.lsp_enabled());
}

#[test]
fn project_share_projection_preserves_semantic_values() {
let project = FakeProject::new();
let share = project.share_projection();
assert!(share.history_is_empty);
assert_eq!(share.history_len, 0);
assert_eq!(share.model, "deepseek-chat");
assert_eq!(share.mode_label, "ACT");
}

#[test]
fn project_goal_state_preserves_semantic_values() {
let project = FakeProject::new();
let goal = project.goal_state();
assert_eq!(goal.objective.as_deref(), Some("Ship FEAT-021"));
assert_eq!(goal.status, ProjectGoalStatus::Active);
assert_eq!(goal.pause_reason, None);
assert_eq!(goal.started_at_elapsed_seconds, Some(42));
assert_eq!(goal.time_used_seconds, 42);
assert_eq!(goal.token_budget, Some(50_000));
assert_eq!(goal.tokens_used, 1_000);
assert_eq!(goal.session_total_tokens, 2_000);
assert_eq!(goal.continuation_count, 3);
assert!(!goal.pending_controls);
assert_eq!(goal.last_known_objective, None);
assert_eq!(goal.last_known_status, None);
assert!(goal.conversation_present);
assert!(!goal.is_loading);
assert!(!goal.goal_continuation_waiting);
}

#[test]
fn project_goal_status_variants_are_distinguishable() {
let paused = ProjectGoalState {
status: ProjectGoalStatus::Paused,
pause_reason: Some("user".to_string()),
..FakeProject::new().goal
};
assert_eq!(paused.status, ProjectGoalStatus::Paused);
assert_eq!(paused.pause_reason.as_deref(), Some("user"));

let complete = ProjectGoalState {
status: ProjectGoalStatus::Complete,
..paused
};
assert_eq!(complete.status, ProjectGoalStatus::Complete);
assert_ne!(complete.status, ProjectGoalStatus::Blocked);
}

#[test]
fn project_facet_transports_through_envelope_when_declared() {
let mut project = FakeProject::new();
let parts = CommandContexts::empty()
.with_project(&mut project)
.into_parts();
assert!(parts.project.is_some());
assert!(parts.session.is_none());

// PROJECT combined with WORKSPACE (init) and PRESENTATION (goal).
let mut workspace = Workspace;
let parts = CommandContexts::empty()
.with_project(&mut project)
.with_workspace(&mut workspace)
.into_parts();
assert!(parts.project.is_some());
assert!(parts.workspace.is_some());
assert!(parts.presentation.is_none());
}

#[test]
fn envelope_rejects_duplicate_project_slot_deterministically() {
let mut a = FakeProject::new();
let mut b = FakeProject::new();
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
CommandContexts::empty()
.with_project(&mut a)
.with_project(&mut b);
}));
assert!(result.is_err(), "duplicate project slot must assert");
}
Loading
Loading