Skip to content

Commit 236217d

Browse files
authored
fix(conversation): derive assistant runtime type from metadata (#555)
## Summary - Derive assistant-backed conversation runtime type from `agent_metadata.agent_type` instead of backend label inference. - Resolve assistant snapshots from registered agent metadata, including OpenClaw ACP bindings, and reject deprecated or unregistered metadata. - Add regression coverage for OpenClaw, custom ACP, deprecated metadata, and missing metadata bindings. ## Test Plan - [x] `just push -u origin agent-metadata-runtime-type` Co-authored-by: zynx <>
1 parent c57970f commit 236217d

2 files changed

Lines changed: 223 additions & 50 deletions

File tree

crates/aionui-conversation/src/service.rs

Lines changed: 22 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -57,25 +57,6 @@ const ACP_CANCEL_DRAIN_TIMEOUT: Duration = Duration::from_secs(15);
5757
const LEGACY_CONVERSATION_ARCHIVED_MESSAGE: &str =
5858
"This historical conversation can no longer be continued. Please start a new conversation.";
5959
const DEPRECATED_AGENT_TYPE_MESSAGE: &str = "This agent type is no longer supported for new conversations.";
60-
const ACP_VENDOR_LABELS: &[&str] = &[
61-
"claude",
62-
"codex",
63-
"gemini",
64-
"qwen",
65-
"codebuddy",
66-
"droid",
67-
"goose",
68-
"auggie",
69-
"kimi",
70-
"opencode",
71-
"copilot",
72-
"qoder",
73-
"vibe",
74-
"cursor",
75-
"kiro",
76-
"hermes",
77-
"snow",
78-
];
7960

8061
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)]
8162
struct AssistantConversationOverrides {
@@ -151,6 +132,8 @@ struct AssistantSnapshot {
151132
agent_source: String,
152133
#[serde(default, alias = "agent_backend", deserialize_with = "deserialize_string_or_null")]
153134
runtime_backend: String,
135+
#[serde(default = "default_assistant_snapshot_agent_type")]
136+
agent_type: AgentType,
154137
rules: AssistantSnapshotRules,
155138
#[serde(default)]
156139
default_modes: AssistantSnapshotDefaultModes,
@@ -165,6 +148,10 @@ where
165148
Ok(<Option<String> as serde::Deserialize>::deserialize(deserializer)?.unwrap_or_default())
166149
}
167150

151+
fn default_assistant_snapshot_agent_type() -> AgentType {
152+
AgentType::Acp
153+
}
154+
168155
#[derive(Debug, Clone, Copy)]
169156
struct AssistantEffectiveDefaultModes<'a> {
170157
model: &'a str,
@@ -195,23 +182,10 @@ fn assistant_snapshot_modes<'a>(
195182
}
196183
}
197184

