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
116 changes: 116 additions & 0 deletions crates/agenthub-core/src/services/chat_runtime/actor_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,122 @@ fn grok_available_commands_update_fills_catalog_not_timeline() {
assert!(cache.catalog_epoch >= 1);
}

#[test]
fn kiro_vendor_commands_available_fills_catalog_not_timeline() {
let db = Database::open_in_memory().unwrap();
conversation_with(&db, "kiro-cmds", AgentId::Kiro, &std::env::temp_dir());
let mut worker = worker(&db, "kiro-cmds");
worker.agent = AgentId::Kiro;
worker.store.enable_if_new("kiro-cmds").unwrap();
start_placeholder(&mut worker);

worker
.notification(
"_kiro.dev/commands/available",
&json!({
"sessionId": "sess-kiro",
"commands": [{
"name": "/context",
"description": "Add context",
"meta": { "hint": "path" }
}, {
"name": "/compact",
"description": "Compact context",
"meta": { "hint": "[instructions]" }
}],
"prompts": [{
"name": "my-skill",
"description": "A skill",
"serverName": "skill:config"
}],
"tools": [{ "name": "read" }],
"mcpServers": []
}),
)
.unwrap();

let snapshot = worker.store.snapshot("kiro-cmds", None).unwrap();
assert!(!snapshot.events.iter().any(|event| matches!(
&event.event,
ChatEvent::AgentProcess { .. }
)));
let cache = worker
.catalogs
.lock()
.unwrap()
.get("kiro-cmds")
.cloned()
.expect("catalog");
assert_eq!(
cache
.native_commands
.iter()
.map(|command| command.name.as_str())
.collect::<Vec<_>>(),
["context", "compact"]
);
assert_eq!(cache.native_commands[0].hint.as_deref(), Some("path"));
assert_eq!(
cache.native_commands[1].hint.as_deref(),
Some("[instructions]")
);
assert!(cache.catalog_epoch >= 1);

worker
.notification(
"_kiro.dev/commands/available",
&json!({
"sessionId": "sess-kiro",
"commands": [],
"prompts": [{ "name": "my-skill" }]
}),
)
.unwrap();
let cleared = worker
.catalogs
.lock()
.unwrap()
.get("kiro-cmds")
.cloned()
.expect("catalog");
assert!(cleared.native_commands.is_empty());
}

#[test]
fn kiro_standard_available_commands_update_also_fills_catalog() {
let db = Database::open_in_memory().unwrap();
conversation_with(&db, "kiro-acp-cmds", AgentId::Kiro, &std::env::temp_dir());
let mut worker = worker(&db, "kiro-acp-cmds");
worker.agent = AgentId::Kiro;
worker.store.enable_if_new("kiro-acp-cmds").unwrap();
start_placeholder(&mut worker);

worker
.notification(
"session/update",
&json!({
"update": {
"sessionUpdate": "available_commands_update",
"availableCommands": [{
"name": "compact",
"description": "Compact context"
}]
}
}),
)
.unwrap();

let cache = worker
.catalogs
.lock()
.unwrap()
.get("kiro-acp-cmds")
.cloned()
.expect("catalog");
assert_eq!(cache.native_commands.len(), 1);
assert_eq!(cache.native_commands[0].name, "compact");
}

