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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions design-system/packages/ui/src/components/Listbox/Listbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,7 @@ export const ListboxOption = forwardRef<HTMLButtonElement, ListboxOptionProps>(
metadata,
selected = false,
tabIndex = -1,
title,
value,
...props
}, ref) {
Expand All @@ -234,6 +235,7 @@ export const ListboxOption = forwardRef<HTMLButtonElement, ListboxOptionProps>(
ref={ref}
role="option"
tabIndex={tabIndex}
title={title}
type="button"
>
{leading !== undefined && leading !== null && (
Expand All @@ -242,15 +244,15 @@ export const ListboxOption = forwardRef<HTMLButtonElement, ListboxOptionProps>(
</span>
)}
<span className={styles.content} data-openbitfun-part="content">
<OverflowText className={styles.label} data-openbitfun-part="label" marqueeActive={active}>{children}</OverflowText>
<OverflowText title={title === "" ? "" : undefined} className={styles.label} data-openbitfun-part="label" marqueeActive={active}>{children}</OverflowText>
{description !== undefined && description !== null && (
<span className={styles.description} data-openbitfun-part="description">
{description}
</span>
)}
</span>
{metadata !== undefined && metadata !== null && (
<OverflowText className={styles.metadata} data-openbitfun-part="metadata" marqueeActive={active}>{metadata}</OverflowText>
<OverflowText title={title === "" ? "" : undefined} className={styles.metadata} data-openbitfun-part="metadata" marqueeActive={active}>{metadata}</OverflowText>
)}
<span aria-hidden="true" className={styles.indicator} data-openbitfun-part="indicator">
{indicator ?? (selected ? <Icon name="check-line" /> : null)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,13 @@ watcher;用户通过统一的 `/reload instructions`(或默认 `/reload`)

### 5.2 Agents、Modes 与 Skills

标准 Skill 根使用有界递归发现(包括 Codex 的 `.system` 等容器目录),遇到 `SKILL.md` 后将该目录视为包边界,不继续收集其示例中的 Skill。
本地扫描跟随目录链接并按规范路径防环;远程扫描通过 workspace filesystem 跟随链接,以深度和目录预算终止循环。远程项目根与本地项目根采用相同的项目优先、用户次之顺序。
直接子目录保留原有 `scope::source-slot::directory` key;嵌套目录以根内 POSIX 相对路径作为末段,避免不同容器内同名目录冲突。
按名称的默认选择维持现有覆盖规则;Web UI 的 `@` 技能选择器只显示当前模式按覆盖规则选出的名称赢家;显式选择以 `[$scope::source-slot::relative/path]` 调用精确 key,仍受全局、模式和作者的用户调用可见性约束。
扫描诊断与可用清单分别返回。Desktop 现有列表命令通过可选 `includeDiagnostics` 返回报告;参数缺省仍返回数组,新客户端接受旧主机的数组并标明诊断不可用。单个目录或文件失败不清空已发现技能。


兼容定义进入现有 Agent 归属模块,而不是新建 OpenCode Agent Runtime。当前已实现范围按是否能保持行为等价划分:

- 可等价映射并激活:名称、description、生产 V1 `prompt/disable` 与 Core V2 `system/disabled` 安全子集、`primary|subagent|all`、隐藏状态、
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3410,7 +3410,7 @@
"visibility": "documented",
"rustPath": "get_mode_skill_configs",
"sourceFile": "src/apps/desktop/src/api/skill_api.rs",
"signature": "fn get_mode_skill_configs( state: State<'_, AppState>, mode_id: String, force_refresh: Option<bool>, workspace_path: Option<String>, ) -> Result<Value, String>",
"signature": "fn get_mode_skill_configs( state: State<'_, AppState>, mode_id: String, force_refresh: Option<bool>, include_diagnostics: Option<bool>, workspace_path: Option<String>, ) -> Result<Value, String>",
"remoteWorkspacePolicy": "LegacyUnaudited"
},
{
Expand Down Expand Up @@ -3810,7 +3810,7 @@
"visibility": "documented",
"rustPath": "get_skill_configs",
"sourceFile": "src/apps/desktop/src/api/skill_api.rs",
"signature": "fn get_skill_configs( state: State<'_, AppState>, force_refresh: Option<bool>, workspace_path: Option<String>, ) -> Result<Value, String>",
"signature": "fn get_skill_configs( state: State<'_, AppState>, force_refresh: Option<bool>, include_diagnostics: Option<bool>, workspace_path: Option<String>, ) -> Result<Value, String>",
"remoteWorkspacePolicy": "LegacyUnaudited"
},
{
Expand Down
2 changes: 2 additions & 0 deletions src/apps/desktop/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@ The `devtools` Cargo feature exists for debugging UI/UX in the desktop app. When
cargo check -p openbitfun-desktop && cargo test -p openbitfun-desktop
```

For skill discovery response compatibility and timeouts, use
`cargo test -p openbitfun-desktop --lib api::skill_api::tests`.
For staged application-update cache and signature behavior, use
`cargo test -p openbitfun-desktop --lib api::update_api::tests`.
For peer system-info response compatibility, run
Expand Down
73 changes: 61 additions & 12 deletions src/apps/desktop/src/api/skill_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ use openbitfun_core::agentic::tools::implementations::skills::mode_overrides::{
};
use openbitfun_core::agentic::tools::implementations::skills::{
resolver::resolve_skill_default_enabled_for_mode, ModeSkillInfo, SkillData, SkillInfo,
SkillLocation, SkillRegistry,
SkillLocation, SkillRegistry, SkillScanReport,
};
use openbitfun_core::agentic::workspace::RemoteWorkspaceFs;
use openbitfun_core::infrastructure::get_path_manager_arc;
Expand Down Expand Up @@ -229,6 +229,18 @@ async fn get_all_skills_for_workspace_input(
registry: &SkillRegistry,
workspace_path: Option<&str>,
) -> Result<Vec<SkillInfo>, String> {
Ok(
get_skill_scan_report_for_workspace_input(state, registry, workspace_path)
.await?
.skills,
)
}

async fn get_skill_scan_report_for_workspace_input(
state: &State<'_, AppState>,
registry: &SkillRegistry,
workspace_path: Option<&str>,
) -> Result<SkillScanReport, String> {
if let Some((remote_root, entry)) = resolve_remote_workspace(state, workspace_path).await? {
await_remote_skill_discovery(
async {
Expand All @@ -238,25 +250,27 @@ async fn get_all_skills_for_workspace_input(
.map_err(|e| format!("Remote file service not available: {}", e))?;
let remote_workspace_fs = RemoteWorkspaceFs::new(entry.connection_id, remote_fs);
Ok(registry
.get_all_skills_for_remote_workspace(&remote_workspace_fs, &remote_root)
.get_skill_scan_report_for_remote_workspace(&remote_workspace_fs, &remote_root)
.await)
},
REMOTE_SKILL_DISCOVERY_TIMEOUT,
)
.await
} else {
Ok(registry
.get_all_skills_for_workspace(workspace_root_from_input(workspace_path).as_deref())
.get_skill_scan_report_for_workspace(
workspace_root_from_input(workspace_path).as_deref(),
)
.await)
}
}

async fn get_mode_skill_infos_for_workspace_input(
async fn get_mode_skill_scan_report_for_workspace_input(
state: &State<'_, AppState>,
registry: &SkillRegistry,
mode_id: &str,
workspace_path: Option<&str>,
) -> Result<Vec<ModeSkillInfo>, String> {
) -> Result<SkillScanReport<ModeSkillInfo>, String> {
if let Some((remote_root, entry)) = resolve_remote_workspace(state, workspace_path).await? {
await_remote_skill_discovery(
async {
Expand All @@ -267,7 +281,7 @@ async fn get_mode_skill_infos_for_workspace_input(
let remote_workspace_fs =
RemoteWorkspaceFs::new(entry.connection_id.clone(), remote_fs.clone());
Ok(registry
.get_mode_skill_infos_for_remote_workspace(
.get_mode_skill_scan_report_for_remote_workspace(
&remote_workspace_fs,
&remote_root,
mode_id,
Expand All @@ -279,18 +293,29 @@ async fn get_mode_skill_infos_for_workspace_input(
.await
} else if let Some(workspace_root) = workspace_root_from_input(workspace_path) {
Ok(registry
.get_mode_skill_infos_for_workspace(Some(&workspace_root), mode_id)
.get_mode_skill_scan_report_for_workspace(Some(&workspace_root), mode_id)
.await)
} else {
// Mode-scoped built-in and user-level skills should still be available even
// when no project workspace is open. In that case there are simply no
// project-level overrides to apply.
Ok(registry
.get_mode_skill_infos_for_workspace(None, mode_id)
.get_mode_skill_scan_report_for_workspace(None, mode_id)
.await)
}
}

fn serialize_skill_scan_response<T: Serialize>(
report: SkillScanReport<T>,
include_diagnostics: bool,
) -> Result<Value, serde_json::Error> {
if include_diagnostics {
serde_json::to_value(report)
} else {
serde_json::to_value(report.skills)
}
}

fn normalize_skill_key_list(keys: Vec<String>) -> Vec<String> {
let mut seen = HashSet::new();
let mut normalized = Vec::new();
Expand Down Expand Up @@ -526,6 +551,7 @@ async fn clear_project_mode_skill_selection_remote(
pub async fn get_skill_configs(
state: State<'_, AppState>,
force_refresh: Option<bool>,
include_diagnostics: Option<bool>,
workspace_path: Option<String>,
) -> Result<Value, String> {
let registry = SkillRegistry::global();
Expand All @@ -535,9 +561,10 @@ pub async fn get_skill_configs(
}

let all_skills =
get_all_skills_for_workspace_input(&state, registry, workspace_path.as_deref()).await?;
get_skill_scan_report_for_workspace_input(&state, registry, workspace_path.as_deref())
.await?;

serde_json::to_value(all_skills)
serialize_skill_scan_response(all_skills, include_diagnostics.unwrap_or(false))
.map_err(|e| format!("Failed to serialize skill configs: {}", e))
}

Expand Down Expand Up @@ -591,6 +618,7 @@ pub async fn get_mode_skill_configs(
state: State<'_, AppState>,
mode_id: String,
force_refresh: Option<bool>,
include_diagnostics: Option<bool>,
workspace_path: Option<String>,
) -> Result<Value, String> {
let registry = SkillRegistry::global();
Expand All @@ -599,15 +627,15 @@ pub async fn get_mode_skill_configs(
registry.refresh().await;
}

let mode_skill_infos = get_mode_skill_infos_for_workspace_input(
let mode_skill_infos = get_mode_skill_scan_report_for_workspace_input(
&state,
registry,
&mode_id,
workspace_path.as_deref(),
)
.await?;

serde_json::to_value(mode_skill_infos)
serialize_skill_scan_response(mode_skill_infos, include_diagnostics.unwrap_or(false))
.map_err(|e| format!("Failed to serialize mode skill configs: {}", e))
}

Expand Down Expand Up @@ -1086,6 +1114,27 @@ mod tests {
use std::future;
use tokio::time::Duration;

#[test]
fn skill_scan_response_preserves_legacy_arrays_and_opt_in_diagnostics() {
let report = super::SkillScanReport {
skills: vec!["existing"],
diagnostics: vec![
openbitfun_core::agentic::tools::implementations::skills::SkillScanDiagnostic {
path: "/remote/denied".into(),
source_id: "codex".into(),
message: "permission denied".into(),
},
],
};
assert_eq!(
super::serialize_skill_scan_response(report.clone(), false).unwrap(),
serde_json::json!(["existing"])
);
let value = super::serialize_skill_scan_response(report, true).unwrap();
assert_eq!(value["skills"], serde_json::json!(["existing"]));
assert_eq!(value["diagnostics"][0]["sourceId"], "codex");
}

#[tokio::test]
async fn remote_skill_discovery_returns_before_the_deadline() {
let result =
Expand Down
6 changes: 6 additions & 0 deletions src/crates/assembly/core/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,12 @@ Skill discovery, installation provenance, and local/remote registry regressions:
cargo test --locked -p openbitfun-core --no-default-features --features agent-runtime,git --lib agentic::tools::implementations::skills::
```

For configured OpenCode discovery and explicit skill loading, include their owner feature and tool tests:

```bash
cargo test --locked -p openbitfun-core --no-default-features --features agent-runtime,git,external-sources --lib agentic::tools::implementations::skill
```

Detached Dispatch controller, target query compatibility, and managed-baseline checks:

```bash
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ How to use skills:
- `command: "user::openbitfun-system::ppt-design"` - invoke a specific built-in skill by stable key

Important:
- Only use skills listed in the current skill listing's <available_skills> section, unless a trusted host task explicitly supplies an exact stable key or the user's message contains an exact `[$skill-name]` invocation
- Only use skills listed in the current skill listing's <available_skills> section, unless a trusted host task explicitly supplies an exact stable key or the user's message contains an exact `[$skill-name]` or `[$scope::source::directory]` invocation
- For an exact stable-key invocation, pass that key unchanged as `command`; never replace it with a same-named skill from another source
- Do not invoke a skill that is already running
</skills_instructions>"#
.to_string()
Expand Down Expand Up @@ -232,7 +233,8 @@ impl Tool for SkillTool {

// Find and load skill through registry
let registry = get_skill_registry();
let use_stable_key = skill_name.split("::").count() == 3;
let use_stable_key =
skill_name.starts_with("user::") || skill_name.starts_with("project::");
let mut skill_data = if context.is_remote() {
if let Some(ws_fs) = context.ws_fs() {
let root = context
Expand Down Expand Up @@ -506,6 +508,64 @@ Use the remote project skill.
assert_eq!(schema["required"], json!(["command"]));
}

#[tokio::test]
async fn stable_key_loads_a_shadowed_nested_skill_without_changing_name_resolution() {
let temp = tempfile::tempdir().unwrap();
for (directory, body) in [
(".openbitfun/skills/same", "default body"),
(".codex/skills/nested/same", "chosen body"),
] {
let path = temp.path().join(directory);
fs::create_dir_all(&path).unwrap();
fs::write(
path.join("SKILL.md"),
format!(
"---\nname: source-collision-regression\ndescription: fixture\n---\n{body}\n"
),
)
.unwrap();
}
let context = local_context(temp.path().to_path_buf());
for (command, expected) in [
("source-collision-regression", "default body"),
("project::codex::nested/same", "chosen body"),
] {
let results = SkillTool::new()
.call_impl(&json!({ "command": command }), &context)
.await
.unwrap();
let ToolResult::Result { data, .. } = &results[0] else {
panic!("expected skill result")
};
assert_eq!(data["content"].as_str().unwrap().trim(), expected);
}
use crate::agentic::tools::implementations::skills::mode_overrides::{
load_project_mode_skills_document_local, save_project_mode_skills_document_local,
set_mode_skill_disabled_in_document,
};
let mut document = load_project_mode_skills_document_local(temp.path())
.await
.unwrap();
set_mode_skill_disabled_in_document(
&mut document,
"agent",
"project::codex::nested/same",
true,
)
.unwrap();
save_project_mode_skills_document_local(temp.path(), &document)
.await
.unwrap();
assert!(SkillRegistry::global()
.find_and_load_skill_by_key_for_workspace(
"project::codex::nested/same",
Some(temp.path()),
Some("agent")
)
.await
.is_err());
}

#[tokio::test]
async fn skill_call_expands_arguments_in_loaded_prompt() {
let temp = tempfile::tempdir().expect("tempdir");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ pub mod types;
pub use registry::SkillRegistry;
pub use types::{
render_loaded_skill_for_assistant, ModeSkillInfo, ModeSkillStateReason, SkillData, SkillInfo,
SkillLocation,
SkillLocation, SkillScanDiagnostic, SkillScanReport,
};

/// Get global Skill registry instance
Expand Down
Loading
Loading