mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-11 10:01:30 -10:00
Merge pull request #5839 from aboimpinto/fix/pr-5833-review
fix(commands): resolve FEAT-019 review findings
This commit is contained in:
38 files changed
+5664
-1954
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -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<'_> {
|
||||
|
||||
@@ -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 ¬_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());
|
||||
}
|
||||
@@ -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)]
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+1007
-12
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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
),
|
||||
])
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(®istry);
|
||||
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(®istry);
|
||||
} 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
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"])
|
||||
|
||||
Reference in new issue
Block a user