198-
fn parse_supported_agent_type_from_backend(backend: &str) -> Result<AgentType, ConversationError> {
199-
if ACP_VENDOR_LABELS.contains(&backend) {
200-
return Ok(AgentType::Acp);
201-
}
202-
203-
let quoted = format!("\"{backend}\"");
204-
if let Ok(agent_type) = serde_json::from_str::<AgentType>(&quoted) {
205-
if agent_type.is_deprecated_runtime() {
206-
return Err(ConversationError::BadRequest {
207-
reason: DEPRECATED_AGENT_TYPE_MESSAGE.into(),
208-
});
209-
}
210-
return Ok(agent_type);
211-
}
212-
213-
Err(ConversationError::BadRequest {
214-
reason: format!("unsupported assistant backend: {backend}"),
185+
fn parse_agent_type_from_metadata(value: &str) -> Result<AgentType, ConversationError> {
186+
let quoted = format!("\"{}\"", value.trim());
187+
serde_json::from_str::<AgentType>(&quoted).map_err(|_| ConversationError::BadRequest {
188+
reason: format!("unsupported assistant agent type in agent_metadata: {value}"),
215189
})
216190
}
217191

@@ -220,7 +194,7 @@ fn resolve_create_agent_type(
220194
assistant_snapshot: Option<&AssistantSnapshot>,
221195
) -> Result<AgentType, ConversationError> {
222196
if let Some(snapshot) = assistant_snapshot {
223-
let derived = parse_supported_agent_type_from_backend(snapshot.runtime_backend.trim())?;
197+
let derived = snapshot.agent_type;
224198
if let Some(explicit) = explicit_type
225199
&& explicit != derived
226200
{
@@ -1340,7 +1314,13 @@ impl ConversationService {
13401314
.as_ref()
13411315
.and_then(|row| row.agent_id_override.clone())
13421316
.unwrap_or_else(|| definition.agent_id.clone());
1343-
let agent_binding = self.resolve_assistant_agent_binding(&effective_agent_id).await?;
1317+
let agent_binding = self
1318+
.resolve_assistant_agent_binding(&effective_agent_id)
1319+
.await?
1320+
.ok_or_else(|| ConversationError::BadRequest {
1321+
reason: format!("assistant agent `{effective_agent_id}` is not registered in agent_metadata"),
1322+
})?;
1323+
let agent_type = parse_agent_type_from_metadata(&agent_binding.agent_type)?;
13441324

13451325
Ok(Some(AssistantSnapshot {
13461326
assistant_definition_id: definition.id,
@@ -1349,17 +1329,10 @@ impl ConversationService {
13491329
name: definition.name,
13501330
avatar_type: definition.avatar_type,
13511331
avatar: definition.avatar_value,
1352-
agent_id: agent_binding
1353-
.as_ref()
1354-
.map(|binding| binding.agent_id.clone())
1355-
.unwrap_or(effective_agent_id.clone()),
1356-
agent_source: agent_binding
1357-
.as_ref()
1358-
.map(|binding| binding.agent_source.clone())
1359-
.unwrap_or_else(|| "builtin".to_owned()),
1360-
runtime_backend: agent_binding
1361-
.map(|binding| binding.runtime_backend)
1362-
.unwrap_or(effective_agent_id),
1332+
agent_id: agent_binding.agent_id,
1333+
agent_source: agent_binding.agent_source,
1334+
runtime_backend: agent_binding.runtime_backend,
1335+
agent_type,
13631336
rules: AssistantSnapshotRules {
13641337
content: if rules_content.is_empty() {
13651338
fallback_rules.to_owned()

crates/aionui-conversation/src/service_test.rs

Lines changed: 201 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -661,6 +661,9 @@ fn stub_agent_metadata_rows() -> Vec<AgentMetadataRow> {
661661
("8e1acf31", Some("codex"), "acp", "Codex CLI", 110),
662662
("cc126dd5", Some("gemini"), "acp", "Gemini CLI", 120),
663663
("632f31d2", None, "aionrs", "Aion CLI", 200),
664+
("b7e8a9c4", Some("openclaw"), "acp", "OpenClaw", 3140),
665+
("f9f61666", None, "openclaw-gateway", "OpenClaw Gateway", 3150),
666+
("custom-acp-1", None, "acp", "My Custom ACP", 1500),
664667
]
665668
.into_iter()
666669
.map(|(id, backend, agent_type, name, sort_order)| AgentMetadataRow {
@@ -672,7 +675,7 @@ fn stub_agent_metadata_rows() -> Vec<AgentMetadataRow> {
672675
description_i18n: None,
673676
backend: backend.map(ToOwned::to_owned),
674677
agent_type: agent_type.to_owned(),
675-
agent_source: "builtin".to_owned(),
678+
agent_source: if id.starts_with("custom-") { "custom" } else { "builtin" }.to_owned(),
676679
agent_source_info: None,
677680
enabled: true,
678681
command: backend.map(ToOwned::to_owned),
@@ -1530,6 +1533,203 @@ async fn create_derives_acp_type_from_assistant_backend_when_type_is_missing() {
15301533
assert!(repo.get_assistant_snapshot(&resp.id).await.unwrap().is_some());
15311534
}
15321535

1536+
#[tokio::test]
1537+
async fn create_derives_acp_type_from_openclaw_agent_metadata_when_type_is_missing() {
1538+
let resolver = Arc::new(FixedSkillResolver { names: vec![] });
1539+
let dispatcher = Arc::new(StaticAssistantDispatcher {
1540+
rules: std::collections::HashMap::new(),
1541+
});
1542+
let (svc, _broadcaster, repo, definition_repo, overlay_repo, _preference_repo) =
1543+
make_service_with_assistant_support(resolver, dispatcher).await;
1544+
1545+
upsert_test_assistant_definition(
1546+
&definition_repo,
1547+
"asstdef_openclaw_missing_type",
1548+
"assistant-openclaw-missing-type",
1549+
"b7e8a9c4",
1550+
"auto",
1551+
"auto",
1552+
)
1553+
.await;
1554+
overlay_repo
1555+
.upsert(&UpsertAssistantOverlayParams {
1556+
assistant_definition_id: "asstdef_openclaw_missing_type",
1557+
enabled: true,
1558+
sort_order: 0,
1559+
agent_id_override: None,
1560+
last_used_at: None,
1561+
})
1562+
.await
1563+
.unwrap();
1564+
1565+
let workspace = ensure_test_workspace_path();
1566+
let req: CreateConversationRequest = serde_json::from_value(json!({
1567+
"assistant": {
1568+
"id": "assistant-openclaw-missing-type",
1569+
"locale": "en-US"
1570+
},
1571+
"extra": {
1572+
"workspace": workspace
1573+
}
1574+
}))
1575+
.unwrap();
1576+
1577+
let resp = svc.create("user_1", req).await.unwrap();
1578+
assert_eq!(resp.r#type, AgentType::Acp);
1579+
assert_eq!(resp.extra["agent_id"], json!("b7e8a9c4"));
1580+
assert_eq!(resp.extra["backend"], json!("openclaw"));
1581+
assert!(repo.get_assistant_snapshot(&resp.id).await.unwrap().is_some());
1582+
}
1583+
1584+
#[tokio::test]
1585+
async fn create_derives_acp_type_from_custom_agent_metadata_when_type_is_missing() {
1586+
let resolver = Arc::new(FixedSkillResolver { names: vec![] });
1587+
let dispatcher = Arc::new(StaticAssistantDispatcher {
1588+
rules: std::collections::HashMap::new(),
1589+
});
1590+
let (svc, _broadcaster, repo, definition_repo, overlay_repo, _preference_repo) =
1591+
make_service_with_assistant_support(resolver, dispatcher).await;
1592+
1593+
upsert_test_assistant_definition(
1594+
&definition_repo,
1595+
"asstdef_custom_acp_missing_type",
1596+
"assistant-custom-acp-missing-type",
1597+
"custom-acp-1",
1598+
"auto",
1599+
"auto",
1600+
)
1601+
.await;
1602+
overlay_repo
1603+
.upsert(&UpsertAssistantOverlayParams {
1604+
assistant_definition_id: "asstdef_custom_acp_missing_type",
1605+
enabled: true,
1606+
sort_order: 0,
1607+
agent_id_override: None,
1608+
last_used_at: None,
1609+
})
1610+
.await
1611+
.unwrap();
1612+
1613+
let workspace = ensure_test_workspace_path();
1614+
let req: CreateConversationRequest = serde_json::from_value(json!({
1615+
"assistant": {
1616+
"id": "assistant-custom-acp-missing-type",
1617+
"locale": "en-US"
1618+
},
1619+
"extra": {
1620+
"workspace": workspace
1621+
}
1622+
}))
1623+
.unwrap();
1624+
1625+
let resp = svc.create("user_1", req).await.unwrap();
1626+
assert_eq!(resp.r#type, AgentType::Acp);
1627+
assert_eq!(resp.extra["agent_id"], json!("custom-acp-1"));
1628+
assert_eq!(resp.extra["agent_source"], json!("custom"));
1629+
assert_eq!(resp.extra["backend"], json!("acp"));
1630+
assert!(repo.get_assistant_snapshot(&resp.id).await.unwrap().is_some());
1631+
}
1632+
1633+
#[tokio::test]
1634+
async fn create_rejects_assistant_bound_to_deprecated_agent_metadata() {
1635+
let resolver = Arc::new(FixedSkillResolver { names: vec![] });
1636+
let dispatcher = Arc::new(StaticAssistantDispatcher {
1637+
rules: std::collections::HashMap::new(),
1638+
});
1639+
let (svc, _broadcaster, _repo, definition_repo, overlay_repo, _preference_repo) =
1640+
make_service_with_assistant_support(resolver, dispatcher).await;
1641+
1642+
upsert_test_assistant_definition(
1643+
&definition_repo,
1644+
"asstdef_openclaw_gateway_deprecated",
1645+
"assistant-openclaw-gateway-deprecated",
1646+
"f9f61666",
1647+
"auto",
1648+
"auto",
1649+
)
1650+
.await;
1651+
overlay_repo
1652+
.upsert(&UpsertAssistantOverlayParams {
1653+
assistant_definition_id: "asstdef_openclaw_gateway_deprecated",
1654+
enabled: true,
1655+
sort_order: 0,
1656+
agent_id_override: None,
1657+
last_used_at: None,
1658+
})
1659+
.await
1660+
.unwrap();
1661+
1662+
let workspace = ensure_test_workspace_path();
1663+
let req: CreateConversationRequest = serde_json::from_value(json!({
1664+
"assistant": {
1665+
"id": "assistant-openclaw-gateway-deprecated",
1666+
"locale": "en-US"
1667+
},
1668+
"extra": {
1669+
"workspace": workspace
1670+
}
1671+
}))
1672+
.unwrap();
1673+
1674+
let err = svc.create("user_1", req).await.unwrap_err();
1675+
assert_eq!(err.error_code(), "BAD_REQUEST");
1676+
assert!(
1677+
err.to_string()
1678+
.contains("This agent type is no longer supported for new conversations."),
1679+
"unexpected error: {err}"
1680+
);
1681+
}
1682+
1683+
#[tokio::test]
1684+
async fn create_rejects_assistant_with_unregistered_agent_metadata() {
1685+
let resolver = Arc::new(FixedSkillResolver { names: vec![] });
1686+
let dispatcher = Arc::new(StaticAssistantDispatcher {
1687+
rules: std::collections::HashMap::new(),
1688+
});
1689+
let (svc, _broadcaster, _repo, definition_repo, overlay_repo, _preference_repo) =
1690+
make_service_with_assistant_support(resolver, dispatcher).await;
1691+
1692+
upsert_test_assistant_definition(
1693+
&definition_repo,
1694+
"asstdef_missing_agent",
1695+
"assistant-missing-agent",
1696+
"missing-agent",
1697+
"auto",
1698+
"auto",
1699+
)
1700+
.await;
1701+
overlay_repo
1702+
.upsert(&UpsertAssistantOverlayParams {
1703+
assistant_definition_id: "asstdef_missing_agent",
1704+
enabled: true,
1705+
sort_order: 0,
1706+
agent_id_override: None,
1707+
last_used_at: None,
1708+
})
1709+
.await
1710+
.unwrap();
1711+
1712+
let workspace = ensure_test_workspace_path();
1713+
let req: CreateConversationRequest = serde_json::from_value(json!({
1714+
"assistant": {
1715+
"id": "assistant-missing-agent",
1716+
"locale": "en-US"
1717+
},
1718+
"extra": {
1719+
"workspace": workspace
1720+
}
1721+
}))
1722+
.unwrap();
1723+
1724+
let err = svc.create("user_1", req).await.unwrap_err();
1725+
assert_eq!(err.error_code(), "BAD_REQUEST");
1726+
assert!(
1727+
err.to_string()
1728+
.contains("assistant agent `missing-agent` is not registered in agent_metadata"),
1729+
"unexpected error: {err}"
1730+
);
1731+
}
1732+
15331733
// ── Get tests ──────────────────────────────────────────────────────
15341734

15351735
#[tokio::test]

0 commit comments

Comments
 (0)