Merge pull request #5839 from aboimpinto/fix/pr-5833-review

fix(commands): resolve FEAT-019 review findings
This commit is contained in:
Hunter Bown authored and GitHub committed 2026-09-02 13:05:34 -07:00
commit 80dd2afaf2
38 files changed
+5664 -1954

No files matched your search

+271 -10
View File
@@ -186,16 +186,6 @@ pub struct ProjectGoalState {
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.
/// Host project data for the project command group (FEAT-021 D1).
///
/// Exposes the typed, exact-minimum operations the live project handlers
@@ -338,3 +328,274 @@ pub trait CommandMemoryContext {
/// Delete the given workspace scope; workspace path is the first argument.
fn delete_workspace(&self, workspace: &Path) -> Result<MemoryDelete, String>;
}
// ---------------------------------------------------------------------------
// Skill group (FEAT-022 D1)
// ---------------------------------------------------------------------------
/// Source provenance of a discovered skill (native file vs reviewed plugin snapshot).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SkillSourceKind {
Native,
Plugin {
plugin_name: String,
plugin_id: String,
},
}
/// Curated product tier for bundled (shipped) skills.
///
/// The canonical name→tier classification stays in the TUI host
/// (`crate::skills::system::bundled_skill_tier`); the portable projection
/// carries the resolved tier so the handler can render the curated listing
/// without duplicating the canonical bundle list.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SkillBundledTier {
CoreAgentic,
FormatTooling,
}
impl SkillBundledTier {
/// Product-facing tier heading used by the `/skills` listing.
#[must_use]
pub fn heading(self) -> &'static str {
match self {
Self::CoreAgentic => "Core agentic",
Self::FormatTooling => "Format & tooling",
}
}
}
/// One discovered skill entry (portable).
///
/// The body is intentionally excluded: activation and review receive body
/// text through their own delegates (`SkillActivationOutcome`/`ReviewOutcome`);
/// listing and inspect render name, description, source, and path only (D1
/// exact-minimum).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SkillEntry {
pub name: String,
pub description: String,
pub source: SkillSourceKind,
/// Native skills carry their on-disk path (inspect output).
pub path: Option<String>,
/// Bundled catalog tier; `None` for user/compatible skills.
pub bundled_tier: Option<SkillBundledTier>,
}
/// Portable projection of the host skill registry (discovery, D1).
///
/// Carries every value the `/skills` and `/skill` handlers render: workspace
/// and configured skills dir displays, discovery mode label, searched
/// directories, entries, warnings, and the enabled-skill total.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SkillRegistryProjection {
pub workspace: String,
pub skills_dir: String,
pub mode_label: String,
pub dirs: Vec<String>,
pub entries: Vec<SkillEntry>,
pub warnings: Vec<String>,
pub total: usize,
}
/// Target scope for skill mutations (`/skill install|update|uninstall|trust`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SkillTargetScope {
Project,
Global,
}
/// Portable mutation outcome mirroring the host receipt variants.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SkillMutationOutcome {
Installed,
Updated,
NoChange,
Removed,
Trusted,
Imported,
AlreadyPresent,
NeedsApproval(String),
NetworkDenied(String),
}
/// Synchronous portable receipt for a skill mutation (FEAT-020 D11 mirror):
/// the host owns the async network bridge; the handler renders the receipt
/// byte-identically from these values.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SkillMutationReceipt {
pub name: String,
pub safe_target_path: String,
pub outcome: SkillMutationOutcome,
}
/// One curated remote registry entry (`/skills --remote`).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RemoteSkillEntry {
pub name: String,
pub description: Option<String>,
pub source: String,
}
/// Remote registry fetch outcome (`/skills --remote`, suggest source).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RemoteRegistryOutcome {
Loaded { entries: Vec<RemoteSkillEntry> },
NeedsApproval(String),
Denied(String),
}
/// Remote recommendation for `/skills suggest <task>`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SkillRecommendation {
pub name: String,
pub description: Option<String>,
pub matched_terms: Vec<String>,
}
/// Per-skill outcome of `/skills sync`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SkillSyncEntry {
Downloaded { name: String, path: String },
Fresh { name: String },
Failed { name: String, reason: String },
Denied { name: String, host: String },
NeedsApproval { name: String, host: String },
}
/// Aggregate `/skills sync` outcome.
///
/// Registry-level network-policy outcomes are carried as variants so the
/// portable handler composes the exact `needs_approval` / `denied` messages.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SkillSyncOutcome {
Done {
total: usize,
downloaded: usize,
fresh: usize,
failed: usize,
entries: Vec<SkillSyncEntry>,
},
RegistryNeedsApproval(String),
RegistryDenied(String),
}
/// Successful skill activation data (host performs the side effects).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SkillActivationOutcome {
pub name: String,
pub description: String,
}
/// Activation failures with the exact data the handler renders.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SkillActivationError {
NotFound {
requested: String,
available: Vec<String>,
warnings: Vec<String>,
},
PluginRejected {
name: String,
reason: String,
},
}
/// `/review` outcome data (host performs the side effects).
///
/// On success the baseline `/review` renders no message — it only emits the
/// `SendMessage` action — so `Ready` carries no payload (D1 exact-minimum).
/// Warnings are only rendered on the not-found path.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ReviewOutcome {
Ready,
NotFound {
skills_dir: String,
global_dir: String,
warnings: Vec<String>,
},
}
/// One snapshot entry for `/restore` listings.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SnapshotEntry {
pub id: String,
pub label: String,
pub timestamp: i64,
}
/// Host approval posture for the `/restore` trust gate (D4: no MODE_POLICY).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CommandApprovalState {
pub yolo: bool,
pub trust_mode: bool,
}
/// Host skill data for the skills command group (FEAT-022 D1).
///
/// Exposes the typed, exact-minimum operations the live skills handlers
/// consume: discovery (`/skills`), activation (`/skill`), synchronous
/// mutation receipts (`/skill install|update|uninstall|trust`), remote
/// registry + sync (`/skills --remote|sync|suggest`), review (`/review`),
/// and snapshot list/restore plus approval state (`/restore`). The host
/// adapter is the only place that touches `App`, `crate::plugins`,
/// `SnapshotRepo`, `crate::skills` services, config/network policy, and the
/// async runtime bridge. The shared FEAT-015 `CommandSkillsContext` is never
/// widened; active-skill reads use that facet, mutations flow through the
/// delegates here (D2). All results are contract-owned portable values;
/// implementation errors cross as safe text. `/skill` declares this facet
/// plus `CommandSkillsContext` for the baseline cache-refresh policy;
/// `/skills`, `/review`, and `/restore` declare exactly this facet.
pub trait CommandSkillGroupContext {
/// `/skills` discovery projection (workspace, skills dir, scan mode,
/// searched directories, plugin-provided skills, warnings).
fn skill_registry_projection(&self) -> SkillRegistryProjection;
/// `/skill` activation: host lookup, plugin-authority verification, and
/// active-skill/history side effects. `SendMessage` task composition is
/// handler-side.
fn activate_skill(
&mut self,
name: &str,
) -> Result<SkillActivationOutcome, SkillActivationError>;
/// `/skill install` — synchronous portable receipt; host owns network/async.
fn install_skill(
&mut self,
scope: Option<SkillTargetScope>,
spec: &str,
) -> Result<SkillMutationReceipt, String>;
/// `/skill update` — synchronous portable receipt; host owns network/async.
fn update_skill(
&mut self,
scope: Option<SkillTargetScope>,
name: &str,
) -> Result<SkillMutationReceipt, String>;
/// `/skill uninstall` — synchronous portable receipt.
fn uninstall_skill(
&mut self,
scope: Option<SkillTargetScope>,
name: &str,
) -> Result<SkillMutationReceipt, String>;
/// `/skill trust` — synchronous portable receipt.
fn trust_skill(
&mut self,
scope: Option<SkillTargetScope>,
name: &str,
) -> Result<SkillMutationReceipt, String>;
/// `/skills --remote` registry fetch (network policy host-side).
fn fetch_remote_registry(&mut self) -> Result<RemoteRegistryOutcome, String>;
/// `/skills suggest <task>` — host fetch + recommendation computation.
fn recommend_skills(&mut self, task: &str) -> Result<Vec<SkillRecommendation>, String>;
/// `/skills sync` — host registry sync (async bridge host-side).
fn sync_registry(&mut self) -> Result<SkillSyncOutcome, String>;
/// `/review` activation: host discovery + side effects (empty-target
/// validation and `SendMessage` composition are handler-side).
fn run_review(&mut self) -> Result<ReviewOutcome, String>;
/// `/restore` snapshot listing.
fn snapshot_list(&mut self, limit: usize) -> Result<Vec<SnapshotEntry>, String>;
/// `/restore <N>`: host restores by snapshot id; handler composes the
/// exact success message from its list entry.
fn restore_snapshot(&mut self, id: &str) -> Result<(), String>;
/// `/restore` trust gate posture (yolo / trust_mode).
fn approval_state(&self) -> CommandApprovalState;
}
+17 -2
View File
@@ -7,7 +7,8 @@
use crate::facets::{
CommandCostContext, CommandMediaContext, CommandMemoryContext, CommandModePolicyContext,
CommandModelContext, CommandPresentationContext, CommandProjectContext, CommandSessionContext,
CommandSkillsContext, CommandSystemPromptContext, CommandWorkspaceContext,
CommandSkillGroupContext, CommandSkillsContext, CommandSystemPromptContext,
CommandWorkspaceContext,
};
/// Exact host capabilities exposed to one contextual command handler.
@@ -33,13 +34,15 @@ impl CommandCapabilities {
pub const MEMORY: Self = Self(1 << 9);
/// Project-group host data (FEAT-021 D1).
pub const PROJECT: Self = Self(1 << 10);
/// Skills-group host data (FEAT-022 D1).
pub const SKILL_GROUP: Self = Self(1 << 11);
pub const fn union(self, other: Self) -> Self {
Self(self.0 | other.0)
}
pub const fn contains(self, capability: Self) -> bool {
self.0 & capability.0 == capability.0
!capability.is_empty() && self.0 & capability.0 == capability.0
}
pub const fn is_empty(self) -> bool {
@@ -78,6 +81,7 @@ pub struct CommandContexts<'a> {
media: Option<&'a mut dyn CommandMediaContext>,
memory: Option<&'a mut dyn CommandMemoryContext>,
project: Option<&'a mut dyn CommandProjectContext>,
skill_group: Option<&'a mut dyn CommandSkillGroupContext>,
}
/// Consumed envelope used when one handler needs several independent facets.
@@ -93,6 +97,7 @@ pub struct ContextParts<'a> {
pub media: Option<&'a mut dyn CommandMediaContext>,
pub memory: Option<&'a mut dyn CommandMemoryContext>,
pub project: Option<&'a mut dyn CommandProjectContext>,
pub skill_group: Option<&'a mut dyn CommandSkillGroupContext>,
}
impl<'a> CommandContexts<'a> {
@@ -109,6 +114,7 @@ impl<'a> CommandContexts<'a> {
media: None,
memory: None,
project: None,
skill_group: None,
}
}
@@ -125,6 +131,7 @@ impl<'a> CommandContexts<'a> {
media: self.media,
memory: self.memory,
project: self.project,
skill_group: self.skill_group,
}
}
@@ -212,6 +219,14 @@ impl<'a> CommandContexts<'a> {
);
self
}
pub fn with_skill_group(mut self, value: &'a mut dyn CommandSkillGroupContext) -> Self {
assert!(
self.skill_group.replace(value).is_none(),
"skill-group facet already set"
);
self
}
}
impl Default for CommandContexts<'_> {
+451
View File
@@ -853,6 +853,8 @@ fn capabilities_declare_exact_memory_authority() {
assert!(!workspace.contains(CommandCapabilities::MEMORY));
assert!(!memory.contains(CommandCapabilities::WORKSPACE));
assert!(CommandCapabilities::NONE.is_empty());
assert!(!workspace_memory.contains(CommandCapabilities::NONE));
assert!(!CommandCapabilities::NONE.contains(CommandCapabilities::NONE));
// No presentation or media authority is declared for the memory group.
assert!(!workspace_memory.contains(CommandCapabilities::PRESENTATION));
assert!(!workspace_memory.contains(CommandCapabilities::MEDIA));
@@ -893,3 +895,452 @@ fn envelope_rejects_duplicate_memory_slot_deterministically() {
}));
assert!(result.is_err(), "duplicate memory slot must assert");
}
// FEAT-022: skill-group facet (CommandSkillGroupContext)
// ---------------------------------------------------------------------------
struct FakeSkillGroup {
projection: SkillRegistryProjection,
activation_result: Result<SkillActivationOutcome, SkillActivationError>,
receipt: SkillMutationReceipt,
remote: Result<RemoteRegistryOutcome, String>,
sync: Result<SkillSyncOutcome, String>,
review: Result<ReviewOutcome, String>,
snapshots: Vec<SnapshotEntry>,
restore_ok: bool,
approval: CommandApprovalState,
}
impl FakeSkillGroup {
fn new() -> Self {
Self {
projection: SkillRegistryProjection {
workspace: "/ws".into(),
skills_dir: "/ws/.codewhale/skills".into(),
mode_label: "compatible".into(),
dirs: vec!["/ws/.codewhale/skills".into()],
entries: vec![SkillEntry {
name: "demo".into(),
description: "Demo skill".into(),
source: SkillSourceKind::Native,
path: Some("/ws/.codewhale/skills/demo/SKILL.md".into()),
bundled_tier: None,
}],
warnings: vec!["one warning".into()],
total: 1,
},
activation_result: Ok(SkillActivationOutcome {
name: "demo".into(),
description: "Demo skill".into(),
}),
receipt: SkillMutationReceipt {
name: "demo".into(),
safe_target_path: "/ws/.codewhale/skills/demo".into(),
outcome: SkillMutationOutcome::Installed,
},
remote: Ok(RemoteRegistryOutcome::Loaded {
entries: vec![RemoteSkillEntry {
name: "demo".into(),
description: Some("Remote demo".into()),
source: "github.com/acme/skills".into(),
}],
}),
sync: Ok(SkillSyncOutcome::Done {
total: 1,
downloaded: 1,
fresh: 0,
failed: 0,
entries: vec![SkillSyncEntry::Downloaded {
name: "demo".into(),
path: "/cache/demo".into(),
}],
}),
review: Ok(ReviewOutcome::Ready),
snapshots: vec![SnapshotEntry {
id: "abcdef123456".into(),
label: "pre-turn:1".into(),
timestamp: 1_700_000_000,
}],
restore_ok: true,
approval: CommandApprovalState {
yolo: true,
trust_mode: false,
},
}
}
}
impl CommandSkillGroupContext for FakeSkillGroup {
fn skill_registry_projection(&self) -> SkillRegistryProjection {
self.projection.clone()
}
fn activate_skill(
&mut self,
_name: &str,
) -> Result<SkillActivationOutcome, SkillActivationError> {
self.activation_result.clone()
}
fn install_skill(
&mut self,
_scope: Option<SkillTargetScope>,
_spec: &str,
) -> Result<SkillMutationReceipt, String> {
Ok(self.receipt.clone())
}
fn update_skill(
&mut self,
_scope: Option<SkillTargetScope>,
_name: &str,
) -> Result<SkillMutationReceipt, String> {
Ok(self.receipt.clone())
}
fn uninstall_skill(
&mut self,
_scope: Option<SkillTargetScope>,
_name: &str,
) -> Result<SkillMutationReceipt, String> {
Ok(self.receipt.clone())
}
fn trust_skill(
&mut self,
_scope: Option<SkillTargetScope>,
_name: &str,
) -> Result<SkillMutationReceipt, String> {
Ok(self.receipt.clone())
}
fn fetch_remote_registry(&mut self) -> Result<RemoteRegistryOutcome, String> {
self.remote.clone()
}
fn recommend_skills(&mut self, task: &str) -> Result<Vec<SkillRecommendation>, String> {
Ok(vec![SkillRecommendation {
name: format!("rec-{task}"),
description: Some("Recommended".into()),
matched_terms: vec!["term".into()],
}])
}
fn sync_registry(&mut self) -> Result<SkillSyncOutcome, String> {
self.sync.clone()
}
fn run_review(&mut self) -> Result<ReviewOutcome, String> {
self.review.clone()
}
fn snapshot_list(&mut self, _limit: usize) -> Result<Vec<SnapshotEntry>, String> {
Ok(self.snapshots.clone())
}
fn restore_snapshot(&mut self, _id: &str) -> Result<(), String> {
if self.restore_ok {
Ok(())
} else {
Err("Restore failed: boom".into())
}
}
fn approval_state(&self) -> CommandApprovalState {
self.approval
}
}
#[test]
fn skill_group_facet_is_object_safe_and_typed() {
fn project(_: &dyn CommandSkillGroupContext) {}
project(&FakeSkillGroup::new());
let group = FakeSkillGroup::new();
let projection = group.skill_registry_projection();
assert_eq!(projection.total, 1);
assert_eq!(projection.entries[0].name, "demo");
assert!(group.approval_state().yolo);
}
#[test]
fn skill_registry_projection_preserves_semantic_values() {
let group = FakeSkillGroup::new();
let projection = group.skill_registry_projection();
assert_eq!(projection.workspace, "/ws");
assert_eq!(projection.skills_dir, "/ws/.codewhale/skills");
assert_eq!(projection.mode_label, "compatible");
assert_eq!(projection.dirs, vec!["/ws/.codewhale/skills"]);
assert_eq!(projection.warnings, vec!["one warning"]);
assert_eq!(projection.entries.len(), 1);
let entry = &projection.entries[0];
assert_eq!(entry.name, "demo");
assert_eq!(entry.description, "Demo skill");
assert_eq!(entry.source, SkillSourceKind::Native);
assert_eq!(
entry.path.as_deref(),
Some("/ws/.codewhale/skills/demo/SKILL.md")
);
assert_eq!(entry.bundled_tier, None);
}
#[test]
fn skill_bundled_tier_headings_are_stable() {
assert_eq!(SkillBundledTier::CoreAgentic.heading(), "Core agentic");
assert_eq!(
SkillBundledTier::FormatTooling.heading(),
"Format & tooling"
);
}
#[test]
fn skill_mutation_receipt_preserves_outcome_variants() {
let installed = FakeSkillGroup::new().receipt;
assert_eq!(installed.name, "demo");
assert_eq!(installed.outcome, SkillMutationOutcome::Installed);
let denied = SkillMutationReceipt {
outcome: SkillMutationOutcome::NetworkDenied("acme.com".into()),
..installed.clone()
};
assert_eq!(
denied.outcome,
SkillMutationOutcome::NetworkDenied("acme.com".into())
);
let approval = SkillMutationReceipt {
outcome: SkillMutationOutcome::NeedsApproval("acme.com".into()),
..installed.clone()
};
assert_eq!(
approval.outcome,
SkillMutationOutcome::NeedsApproval("acme.com".into())
);
assert_ne!(installed.outcome, denied.outcome);
assert_ne!(installed.outcome, approval.outcome);
assert_ne!(denied.outcome, approval.outcome);
}
#[test]
fn skill_source_kind_variants_are_distinguishable() {
let native = SkillSourceKind::Native;
let plugin = SkillSourceKind::Plugin {
plugin_name: "acme".into(),
plugin_id: "acme-1".into(),
};
assert_ne!(native, plugin);
assert_eq!(
plugin,
SkillSourceKind::Plugin {
plugin_name: "acme".into(),
plugin_id: "acme-1".into(),
}
);
}
#[test]
fn remote_registry_outcome_variants_are_distinguishable() {
let loaded = RemoteRegistryOutcome::Loaded {
entries: vec![RemoteSkillEntry {
name: "demo".into(),
description: None,
source: "acme".into(),
}],
};
let approval = RemoteRegistryOutcome::NeedsApproval("acme.com".into());
let denied = RemoteRegistryOutcome::Denied("acme.com".into());
assert_ne!(loaded, approval);
assert_ne!(loaded, denied);
assert_ne!(approval, denied);
}
#[test]
fn skill_sync_outcome_preserves_all_entry_variants() {
let outcome = SkillSyncOutcome::Done {
total: 4,
downloaded: 1,
fresh: 1,
failed: 2,
entries: vec![
SkillSyncEntry::Downloaded {
name: "a".into(),
path: "/cache/a".into(),
},
SkillSyncEntry::Fresh { name: "b".into() },
SkillSyncEntry::Failed {
name: "c".into(),
reason: "boom".into(),
},
SkillSyncEntry::Denied {
name: "d".into(),
host: "acme.com".into(),
},
SkillSyncEntry::NeedsApproval {
name: "e".into(),
host: "acme.com".into(),
},
],
};
let SkillSyncOutcome::Done {
total,
downloaded,
fresh,
failed,
entries,
} = &outcome
else {
panic!("expected Done");
};
assert_eq!(*total, 4);
assert_eq!(*downloaded, 1);
assert_eq!(*fresh, 1);
assert_eq!(*failed, 2);
assert_eq!(entries.len(), 5);
assert!(matches!(entries[0], SkillSyncEntry::Downloaded { .. }));
assert!(matches!(entries[1], SkillSyncEntry::Fresh { .. }));
assert!(matches!(entries[2], SkillSyncEntry::Failed { .. }));
assert!(matches!(entries[3], SkillSyncEntry::Denied { .. }));
assert!(matches!(entries[4], SkillSyncEntry::NeedsApproval { .. }));
}
#[test]
fn skill_sync_registry_policy_variants_are_distinguishable() {
let approval = SkillSyncOutcome::RegistryNeedsApproval("acme.com".into());
let denied = SkillSyncOutcome::RegistryDenied("acme.com".into());
assert_ne!(approval, denied);
assert!(matches!(
approval,
SkillSyncOutcome::RegistryNeedsApproval(host) if host == "acme.com"
));
assert!(matches!(
denied,
SkillSyncOutcome::RegistryDenied(host) if host == "acme.com"
));
}
#[test]
fn skill_activation_error_variants_are_distinguishable() {
let mut group = FakeSkillGroup::new();
group.activation_result = Err(SkillActivationError::NotFound {
requested: "missing".into(),
available: vec!["demo".into()],
warnings: vec![],
});
let not_found = group.activate_skill("missing").unwrap_err();
match &not_found {
SkillActivationError::NotFound {
requested,
available,
..
} => {
assert_eq!(requested, "missing");
assert_eq!(available, &vec!["demo".to_string()]);
}
_ => panic!("expected NotFound"),
}
let mut group = FakeSkillGroup::new();
group.activation_result = Err(SkillActivationError::PluginRejected {
name: "plug".into(),
reason: "authority revoked".into(),
});
let rejected = group.activate_skill("plug").unwrap_err();
match rejected {
SkillActivationError::PluginRejected { name, reason } => {
assert_eq!(name, "plug");
assert_eq!(reason, "authority revoked");
}
_ => panic!("expected PluginRejected"),
}
}
#[test]
fn review_outcome_variants_are_distinguishable() {
let mut group = FakeSkillGroup::new();
group.review = Ok(ReviewOutcome::NotFound {
skills_dir: "/ws/skills".into(),
global_dir: "/home/u/.codewhale/skills".into(),
warnings: vec!["w".into()],
});
let outcome = group.run_review().unwrap();
match outcome {
ReviewOutcome::NotFound {
skills_dir,
global_dir,
warnings,
} => {
assert_eq!(skills_dir, "/ws/skills");
assert_eq!(global_dir, "/home/u/.codewhale/skills");
assert_eq!(warnings, vec!["w".to_string()]);
}
_ => panic!("expected NotFound"),
}
}
#[test]
fn snapshot_and_approval_values_preserve_semantics() {
let mut group = FakeSkillGroup::new();
let snapshots = group.snapshot_list(20).unwrap();
assert_eq!(snapshots.len(), 1);
assert_eq!(snapshots[0].id, "abcdef123456");
assert_eq!(snapshots[0].label, "pre-turn:1");
assert_eq!(snapshots[0].timestamp, 1_700_000_000);
let approval = group.approval_state();
assert!(approval.yolo);
assert!(!approval.trust_mode);
}
#[test]
fn skill_group_facet_transports_through_envelope_when_declared() {
let mut group = FakeSkillGroup::new();
let parts = CommandContexts::empty()
.with_skill_group(&mut group)
.into_parts();
assert!(parts.skill_group.is_some());
assert!(parts.session.is_none());
assert!(parts.project.is_none());
// /skill combines skill_group with SKILLS for baseline cache refreshes.
let mut skills = Skills;
let parts = CommandContexts::empty()
.with_skill_group(&mut group)
.with_skills(&mut skills)
.into_parts();
assert!(parts.skill_group.is_some());
assert!(parts.skills.is_some());
assert!(parts.workspace.is_none());
}
#[test]
fn envelope_rejects_duplicate_skill_group_slot_deterministically() {
let mut a = FakeSkillGroup::new();
let mut b = FakeSkillGroup::new();
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
CommandContexts::empty()
.with_skill_group(&mut a)
.with_skill_group(&mut b);
}));
assert!(result.is_err(), "duplicate skill_group slot must assert");
}
/// Regression: the shared FEAT-015 `CommandSkillsContext` surface is unchanged
/// (getters + cache refresh only, no setter) and still transports through the
/// envelope alongside the new skill-group facet (D2).
#[test]
fn shared_skills_facet_surface_remains_read_only_and_transportable() {
let mut skills = Skills;
let active = skills.active_skill();
assert_eq!(active, None);
assert_eq!(skills.active_skill_provenance(), None);
skills.refresh_skill_cache();
let mut group = FakeSkillGroup::new();
let parts = CommandContexts::empty()
.with_skills(&mut skills)
.with_skill_group(&mut group)
.into_parts();
assert!(parts.skills.is_some());
assert!(parts.skill_group.is_some());
}
+20
View File
@@ -882,6 +882,11 @@ pub struct ConfigToml {
/// empty `path` disables the feature and leaves behavior unchanged.
#[serde(default)]
pub lifecycle_outbox: Option<LifecycleOutboxToml>,
/// Per-session control socket (`[control_socket]`). Opt-in: an absent
/// table or `enabled = false` (the default) leaves the feature off and
/// behavior unchanged.
#[serde(default)]
pub control_socket: Option<ControlSocketToml>,
/// Agent Fleet trust and security policy (#3165). When absent, fleet
/// workers inherit conservative Sandbox defaults.
#[serde(default)]
@@ -1621,6 +1626,21 @@ pub struct LifecycleOutboxToml {
pub webhook_token: Option<String>,
}
/// On-disk schema for the `[control_socket]` table.
///
/// Opt-in per-session control surface: when `enabled`, the interactive TUI
/// binds a unix domain socket at `<sessions-dir>/<session-id>/control.sock`
/// for the running session. The socket speaks newline-framed JSON-RPC with
/// the verbs `message`, `interrupt`, `relaunch`, and `status`. An absent
/// table, or `enabled = false` (the default), disables the feature entirely —
/// behavior is unchanged from a release without the table.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ControlSocketToml {
/// Bind the per-session control socket. Default: false (OFF).
#[serde(default)]
pub enabled: bool,
}
/// On-disk schema for the `[skills]` table (#140). See `config.example.toml`
/// for documentation.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
+42
View File
@@ -131,6 +131,48 @@ fn lifecycle_outbox_toml_webhook_is_optional() {
assert!(outbox.webhook_token.is_none());
}
#[test]
fn control_socket_toml_is_off_by_default_and_parses_when_enabled() {
// Unset = feature OFF: the table is absent and the field is None.
let absent: ConfigToml = toml::from_str("model = \"demo\"\n").expect("minimal config");
assert!(
absent.control_socket.is_none(),
"unset [control_socket] must leave the feature off"
);
// An empty table is also off: enabled defaults to false.
let empty: ConfigToml =
toml::from_str("[control_socket]\n").expect("empty control_socket table");
let socket = empty.control_socket.expect("table should parse");
assert!(!socket.enabled, "empty table must leave the socket off");
// Explicit enable.
let enabled: ConfigToml = toml::from_str(
r#"
[control_socket]
enabled = true
"#,
)
.expect("enabled control_socket table");
assert!(
enabled.control_socket.expect("table should parse").enabled,
"enabled = true must turn the socket on"
);
// Explicit disable stays off.
let disabled: ConfigToml = toml::from_str(
r#"
[control_socket]
enabled = false
"#,
)
.expect("disabled control_socket table");
assert!(
!disabled.control_socket.expect("table should parse").enabled,
"enabled = false must keep the socket off"
);
}
#[test]
fn permissions_toml_deserializes_typed_ask_rules() {
let permissions: PermissionsToml = toml::from_str(
+6
View File
@@ -4215,6 +4215,12 @@ pub(crate) use prepared::{
};
pub(crate) use provider_native_search::{ProviderNativeSearchClient, ProviderNativeSearchRequest};
/// Whether a route speaks ordinary `/chat/completions` (not Messages or Responses).
#[must_use]
pub(crate) fn provider_speaks_chat_completions(api_provider: ApiProvider) -> bool {
provider_default_wire_format(api_provider) == WireFormat::ChatCompletions
}
pub(crate) fn inspect_prompt_for_request(request: &MessageRequest) -> PromptInspection {
chat::inspect_prompt_for_request(request)
}
File diff suppressed because it is too large. Load diff
@@ -1,28 +1,18 @@
//! Balance: query the active provider's account balance or credit status.
//!
//! Provider-specific network dispatch is still pending. Until that lands, keep
//! this command explicit about being a scaffold so users do not mistake it for
//! a live balance lookup.
//! Balance: query the active provider's remaining prepaid credit.
use crate::config::ApiProvider;
use crate::tui::app::App;
use crate::config::provider_has_balance_api;
use crate::tui::app::{App, AppAction};
use super::CommandResult;
/// Query provider account balance / credits.
pub fn balance(app: &mut App) -> CommandResult {
let provider = app.api_provider;
match provider {
ApiProvider::Deepseek
| ApiProvider::DeepseekCN
| ApiProvider::Openrouter
| ApiProvider::Novita => CommandResult::message(format!(
"Balance check for {} is planned, but provider balance network dispatch is not wired in this build yet.",
provider.display_name()
)),
_ => CommandResult::message(format!(
if !provider_has_balance_api(provider) {
return CommandResult::message(format!(
"Balance check is not supported for {} yet. Check the provider dashboard for account balance details.",
provider.display_name()
)),
));
}
CommandResult::action(AppAction::FetchBalance)
}
@@ -291,8 +291,7 @@ impl RegisterCommand<CommandResult> for GoalCmd {
fn handler() -> CommandHandler<CommandResult> {
CommandHandler::Contextual {
capabilities: codewhale_command_contract::handler::CommandCapabilities::PROJECT
.union(codewhale_command_contract::handler::CommandCapabilities::PRESENTATION)
.union(codewhale_command_contract::handler::CommandCapabilities::WORKSPACE),
.union(codewhale_command_contract::handler::CommandCapabilities::PRESENTATION),
handler: goal_contextual,
}
}
+16 -17
View File
@@ -10,29 +10,28 @@ mod skills;
pub(in crate::commands) use self::skills::run_skill_by_name;
use crate::commands::traits::{Command, CommandGroup, FunctionCommand, RegisterCommand};
use crate::commands::traits::{Command, CommandGroup, ContextualCommand};
pub struct SkillsCommands;
impl CommandGroup for SkillsCommands {
fn commands(&self) -> &'static [Box<dyn Command>] {
cached_command_list!(vec![
Box::new(FunctionCommand::new(
skills::SkillsCmd::info(),
skills::SkillsCmd::execute,
)),
Box::new(FunctionCommand::new(
skills::SkillCmd::info(),
skills::SkillCmd::execute,
)),
Box::new(FunctionCommand::new(
review::ReviewCmd::info(),
review::ReviewCmd::execute,
)),
Box::new(FunctionCommand::new(
restore::RestoreCmd::info(),
restore::RestoreCmd::execute,
)),
Box::new(
ContextualCommand::from_contract::<skills::SkillsCmd>()
.expect("skills registration")
),
Box::new(
ContextualCommand::from_contract::<skills::SkillCmd>().expect("skill registration")
),
Box::new(
ContextualCommand::from_contract::<review::ReviewCmd>()
.expect("review registration")
),
Box::new(
ContextualCommand::from_contract::<restore::RestoreCmd>()
.expect("restore registration")
),
])
}
}
+206 -224
View File
@@ -7,33 +7,64 @@
//! the user has explicitly trusted the workspace (`/trust on` or Full Access) —
//! the user can always view the list, just not one-shot revert without a
//! safety net.
//!
//! FEAT-022 Phase 4: portable contextual dispatch. `SnapshotRepo` and the
//! approval state stay host-side (`CommandSkillGroupContext` delegates); the
//! portable handler owns all parsing, formatting, and the trust gate.
use chrono::TimeZone;
use codewhale_command_contract::facets::{CommandSkillGroupContext, SnapshotEntry};
use codewhale_command_contract::handler::{CommandContexts, CommandHandler};
use codewhale_command_contract::metadata::{CommandInfo, RegisterCommand};
use crate::commands::CommandResult;
use crate::snapshot::{Snapshot, SnapshotRepo};
use crate::tui::app::App;
use chrono::TimeZone;
const DEFAULT_LIST_LIMIT: usize = 20;
const MAX_LIST_LIMIT: usize = 100;
const MAX_RESTORE_INDEX: usize = 1000;
/// Entry point for `/restore [N|list [N]]`.
fn restore(app: &mut App, arg: Option<&str>) -> CommandResult {
let workspace = app.workspace.clone();
let repo = match SnapshotRepo::open_or_init(&workspace) {
Ok(r) => r,
Err(e) => {
return CommandResult::error(format!(
"Snapshot repo unavailable for {}: {e}",
workspace.display(),
));
}
};
pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo {
name: "restore",
aliases: &[],
usage: "/restore [N|list [N]]",
description_key: "cmd_restore_description",
};
pub(in crate::commands) struct RestoreCmd;
impl RegisterCommand<CommandResult> for RestoreCmd {
fn info() -> &'static CommandInfo {
&COMMAND_INFO
}
fn handler() -> CommandHandler<CommandResult> {
CommandHandler::Contextual {
capabilities: codewhale_command_contract::handler::CommandCapabilities::SKILL_GROUP,
handler: restore_contextual,
}
}
}
/// Contextual `/restore` dispatch (FEAT-022 D4): exactly the skill-group facet
/// (snapshot list/restore + approval state — no `MODE_POLICY` declaration).
fn restore_contextual(contexts: CommandContexts<'_>, arg: Option<&str>) -> CommandResult {
let mut parts = contexts.into_parts();
let Some(skill_group) = parts.skill_group.as_deref_mut() else {
return CommandResult::error("Command capability unavailable: skill_group");
};
restore(skill_group, arg)
}
/// Portable `/restore` dispatch — byte-identical to the baseline handler.
///
/// The host owns `SnapshotRepo` open/list/restore and the yolo/trust posture;
/// the handler composes every message, error, listing, and the trust gate.
fn restore(group: &mut dyn CommandSkillGroupContext, arg: Option<&str>) -> CommandResult {
let Some(arg) = arg.map(str::trim).filter(|s| !s.is_empty()) else {
let snapshots = match repo.list(DEFAULT_LIST_LIMIT) {
let snapshots = match group.snapshot_list(DEFAULT_LIST_LIMIT) {
Ok(s) => s,
Err(e) => return CommandResult::error(format!("Failed to list snapshots: {e}")),
Err(err) => return CommandResult::error(err),
};
if snapshots.is_empty() {
return no_snapshots_message();
@@ -45,9 +76,9 @@ fn restore(app: &mut App, arg: Option<&str>) -> CommandResult {
Ok(limit) => limit,
Err(message) => return CommandResult::error(message),
} {
let snapshots = match repo.list(limit) {
let snapshots = match group.snapshot_list(limit) {
Ok(s) => s,
Err(e) => return CommandResult::error(format!("Failed to list snapshots: {e}")),
Err(err) => return CommandResult::error(err),
};
if snapshots.is_empty() {
return no_snapshots_message();
@@ -68,9 +99,9 @@ fn restore(app: &mut App, arg: Option<&str>) -> CommandResult {
));
}
};
let snapshots = match repo.list(n.max(DEFAULT_LIST_LIMIT)) {
let snapshots = match group.snapshot_list(n.max(DEFAULT_LIST_LIMIT)) {
Ok(s) => s,
Err(e) => return CommandResult::error(format!("Failed to list snapshots: {e}")),
Err(err) => return CommandResult::error(err),
};
if snapshots.is_empty() {
return no_snapshots_message();
@@ -87,7 +118,8 @@ fn restore(app: &mut App, arg: Option<&str>) -> CommandResult {
// modal-confirmation path inside slash commands today, so the gate
// is "require trust mode" — `/trust on` or Full Access. Users in plain
// Agent mode get a clear message explaining how to proceed.
if !(app.yolo || app.trust_mode) {
let approval = group.approval_state();
if !(approval.yolo || approval.trust_mode) {
return CommandResult::message(format!(
"Refusing to restore snapshot #{n} ('{}') outside trusted mode.\n\
Run `/trust on` or select Full Access with Shift+Tab, then re-run `/restore {n}`.",
@@ -96,8 +128,8 @@ fn restore(app: &mut App, arg: Option<&str>) -> CommandResult {
}
let target = &snapshots[n - 1];
if let Err(e) = repo.restore(&target.id) {
return CommandResult::error(format!("Restore failed: {e}"));
if let Err(err) = group.restore_snapshot(&target.id) {
return CommandResult::error(err);
}
CommandResult::message(format!(
@@ -141,7 +173,7 @@ fn no_snapshots_message() -> CommandResult {
)
}
fn format_listing(snapshots: &[Snapshot]) -> String {
fn format_listing(snapshots: &[SnapshotEntry]) -> String {
let mut out = String::from(
"Recent snapshots (newest first; pass /restore <N> to revert; /restore list 50 shows more):\n",
);
@@ -168,180 +200,154 @@ fn short_sha(sha: &str) -> &str {
&sha[..sha.len().min(8)]
}
pub(in crate::commands) const COMMAND_INFO: crate::commands::traits::CommandInfo =
crate::commands::traits::CommandInfo {
name: "restore",
aliases: &[],
usage: "/restore [N|list [N]]",
description_id: crate::localization::MessageId::CmdRestoreDescription,
};
pub(in crate::commands) struct RestoreCmd;
impl crate::commands::traits::RegisterCommand for RestoreCmd {
fn info() -> &'static crate::commands::traits::CommandInfo {
&COMMAND_INFO
}
fn execute(
app: &mut crate::tui::app::App,
arg: Option<&str>,
) -> crate::commands::CommandResult {
restore(app, arg)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
use crate::test_support::lock_test_env;
use crate::tui::app::TuiOptions;
use tempfile::TempDir;
use codewhale_command_contract::facets::{
CommandApprovalState, RemoteRegistryOutcome, ReviewOutcome, SkillActivationError,
SkillMutationReceipt, SkillRecommendation, SkillSyncOutcome, SkillTargetScope,
};
fn make_app(tmp: &TempDir, yolo: bool) -> App {
let workspace = tmp.path().to_path_buf();
let options = TuiOptions {
skills_dir: tmp.path().join("skills"),
memory_path: tmp.path().join("memory.md"),
notes_path: tmp.path().join("notes.txt"),
mcp_config_path: tmp.path().join("mcp.json"),
yolo,
..crate::test_support::test_tui_options(workspace)
};
App::new(options, &Config::default())
struct FakeSkillGroup {
snapshots: Result<Vec<SnapshotEntry>, String>,
restore: Result<(), String>,
approval: CommandApprovalState,
}
/// Pins HOME to a tempdir for the duration of the test under the
/// crate-wide env mutex.
struct ScopedHome {
prev: Option<std::ffi::OsString>,
_home: TempDir,
_guard: crate::test_support::TestEnvLock,
}
impl Drop for ScopedHome {
fn drop(&mut self) {
// SAFETY: process-wide lock still held.
unsafe {
match self.prev.take() {
Some(v) => std::env::set_var("HOME", v),
None => std::env::remove_var("HOME"),
}
impl FakeSkillGroup {
fn new(snapshots: Vec<SnapshotEntry>) -> Self {
Self {
snapshots: Ok(snapshots),
restore: Ok(()),
approval: CommandApprovalState {
yolo: true,
trust_mode: false,
},
}
}
}
fn scoped_home(_workspace: &TempDir) -> ScopedHome {
let guard = lock_test_env();
let prev = std::env::var_os("HOME");
let home = TempDir::new().expect("home tempdir");
// SAFETY: serialised by the global env lock.
unsafe {
std::env::set_var("HOME", home.path());
impl CommandSkillGroupContext for FakeSkillGroup {
fn skill_registry_projection(
&self,
) -> codewhale_command_contract::facets::SkillRegistryProjection {
unimplemented!("not used by restore tests")
}
ScopedHome {
prev,
_home: home,
_guard: guard,
fn activate_skill(
&mut self,
_name: &str,
) -> Result<codewhale_command_contract::facets::SkillActivationOutcome, SkillActivationError>
{
unimplemented!("not used by restore tests")
}
fn install_skill(
&mut self,
_scope: Option<SkillTargetScope>,
_spec: &str,
) -> Result<SkillMutationReceipt, String> {
unimplemented!("not used by restore tests")
}
fn update_skill(
&mut self,
_scope: Option<SkillTargetScope>,
_name: &str,
) -> Result<SkillMutationReceipt, String> {
unimplemented!("not used by restore tests")
}
fn uninstall_skill(
&mut self,
_scope: Option<SkillTargetScope>,
_name: &str,
) -> Result<SkillMutationReceipt, String> {
unimplemented!("not used by restore tests")
}
fn trust_skill(
&mut self,
_scope: Option<SkillTargetScope>,
_name: &str,
) -> Result<SkillMutationReceipt, String> {
unimplemented!("not used by restore tests")
}
fn fetch_remote_registry(&mut self) -> Result<RemoteRegistryOutcome, String> {
unimplemented!("not used by restore tests")
}
fn recommend_skills(&mut self, _task: &str) -> Result<Vec<SkillRecommendation>, String> {
unimplemented!("not used by restore tests")
}
fn sync_registry(&mut self) -> Result<SkillSyncOutcome, String> {
unimplemented!("not used by restore tests")
}
fn run_review(&mut self) -> Result<ReviewOutcome, String> {
unimplemented!("not used by restore tests")
}
fn snapshot_list(&mut self, limit: usize) -> Result<Vec<SnapshotEntry>, String> {
match &self.snapshots {
Ok(snapshots) => Ok(snapshots.iter().take(limit).cloned().collect()),
Err(err) => Err(err.clone()),
}
}
fn restore_snapshot(&mut self, _id: &str) -> Result<(), String> {
self.restore.clone()
}
fn approval_state(&self) -> CommandApprovalState {
self.approval
}
}
fn snap(label: &str, id: &str, timestamp: i64) -> SnapshotEntry {
SnapshotEntry {
id: id.to_string(),
label: label.to_string(),
timestamp,
}
}
#[test]
fn restore_with_no_snapshots_shows_empty_message() {
let tmp = TempDir::new().unwrap();
let _home = scoped_home(&tmp);
let mut app = make_app(&tmp, true);
let result = restore(&mut app, None);
let mut group = FakeSkillGroup::new(vec![]);
let result = restore(&mut group, None);
let msg = result.message.expect("expected message");
assert!(msg.contains("No snapshots"));
}
#[test]
fn restore_lists_when_no_arg_provided() {
let tmp = TempDir::new().unwrap();
let _home = scoped_home(&tmp);
let mut app = make_app(&tmp, true);
let repo = SnapshotRepo::open_or_init(&app.workspace).unwrap();
std::fs::write(app.workspace.join("a.txt"), b"v1").unwrap();
repo.snapshot("pre-turn:1").unwrap();
std::fs::write(app.workspace.join("a.txt"), b"v2").unwrap();
repo.snapshot("post-turn:1").unwrap();
let result = restore(&mut app, None);
let mut group = FakeSkillGroup::new(vec![
snap("post-turn:1", "11111111", 1_700_000_000),
snap("pre-turn:1", "22222222", 1_699_000_000),
]);
let result = restore(&mut group, None);
let msg = result.message.expect("expected message");
assert!(msg.contains("post-turn:1"));
assert!(msg.contains("pre-turn:1"));
assert!(msg.contains("#1"));
assert!(msg.contains("#2"));
}
#[test]
fn restore_lists_more_than_ten_snapshots_by_default() {
let tmp = TempDir::new().unwrap();
let _home = scoped_home(&tmp);
let mut app = make_app(&tmp, true);
let repo = SnapshotRepo::open_or_init(&app.workspace).unwrap();
for i in 0..12 {
std::fs::write(app.workspace.join("a.txt"), format!("v{i}")).unwrap();
repo.snapshot(&format!("turn:{i}")).unwrap();
}
let result = restore(&mut app, None);
let msg = result.message.expect("expected message");
assert!(msg.contains("#12"), "{msg}");
assert!(msg.contains("turn:0"), "{msg}");
}
#[test]
fn restore_listing_includes_snapshot_utc_time() {
let snapshots = [Snapshot {
id: crate::snapshot::SnapshotId("abcdef123456".to_string()),
label: "turn:demo".to_string(),
timestamp: 1_700_000_000,
session_id: None,
}];
let msg = format_listing(&snapshots);
assert!(msg.contains("2023-11-14 22:13 UTC"), "{msg}");
assert!(msg.contains("abcdef12"), "{msg}");
assert!(msg.contains("turn:demo"), "{msg}");
}
#[test]
fn restore_list_subcommand_accepts_explicit_limit() {
let tmp = TempDir::new().unwrap();
let _home = scoped_home(&tmp);
let mut app = make_app(&tmp, true);
let repo = SnapshotRepo::open_or_init(&app.workspace).unwrap();
for i in 0..15 {
std::fs::write(app.workspace.join("a.txt"), format!("v{i}")).unwrap();
repo.snapshot(&format!("turn:{i}")).unwrap();
}
let result = restore(&mut app, Some("list 12"));
let mut group = FakeSkillGroup::new(vec![
snap("turn:1", "11111111", 1_700_000_000),
snap("turn:2", "22222222", 1_699_000_000),
snap("turn:3", "33333333", 1_698_000_000),
]);
let result = restore(&mut group, Some("list 2"));
let msg = result.message.expect("expected message");
assert!(msg.contains("#12"), "{msg}");
assert!(!msg.contains("#13"), "{msg}");
assert!(msg.contains("#2"), "{msg}");
assert!(!msg.contains("#3"), "{msg}");
}
#[test]
fn restore_list_subcommand_rejects_invalid_limit() {
let tmp = TempDir::new().unwrap();
let _home = scoped_home(&tmp);
let mut app = make_app(&tmp, true);
let result = restore(&mut app, Some("list nope"));
let mut group = FakeSkillGroup::new(vec![]);
let result = restore(&mut group, Some("list nope"));
assert!(result.is_error);
assert!(result.message.unwrap().contains("Usage: /restore list [N]"));
}
#[test]
fn restore_list_subcommand_rejects_limit_above_cap() {
let tmp = TempDir::new().unwrap();
let _home = scoped_home(&tmp);
let mut app = make_app(&tmp, true);
let result = restore(&mut app, Some("list 101"));
let mut group = FakeSkillGroup::new(vec![]);
let result = restore(&mut group, Some("list 101"));
assert!(result.is_error);
assert!(
result
@@ -351,32 +357,10 @@ mod tests {
);
}
#[test]
fn restore_numeric_index_can_target_beyond_default_listing() {
let tmp = TempDir::new().unwrap();
let _home = scoped_home(&tmp);
let mut app = make_app(&tmp, true);
let repo = SnapshotRepo::open_or_init(&app.workspace).unwrap();
let f = app.workspace.join("a.txt");
for i in 0..12 {
std::fs::write(&f, format!("v{i}")).unwrap();
repo.snapshot(&format!("turn:{i}")).unwrap();
}
std::fs::write(&f, "changed").unwrap();
let result = restore(&mut app, Some("12"));
assert!(result.message.unwrap().contains("Restored"));
assert_eq!(std::fs::read_to_string(&f).unwrap(), "v0");
}
#[test]
fn restore_numeric_index_rejects_unbounded_query() {
let tmp = TempDir::new().unwrap();
let _home = scoped_home(&tmp);
let mut app = make_app(&tmp, true);
let result = restore(&mut app, Some("1001"));
let mut group = FakeSkillGroup::new(vec![]);
let result = restore(&mut group, Some("1001"));
assert!(result.is_error);
assert!(
result
@@ -388,33 +372,23 @@ mod tests {
#[test]
fn restore_in_yolo_reverts_workspace() {
let tmp = TempDir::new().unwrap();
let _home = scoped_home(&tmp);
let mut app = make_app(&tmp, true);
let repo = SnapshotRepo::open_or_init(&app.workspace).unwrap();
let f = app.workspace.join("a.txt");
std::fs::write(&f, b"original").unwrap();
repo.snapshot("pre-turn:1").unwrap();
std::fs::write(&f, b"clobbered").unwrap();
repo.snapshot("post-turn:1").unwrap();
let result = restore(&mut app, Some("2"));
assert!(result.message.unwrap().contains("Restored"));
let after = std::fs::read_to_string(&f).unwrap();
assert_eq!(after, "original");
let mut group = FakeSkillGroup::new(vec![
snap("post-turn:1", "22222222", 1_700_000_000),
snap("pre-turn:1", "11111111", 1_699_000_000),
]);
let result = restore(&mut group, Some("2"));
assert!(!result.is_error);
assert!(result.message.unwrap().contains("Restored snapshot #2"));
}
#[test]
fn restore_outside_trust_mode_refuses() {
let tmp = TempDir::new().unwrap();
let _home = scoped_home(&tmp);
let mut app = make_app(&tmp, false);
let repo = SnapshotRepo::open_or_init(&app.workspace).unwrap();
std::fs::write(app.workspace.join("a.txt"), b"v1").unwrap();
repo.snapshot("pre-turn:1").unwrap();
let result = restore(&mut app, Some("1"));
let mut group = FakeSkillGroup::new(vec![snap("pre-turn:1", "11111111", 1_700_000_000)]);
group.approval = CommandApprovalState {
yolo: false,
trust_mode: false,
};
let result = restore(&mut group, Some("1"));
let msg = result.message.expect("expected message");
assert!(msg.contains("Refusing"));
assert!(msg.contains("/trust on"));
@@ -422,31 +396,39 @@ mod tests {
#[test]
fn restore_invalid_index_returns_error() {
let tmp = TempDir::new().unwrap();
let _home = scoped_home(&tmp);
let mut app = make_app(&tmp, true);
let repo = SnapshotRepo::open_or_init(&app.workspace).unwrap();
std::fs::write(app.workspace.join("a.txt"), b"v1").unwrap();
repo.snapshot("pre-turn:1").unwrap();
let result = restore(&mut app, Some("99"));
let mut group = FakeSkillGroup::new(vec![snap("pre-turn:1", "11111111", 1_700_000_000)]);
let result = restore(&mut group, Some("99"));
let msg = result.message.expect("expected message");
assert!(msg.contains("Only 1 snapshot"));
}
#[test]
fn restore_zero_index_returns_error() {
let tmp = TempDir::new().unwrap();
let _home = scoped_home(&tmp);
let mut app = make_app(&tmp, true);
// Need at least one snapshot so we exercise the parse-index
// branch instead of the "no snapshots" early return.
let repo = SnapshotRepo::open_or_init(&app.workspace).unwrap();
std::fs::write(app.workspace.join("a.txt"), b"v1").unwrap();
repo.snapshot("pre-turn:1").unwrap();
let mut group = FakeSkillGroup::new(vec![snap("pre-turn:1", "11111111", 1_700_000_000)]);
let result = restore(&mut group, Some("0"));
assert!(result.is_error);
assert!(result.message.unwrap().contains("Usage:"));
}
let result = restore(&mut app, Some("0"));
let msg = result.message.expect("expected message");
assert!(msg.contains("Usage:"));
#[test]
fn restore_host_error_reaches_boundary() {
let mut group = FakeSkillGroup::new(vec![]);
group.snapshots = Err("Snapshot repo unavailable for /ws: boom".to_string());
let result = restore(&mut group, None);
assert!(result.is_error);
assert_eq!(
result.message.unwrap(),
"Error: Snapshot repo unavailable for /ws: boom"
);
}
#[test]
fn restore_missing_facet_errors_are_safe() {
let result = restore_contextual(CommandContexts::empty(), Some("1"));
assert!(result.is_error);
assert_eq!(
result.message.unwrap(),
"Error: Command capability unavailable: skill_group"
);
}
}
+186 -111
View File
@@ -1,148 +1,223 @@
//! Review command: activate review skill and send a target immediately.
//!
//! FEAT-022 Phase 4: portable contextual dispatch. The host performs the
//! discovery + side effects (`CommandSkillGroupContext::run_review`); the
//! portable handler composes the exact error text and the `SendMessage` action.
use crate::skills::{SkillRegistry, default_skills_dir};
use crate::tui::app::{App, AppAction};
use crate::tui::history::HistoryCell;
use codewhale_command_contract::facets::{CommandSkillGroupContext, ReviewOutcome};
use codewhale_command_contract::handler::{CommandContexts, CommandHandler};
use codewhale_command_contract::metadata::{CommandInfo, RegisterCommand};
use crate::commands::CommandResult;
use crate::tui::app::AppAction;
fn warnings_suffix(registry: &SkillRegistry) -> String {
if registry.warnings().is_empty() {
/// Render the review warnings suffix (baseline `warnings_suffix`).
fn warnings_suffix(warnings: &[String]) -> String {
if warnings.is_empty() {
return String::new();
}
format!("\n\nWarnings:\n- {}", registry.warnings().join("\n- "))
format!("\n\nWarnings:\n- {}", warnings.join("\n- "))
}
fn review(app: &mut App, args: Option<&str>) -> CommandResult {
let target = args.unwrap_or("").trim();
pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo {
name: "review",
aliases: &["shencha"],
usage: "/review <target>",
description_key: "cmd_review_description",
};
pub(in crate::commands) struct ReviewCmd;
impl RegisterCommand<CommandResult> for ReviewCmd {
fn info() -> &'static CommandInfo {
&COMMAND_INFO
}
fn handler() -> CommandHandler<CommandResult> {
CommandHandler::Contextual {
capabilities: codewhale_command_contract::handler::CommandCapabilities::SKILL_GROUP,
handler: review_contextual,
}
}
}
/// Contextual `/review` dispatch: exactly the skill-group facet. The baseline
/// command never refreshed the shared skill cache, so `/review` must not
/// request the unrelated SKILLS facet.
fn review_contextual(contexts: CommandContexts<'_>, arg: Option<&str>) -> CommandResult {
let mut parts = contexts.into_parts();
let Some(skill_group) = parts.skill_group.as_deref_mut() else {
return CommandResult::error("Command capability unavailable: skill_group");
};
review(skill_group, arg)
}
/// Portable `/review` dispatch — byte-identical to the baseline handler.
///
/// The host performs discovery, warning merge, session-message insertion, and
/// active-skill mutation (`run_review`); the handler validates the target,
/// renders the not-found error, and emits the `SendMessage` action. The
/// baseline success path renders no message and does not refresh the cache.
fn review(group: &mut dyn CommandSkillGroupContext, arg: Option<&str>) -> CommandResult {
let target = arg.unwrap_or("").trim();
if target.is_empty() {
return CommandResult::error("Usage: /review <target>");
}
let skills_dir = app.skills_dir.clone();
let registry = SkillRegistry::discover(&skills_dir).into_enabled();
let mut warnings = warnings_suffix(&registry);
let mut skill = registry.get("review").cloned();
let global_dir = default_skills_dir();
if skill.is_none() && global_dir != skills_dir {
let registry = SkillRegistry::discover(&global_dir).into_enabled();
if warnings.is_empty() {
warnings = warnings_suffix(&registry);
} else if !registry.warnings().is_empty() {
warnings.push_str(&format!("\n- {}", registry.warnings().join("\n- ")));
match group.run_review() {
Ok(ReviewOutcome::Ready) => {
CommandResult::action(AppAction::SendMessage(target.to_string()))
}
skill = registry.get("review").cloned();
}
let skill = match skill {
Some(skill) => skill,
None => {
let global_display = global_dir.display();
return CommandResult::error(format!(
Ok(ReviewOutcome::NotFound {
skills_dir,
global_dir,
warnings,
}) => {
let warnings = warnings_suffix(&warnings);
CommandResult::error(format!(
"Review skill not found in {} or {}. Create ~/.codewhale/skills/review/SKILL.md.{}",
skills_dir.display(),
global_display,
warnings
));
skills_dir, global_dir, warnings
))
}
};
let instruction = format!(
"You are now using a skill. Follow these instructions:\n\n# Skill: {}\n\n{}\n\n---\n\nNow respond to the user's request following the above skill instructions.",
skill.name, skill.body
);
app.add_message(HistoryCell::System {
content: format!("Activated skill: {}\n\n{}", skill.name, skill.description),
});
app.active_skill = Some(instruction);
app.active_skill_provenance = None;
CommandResult::action(AppAction::SendMessage(target.to_string()))
}
pub(in crate::commands) const COMMAND_INFO: crate::commands::traits::CommandInfo =
crate::commands::traits::CommandInfo {
name: "review",
aliases: &["shencha"],
usage: "/review <target>",
description_id: crate::localization::MessageId::CmdReviewDescription,
};
pub(in crate::commands) struct ReviewCmd;
impl crate::commands::traits::RegisterCommand for ReviewCmd {
fn info() -> &'static crate::commands::traits::CommandInfo {
&COMMAND_INFO
}
fn execute(
app: &mut crate::tui::app::App,
arg: Option<&str>,
) -> crate::commands::CommandResult {
review(app, arg)
Err(err) => CommandResult::error(err),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
use crate::tui::app::{App, TuiOptions};
use tempfile::TempDir;
use codewhale_command_contract::facets::{
CommandApprovalState, RemoteRegistryOutcome, SkillActivationError, SkillMutationReceipt,
SkillRecommendation, SkillSyncOutcome, SkillTargetScope, SnapshotEntry,
};
fn create_test_app_with_tmpdir(tmpdir: &TempDir) -> App {
let options = TuiOptions {
skills_dir: tmpdir.path().join("skills"),
memory_path: tmpdir.path().join("memory.md"),
notes_path: tmpdir.path().join("notes.txt"),
mcp_config_path: tmpdir.path().join("mcp.json"),
..crate::test_support::test_tui_options(tmpdir.path())
};
App::new(options, &Config::default())
struct FakeSkillGroup {
review: Result<ReviewOutcome, String>,
approval: CommandApprovalState,
}
fn create_review_skill_dir(tmpdir: &TempDir) {
let skill_dir = tmpdir.path().join("skills").join("review");
std::fs::create_dir_all(&skill_dir).unwrap();
std::fs::write(
skill_dir.join("SKILL.md"),
"---\nname: review\ndescription: Code review skill\n---\nReview the code",
)
.unwrap();
impl FakeSkillGroup {
fn ready() -> Self {
Self {
review: Ok(ReviewOutcome::Ready),
approval: CommandApprovalState {
yolo: true,
trust_mode: false,
},
}
}
}
impl CommandSkillGroupContext for FakeSkillGroup {
fn skill_registry_projection(
&self,
) -> codewhale_command_contract::facets::SkillRegistryProjection {
unimplemented!("not used by review tests")
}
fn activate_skill(
&mut self,
_name: &str,
) -> Result<codewhale_command_contract::facets::SkillActivationOutcome, SkillActivationError>
{
unimplemented!("not used by review tests")
}
fn install_skill(
&mut self,
_scope: Option<SkillTargetScope>,
_spec: &str,
) -> Result<SkillMutationReceipt, String> {
unimplemented!("not used by review tests")
}
fn update_skill(
&mut self,
_scope: Option<SkillTargetScope>,
_name: &str,
) -> Result<SkillMutationReceipt, String> {
unimplemented!("not used by review tests")
}
fn uninstall_skill(
&mut self,
_scope: Option<SkillTargetScope>,
_name: &str,
) -> Result<SkillMutationReceipt, String> {
unimplemented!("not used by review tests")
}
fn trust_skill(
&mut self,
_scope: Option<SkillTargetScope>,
_name: &str,
) -> Result<SkillMutationReceipt, String> {
unimplemented!("not used by review tests")
}
fn fetch_remote_registry(&mut self) -> Result<RemoteRegistryOutcome, String> {
unimplemented!("not used by review tests")
}
fn recommend_skills(&mut self, _task: &str) -> Result<Vec<SkillRecommendation>, String> {
unimplemented!("not used by review tests")
}
fn sync_registry(&mut self) -> Result<SkillSyncOutcome, String> {
unimplemented!("not used by review tests")
}
fn run_review(&mut self) -> Result<ReviewOutcome, String> {
self.review.clone()
}
fn snapshot_list(&mut self, _limit: usize) -> Result<Vec<SnapshotEntry>, String> {
unimplemented!("not used by review tests")
}
fn restore_snapshot(&mut self, _id: &str) -> Result<(), String> {
unimplemented!("not used by review tests")
}
fn approval_state(&self) -> CommandApprovalState {
self.approval
}
}
#[test]
fn test_review_without_target() {
let tmpdir = TempDir::new().unwrap();
let mut app = create_test_app_with_tmpdir(&tmpdir);
let result = review(&mut app, None);
assert!(result.message.is_some());
fn review_without_target_prints_usage() {
let mut group = FakeSkillGroup::ready();
let result = review(&mut group, None);
assert!(result.is_error);
assert!(result.message.unwrap().contains("Usage: /review"));
}
#[test]
fn test_review_without_skill_installed() {
let tmpdir = TempDir::new().unwrap();
let mut app = create_test_app_with_tmpdir(&tmpdir);
// Set skills dir to empty temp dir
app.skills_dir = tmpdir.path().join("nonexistent_skills");
let result = review(&mut app, Some("file.rs"));
// The command should either error about missing skill or work if global skill exists
assert!(result.message.is_some() || result.action.is_some());
fn review_ready_sends_target_without_skills_context() {
let mut group = FakeSkillGroup::ready();
let contexts = CommandContexts::empty().with_skill_group(&mut group);
let result = review_contextual(contexts, Some("file.rs"));
assert!(result.message.is_none());
assert!(matches!(
result.action,
Some(AppAction::SendMessage(ref t)) if t == "file.rs"
));
}
#[test]
fn test_review_with_skill_activates_and_sends() {
let tmpdir = TempDir::new().unwrap();
create_review_skill_dir(&tmpdir);
let mut app = create_test_app_with_tmpdir(&tmpdir);
let result = review(&mut app, Some("file.rs"));
assert!(result.message.is_none());
assert!(matches!(result.action, Some(AppAction::SendMessage(_))));
assert!(app.active_skill.is_some());
assert!(!app.history.is_empty());
fn review_not_found_renders_exact_error_with_warnings() {
let mut group = FakeSkillGroup::ready();
group.review = Ok(ReviewOutcome::NotFound {
skills_dir: "/ws/skills".to_string(),
global_dir: "/home/u/.codewhale/skills".to_string(),
warnings: vec!["one warning".to_string()],
});
let result = review(&mut group, Some("file.rs"));
assert!(result.is_error);
let msg = result.message.unwrap();
assert!(
msg.contains(
"Review skill not found in /ws/skills or /home/u/.codewhale/skills. Create ~/.codewhale/skills/review/SKILL.md."
),
"{msg}"
);
assert!(msg.contains("Warnings:\n- one warning"), "{msg}");
}
#[test]
fn review_missing_facet_errors_are_safe() {
let result = review_contextual(CommandContexts::empty(), Some("file.rs"));
assert!(result.is_error);
assert_eq!(
result.message.unwrap(),
"Error: Command capability unavailable: skill_group"
);
}
}
File diff suppressed because it is too large. Load diff
@@ -36,7 +36,9 @@ impl RegisterCommand<CommandResult> for DispatchCmd {
fn dispatch_contextual(contexts: CommandContexts<'_>, arg: Option<&str>) -> CommandResult {
let mut parts = contexts.into_parts();
let workspace = parts.workspace.as_deref_mut().expect("workspace facet");
let Some(workspace) = parts.workspace.as_deref_mut() else {
return CommandResult::error("Command capability unavailable: workspace");
};
dispatch(workspace, arg)
}
@@ -229,6 +231,17 @@ mod tests {
assert!(DispatchCmd::info().usage.starts_with("/dispatch"));
}
#[test]
fn missing_workspace_facet_fails_safely() {
let result = dispatch_contextual(CommandContexts::empty(), None);
assert!(result.is_error, "{result:?}");
assert_eq!(
result.message.as_deref(),
Some("Error: Command capability unavailable: workspace")
);
assert!(result.action.is_none());
}
#[test]
fn bare_dispatch_is_a_status_card_not_a_silent_launch() {
let mut workspace = FakeWorkspace(PathBuf::from("."));
+235 -28
View File
@@ -152,9 +152,18 @@ fn feat015_contextual(
) -> CommandResult {
use codewhale_command_contract::handler::ContextParts;
let parts: ContextParts<'_> = contexts.into_parts();
let workspace = parts.workspace.expect("workspace facet").workspace();
let mode = parts.mode_policy.expect("mode-policy facet").mode();
let currency = parts.cost.expect("cost facet").display_currency();
let Some(workspace) = parts.workspace else {
return CommandResult::error("Command capability unavailable: workspace");
};
let Some(mode_policy) = parts.mode_policy else {
return CommandResult::error("Command capability unavailable: mode-policy");
};
let Some(cost) = parts.cost else {
return CommandResult::error("Command capability unavailable: cost");
};
let workspace = workspace.workspace();
let mode = mode_policy.mode();
let currency = cost.display_currency();
let normalized = arg.unwrap_or("");
CommandResult::message(format!(
"feat015ctx workspace={} mode={:?} currency={:?} arg={}",
@@ -1733,19 +1742,21 @@ mod tests {
}
#[test]
fn balance_command_reports_scaffold_without_claiming_dispatch() {
fn balance_command_dispatches_live_fetch_for_prepaid_providers() {
let mut app = create_test_app();
app.api_provider = ApiProvider::Deepseek;
let result = execute("/balance", &mut app);
let msg = result
.message
.expect("balance scaffold should explain current state");
assert!(!result.is_error);
assert!(msg.contains("DeepSeek"));
assert!(msg.contains("not wired"));
assert!(!msg.contains("sent"));
for provider in [
ApiProvider::Deepseek,
ApiProvider::Openrouter,
ApiProvider::Siliconflow,
] {
app.api_provider = provider;
let result = execute("/balance", &mut app);
assert!(!result.is_error, "{provider:?}");
assert!(
matches!(result.action, Some(AppAction::FetchBalance)),
"{provider:?} should dispatch a live remaining-credit fetch"
);
}
}
#[test]
@@ -1924,6 +1935,20 @@ mod tests {
assert!(result.action.is_none());
}
#[test]
fn feat015_contextual_command_fails_safely_without_declared_facets() {
let result = feat015_contextual(
codewhale_command_contract::handler::CommandContexts::empty(),
None,
);
assert!(result.is_error, "{result:?}");
assert_eq!(
result.message.as_deref(),
Some("Error: Command capability unavailable: workspace")
);
assert!(result.action.is_none());
}
#[test]
fn feat015_contextual_command_is_registered_only_in_test_builds() {
// The fixture entry is present in the test-build registry with a
@@ -1961,6 +1986,11 @@ mod tests {
// FEAT-019 memory group.
"note",
"memory",
// FEAT-022 skills group.
"skills",
"skill",
"review",
"restore",
];
for info in command_infos() {
if info.name == "feat015ctx" || MIGRATED_GROUPS.contains(&info.name) {
@@ -2077,12 +2107,17 @@ mod tests {
#[test]
fn feat021_project_entries_register_through_portable_bridge() {
// main's model carries no capability bitmask: each project command
// must register through the portable bridge as a contextual handler
// and dispatch safely through the public seam. Exact facet
// destructuring (D4) is proven by the handler tests and the adapter
// exposure test.
for name in ["init", "lsp", "share", "goal"] {
use codewhale_command_contract::handler::{CommandCapabilities, CommandHandler};
for (name, expected) in [
("init", CommandCapabilities::WORKSPACE),
("lsp", CommandCapabilities::PROJECT),
("share", CommandCapabilities::PROJECT),
(
"goal",
CommandCapabilities::PROJECT.union(CommandCapabilities::PRESENTATION),
),
] {
assert!(
registry().has_contextual_handler(name),
"/{name} must register through the portable bridge"
@@ -2092,13 +2127,10 @@ mod tests {
.expect("entry")
.contextual_handler()
.expect("contextual handler");
assert!(
matches!(
handler,
codewhale_command_contract::handler::CommandHandler::Contextual { .. }
),
"/{name} must be contextual"
);
let CommandHandler::Contextual { capabilities, .. } = handler else {
panic!("/{name} must be contextual");
};
assert_eq!(capabilities, expected, "/{name} exact capability set");
}
}
@@ -2229,6 +2261,100 @@ mod tests {
}
}
// ---------------------------------------------------------------------
// FEAT-022: skills group registration + public dispatch (Task 6.2).
// All four commands register through the portable bridge; frontier state
// is asserted by the migration fixtures and live gate.
// ---------------------------------------------------------------------
/// Pins HOME to a tempdir so global skill discovery stays hermetic.
struct Feat022ScopedHome {
prev: Option<std::ffi::OsString>,
_home: tempfile::TempDir,
_guard: crate::test_support::TestEnvLock,
}
impl Drop for Feat022ScopedHome {
fn drop(&mut self) {
// SAFETY: process-wide lock still held.
unsafe {
match self.prev.take() {
Some(v) => std::env::set_var("HOME", v),
None => std::env::remove_var("HOME"),
}
}
}
}
fn feat022_scoped_home(_tmp: &tempfile::TempDir) -> Feat022ScopedHome {
let guard = crate::test_support::lock_test_env();
let prev = std::env::var_os("HOME");
let home = tempfile::TempDir::new().expect("home tempdir");
// SAFETY: serialised by the global env lock.
unsafe {
std::env::set_var("HOME", home.path());
}
Feat022ScopedHome {
prev,
_home: home,
_guard: guard,
}
}
fn feat022_test_app(tmp: &tempfile::TempDir) -> App {
let mut options = crate::test_support::test_tui_options(tmp.path());
options.skills_dir = tmp.path().join("skills");
crate::test_support::test_app_with_options(options)
}
fn feat022_write_skill(dir: &std::path::Path, name: &str) {
let skill_dir = dir.join(name);
std::fs::create_dir_all(&skill_dir).unwrap();
std::fs::write(
skill_dir.join("SKILL.md"),
format!("---\nname: {name}\ndescription: {name} skill\n---\n{name} instructions"),
)
.unwrap();
}
#[test]
fn feat022_all_four_skills_entries_are_registered_with_portable_handlers() {
use codewhale_command_contract::handler::{CommandCapabilities, CommandHandler};
for (name, alias, expected) in [
(
"skills",
Some("jinengliebiao"),
CommandCapabilities::SKILL_GROUP,
),
(
"skill",
Some("jineng"),
CommandCapabilities::SKILL_GROUP.union(CommandCapabilities::SKILLS),
),
("review", Some("shencha"), CommandCapabilities::SKILL_GROUP),
("restore", None, CommandCapabilities::SKILL_GROUP),
] {
let info = registry()
.get_info(name)
.unwrap_or_else(|| panic!("/{name} must be registered"));
assert_eq!(info.name, name, "canonical name");
let handler = registry()
.get(name)
.expect("entry")
.contextual_handler()
.expect("contextual handler");
let CommandHandler::Contextual { capabilities, .. } = handler else {
panic!("/{name} must be contextual");
};
assert_eq!(capabilities, expected, "/{name} exact capability set");
if let Some(alias) = alias {
assert!(
registry().get_info(alias).is_some(),
"/{name} alias {alias} must resolve"
);
}
}
}
#[test]
fn feat019_note_dispatches_through_public_seam() {
let tmpdir = tempfile::TempDir::new().unwrap();
@@ -2297,4 +2423,85 @@ mod tests {
assert!(result.message.is_some(), "{command}: {result:?}");
}
}
#[test]
fn feat022_skills_commands_dispatch_through_public_seam() {
let tmp = tempfile::TempDir::new().unwrap();
let _home = feat022_scoped_home(&tmp);
let mut app = feat022_test_app(&tmp);
std::fs::create_dir_all(tmp.path().join("skills")).unwrap();
feat022_write_skill(&tmp.path().join("skills"), "demo");
// Bare /skills opens the unified manager (zero network).
let result = execute("/skills", &mut app);
assert!(!result.is_error, "{result:?}");
assert!(
matches!(
result.action,
Some(crate::tui::app::AppAction::OpenSkillsManager)
),
"{result:?}"
);
// /skill activates the demo skill and sets active_skill.
let result = execute("/skill demo", &mut app);
assert!(!result.is_error, "{result:?}");
assert!(result.message.unwrap().contains("Skill 'demo' activated."));
assert!(app.active_skill.is_some());
// /restore with no snapshots shows the empty message.
let result = execute("/restore", &mut app);
assert!(!result.is_error, "{result:?}");
assert!(result.message.unwrap().contains("No snapshots"));
// /review without a target prints usage.
let result = execute("/review", &mut app);
assert!(result.is_error, "{result:?}");
assert!(result.message.unwrap().contains("Usage: /review"));
}
#[test]
fn feat022_aliases_dispatch_through_public_seam() {
// All four aliases (jinengliebiao, jineng, shencha) resolve through the
// registry to the same portable handlers as the canonical names.
let tmp = tempfile::TempDir::new().unwrap();
let _home = feat022_scoped_home(&tmp);
let mut app = feat022_test_app(&tmp);
std::fs::create_dir_all(tmp.path().join("skills")).unwrap();
feat022_write_skill(&tmp.path().join("skills"), "demo");
let result = execute("/jinengliebiao", &mut app);
assert!(
matches!(
result.action,
Some(crate::tui::app::AppAction::OpenSkillsManager)
),
"{result:?}"
);
let result = execute("/jineng demo", &mut app);
assert!(!result.is_error, "{result:?}");
assert!(result.message.unwrap().contains("Skill 'demo' activated."));
let result = execute("/shencha", &mut app);
assert!(result.is_error, "{result:?}");
assert!(result.message.unwrap().contains("Usage: /review"));
}
#[test]
fn feat022_context_exposure_is_exact_per_d4() {
// The test-only full envelope exposes every adapter; production
// dispatch exposes only each handler's declared facets.
// skills/review/restore consume only skill_group; skill also consumes
// skills for cache refreshes.
let tmp = tempfile::TempDir::new().unwrap();
let _home = feat022_scoped_home(&tmp);
let mut app = feat022_test_app(&tmp);
let mut bundle = app.command_contexts();
let parts = bundle.parts();
assert!(parts.skill_group.is_some());
assert!(parts.skills.is_some());
// Missing-facet safety through the public seam is covered by the
// handler-level tests; here we assert the envelope carries both.
}
}
+25 -5
View File
@@ -2351,7 +2351,7 @@ pub enum StatusItem {
RateLimit,
/// Session token usage: input / cache-hit / output.
Tokens,
/// DeepSeek account balance, refreshed once per turn completion.
/// Prepaid remaining credit, refreshed once per turn completion.
Balance,
/// Session metrics strip: turns · steps │ LLM · tools │ TTFT · tok/s │
/// cache │ in — sourced from engine timings and provider usage.
@@ -2466,7 +2466,7 @@ impl StatusItem {
StatusItem::LastToolElapsed => "ms of the most recent tool call (reserved)",
StatusItem::RateLimit => "remaining requests in the budget (reserved)",
StatusItem::Tokens => "input / cache-hit / output token totals",
StatusItem::Balance => "topped-up + granted balance from DeepSeek",
StatusItem::Balance => "remaining prepaid credit from the active provider",
StatusItem::SessionMetrics => "turns · steps · LLM/tool time · TTFT · tok/s · input",
}
}
@@ -2499,14 +2499,26 @@ impl StatusItem {
#[must_use]
pub fn is_available_for(self, provider: ApiProvider) -> bool {
match self {
StatusItem::Balance => {
matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)
}
StatusItem::Balance => provider_has_balance_api(provider),
_ => true,
}
}
}
/// Prepaid providers that publish a remaining-credit endpoint Codewhale
/// can fetch. Local runtimes and invoice-only vendors stay out.
#[must_use]
pub fn provider_has_balance_api(provider: ApiProvider) -> bool {
matches!(
provider,
ApiProvider::Deepseek
| ApiProvider::DeepseekCN
| ApiProvider::Openrouter
| ApiProvider::Siliconflow
| ApiProvider::SiliconflowCn
)
}
/// One configurable header item
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]
@@ -3107,6 +3119,13 @@ pub struct Config {
#[serde(default)]
pub lifecycle_outbox: Option<codewhale_config::LifecycleOutboxToml>,
/// Per-session control socket (`[control_socket]`). Opt-in: an absent
/// table or `enabled = false` (the default) leaves the feature off.
/// When enabled, the interactive TUI binds a unix socket per running
/// session (see `crate::tui::control_socket`).
#[serde(default)]
pub control_socket: Option<codewhale_config::ControlSocketToml>,
/// Provider-specific credentials and defaults shared with the `codewhale` facade.
#[serde(default)]
pub providers: Option<ProvidersConfig>,
@@ -10493,6 +10512,7 @@ fn merge_config(base: Config, override_cfg: Config) -> Config {
transcript: override_cfg.transcript.or(base.transcript),
hooks: override_cfg.hooks.or(base.hooks),
lifecycle_outbox: override_cfg.lifecycle_outbox.or(base.lifecycle_outbox),
control_socket: override_cfg.control_socket.or(base.control_socket),
providers: merge_providers(base.providers, override_cfg.providers),
features: merge_features(base.features, override_cfg.features),
notifications: override_cfg.notifications.or(base.notifications),
+37 -4
View File
@@ -774,6 +774,38 @@ webhook_token = "secret-token"
assert!(absent.base.lifecycle_outbox.is_none());
}
#[test]
fn tui_config_parses_control_socket_table() {
let raw = r#"
[control_socket]
enabled = true
"#;
let parsed: ConfigFile = toml::from_str(raw).expect("parse control_socket config");
let socket = parsed
.base
.control_socket
.expect("control_socket table should parse");
assert!(socket.enabled);
// Off by default: a config without the table leaves the feature off.
let absent: ConfigFile =
toml::from_str("model = \"demo\"").expect("parse config without control_socket table");
assert!(absent.base.control_socket.is_none());
// An empty table stays off.
let empty: ConfigFile =
toml::from_str("[control_socket]").expect("parse empty control_socket table");
assert!(
!empty
.base
.control_socket
.expect("table should parse")
.enabled,
"empty table must leave the socket off"
);
}
#[test]
fn tui_config_parses_hotbar_bindings() {
let raw = r#"
@@ -11226,12 +11258,13 @@ fn provider_capability_roundtrip_serialization() {
}
#[test]
fn status_item_balance_available_only_for_deepseek_providers() {
// Balance item should only be offered for DeepSeek / DeepSeekCN.
fn status_item_balance_available_for_prepaid_providers() {
assert!(StatusItem::Balance.is_available_for(ApiProvider::Deepseek));
assert!(StatusItem::Balance.is_available_for(ApiProvider::DeepseekCN));
// Sanity: all other known providers should hide the Balance toggle.
assert!(!StatusItem::Balance.is_available_for(ApiProvider::Openrouter));
assert!(StatusItem::Balance.is_available_for(ApiProvider::Openrouter));
assert!(StatusItem::Balance.is_available_for(ApiProvider::Siliconflow));
assert!(StatusItem::Balance.is_available_for(ApiProvider::SiliconflowCn));
// Invoice-only, local, and unimplemented prepaid vendors stay hidden.
assert!(!StatusItem::Balance.is_available_for(ApiProvider::Novita));
assert!(!StatusItem::Balance.is_available_for(ApiProvider::NvidiaNim));
assert!(!StatusItem::Balance.is_available_for(ApiProvider::Fireworks));
+20
View File
@@ -6068,6 +6068,10 @@ fn print_doctor_setup_report(
" · runtime posture: {}",
doctor_runtime_posture_line(config, workspace)
);
println!(
" · control socket: {}",
doctor_control_socket_posture_line(config)
);
let consistency = doctor_setup_consistency(state, source);
if consistency["status"] == "inconsistent" {
let issues = consistency["issues"]
@@ -6285,6 +6289,22 @@ fn doctor_runtime_posture_line(config: &Config, workspace: &Path) -> String {
)
}
/// Doctor posture for the per-session control socket, enabled via
/// `[control_socket].enabled` (false = off, the default). Report the
/// resolved state and, when enabled, where the socket appears for the
/// running session.
fn doctor_control_socket_posture_line(config: &Config) -> String {
let enabled = config
.control_socket
.as_ref()
.is_some_and(|socket| socket.enabled);
if enabled {
"control_socket=on (sessions/<id>/control.sock per running session)".to_string()
} else {
"control_socket=off (default)".to_string()
}
}
/// Resolved telemetry consent and where it came from (#5441).
///
/// Telemetry ships ON by default, and no posture surface reported that — a
+52 -12
View File
@@ -71,6 +71,16 @@ struct ExposedTool {
internal: String,
}
fn mcp_request_model(arguments: &Value, default_model: &str) -> String {
arguments
.get("model")
.and_then(Value::as_str)
.map(str::trim)
.filter(|model| !model.is_empty())
.unwrap_or(default_model)
.to_string()
}
pub fn run_mcp_server(workspace: PathBuf) -> Result<()> {
let settings = McpServerSettings::load()?;
let mut server = McpServer::new(workspace, settings)?;
@@ -188,7 +198,7 @@ impl McpServer {
},
"model": {
"type": "string",
"description": "Optional model identifier (default: deepseek-v4-pro)"
"description": "Optional model identifier. When omitted, uses the active provider's default model."
},
"cwd": {
"type": "string",
@@ -345,10 +355,14 @@ impl McpServer {
message: "Missing required argument: prompt".to_string(),
})?;
let model = arguments
.get("model")
.and_then(Value::as_str)
.unwrap_or("deepseek-v4-pro");
// Load config first so an omitted model follows the active provider
// default instead of a hardcoded DeepSeek id.
let config = Config::load(None, None).map_err(|e| RpcError {
code: -32000,
message: format!("Failed to load config: {e}"),
})?;
let default_model = config.default_model();
let model = mcp_request_model(arguments, &default_model);
// Resolve thread_id
let thread_id = if internal_name == "deepseek" {
@@ -365,11 +379,6 @@ impl McpServer {
.to_string()
};
// Load config and create client
let config = Config::load(None, None).map_err(|e| RpcError {
code: -32000,
message: format!("Failed to load config: {e}"),
})?;
let client = DeepSeekClient::new(&config).map_err(model_client_init_error)?;
// Build message list
@@ -395,9 +404,9 @@ impl McpServer {
// Send the API request (non-streaming for the basic version). Internal
// chat uses the same resolved output policy as an ordinary turn.
let request_route = client.effective_route_envelope(model, chrono::Utc::now());
let request_route = client.effective_route_envelope(&model, chrono::Utc::now());
let request = MessageRequest {
model: model.to_string(),
model: model.clone(),
messages: messages.clone(),
max_tokens: client.effective_max_output_tokens(&request_route.model),
system: None,
@@ -721,4 +730,35 @@ mod tests {
assert!(!init.message.contains("DeepSeek"));
assert!(!request.message.contains("DeepSeek"));
}
#[test]
fn omitted_mcp_model_follows_the_active_provider_default() {
assert_eq!(mcp_request_model(&json!({}), "gpt-5.6"), "gpt-5.6");
assert_eq!(
mcp_request_model(&json!({ "model": " " }), "GLM-5.3"),
"GLM-5.3"
);
assert_eq!(
mcp_request_model(&json!({ "model": "kimi-k2.5" }), "gpt-5.6"),
"kimi-k2.5"
);
}
#[test]
fn list_tools_does_not_hardcode_a_deepseek_default_model() {
let settings = McpServerSettings {
expose_tools: vec!["deepseek".to_string()],
require_approval: false,
};
let server = McpServer::new(PathBuf::from("."), settings).expect("build server");
let tools = server.list_tools_response();
let description = tools["tools"][0]["inputSchema"]["properties"]["model"]["description"]
.as_str()
.expect("model description");
assert!(
!description.to_ascii_lowercase().contains("deepseek"),
"{description}"
);
assert!(description.contains("active provider"), "{description}");
}
}
+79 -14
View File
@@ -107,9 +107,10 @@ impl CostEstimate {
}
}
// === DeepSeek Account Balance ===
// === Provider Account Balance ===
/// Response from `GET https://api.deepseek.com/user/balance`.
/// Response from DeepSeek `GET /user/balance`. Other prepaid providers are
/// mapped onto [`BalanceInfo`] at the fetch seam.
#[derive(Debug, Clone, Default, serde::Deserialize)]
pub struct BalanceResponse {
#[allow(dead_code)]
@@ -117,28 +118,64 @@ pub struct BalanceResponse {
pub balance_infos: Vec<BalanceInfo>,
}
/// Per-currency balance entry from the balance API.
/// Per-currency remaining-credit entry shown by `/balance` and the status chip.
#[derive(Debug, Clone, Default, serde::Deserialize)]
pub struct BalanceInfo {
// Wire fields of the live `GET /user/balance` response deserialized in
// `tui::ui::fetch_deepseek_balance` and parked in `App::balance_cell`.
// Nothing renders them today — the footer balance chip went with the
// legacy FooterWidget — so `dead_code` cannot see the producer. Kept
// because they are the API contract, matching the sibling fields below.
#[allow(dead_code)]
pub currency: String,
#[serde(default)]
#[allow(dead_code)]
pub total_balance: String,
#[serde(default)]
#[allow(dead_code)]
pub topped_up_balance: String,
#[serde(default)]
#[allow(dead_code)]
pub granted_balance: String,
}
impl BalanceInfo {}
impl BalanceInfo {
/// Compact ledger chip, e.g. `$12.50` or `¥123.45`.
#[must_use]
pub fn chip_label(&self) -> Option<String> {
let amount = self.total_balance.trim();
if amount.is_empty() {
return None;
}
Some(format_balance_amount(amount, &self.currency))
}
/// Full `/balance` report for one prepaid provider.
#[must_use]
pub fn report(&self, provider_name: &str) -> String {
let amount = self
.chip_label()
.unwrap_or_else(|| self.total_balance.trim().to_string());
let mut report = if amount.is_empty() {
format!("{provider_name} account balance is unknown")
} else {
format!("{provider_name} account balance: {amount}")
};
let topped = self.topped_up_balance.trim();
let granted = self.granted_balance.trim();
if !topped.is_empty() || !granted.is_empty() {
let mut parts = Vec::new();
if !topped.is_empty() {
parts.push(format!("topped up {topped}"));
}
if !granted.is_empty() {
parts.push(format!("granted {granted}"));
}
report.push_str(&format!(" ({})", parts.join(", ")));
}
report
}
}
fn format_balance_amount(amount: &str, currency: &str) -> String {
match currency.trim().to_ascii_uppercase().as_str() {
"CNY" | "RMB" | "¥" => format!("¥{amount}"),
"USD" | "US$" | "$" => format!("${amount}"),
"" => amount.to_string(),
other => format!("{amount} {other}"),
}
}
/// How a hand-sourced row bills cache-creation (cache-write) tokens.
///
@@ -4745,5 +4782,33 @@ mod tests {
assert!(resp.balance_infos.is_empty());
}
// ── BalanceInfo::total_balance_f64 ─────────────────────────────
#[test]
fn balance_info_chip_label_uses_currency_prefix() {
let cny = BalanceInfo {
currency: "CNY".to_string(),
total_balance: "123.45".to_string(),
..BalanceInfo::default()
};
assert_eq!(cny.chip_label().as_deref(), Some("¥123.45"));
let usd = BalanceInfo {
currency: "USD".to_string(),
total_balance: "12.50".to_string(),
..BalanceInfo::default()
};
assert_eq!(usd.chip_label().as_deref(), Some("$12.50"));
assert_eq!(
usd.report("OpenRouter"),
"OpenRouter account balance: $12.50"
);
let deepseek = BalanceInfo {
currency: "CNY".to_string(),
total_balance: "123.45".to_string(),
topped_up_balance: "100.00".to_string(),
granted_balance: "23.45".to_string(),
};
assert_eq!(
deepseek.report("DeepSeek"),
"DeepSeek account balance: ¥123.45 (topped up 100.00, granted 23.45)"
);
}
}
+1
View File
@@ -2583,6 +2583,7 @@ fn merge_usage_totals(into: &mut UsageTotals, from: &UsageTotals) {
into.turns = into.turns.saturating_add(from.turns);
}
#[allow(clippy::too_many_arguments)] // pre-existing baseline signature; FEAT-022 gate repair
fn accumulate_runtime_cost_coverage(
audit: Option<&crate::pricing::TurnCostAudit>,
priced_turns: &mut u64,
-8
View File
@@ -273,14 +273,6 @@ impl BundledSkillTier {
Self::FormatTooling => "tools",
}
}
#[must_use]
pub const fn heading(self) -> &'static str {
match self {
Self::CoreAgentic => "Core agentic",
Self::FormatTooling => "Format & tooling",
}
}
}
/// Return the curated tier for a bundled skill name.
+2
View File
@@ -1108,6 +1108,8 @@ pub enum AppAction {
title: String,
content: String,
},
/// Live remaining-credit lookup for prepaid providers (`/balance`).
FetchBalance,
FetchModels,
/// Force a Models.dev live-catalog refresh into ProviderLake (#4187).
RefreshModelsDevCatalog,
File diff suppressed because it is too large. Load diff
+1
View File
@@ -32,6 +32,7 @@ pub mod composer_chrome;
pub mod composer_ui;
pub mod context_inspector;
pub mod context_menu;
pub(crate) mod control_socket;
pub(crate) mod coordination_detail;
pub(crate) mod cursor_accent;
pub mod diff_render;
+15
View File
@@ -977,3 +977,18 @@ pub(crate) fn tideline_footer_from_app(app: &mut App, width: u16) -> TidelineFoo
#[cfg(test)]
mod tideline_tests;
#[cfg(test)]
mod neutrality_tests {
#[test]
fn session_metrics_strip_is_on_by_default() {
assert!(
crate::config::StatusItem::default_footer()
.contains(&crate::config::StatusItem::SessionMetrics)
);
assert_eq!(
crate::config::StatusItem::from_key("session_metrics"),
Some(crate::config::StatusItem::SessionMetrics)
);
}
}
+88 -28
View File
@@ -149,15 +149,14 @@ impl fmt::Debug for SuggestionLaunch {
}
}
/// Whether a provider speaks the exact DeepSeek OpenAI-compatible
/// Whether a provider speaks the ordinary OpenAI-compatible
/// `/chat/completions` shape [`generate_suggestion`] hardcodes.
///
/// This is deliberately narrow: `DeepseekAnthropic` is a different wire
/// protocol, and every other provider is out of scope. Widening this set is a
/// feature change, not a bug fix.
/// Gate on wire protocol, not a vendor enum: Anthropic Messages and the
/// OpenAI Responses API are different request shapes and stay out.
#[must_use]
pub fn route_is_supported_suggestion_provider(provider: ApiProvider) -> bool {
matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)
crate::client::provider_speaks_chat_completions(provider)
}
/// Resolve credentials for exactly one configured route identity.
@@ -181,8 +180,8 @@ fn resolve_credentials_for_identity(
provider_identity: &str,
model: &str,
) -> Option<SuggestionRouteCredentials> {
// Belt and braces: callers already gated, but this function must never be
// the thing that reads a non-DeepSeek route's credentials.
// Belt and braces: callers already gated, but this function must never
// read credentials for a wire this helper does not speak.
if !route_is_supported_suggestion_provider(provider) {
return None;
}
@@ -225,9 +224,9 @@ fn resolve_credentials_for_identity(
/// whatever route config describes *now*, not the route the turn is running on.
/// The receipt was minted from the installed client itself, so it cannot drift.
///
/// Unsupported providers — every non-DeepSeek route, including
/// `DeepseekAnthropic` — return `None`, and no credential material of any
/// provider is inspected on this path at all.
/// Unsupported providers — Anthropic Messages, Responses, and any other
/// non-Chat-Completions wire — return `None`, and no credential material
/// of any provider is inspected on this path at all.
#[must_use]
pub fn capture_route_authority(route: &TurnRoute) -> Option<SuggestionRouteAuthority> {
if !route_is_supported_suggestion_provider(route.provider) {
@@ -261,8 +260,8 @@ pub fn capture_route_authority(route: &TurnRoute) -> Option<SuggestionRouteAutho
///
/// Fail-closed by construction:
/// - `resolve_route_credentials` is only invoked once every non-credential gate
/// has passed for a supported DeepSeek route, so a non-DeepSeek completion
/// never reaches DeepSeek credential material at all.
/// has passed for a Chat Completions route, so a Messages/Responses
/// completion never reaches another provider's credentials at all.
/// - The decision reads only `completed_route`, never live selection state, so
/// a later route switch cannot redirect it.
/// - The route must still resolve to the *same* provider, identity, wire model,
@@ -428,15 +427,21 @@ pub async fn generate_suggestion(
%model,
"generating prompt suggestion"
);
let response = match client
let mut request = client
.post(&url)
.header(AUTHORIZATION, format!("Bearer {api_key}"))
.header(CONTENT_TYPE, "application/json")
.timeout(std::time::Duration::from_secs(10))
.json(&body)
.send()
.await
{
.json(&body);
// OpenRouter app attribution: same headers the Chat Completions client
// already sends. Infer from the turn's actual endpoint so this helper
// does not grow a provider enum of its own.
if endpoint_identity(&url).contains("openrouter.ai") {
request = request
.header("HTTP-Referer", "https://codewhale.net")
.header("X-Title", "Codewhale");
}
let response = match request.send().await {
Ok(r) => r,
Err(_) => return None,
};
@@ -689,9 +694,9 @@ mod tests {
}
#[test]
fn non_deepseek_completion_never_touches_deepseek_credentials() {
// A DeepSeek key exists and would resolve fine — the gate must run
// before the resolver is ever consulted.
fn non_chat_completions_completion_never_touches_foreign_credentials() {
// A Chat Completions key exists and would resolve fine — the gate must
// run before the resolver is ever consulted.
let resolver = RecordingResolver::new(vec![(
ApiProvider::Deepseek,
"deepseek",
@@ -699,9 +704,7 @@ mod tests {
)]);
for (provider, identity, model) in [
(ApiProvider::Anthropic, "anthropic", "claude-sonnet-4"),
(ApiProvider::Openai, "openai", "gpt-4.1"),
(ApiProvider::Openrouter, "openrouter", "some/model"),
(ApiProvider::Custom, "lm-studio", "local-model"),
(ApiProvider::OpenaiCodex, "openai-codex", "gpt-5.4"),
(
ApiProvider::DeepseekAnthropic,
"deepseek-anthropic",
@@ -728,6 +731,65 @@ mod tests {
);
}
#[test]
fn chat_completions_routes_launch_with_their_own_credentials() {
for (provider, identity, model, base, key) in [
(
ApiProvider::Deepseek,
"deepseek",
"deepseek-chat",
DEEPSEEK_BASE,
DEEPSEEK_KEY,
),
(
ApiProvider::Openai,
"openai",
"gpt-5.6",
"https://api.openai.com/v1",
"sk-openai",
),
(
ApiProvider::Openrouter,
"openrouter",
"some/model",
"https://openrouter.ai/api/v1",
"sk-or",
),
(
ApiProvider::Custom,
"lm-studio",
"local-model",
"http://127.0.0.1:1234/v1",
"lm-key",
),
(
ApiProvider::Zai,
"zai",
"GLM-5.3",
"https://api.z.ai/api/paas/v4",
"zai-key",
),
] {
let resolver =
RecordingResolver::new(vec![(provider, identity, credentials(key, base, model))]);
let authority = route_authority(provider, identity, model, base, key);
let route = SuggestionRouteSnapshot {
provider,
provider_identity: identity,
model,
authority: &authority,
actual_base_url: Some(base),
};
let launch =
plan_suggestion_launch(true, true, 2, Some(route), |route| resolver.resolve(route))
.unwrap_or_else(|| panic!("{provider:?} Chat Completions route must launch"));
assert_eq!(launch.api_key, key, "{provider:?}");
assert_eq!(launch.base_url, base, "{provider:?}");
assert_eq!(launch.model, model, "{provider:?}");
assert_eq!(resolver.asked(), vec![(provider, identity.to_string())]);
}
}
#[test]
fn missing_credentials_fail_closed() {
let authority = deepseek_authority("deepseek-chat");
@@ -1552,16 +1614,14 @@ mod tests {
#[test]
fn config_unsupported_providers_capture_no_authority() {
let _env = seal_deepseek_env();
// A usable DeepSeek credential exists in this config, and each route
// below is even handed a DeepSeek-shaped receipt. An unsupported
// A usable Chat Completions credential exists in this config, and
// each route below is even handed a receipt. A Messages/Responses
// completed route must still capture nothing.
let config = deepseek_config(DEEPSEEK_KEY, DEEPSEEK_BASE);
for (provider, identity, model) in [
(ApiProvider::Anthropic, "anthropic", "claude-sonnet-4"),
(ApiProvider::Openai, "openai", "gpt-4.1"),
(ApiProvider::Openrouter, "openrouter", "some/model"),
(ApiProvider::Custom, "lm-studio", "local-model"),
(ApiProvider::OpenaiCodex, "openai-codex", "gpt-5.4"),
(
ApiProvider::DeepseekAnthropic,
"deepseek-anthropic",
+46
View File
@@ -1055,6 +1055,52 @@ fn mark_active_turn_cancelled_locally(app: &mut App) {
crate::tui::notifications::stop_title_animation_quietly();
}
/// The Esc-shaped "cancel the active turn" body, extracted verbatim from the
/// event loop's `EscapeAction::CancelRequest` arm so the session control
/// socket's `interrupt` verb and the Esc key cannot drift apart. Returns
/// `true` when the caller should stop handling the event (compaction cancel
/// or goal-continuation stop consumed it), `false` otherwise. The caller
/// keeps its own Esc-specific state (backtrack reset) outside this body.
pub(crate) fn escape_cancel_request(
app: &mut App,
engine_handle: &EngineHandle,
current_streaming_text: &mut String,
stream_display_clock: &mut StreamDisplayClock,
) -> bool {
if try_cancel_compaction(app, engine_handle) {
return true;
}
if app.paused || app.paused_goal_objective.is_some() {
clear_paused_command_state(app, engine_handle);
if app.is_loading || matches!(app.runtime_turn_status.as_deref(), Some("in_progress")) {
engine_handle.cancel();
mark_active_turn_cancelled_locally(app);
current_streaming_text.clear();
stream_display_clock.reset();
}
app.active_allowed_tools = None;
app.goal.objective = None;
app.goal.tokens_used = 0;
app.goal.time_used_seconds = 0;
app.goal.continuation_count = 0;
app.status_message = Some(parent_stop_status(app, "Paused command cancelled"));
false
} else {
let was_waiting = app.goal_continuation_waiting;
engine_handle.cancel();
if was_waiting {
app.goal_continuation_waiting = false;
app.status_message = Some(app.tr(MessageId::GoalContinuationStopped).to_string());
return true;
}
mark_active_turn_cancelled_locally(app);
current_streaming_text.clear();
stream_display_clock.reset();
app.status_message = Some(parent_stop_status(app, "Request cancelled"));
false
}
}
fn suppress_engine_event_after_local_cancel(event: &EngineEvent) -> bool {
matches!(
event,
+60 -24
View File
@@ -1505,6 +1505,63 @@ pub(crate) async fn apply_command_result(
app.status_message = Some(format!("Could not cancel {agent_id}"));
}
}
AppAction::FetchBalance => {
let provider = app.api_provider;
if !crate::config::provider_has_balance_api(provider) {
app.add_message(HistoryCell::System {
content: format!(
"Balance check is not supported for {} yet. Check the provider dashboard for account balance details.",
provider.display_name()
),
});
} else {
let api_key = config.deepseek_api_key().unwrap_or_default();
if api_key.trim().is_empty() {
app.add_message(HistoryCell::System {
content: format!(
"No API key configured for {}.",
provider.display_name()
),
});
} else {
let base_url = config.deepseek_base_url();
match fetch_provider_balance(provider, &api_key, &base_url).await {
Some(info) => {
if let Ok(mut guard) = app.balance_cell.lock() {
*guard = Some(info.clone());
}
app.last_balance_fetch = Some(Instant::now());
app.add_message(HistoryCell::System {
content: info.report(provider.display_name()),
});
}
None => {
let fallback = app
.balance_cell
.lock()
.ok()
.and_then(|guard| guard.clone())
.and_then(|info| {
info.chip_label().map(|amount| {
format!(
"Could not refresh {} balance; last known: {amount}",
provider.display_name()
)
})
});
app.add_message(HistoryCell::System {
content: fallback.unwrap_or_else(|| {
format!(
"Could not fetch {} account balance. Check the provider dashboard.",
provider.display_name()
)
}),
});
}
}
}
}
}
AppAction::FetchModels => {
app.status_message = Some("Fetching models...".to_string());
match fetch_available_models(config).await {
@@ -1603,30 +1660,9 @@ pub(crate) async fn apply_command_result(
}
AppAction::SwitchProvider { provider, model } => {
switch_provider(app, engine_handle, config, provider, model).await;
// Refresh balance after provider switch.
let balance_cooldown_expired = app
.last_balance_fetch
.is_none_or(|t| t.elapsed() >= BALANCE_FETCH_COOLDOWN);
if balance_cooldown_expired && should_fetch_deepseek_balance(app) {
let cell = app.balance_cell.clone();
let api_key = config.deepseek_api_key().unwrap_or_default();
let base_url = config.deepseek_base_url();
if !api_key.is_empty() {
app.last_balance_fetch = Some(Instant::now());
tokio::spawn(async move {
if let Some(info) = fetch_deepseek_balance(&api_key, &base_url).await
&& let Ok(mut guard) = cell.lock()
{
*guard = Some(info);
}
});
}
} else {
// Clear balance when switching to a non-DeepSeek provider.
if let Ok(mut guard) = app.balance_cell.lock() {
*guard = None;
}
}
let api_key = config.deepseek_api_key().unwrap_or_default();
let base_url = config.deepseek_base_url();
schedule_balance_fetch(app, &api_key, &base_url, false);
}
AppAction::SwitchModelRoute { provider, model } => {
let previous_model = if app.auto_model {
+44 -71
View File
@@ -17,6 +17,8 @@ use super::*;
use crate::models::Role;
use crate::tui::shell_key_routing::ShellBindingId;
use crate::tui::control_socket::SessionControl;
pub(super) fn event_owner_is_active(
current_session_id: Option<&str>,
owner_session_id: &str,
@@ -1160,6 +1162,15 @@ pub(crate) async fn run_event_loop(
// Widgets request future animation frames here; the poll loop remains the
// sole `terminal.draw` emitter (no competing animation loop).
let mut frame_requester = FrameRequester::new();
// Per-session control socket (`[control_socket]`): disabled unless the
// config enables it; even then, nothing binds until the owned session id
// appears (see the per-iteration reconcile below).
let mut session_control = SessionControl::new(
config
.control_socket
.as_ref()
.is_some_and(|socket| socket.enabled),
);
let mut web_config_session: Option<WebConfigSession> = None;
let mut prev_input_snapshot = String::new();
let mut terminal_paused_at: Option<Instant> = None;
@@ -1212,23 +1223,13 @@ pub(crate) async fn run_event_loop(
}
}
// Fire a one-shot initial balance fetch for DeepSeek providers
// so the footer chip shows balance on the first frame without
// Fire a one-shot initial remaining-credit fetch for prepaid
// providers so the footer chip can show on the first frame without
// waiting for a turn to complete.
if !app.balance_initiated && should_fetch_deepseek_balance(app) {
let cell = app.balance_cell.clone();
if !app.balance_initiated {
let api_key = config.deepseek_api_key().unwrap_or_default();
let base_url = config.deepseek_base_url();
if !api_key.is_empty() {
app.last_balance_fetch = Some(Instant::now());
tokio::spawn(async move {
if let Some(info) = fetch_deepseek_balance(&api_key, &base_url).await
&& let Ok(mut guard) = cell.lock()
{
*guard = Some(info);
}
});
}
schedule_balance_fetch(app, &api_key, &base_url, false);
app.balance_initiated = true;
}
@@ -1257,6 +1258,25 @@ pub(crate) async fn run_event_loop(
// block keyboard input or silently drop the accepted control.
flush_pending_goal_controls(app, &engine_handle);
// Per-session control socket: rebind when the owned session id
// changes, republish the `status` snapshot, and execute queued
// verbs on the UI thread. A verb that asks for quit (the `relaunch`
// seam) exits the loop through the ordinary `/exit` teardown.
session_control.reconcile(app.current_session_id.as_deref());
session_control.update_status(app);
if session_control
.drain(
app,
config,
&engine_handle,
&mut current_streaming_text,
&mut stream_display_clock,
)
.await
{
return Ok(());
}
while let Some(completion) = app.clipboard.poll_write_completion() {
if let Err(err) = completion {
tracing::warn!(error = %err, "background terminal clipboard write failed");
@@ -2419,28 +2439,12 @@ pub(crate) async fn run_event_loop(
// could not be built or queued, the in-flight
// checkpoint survives for startup recovery review.
// Refresh DeepSeek account balance after each completed
// Refresh prepaid remaining credit after each completed
// turn so the footer balance chip stays current without
// adding latency to any request path.
let balance_cooldown_expired = app
.last_balance_fetch
.is_none_or(|t| t.elapsed() >= BALANCE_FETCH_COOLDOWN);
if balance_cooldown_expired && should_fetch_deepseek_balance(app) {
let cell = app.balance_cell.clone();
let api_key = config.deepseek_api_key().unwrap_or_default();
let base_url = config.deepseek_base_url();
if !api_key.is_empty() {
app.last_balance_fetch = Some(Instant::now());
tokio::spawn(async move {
if let Some(info) =
fetch_deepseek_balance(&api_key, &base_url).await
&& let Ok(mut guard) = cell.lock()
{
*guard = Some(info);
}
});
}
}
let api_key = config.deepseek_api_key().unwrap_or_default();
let base_url = config.deepseek_base_url();
schedule_balance_fetch(app, &api_key, &base_url, false);
// Legacy pending-steer recovery. Current keyboard
// handling keeps Esc as cancel-only, but older saved
@@ -5522,45 +5526,14 @@ pub(crate) async fn run_event_loop(
}
EscapeAction::CancelRequest => {
app.backtrack.reset();
if try_cancel_compaction(app, &engine_handle) {
if escape_cancel_request(
app,
&engine_handle,
&mut current_streaming_text,
&mut stream_display_clock,
) {
continue;
}
if app.paused || app.paused_goal_objective.is_some() {
clear_paused_command_state(app, &engine_handle);
if app.is_loading
|| matches!(
app.runtime_turn_status.as_deref(),
Some("in_progress")
)
{
engine_handle.cancel();
mark_active_turn_cancelled_locally(app);
current_streaming_text.clear();
stream_display_clock.reset();
}
app.active_allowed_tools = None;
app.goal.objective = None;
app.goal.tokens_used = 0;
app.goal.time_used_seconds = 0;
app.goal.continuation_count = 0;
app.status_message =
Some(parent_stop_status(app, "Paused command cancelled"));
} else {
let was_waiting = app.goal_continuation_waiting;
engine_handle.cancel();
if was_waiting {
app.goal_continuation_waiting = false;
app.status_message = Some(
app.tr(MessageId::GoalContinuationStopped).to_string(),
);
continue;
}
mark_active_turn_cancelled_locally(app);
current_streaming_text.clear();
stream_display_clock.reset();
app.status_message =
Some(parent_stop_status(app, "Request cancelled"));
}
}
EscapeAction::PauseCommand => {
app.backtrack.reset();
+173 -12
View File
@@ -157,15 +157,131 @@ pub(crate) fn complete_provider_picker_onboarding_if_switched(
}
}
/// Fetch the DeepSeek account balance from the balance API.
/// How one prepaid provider publishes remaining credit. Each variant is a
/// distinct wire contract — do not send DeepSeek `/user/balance` to a
/// provider that does not speak it.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum BalanceApi {
DeepSeekUserBalance,
OpenRouterCredits,
SiliconFlowUserInfo,
}
fn balance_api_for(provider: ApiProvider) -> Option<BalanceApi> {
match provider {
ApiProvider::Deepseek | ApiProvider::DeepseekCN => Some(BalanceApi::DeepSeekUserBalance),
ApiProvider::Openrouter => Some(BalanceApi::OpenRouterCredits),
ApiProvider::Siliconflow | ApiProvider::SiliconflowCn => {
Some(BalanceApi::SiliconFlowUserInfo)
}
_ => None,
}
}
/// Fetch remaining credit for the active prepaid provider.
///
/// Returns `None` on any error (network, auth, parse) — callers should treat
/// a `None` return as "balance unknown" and keep the previous value.
pub(crate) async fn fetch_deepseek_balance(
/// Returns `None` on any error (network, auth, parse) — callers treat that
/// as "balance unknown" and keep the previous value.
pub(crate) async fn fetch_provider_balance(
provider: ApiProvider,
api_key: &str,
base_url: &str,
) -> Option<crate::pricing::BalanceInfo> {
let api_key = api_key.trim();
if api_key.is_empty() {
return None;
}
match balance_api_for(provider)? {
BalanceApi::DeepSeekUserBalance => fetch_deepseek_user_balance(api_key, base_url).await,
BalanceApi::OpenRouterCredits => fetch_openrouter_credits(api_key, base_url).await,
BalanceApi::SiliconFlowUserInfo => {
fetch_siliconflow_user_info(api_key, base_url, provider).await
}
}
}
async fn fetch_deepseek_user_balance(
api_key: &str,
base_url: &str,
) -> Option<crate::pricing::BalanceInfo> {
let url = format!("{}/user/balance", base_url.trim_end_matches('/'));
let body: crate::pricing::BalanceResponse = balance_get_json(api_key, &url).await?;
body.balance_infos.into_iter().next()
}
#[derive(serde::Deserialize)]
struct OpenRouterCreditsResponse {
data: OpenRouterCreditsData,
}
#[derive(serde::Deserialize)]
struct OpenRouterCreditsData {
total_credits: f64,
total_usage: f64,
}
fn openrouter_remaining_credits(total_credits: f64, total_usage: f64) -> f64 {
(total_credits - total_usage).max(0.0)
}
async fn fetch_openrouter_credits(
api_key: &str,
base_url: &str,
) -> Option<crate::pricing::BalanceInfo> {
let url = format!("{}/credits", base_url.trim_end_matches('/'));
let body: OpenRouterCreditsResponse = balance_get_json(api_key, &url).await?;
let remaining = openrouter_remaining_credits(body.data.total_credits, body.data.total_usage);
Some(crate::pricing::BalanceInfo {
currency: "USD".to_string(),
total_balance: format!("{remaining:.2}"),
topped_up_balance: format!("{:.2}", body.data.total_credits),
granted_balance: String::new(),
})
}
#[derive(serde::Deserialize)]
struct SiliconFlowUserInfo {
data: Option<SiliconFlowUserData>,
}
#[derive(serde::Deserialize)]
struct SiliconFlowUserData {
#[serde(default, alias = "totalBalance")]
total_balance: Option<String>,
#[serde(default, alias = "chargeBalance")]
charge_balance: Option<String>,
#[serde(default)]
balance: Option<String>,
}
async fn fetch_siliconflow_user_info(
api_key: &str,
base_url: &str,
provider: ApiProvider,
) -> Option<crate::pricing::BalanceInfo> {
let url = format!("{}/user/info", base_url.trim_end_matches('/'));
let body: SiliconFlowUserInfo = balance_get_json(api_key, &url).await?;
let data = body.data?;
let total = data
.total_balance
.as_deref()
.or(data.balance.as_deref())
.map(str::trim)
.filter(|value| !value.is_empty())?;
let currency = if provider == ApiProvider::SiliconflowCn {
"CNY"
} else {
"USD"
};
Some(crate::pricing::BalanceInfo {
currency: currency.to_string(),
total_balance: total.to_string(),
topped_up_balance: data.charge_balance.unwrap_or_default(),
granted_balance: String::new(),
})
}
async fn balance_get_json<T: serde::de::DeserializeOwned>(api_key: &str, url: &str) -> Option<T> {
let client = &*BALANCE_CLIENT;
let response = client
.get(url)
@@ -181,17 +297,62 @@ pub(crate) async fn fetch_deepseek_balance(
);
return None;
}
let body: crate::pricing::BalanceResponse = response.json().await.ok()?;
// Return the first balance entry (typically the user's primary currency).
body.balance_infos.into_iter().next()
response.json().await.ok()
}
pub(crate) fn should_fetch_deepseek_balance(app: &App) -> bool {
pub(crate) fn should_fetch_provider_balance(app: &App) -> bool {
app.status_items.contains(&StatusItem::Balance)
&& matches!(
app.api_provider,
ApiProvider::Deepseek | ApiProvider::DeepseekCN
)
&& crate::config::provider_has_balance_api(app.api_provider)
}
/// Kick a background remaining-credit fetch for the live route.
///
/// `force` skips the status-item gate (used by `/balance`). Providers without
/// a known endpoint clear the parked chip so a previous route cannot linger.
pub(crate) fn schedule_balance_fetch(app: &mut App, api_key: &str, base_url: &str, force: bool) {
if !crate::config::provider_has_balance_api(app.api_provider) {
if let Ok(mut guard) = app.balance_cell.lock() {
*guard = None;
}
return;
}
if !force && !should_fetch_provider_balance(app) {
return;
}
if api_key.trim().is_empty() {
return;
}
let cooldown_ok = force
|| app
.last_balance_fetch
.is_none_or(|t| t.elapsed() >= BALANCE_FETCH_COOLDOWN);
if !cooldown_ok {
return;
}
app.last_balance_fetch = Some(Instant::now());
let cell = app.balance_cell.clone();
let provider = app.api_provider;
let api_key = api_key.to_string();
let base_url = base_url.to_string();
tokio::spawn(async move {
if let Some(info) = fetch_provider_balance(provider, &api_key, &base_url).await
&& let Ok(mut guard) = cell.lock()
{
*guard = Some(info);
}
});
}
#[cfg(test)]
pub(crate) fn openrouter_credits_from_json(json: &str) -> Option<crate::pricing::BalanceInfo> {
let body: OpenRouterCreditsResponse = serde_json::from_str(json).ok()?;
let remaining = openrouter_remaining_credits(body.data.total_credits, body.data.total_usage);
Some(crate::pricing::BalanceInfo {
currency: "USD".to_string(),
total_balance: format!("{remaining:.2}"),
topped_up_balance: format!("{:.2}", body.data.total_credits),
granted_balance: String::new(),
})
}
/// Route text from either clipboard transport into the canonical provider
+23 -7
View File
@@ -21574,7 +21574,7 @@ fn default_footer_excludes_provider_specific_diagnostic_chips() {
);
assert!(
!items.contains(&crate::config::StatusItem::Balance),
"balance is DeepSeek-only and should not crowd the default footer for non-DeepSeek users"
"balance is an opt-in prepaid chip and should not crowd the default footer"
);
assert!(
items.contains(&crate::config::StatusItem::Cache),
@@ -21589,27 +21589,43 @@ fn default_footer_excludes_provider_specific_diagnostic_chips() {
// ── Balance footer chip tests ─────────────────────────────────────
#[test]
fn should_fetch_deepseek_balance_requires_balance_status_item() {
fn should_fetch_provider_balance_requires_balance_status_item() {
let mut app = create_test_app();
app.api_provider = ApiProvider::Deepseek;
app.status_items = crate::config::StatusItem::default_footer();
assert!(!should_fetch_deepseek_balance(&app));
assert!(!should_fetch_provider_balance(&app));
app.status_items.push(crate::config::StatusItem::Balance);
assert!(should_fetch_deepseek_balance(&app));
assert!(should_fetch_provider_balance(&app));
}
#[test]
fn should_fetch_deepseek_balance_requires_deepseek_provider() {
fn should_fetch_provider_balance_covers_prepaid_providers() {
let mut app = create_test_app();
app.status_items = vec![crate::config::StatusItem::Balance];
app.api_provider = ApiProvider::Ollama;
assert!(!should_fetch_provider_balance(&app));
app.api_provider = ApiProvider::Openrouter;
assert!(!should_fetch_deepseek_balance(&app));
assert!(should_fetch_provider_balance(&app));
app.api_provider = ApiProvider::Siliconflow;
assert!(should_fetch_provider_balance(&app));
app.api_provider = ApiProvider::DeepseekCN;
assert!(should_fetch_deepseek_balance(&app));
assert!(should_fetch_provider_balance(&app));
}
#[test]
fn openrouter_credits_map_remaining_usd() {
let info =
openrouter_credits_from_json(r#"{"data":{"total_credits":50.0,"total_usage":12.345}}"#)
.expect("openrouter credits JSON");
assert_eq!(info.currency, "USD");
assert_eq!(info.total_balance, "37.66");
assert_eq!(info.chip_label().as_deref(), Some("$37.66"));
}
/// Regression for issue #244: visible session spend must not decrease.
+1
View File
@@ -1137,6 +1137,7 @@ impl<'a> LaunchComposerDisplay<'a> {
/// beneath. This is the same composer state the conversation view edits,
/// not a second input system; only the geometry is the startup stage's
/// dock.
#[allow(clippy::too_many_arguments)] // pre-existing baseline signature; FEAT-022 gate repair
fn render_launch_composer(
area: Rect,
buf: &mut Buffer,
+6 -4
View File
@@ -406,11 +406,13 @@ mod tests {
}
#[test]
fn balance_excluded_for_non_deepseek_provider() {
fn balance_offered_for_prepaid_providers_and_hidden_for_local() {
let active = StatusItem::default_footer();
let view = StatusPickerView::new(&active, ApiProvider::Openrouter, Locale::En);
assert!(!view.rows.contains(&StatusItem::Balance));
assert!(view.rows.contains(&StatusItem::Mode));
let openrouter = StatusPickerView::new(&active, ApiProvider::Openrouter, Locale::En);
assert!(openrouter.rows.contains(&StatusItem::Balance));
let ollama = StatusPickerView::new(&active, ApiProvider::Ollama, Locale::En);
assert!(!ollama.rows.contains(&StatusItem::Balance));
assert!(ollama.rows.contains(&StatusItem::Mode));
}
#[test]
+43
View File
@@ -2541,6 +2541,49 @@ Delivery is best-effort: failures are logged and dropped, never retried
into the agent loop, and a failing webhook never blocks the local file
append.
## Control Socket (`[control_socket]`)
Per-session control surface for supervised operation: with the feature
enabled, the interactive TUI binds one unix domain socket per *running*
session at `<sessions-dir>/<session-id>/control.sock` (mode `0600`;
`<sessions-dir>` is the same directory the session store uses, typically
`~/.codewhale/sessions`). The socket is removed with the session's
artifact directory, and a stale socket left by a crashed process is taken
over by the next launch. Unset or `enabled = false` = the feature is
**off** (the default) and behavior is unchanged. Unix-only; on other
platforms the key parses but no socket is bound.
```toml
[control_socket]
enabled = false # default: OFF
```
The socket speaks newline-framed JSON-RPC, one request per connection:
write one request line, read one response line, close.
```json
{"id":"1","method":"message","params":{"text":"hello"}}
{"id":"2","method":"interrupt","params":{}}
{"id":"3","method":"relaunch","params":{}}
{"id":"4","method":"status","params":{}}
```
- `message` — delivers `text` as a structured user message through the
ordinary composer dispatch path; dispatched immediately when idle,
queued when a turn is in flight (the response's `delivery` field says
which).
- `interrupt` — the Esc-shaped cancel of the active turn; `cancelled`
reports whether active work was in flight.
- `relaunch` — routed through the `/relaunch` slash-command path (same
save-and-resume handoff, no separate mechanics).
- `status` — answers `turn_state` (`idle` / `in_progress` / `waiting`) and
`goal` (`objective`, `status`, `paused`).
Success responses echo the request id with a `type`-tagged result;
failures carry `error.code` (`invalid_request`, `command_error`,
`timeout`, `server_unavailable`). Requests are bounded at 1 MiB per line
and a handler that does not answer within 5 s is reported as `timeout`.
## Tool Catalog
Codewhale loads a small core native tool catalog by default and leaves less
+64 -4
View File
@@ -751,6 +751,27 @@ def _first_param_type(fn_sig: str) -> str | None:
return None
# ---------------------------------------------------------------------------
# Retained host machinery (FEAT-042 tracking)
# ---------------------------------------------------------------------------
#
# The migration topology is immutable, so the dispatcher-only host helpers that
# intentionally keep `&mut App` after a group migrates are declared here — the
# gate's own enforcement home. Each entry maps a migrated group to selectors
# that must keep their concrete-App signature until FEAT-042 extracts them to a
# host-side module; a missing or refactored-away signature fails the gate, so
# the tracking cannot silently go stale. FEAT-022: the skills group retains the
# unified slash-command fallback and its activation helpers co-located with the
# portable handlers (D7).
RETAINED_HOST_MACHINERY: dict[str, list[dict]] = {
"skills": [
{"kind": "free", "item": ["crate", "commands", "groups", "skills", "skills", "run_skill_by_name"]},
{"kind": "free", "item": ["crate", "commands", "groups", "skills", "skills", "activate_skill_with_task"]},
{"kind": "free", "item": ["crate", "commands", "groups", "skills", "skills", "activate_skill"]},
],
}
def _is_concrete_app_type(param_type: str | None) -> bool:
if param_type is None:
return False
@@ -951,6 +972,24 @@ def _self_type_qual(self_type: str, module_path: str) -> str:
return f"{module_path}::{base}"
def _selector_matches(selector: dict, item: RustItem) -> bool:
"""Match one RustItem against a checked-in selector (shared by
`resolve_selector` and the retained-host source scan)."""
kind = selector["kind"]
if kind == "free":
target = "::".join(selector["item"])
return item.kind == "free" and item.qual_path == target
if kind == "inherent":
self_qual = _selector_type_to_text(selector["self_type"])
return item.kind == "inherent" and item.name == selector["method"] \
and item.qual_path.startswith(f"{self_qual}::")
self_qual = _selector_type_to_text(selector["self_type"])
trait_qual = _selector_type_to_text(selector["trait_path"])
return item.kind == "trait_impl" and item.name == selector["method"] \
and item.qual_path.startswith(f"{self_qual}::") \
and f"[{trait_qual}]" in item.qual_path
def resolve_selector(selector: dict, items: list[RustItem]) -> list[SourceScanViolation]:
"""Resolve one checked-in handler selector against parsed items.
@@ -1084,6 +1123,25 @@ def check_source_frontier(topology: dict, frontier: list[str], root: Path = REPO
violations.extend(resolve_selector(selector, group_items))
continue
# Validate retained host machinery declarations first so the tracking
# stays fail-closed even when the group has no other concrete-App
# handlers (e.g. every retained helper lost its signature at once).
retained_names: set[str] = set()
for selector in RETAINED_HOST_MACHINERY.get(group_name, []):
matches = [it for it in group_items if _selector_matches(selector, it)]
if not matches:
violations.append(SourceScanViolation(
"retained-host", json.dumps(selector, sort_keys=True),
f"retained host machinery selector resolves to no source item in {group_name!r}",
))
for match in matches:
if not match.is_concrete_app:
violations.append(SourceScanViolation(
"retained-host", match.qual_path,
"retained host machinery must keep its concrete-App signature until FEAT-042 extracts it",
))
retained_names.add(match.qual_path)
if not handlers:
continue
@@ -1116,16 +1174,18 @@ def check_source_frontier(topology: dict, frontier: list[str], root: Path = REPO
))
continue
# Not pending and not split: every remaining handler is a stale removal.
for handler in handlers[:5]:
# Not pending and not split: every remaining handler is a stale removal,
# except the retained host machinery resolved above.
stale = [h for h in handlers if h.qual_path not in retained_names]
for handler in stale[:5]:
violations.append(SourceScanViolation(
"stale-removal", handler.qual_path,
f"handler still uses concrete App but group {group_name!r} is not pending",
))
if len(handlers) > 5:
if len(stale) > 5:
violations.append(SourceScanViolation(
"stale-removal", group_name,
f"... and {len(handlers) - 5} more concrete-App handlers in this group",
f"... and {len(stale) - 5} more concrete-App handlers in this group",
))
return violations
+2 -4
View File
@@ -7,7 +7,6 @@
"crates/tui/src/commands/groups/utility/attachment.rs",
"crates/tui/src/commands/groups/utility/automation.rs",
"crates/tui/src/commands/groups/utility/jobs.rs",
"crates/tui/src/commands/groups/utility/loop_cmd.rs",
"crates/tui/src/commands/groups/utility/mcp.rs",
"crates/tui/src/commands/groups/utility/network.rs",
"crates/tui/src/commands/groups/utility/task.rs",
@@ -284,7 +283,6 @@
"core",
"debug",
"plugins",
"session",
"skills"
"session"
]
}
}
@@ -362,11 +362,11 @@ class LiveGateTests(unittest.TestCase):
frontier = doc["frontier"]
self.assertEqual(frontier, sorted(frontier))
self.assertEqual(len(frontier), len(set(frontier)))
# FEAT-018 removed utility, FEAT-019 removed memory, and FEAT-021 removed project
# from the frontier; the remaining 6 groups stay pending.
# FEAT-018 removed utility, FEAT-019 removed memory, FEAT-021 removed project,
# and FEAT-022 removed skills; the remaining five groups stay pending.
self.assertEqual(
set(frontier),
{"plugins", "skills", "session", "config", "debug", "core"},
{"plugins", "session", "config", "debug", "core"},
)
@@ -513,6 +513,75 @@ class SourceScanTests(unittest.TestCase):
violations = mod.check_source_frontier(doc["topology"], ["session", "utility"], root)
self.assertTrue(any("stale-entry" in str(v) for v in violations))
def test_retained_host_exempts_declared_machinery_from_stale_removal(self) -> None:
"""A migrated group may declare dispatcher host machinery (FEAT-042)
that keeps `&mut App`; the gate exempts it and flags the rest."""
topology = {
"alpha": {
"kind": "group",
"scope": ["alpha/mod.rs"],
"slices": [],
}
}
frontier: list[str] = []
import tempfile
with tempfile.TemporaryDirectory() as d:
root = Path(d)
(root / "alpha").mkdir(parents=True, exist_ok=True)
(root / "alpha" / "mod.rs").write_text(
"use crate::tui::app::App;\n"
"fn retained(app: &mut App, arg: Option<&str>) {} \n"
"fn stale(app: &mut App, arg: Option<&str>) {} \n",
encoding="utf-8",
)
# stub RETAINED_HOST_MACHINERY for the hermetic group
original = mod.RETAINED_HOST_MACHINERY
try:
mod.RETAINED_HOST_MACHINERY = {
"alpha": [
{"kind": "free", "item": ["crate", "commands", "groups", "alpha", "retained"]}
]
}
violations = mod.check_source_frontier(topology, frontier, root)
finally:
mod.RETAINED_HOST_MACHINERY = original
kinds = [v.category for v in violations]
self.assertNotIn("retained-host", kinds)
self.assertEqual(kinds.count("stale-removal"), 1, violations)
def test_retained_host_fails_closed_when_signature_lost(self) -> None:
"""If retained machinery loses its concrete-App signature, the gate fails."""
topology = {
"alpha": {
"kind": "group",
"scope": ["alpha/mod.rs"],
"slices": [],
}
}
frontier: list[str] = []
import tempfile
with tempfile.TemporaryDirectory() as d:
root = Path(d)
(root / "alpha").mkdir(parents=True, exist_ok=True)
(root / "alpha" / "mod.rs").write_text(
"fn retained(arg: Option<&str>) {} \n",
encoding="utf-8",
)
original = mod.RETAINED_HOST_MACHINERY
try:
mod.RETAINED_HOST_MACHINERY = {
"alpha": [
{"kind": "free", "item": ["crate", "commands", "groups", "alpha", "retained"]}
]
}
violations = mod.check_source_frontier(topology, frontier, root)
finally:
mod.RETAINED_HOST_MACHINERY = original
self.assertTrue(
any(v.category == "retained-host" for v in violations),
f"expected retained-host violation, got {violations}",
)
def test_live_source_gate_passes(self) -> None:
doc = mod.load_topology()
violations = mod.check_source_frontier(doc["topology"], doc["frontier"])