#[test]
fn grok_config_option_update_fills_models_not_timeline() {
let db = Database::open_in_memory().unwrap();
Expand Down
41 changes: 31 additions & 10 deletions crates/agenthub-core/src/services/chat_runtime/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2673,6 +2673,9 @@ impl ActorWorker {
"session/update" | "session_update" | "_x.ai/session/update" => {
self.grok_session_update(params)?;
}
"_kiro.dev/commands/available" => {
self.apply_kiro_available_commands(params);
}
"_x.ai/session/prompt_complete" => {
self.turn_completed(params)?;
}
Expand Down Expand Up @@ -3099,19 +3102,37 @@ impl ActorWorker {
}
}

fn apply_native_commands(
&self,
commands: Vec<crate::utils::stream_parse::acp::AcpAvailableCommand>,
) {
self.patch_catalog(|cache| {
cache.native_commands = commands
.into_iter()
.map(|command| RuntimeNativeCommand {
name: command.name,
description: command.description,
hint: command.hint,
})
.collect();
});
}

/// Kiro declares slash commands on this vendor notification after
/// `session/new`. Standard ACP `available_commands_update` is still
/// accepted via [`Self::grok_session_update`]. Skills/prompts are ignored.
fn apply_kiro_available_commands(&self, params: &Value) {
if let Some(commands) =
crate::utils::stream_parse::acp::extract_kiro_available_commands(params)
{
self.apply_native_commands(commands);
}
}

fn grok_session_update(&mut self, params: &Value) -> Result<()> {
if let Some(commands) = crate::utils::stream_parse::acp::extract_available_commands(params)
{
self.patch_catalog(|cache| {
cache.native_commands = commands
.into_iter()
.map(|command| RuntimeNativeCommand {
name: command.name,
description: command.description,
hint: command.hint,
})
.collect();
});
self.apply_native_commands(commands);
}
if let Some(catalog) = crate::utils::stream_parse::acp::extract_config_catalog(params) {
self.apply_acp_config_catalog(catalog);
Expand Down
40 changes: 40 additions & 0 deletions crates/agenthub-core/src/services/chat_runtime/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -594,6 +594,46 @@ fn grok_options_surface_seeded_native_commands_and_handshake_image() {
assert_eq!(again.catalog_epoch, first.catalog_epoch);
}

#[test]
fn kiro_options_keep_injected_native_commands_across_catalog_refresh() {
let db = Database::open_in_memory().unwrap();
let now = "2026-01-01T00:00:00Z".to_string();
ChatRepo::new(db.clone())
.create_conversation(&Conversation {
id: "kiro-opts".into(),
title: String::new(),
agent_ids: vec![AgentId::Kiro],
cwd: Some(std::env::temp_dir().to_string_lossy().into_owned()),
allow_dangerous: false,
created_at: now.clone(),
updated_at: now,
native_session_id: None,
sending: false,
first_user_content: None,
})
.unwrap();
let run = Arc::new(RunService::new(AdapterRegistry::default()));
let runtime = Arc::new(ChatRuntime::new(db, run));
let empty = runtime.options("kiro-opts").unwrap();
assert_eq!(empty.transport, RuntimeChannel::Acp);
assert!(empty.native_commands.is_empty());
assert!(!empty.session_ready);

runtime.seed_native_commands_for_test(
"kiro-opts",
vec![RuntimeNativeCommand {
name: "context".into(),
description: "Add context".into(),
hint: Some("path".into()),
}],
);
let refreshed = runtime.refresh_options("kiro-opts").unwrap();
assert_eq!(refreshed.transport, RuntimeChannel::Acp);
assert_eq!(refreshed.native_commands.len(), 1);
assert_eq!(refreshed.native_commands[0].name, "context");
assert!(!refreshed.session_ready);
}

#[test]
fn started_runtime_rejects_agent_and_cwd_changes() {
let dir = tempdir().unwrap();
Expand Down
20 changes: 18 additions & 2 deletions crates/agenthub-core/src/utils/stream_parse/acp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,17 @@ pub(crate) fn extract_available_commands(v: &Value) -> Option<Vec<AcpAvailableCo
)
}

/// Kiro ACP vendor notification `_kiro.dev/commands/available` (not standard
/// `available_commands_update`). Only `commands[]` are slash items. `prompts`
/// (skills), `tools`, and `mcpServers` stay out of the `/` native group.
///
/// `Some` when `commands` is present, even if the list is empty (fail-closed).
pub(crate) fn extract_kiro_available_commands(v: &Value) -> Option<Vec<AcpAvailableCommand>> {
let payload = v.get("data").or_else(|| v.get("params")).unwrap_or(v);
let list = payload.get("commands").and_then(|value| value.as_array())?;
Some(list.iter().filter_map(parse_available_command).collect())
}

/// Model/effort lists from ACP config options. Not a `ProcessStep`.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub(crate) struct AcpConfigCatalog {
Expand Down Expand Up @@ -267,17 +278,22 @@ fn parse_plan_entry(value: &Value) -> Option<AcpPlanEntry> {
}

fn parse_available_command(value: &Value) -> Option<AcpAvailableCommand> {
let name = first_str(value, &["name", "command"])?;
let raw = first_str(value, &["name", "command"])?;
let name = raw.trim_start_matches('/').trim();
if name.is_empty() {
return None;
}
let description = first_str(value, &["description"]).unwrap_or_default();
let hint = value
.pointer("/input/hint")
.or_else(|| value.pointer("/meta/hint"))
.and_then(|h| h.as_str())
.or_else(|| value.get("hint").and_then(|h| h.as_str()))
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string);
Some(AcpAvailableCommand {
name,
name: name.to_string(),
description,
hint,
})
Expand Down
39 changes: 39 additions & 0 deletions crates/agenthub-core/src/utils/stream_parse/grok/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,45 @@ fn extract_available_commands_is_none_for_thought() -> bool {
.is_none()
}

#[test]
fn kiro_vendor_commands_available_is_catalog_not_process_step() {
let payload = serde_json::json!({
"sessionId": "sess-1",
"commands": [
{
"name": "/context",
"description": "Add context",
"meta": { "hint": "path" }
},
{ "name": "/", "description": "empty name" }
],
"prompts": [
{
"name": "my-skill",
"description": "A skill",
"serverName": "skill:config"
}
],
"tools": [{ "name": "read" }],
"mcpServers": []
});
let commands = super::super::acp::extract_kiro_available_commands(&payload).unwrap();
assert_eq!(commands.len(), 1);
assert_eq!(commands[0].name, "context");
assert_eq!(commands[0].description, "Add context");
assert_eq!(commands[0].hint.as_deref(), Some("path"));
assert!(super::super::acp::extract_available_commands(&payload).is_none());
assert!(super::super::acp::extract_kiro_available_commands(&serde_json::json!({
"prompts": [{ "name": "my-skill" }]
}))
.is_none());
let empty = super::super::acp::extract_kiro_available_commands(&serde_json::json!({
"commands": []
}))
.unwrap();
assert!(empty.is_empty());
}

#[test]
fn tool_kind_without_title_maps_to_read_edit_or_execute() {
let read = parse_line(
Expand Down
2 changes: 1 addition & 1 deletion docs/STATUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ updated: 2026-09-17
- **其余 Agent 与旧会话**:仍走原发送方式。
- **过程面板**:主列一行过程摘要(正在读取 / 正在修改 / 正在执行,完成则已读取 / 已修改 / 已执行)。点开后在右侧栏看你说了什么、思考、工具行、等待允许或拒绝、本轮用量;工具名、状态和 JSON 进折叠的「细节」;命令、过程日志、退出码和状态事件仍在「运行详情」。允许 / 拒绝按钮仍在卡片上,不在过程行上造假按钮。右侧栏不随发送自动打开;点 Markdown 仍预览文件。种类约定见 [过程事件](concepts/chat-process-events.md)。斜杠目录和模型目录更新不进过程时间线。Grok / Kiro 若推了当前轮 `plan`,输入区上方出现计划条,换轮丢掉。ACP 若声明 `terminal`,对方跑的那条命令一张卡片,可停这一条(不是对话页终端)。仍约 80ms 读快照,过程行按序号增量挂现有面板,不另开总线。
- **过程内用量**:新空 Codex 会话仍解析 `thread/tokenUsage/updated` 的当前轮 `last`(累计 `total` / 窗口只留在总览等用量页)。新空 Grok 会话解析 **当前轮**(`turn_completed.usage`);ACP 没有会话累计字段,不把各轮相加冒充累计。Grok / Kiro 的 `context_usage` 有数字才进用量小字(窗口用量),全 0 不画。解析路径已接;真窗 2026-09-09 见过部分轮次 **没有** `turn_completed.usage`,此时界面不画假数字。对话里只在本轮结束后用小字写输入 / 输出(有缓存才写缓存);生成中不画用量。只显示协议里的数字,不估算费用。Kiro 没有 token 累计数据源。
- **`/` 菜单**:立刻执行的动作(新建对话、复制最近回复;换模型/思考/技能要搜到才列出,避免把整份目录摊在 `/` 上)。Grok / Kiro 会话就绪且对方声明了命令时,另列对方斜杠项:选中后当作一轮正常发出(无必填参数则直接发送 `/名字`;必填参数则插入 `/名字 ` 供补全后再发)。目录变了会重拉;未就绪或未声明则不画,不猜菜单。没有真窗证据前,不宣称「对方命令已验收」。有可启动的命令行时,`/` 可列出「启动命令行」(DeepSeek 为「打开网页会话」),在外部打开,不标成对话页能力。
- **`/` 菜单**:立刻执行的动作(新建对话、复制最近回复;换模型/思考/技能要搜到才列出,避免把整份目录摊在 `/` 上)。Grok / Kiro 会话就绪且对方声明了命令时,另列对方斜杠项:选中后当作一轮正常发出(无必填参数则直接发送 `/名字`;必填参数则插入 `/名字 ` 供补全后再发)。Grok 走标准 ACP `available_commands_update`;Kiro 走 `_kiro.dev/commands/available` 的 `commands[]`(不把技能/工具目录摊进 `/`)。目录变了会重拉;未就绪或未声明则不画,不猜菜单。Kiro 就绪会话「对方命令」Linux 真窗已 PASS(修复 tip `daed5ccf`,现行 tip 仍含该修复):列出对方声明的斜杠项,裸 `/` 不摊技能目录;记录见 `/workspace/qa-issues/CHAT-SLASH-PR365-RETEST-daed5ccf.md`。本条只记这次验过的展示,不把选中发送或其它 Agent 写成已验收。有可启动的命令行时,`/` 可列出「启动命令行」(DeepSeek 为「打开网页会话」),在外部打开,不标成对话页能力。
- **本机对接与本会话**:本机持续通道按 Agent 写死:新空 Codex 走 app-server,新空 Grok / Kiro 走 ACP,新空 Claude 走 stream-json;其余与旧会话仍走原发送方式。Cursor 默认软隐藏,不在允许/拒绝之列,也不进持续聊天白名单。**Agents 详情**写这份 Agent 的新对话怎么接(ACP / 持续对话 / 原来的发送方式),不是一份可改的「ACP 总表」。**Chat 顶栏和会话设置**写这次对话实际在走哪条;点了卡片上的一直允许之后,会话设置里可以关掉「本会话已一直允许」(不能在这里假装打开)。一次对话是否在用持续通道,看这次会话是不是上述新空路径。Kiro 旧对话没有切到 ACP 的入口。会话字段见 [会话身份](concepts/chat-session-identity.md)。
- **新空 Claude 会话(B3 首片)**:走 Claude Code `-p --input-format stream-json --output-format stream-json` 持续通道(同进程多轮、本地图片 base64、模型/思考强度参数);**不支持**生成中补充;本片**不**接可点允许/拒绝(默认 `dontAsk`,危险模式 `bypassPermissions`)。有历史的旧 Claude 会话仍走 print+resume。print 路径在已经出过 assistant 正文后不再把最终 `result` 再拼进气泡(短回复不会同一句写两遍);只有没见过 assistant 文本时才用 `result` 当正文。Linux 真窗短回复已验不双写。见 [Claude B3](archive/chat-claude-b3.md)。
- **对话标题**:新建对话先用首条消息提炼的短句。一轮结束后读对方写在自己会话记录里的标题并改用它:Codex(app-server 汇总的 `sqlite/*.db` 里 `local_thread_catalog.display_title`,退回 `session_index.jsonl` 的 `thread_name`)、Grok(`summary.json` 的 `generated_title`)、Kiro(`sessions/cli/<id>.json` 的 `title`)、DSH(会话日志的 `session/title` 行)。Claude 没有标题来源,保持首条消息推导。手动改过名字的对话不再被覆盖(不新增「谁起的名字」列,也不做迁移);连续通道取运行时线程 id,旧会话取 `native_session_id`。AgentHub 不进协议里要标题。实机核对:Grok 与 Codex 的真实会话都能取到;Codex 的 `session_index.jsonl` 只收 IDE / 桌面端自己建的会话,所以优先读 `local_thread_catalog`;Kiro 常见只有 1–2 字的占位;DSH 普通发送不带会话 id,实际触发不到;Claude 无来源。
Expand Down
4 changes: 2 additions & 2 deletions docs/concepts/chat-process-events.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ status: current
owner: maintainers
audience: chat and core contributors
source-of-truth: ProcessStep, ChatEvent, RuntimeSnapshot
updated: 2026-09-16
updated: 2026-09-17
---

# Chat 过程事件
Expand Down Expand Up @@ -33,7 +33,7 @@ updated: 2026-09-16

下列进 Options 或专用条,**禁止**推进过程时间线冒充「对方做了一步」:

- 斜杠命令目录(`available_commands` / `nativeCommands`)
- 斜杠命令目录(`available_commands` / Kiro `_kiro.dev/commands/available` / `nativeCommands`)
- ACP `config_option_update`(模型 / 思考目录)
- 世代号 `catalogEpoch`(只用来重拉 Options)

Expand Down
4 changes: 2 additions & 2 deletions docs/reference/chat-session-options.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ description: Runtime Options 的 seed / 探测来源与 fail-closed 边界。
type: reference
status: current
owner: maintainers
updated: 2026-09-16
updated: 2026-09-17
---

# Chat 会话选项目录
Expand All @@ -29,7 +29,7 @@ Chat「模型 / 思考 / 扩展 / 斜杠原生命令」等来自会话 **Options
| Claude | `claude_fallback_catalog`(seed) | 无活探测权威时仅用 seed |
| Codex(及其他走 app-server 探测的路径) | `model/list`、`skills/list`、`plugin/installed` | 探测失败 → 空/缓存策略;不伪装厂商未声明的项 |

原生命令目录(斜杠)由持续通道在会话就绪后注入 `native_commands`;未就绪或目录空 → UI fail-closed(不假装有对方命令)。立刻执行的动作(如新建对话)与对方命令分来源,见 STATUS / Chat 概念页。`transport` 会画在 Chat 顶栏和会话设置「这次对话怎么接」,不进过程时间线。
原生命令目录(斜杠)由持续通道在会话就绪后注入 `native_commands`;未就绪或目录空 → UI fail-closed(不假装有对方命令)。Grok 走标准 ACP `available_commands_update`;Kiro 在 `session/new` 之后用厂商通知 `_kiro.dev/commands/available` 的 `commands[]`(不要把 `prompts` / 技能、`tools`、`mcpServers` 摊进 `/`)。立刻执行的动作(如新建对话)与对方命令分来源,见 STATUS / Chat 概念页。`transport` 会画在 Chat 顶栏和会话设置「这次对话怎么接」,不进过程时间线。

## 纪律

Expand Down
2 changes: 1 addition & 1 deletion src/dev/mocks/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ export const MOCK_CAPABILITIES: Record<string, AgentCapabilities> = {
projectDelete: { level: 'full' },
providerPresets: { level: 'full' },
usage: { level: 'full' },
mcp: { level: 'planned', reason: '待验证接入' },
mcp: { level: 'partial', reason: '可写入本机 MCP 配置(目录/探测/启用;无 OAuth)' },
modelSelect: { level: 'partial', reason: '对话页可选模型和思考等级' },
sessionResume: {
level: 'partial',
Expand Down
Loading