From de549a67d741cb957b016c08c0fba6f1a0be7584 Mon Sep 17 00:00:00 2001 From: zynx <> Date: Tue, 8 Sep 2026 15:53:47 +0800 Subject: [PATCH 1/6] feat(api-types): add conversation_tools registry for the aioncore conversation CLI --- .../src/conversation_tools.rs | 332 ++++++++++++++++++ crates/aionui-api-types/src/lib.rs | 7 + 2 files changed, 339 insertions(+) create mode 100644 crates/aionui-api-types/src/conversation_tools.rs diff --git a/crates/aionui-api-types/src/conversation_tools.rs b/crates/aionui-api-types/src/conversation_tools.rs new file mode 100644 index 000000000..4b8f7b364 --- /dev/null +++ b/crates/aionui-api-types/src/conversation_tools.rs @@ -0,0 +1,332 @@ +//! Agent-facing contract for the `aioncore conversation` CLI. +//! +//! Shape follows `session_tools.rs`: a descriptor registry is the single source +//! of truth, so `conversation capabilities` and the auto-inject skill cannot +//! drift from the wired CLI. Deliberately a separate type family from the +//! session one — the two command families are independent surfaces. + +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +pub const CONVERSATION_TOOLS_SCHEMA_VERSION: u32 = 1; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ConversationToolName { + ConversationCreate, +} + +impl ConversationToolName { + pub fn as_str(self) -> &'static str { + match self { + Self::ConversationCreate => "conversation_create", + } + } + + pub fn parse(value: &str) -> Option { + Some(match value { + "conversation_create" => Self::ConversationCreate, + _ => return None, + }) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConversationToolDescriptor { + pub name: String, + pub description: String, + pub input_schema: Value, + pub cli_command: Vec, + pub when: String, + pub input_summary: String, +} + +#[derive(Debug, Clone)] +struct ConversationToolSpec { + name: ConversationToolName, + description: &'static str, + input_schema: Value, + cli_command: &'static [&'static str], + when: &'static str, + input_summary: &'static str, +} + +fn tool_specs() -> Vec { + vec![ConversationToolSpec { + name: ConversationToolName::ConversationCreate, + description: "Create a new conversation for this user. By default it inherits this \ + conversation's working directory and assistant; pass `workspace` or \ + `assistant_id` to choose another. Creating does not send a message, \ + does not open the new conversation, and does not switch the user's \ + current conversation.", + input_schema: json!({ + "type": "object", + "properties": { + "name": { "type": "string", "description": "Short name describing the task, in the user's language. Required; must not be blank." }, + "workspace": { "type": "string", "description": "Absolute path of an existing directory. Omit to reuse this conversation's workspace." }, + "assistant_id": { "type": "string", "description": "Id of an enabled assistant. Omit to reuse this conversation's assistant." } + }, + "required": ["name"], + "additionalProperties": false + }), + cli_command: &["create"], + when: "The user asked you to open, start, or spin up a new conversation.", + input_summary: "{ name, workspace?, assistant_id? }", + }] +} + +pub fn conversation_tool_descriptors() -> Vec { + tool_specs() + .into_iter() + .map(|spec| ConversationToolDescriptor { + name: spec.name.as_str().to_owned(), + description: spec.description.to_owned(), + input_schema: spec.input_schema, + cli_command: spec.cli_command.iter().map(|part| (*part).to_owned()).collect(), + when: spec.when.to_owned(), + input_summary: spec.input_summary.to_owned(), + }) + .collect() +} + +pub fn conversation_tool_descriptor(name: &str) -> Option { + conversation_tool_descriptors().into_iter().find(|d| d.name == name) +} + +pub fn tool_name_for_conversation_cli_path(path: &[String]) -> Option { + tool_specs() + .into_iter() + .find(|spec| spec.cli_command == path.iter().map(String::as_str).collect::>()) + .map(|spec| spec.name) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ConversationToolErrorCode { + CallerIsTeam, + WorkspaceNotAbsolute, + WorkspaceUnavailable, + AssistantNotFound, + AssistantDisabled, + AssistantModelUnresolved, + RuntimeAuthFailed, + SchemaValidationFailed, + TransportUnavailable, +} + +impl ConversationToolErrorCode { + /// The wire value, for structured log fields. Kept in lock-step with the + /// serde rename by a unit test. + pub fn as_str(self) -> &'static str { + match self { + Self::CallerIsTeam => "caller_is_team", + Self::WorkspaceNotAbsolute => "workspace_not_absolute", + Self::WorkspaceUnavailable => "workspace_unavailable", + Self::AssistantNotFound => "assistant_not_found", + Self::AssistantDisabled => "assistant_disabled", + Self::AssistantModelUnresolved => "assistant_model_unresolved", + Self::RuntimeAuthFailed => "runtime_auth_failed", + Self::SchemaValidationFailed => "schema_validation_failed", + Self::TransportUnavailable => "transport_unavailable", + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ConversationToolErrorPayload { + pub code: ConversationToolErrorCode, + pub message: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub details: Option, +} + +impl ConversationToolErrorPayload { + pub fn new(code: ConversationToolErrorCode, message: impl Into) -> Self { + Self { + code, + message: message.into(), + details: None, + } + } + + pub fn with_details(mut self, details: Value) -> Self { + self.details = Some(details); + self + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConversationCliMeta { + pub schema_version: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub command: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConversationCliEnvelope { + pub success: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub data: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, + pub meta: ConversationCliMeta, +} + +impl ConversationCliEnvelope { + pub fn success(data: T, command: Option) -> Self { + Self { + success: true, + data: Some(data), + error: None, + meta: ConversationCliMeta { + schema_version: CONVERSATION_TOOLS_SCHEMA_VERSION, + command, + }, + } + } + + pub fn failure(error: ConversationToolErrorPayload, command: Option) -> Self { + Self { + success: false, + data: None, + error: Some(error), + meta: ConversationCliMeta { + schema_version: CONVERSATION_TOOLS_SCHEMA_VERSION, + command, + }, + } + } +} + +/// Body of `POST /api/runtime/conversations/create`. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ConversationCreateRequest { + pub name: String, + #[serde(default)] + pub workspace: Option, + #[serde(default)] + pub assistant_id: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ConversationCreateAssistant { + pub id: String, + pub name: String, + pub backend: String, +} + +/// `data` of a successful `conversation create`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ConversationCreateResponse { + pub id: String, + pub name: String, + pub workspace: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub assistant: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn registry_exposes_exactly_create_and_it_round_trips_through_its_cli_path() { + let descriptors = conversation_tool_descriptors(); + assert_eq!(descriptors.len(), 1, "v1 exposes create only"); + for descriptor in &descriptors { + assert!(!descriptor.cli_command.is_empty(), "{}", descriptor.name); + assert!(conversation_tool_descriptor(&descriptor.name).is_some()); + assert_eq!( + tool_name_for_conversation_cli_path(&descriptor.cli_command).map(ConversationToolName::as_str), + Some(descriptor.name.as_str()), + "cli path must round-trip to the tool name for {}", + descriptor.name + ); + } + assert_eq!( + ConversationToolName::parse("conversation_create"), + Some(ConversationToolName::ConversationCreate) + ); + assert_eq!(ConversationToolName::parse("conversation_delete"), None); + } + + #[test] + fn create_schema_accepts_only_name_workspace_and_assistant_id_with_name_required() { + // additionalProperties=false, `name` is the only required field. + let descriptor = conversation_tool_descriptor("conversation_create").unwrap(); + let properties = descriptor.input_schema["properties"].as_object().unwrap(); + let mut keys: Vec<&str> = properties.keys().map(String::as_str).collect(); + keys.sort_unstable(); + assert_eq!(keys, vec!["assistant_id", "name", "workspace"]); + assert_eq!(descriptor.input_schema["required"], serde_json::json!(["name"])); + assert_eq!( + descriptor.input_schema["additionalProperties"], + serde_json::json!(false) + ); + assert_eq!(descriptor.cli_command, vec!["create".to_owned()]); + } + + #[test] + fn every_error_code_has_a_distinct_snake_case_wire_value_matching_as_str() { + let codes = [ + ConversationToolErrorCode::CallerIsTeam, + ConversationToolErrorCode::WorkspaceNotAbsolute, + ConversationToolErrorCode::WorkspaceUnavailable, + ConversationToolErrorCode::AssistantNotFound, + ConversationToolErrorCode::AssistantDisabled, + ConversationToolErrorCode::AssistantModelUnresolved, + ConversationToolErrorCode::RuntimeAuthFailed, + ConversationToolErrorCode::SchemaValidationFailed, + ConversationToolErrorCode::TransportUnavailable, + ]; + let mut wire: Vec = codes + .iter() + .map(|code| { + let value = serde_json::to_value(code).unwrap().as_str().unwrap().to_owned(); + assert_eq!(value, code.as_str(), "as_str must equal the serde wire value"); + assert!( + value.chars().all(|c| c.is_ascii_lowercase() || c == '_'), + "{value} is not snake_case" + ); + value + }) + .collect(); + wire.sort(); + let count = wire.len(); + wire.dedup(); + assert_eq!(wire.len(), count, "duplicate wire value in {wire:?}"); + assert_eq!(ConversationToolErrorCode::CallerIsTeam.as_str(), "caller_is_team"); + } + + #[test] + fn envelope_failure_carries_the_code_and_omits_data() { + let envelope = ConversationCliEnvelope::::failure( + ConversationToolErrorPayload::new(ConversationToolErrorCode::CallerIsTeam, "caller is a team conversation"), + Some("conversation create".to_owned()), + ); + let json = serde_json::to_value(&envelope).unwrap(); + assert_eq!(json["success"], serde_json::json!(false)); + assert_eq!(json["error"]["code"], serde_json::json!("caller_is_team")); + assert!(json.get("data").is_none(), "{json}"); + assert_eq!(json["meta"]["schema_version"], serde_json::json!(1)); + assert_eq!(json["meta"]["command"], serde_json::json!("conversation create")); + } + + #[test] + fn create_request_rejects_unknown_fields_and_requires_name() { + // Server-side second line of defence behind the CLI's descriptor check. + assert!(serde_json::from_str::(r#"{"name":"x","files":[]}"#).is_err()); + assert!(serde_json::from_str::(r#"{"workspace":"/tmp"}"#).is_err()); + let ok: ConversationCreateRequest = serde_json::from_str(r#"{"name":"x"}"#).unwrap(); + assert_eq!(ok.name, "x"); + assert!(ok.workspace.is_none() && ok.assistant_id.is_none()); + } + + #[test] + fn an_unwired_cli_path_does_not_resolve_to_a_tool() { + assert!(tool_name_for_conversation_cli_path(&["capabilities".to_owned()]).is_none()); + assert!(tool_name_for_conversation_cli_path(&[]).is_none()); + assert!(conversation_tool_descriptor("conversation_delete").is_none()); + } +} diff --git a/crates/aionui-api-types/src/lib.rs b/crates/aionui-api-types/src/lib.rs index 73b235b51..6bb062a70 100644 --- a/crates/aionui-api-types/src/lib.rs +++ b/crates/aionui-api-types/src/lib.rs @@ -15,6 +15,7 @@ mod chat_file; mod confirmation; mod connection_test; mod conversation; +mod conversation_tools; mod cron; mod custom_agent; mod extension; @@ -102,6 +103,12 @@ pub use conversation::{ MessageSearchItem, MessageSearchResponse, MessageStatusChangedPayload, PromptCapabilityView, SearchMessagesQuery, SendMessageRequest, SendMessageResponse, SessionRef, UpdateConversationArtifactRequest, UpdateConversationRequest, }; +pub use conversation_tools::{ + CONVERSATION_TOOLS_SCHEMA_VERSION, ConversationCliEnvelope, ConversationCliMeta, ConversationCreateAssistant, + ConversationCreateRequest, ConversationCreateResponse, ConversationToolDescriptor, ConversationToolErrorCode, + ConversationToolErrorPayload, ConversationToolName, conversation_tool_descriptor, conversation_tool_descriptors, + tool_name_for_conversation_cli_path, +}; pub use cron::{ CreateConversationCronRequest, CreateConversationCronResponse, CreateCronJobRequest, CronAgentConfigReadDto, CronAgentConfigWriteDto, CronJobExecutedEvent, CronJobMetadataDto, CronJobPayloadDto, CronJobRemovedPayload, From d6bc552d12db9c3919cd747d72adb8fc4c003c3c Mon Sep 17 00:00:00 2001 From: zynx <> Date: Tue, 8 Sep 2026 16:01:15 +0800 Subject: [PATCH 2/6] feat(conversation): add create_for_conversation_helper with inherit branch and provider repo hook --- crates/aionui-conversation/src/lib.rs | 2 + .../aionui-conversation/src/runtime_create.rs | 466 ++++++++++++++++++ crates/aionui-conversation/src/service.rs | 46 +- .../aionui-conversation/src/service_test.rs | 3 + .../src/service_test/runtime_create_test.rs | 275 +++++++++++ 5 files changed, 777 insertions(+), 15 deletions(-) create mode 100644 crates/aionui-conversation/src/runtime_create.rs create mode 100644 crates/aionui-conversation/src/service_test/runtime_create_test.rs diff --git a/crates/aionui-conversation/src/lib.rs b/crates/aionui-conversation/src/lib.rs index 7d0cea418..2b740cec4 100644 --- a/crates/aionui-conversation/src/lib.rs +++ b/crates/aionui-conversation/src/lib.rs @@ -12,6 +12,7 @@ pub mod response_middleware; pub mod routes; pub mod routes_aux; mod runtime_completion; +pub mod runtime_create; mod runtime_persistence; pub mod runtime_state; pub mod service; @@ -34,6 +35,7 @@ pub use error::ConversationError; pub use response_middleware::{MessageMiddleware, MiddlewareResult, strip_think_tags}; pub use routes::conversation_routes; pub use routes_aux::conversation_ops_routes; +pub use runtime_create::ConversationCreateError; pub use service::is_temp_session_workspace; pub use service::{ ConversationAgentTurnOutcome, ConversationAgentTurnRequest, ConversationAgentTurnStarted, diff --git a/crates/aionui-conversation/src/runtime_create.rs b/crates/aionui-conversation/src/runtime_create.rs new file mode 100644 index 000000000..4a98df718 --- /dev/null +++ b/crates/aionui-conversation/src/runtime_create.rs @@ -0,0 +1,466 @@ +//! Service half of `POST /api/runtime/conversations/create`: resolve the helper +//! CLI's `{name, workspace?, assistant_id?}` into a full +//! `CreateConversationRequest` and hand it to `ConversationService::create`. +//! +//! Every check runs BEFORE `create` is called, so a rejection never leaves a +//! half-built conversation behind. The caller's identity comes from the +//! runtime token's bound conversation id — never from the request body. + +use std::path::Path; + +use aionui_api_types::{ + AssistantConversationRequest, ConversationCreateAssistant, ConversationCreateRequest, ConversationCreateResponse, + ConversationToolErrorCode, CreateConversationRequest, +}; +use aionui_common::{AgentType, ConversationSource, ProviderWithModel}; +use aionui_db::models::ConversationRow; +use serde_json::{Map, Value}; +use tracing::{info, warn}; + +use crate::convert::string_to_enum; +use crate::error::ConversationError; +use crate::service::ConversationService; +use crate::session_mentions::{team_id_from_extra_str, workspace_from_extra}; +use crate::task_options::provider_model_from_conversation_row; + +/// Crate-owned error, mapped to the CLI envelope only at the route boundary +/// (AGENTS.md: service code must not touch `ApiError`). `runtime_auth_failed` +/// has no variant here — the route owns token validation. +#[derive(Debug, thiserror::Error)] +pub enum ConversationCreateError { + #[error("caller conversation is team-owned: {id}")] + CallerIsTeam { id: String }, + + #[error("workspace must be an absolute path: {path}")] + WorkspaceNotAbsolute { path: String }, + + #[error("workspace is not an existing directory: {path}")] + WorkspaceUnavailable { path: String }, + + #[error("assistant not found: {id}")] + AssistantNotFound { id: String }, + + #[error("assistant is disabled: {id}")] + AssistantDisabled { id: String }, + + /// `model_id` is `None` when the assistant resolves to no model at all + /// (auto mode with no preference yet), `Some` when a model id exists but + /// no provider of this user lists it. + #[error("assistant {assistant_id} has no usable aionrs model{}", model_id.as_deref().map(|m| format!(": `{m}` is not offered by any enabled provider")).unwrap_or_default())] + AssistantModelUnresolved { + assistant_id: String, + model_id: Option, + }, + + #[error("request does not match the schema: {reason}")] + SchemaValidation { reason: String }, + + #[error("conversation service unavailable: {reason}")] + TransportUnavailable { reason: String }, +} + +impl ConversationCreateError { + pub fn code(&self) -> ConversationToolErrorCode { + match self { + Self::CallerIsTeam { .. } => ConversationToolErrorCode::CallerIsTeam, + Self::WorkspaceNotAbsolute { .. } => ConversationToolErrorCode::WorkspaceNotAbsolute, + Self::WorkspaceUnavailable { .. } => ConversationToolErrorCode::WorkspaceUnavailable, + Self::AssistantNotFound { .. } => ConversationToolErrorCode::AssistantNotFound, + Self::AssistantDisabled { .. } => ConversationToolErrorCode::AssistantDisabled, + Self::AssistantModelUnresolved { .. } => ConversationToolErrorCode::AssistantModelUnresolved, + Self::SchemaValidation { .. } => ConversationToolErrorCode::SchemaValidationFailed, + Self::TransportUnavailable { .. } => ConversationToolErrorCode::TransportUnavailable, + } + } + + /// Pinned by a unit test below. + pub fn http_status(&self) -> u16 { + match self { + Self::CallerIsTeam { .. } => 403, + Self::WorkspaceNotAbsolute { .. } + | Self::WorkspaceUnavailable { .. } + | Self::AssistantDisabled { .. } + | Self::AssistantModelUnresolved { .. } => 422, + Self::AssistantNotFound { .. } => 404, + Self::SchemaValidation { .. } => 400, + Self::TransportUnavailable { .. } => 503, + } + } + + fn transport(error: impl std::fmt::Display) -> Self { + Self::TransportUnavailable { + reason: error.to_string(), + } + } +} + +/// Which branch produced the request. Logged verbatim. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Inheritance { + Snapshot, + LegacyTriple, + // Constructed by the assistant-override branch (next task). + #[allow(dead_code)] + AssistantOverride, +} + +impl Inheritance { + fn as_str(self) -> &'static str { + match self { + Self::Snapshot => "snapshot", + Self::LegacyTriple => "legacy_triple", + Self::AssistantOverride => "assistant_override", + } + } +} + +/// Where the aionrs `model` came from. Logged verbatim. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ModelResolution { + Inherited, + // Constructed by the assistant-override branch (next task). + #[allow(dead_code)] + ProviderMatch, + NotRequired, +} + +impl ModelResolution { + fn as_str(self) -> &'static str { + match self { + Self::Inherited => "inherited", + Self::ProviderMatch => "provider_match", + Self::NotRequired => "not_required", + } + } +} + +/// Everything the assistant/type resolution decided, before `extra` is assembled. +pub(crate) struct CreatePlan { + pub(crate) r#type: Option, + pub(crate) assistant_id: Option, + pub(crate) model: Option, + /// `backend / agent_id / agent_source` copied from the caller (legacy + /// triple only). Empty otherwise. + pub(crate) legacy_triple: Map, + pub(crate) inheritance: Inheritance, + pub(crate) model_resolution: ModelResolution, +} + +impl ConversationService { + /// `{name, workspace?, assistant_id?}` → `create`. Logs one line per + /// outcome and never records the name, the workspace path, or a model id. + pub async fn create_for_conversation_helper( + &self, + user_id: &str, + caller_conversation_id: &str, + req: &ConversationCreateRequest, + ) -> Result { + match self + .create_for_conversation_helper_inner(user_id, caller_conversation_id, req) + .await + { + Ok(response) => Ok(response), + Err(error) => { + warn!( + from_conversation_id = caller_conversation_id, + outcome = "rejected", + error_code = error.code().as_str(), + "agent conversation create refused" + ); + Err(error) + } + } + } + + async fn create_for_conversation_helper_inner( + &self, + user_id: &str, + caller_conversation_id: &str, + req: &ConversationCreateRequest, + ) -> Result { + // 1. The caller's own row. Missing means the token names a conversation + // that no longer exists — a transport problem, not a user error. + let caller = self + .conversation_repo() + .get(user_id, caller_conversation_id) + .await + .map_err(ConversationCreateError::transport)? + .ok_or_else(|| ConversationCreateError::TransportUnavailable { + reason: format!("caller conversation missing: {caller_conversation_id}"), + })?; + + // 2. Team callers get no ordinary-conversation surface. + if team_id_from_extra_str(&caller.extra).is_some() { + return Err(ConversationCreateError::CallerIsTeam { + id: caller_conversation_id.to_owned(), + }); + } + + // 3. name + let name = req.name.trim(); + if name.is_empty() { + return Err(ConversationCreateError::SchemaValidation { + reason: "`name` must not be blank".to_owned(), + }); + } + + // 4. workspace — absolute-ness here, existence inside `create`. + let (workspace, workspace_inherited) = match req + .workspace + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + Some(explicit) => { + if !Path::new(explicit).is_absolute() { + return Err(ConversationCreateError::WorkspaceNotAbsolute { + path: explicit.to_owned(), + }); + } + (explicit.to_owned(), false) + } + None => ( + // `create` always persists `extra.workspace`, so a caller + // without one is a broken row, not a user error. + workspace_from_extra(&caller.extra).ok_or_else(|| ConversationCreateError::TransportUnavailable { + reason: "caller conversation has no workspace".to_owned(), + })?, + true, + ), + }; + + // 5. assistant + let plan = match req + .assistant_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + None => self.inherit_plan(user_id, &caller).await?, + Some(assistant_id) => self.override_plan(user_id, assistant_id).await?, + }; + + // 6. Assemble and delegate. `custom_workspace` is a request-only toggle + // that `create` strips; the non-empty `extra.workspace` is what + // actually suppresses the temp-dir provisioning. + let mut extra = Map::new(); + extra.insert("workspace".to_owned(), Value::String(workspace.clone())); + extra.insert("custom_workspace".to_owned(), Value::Bool(true)); + for (key, value) in plan.legacy_triple.iter() { + extra.insert(key.clone(), value.clone()); + } + let request = CreateConversationRequest { + r#type: plan.r#type, + name: Some(name.to_owned()), + model: plan.model, + assistant: plan.assistant_id.map(|id| AssistantConversationRequest { + id, + locale: None, + conversation_overrides: None, + }), + source: Some(ConversationSource::Aionui), + channel_chat_id: None, + extra: Value::Object(extra), + }; + + let created = self + .create(user_id, request) + .await + .map_err(|error| map_create_error(error, &workspace))?; + + let backend = created + .assistant + .as_ref() + .map(|assistant| assistant.backend.clone()) + .or_else(|| created.extra.get("backend").and_then(Value::as_str).map(str::to_owned)) + .unwrap_or_else(|| created.r#type.serde_name().to_owned()); + info!( + from_conversation_id = caller_conversation_id, + conversation_id = %created.id, + inheritance = plan.inheritance.as_str(), + workspace_inherited, + model_resolution = plan.model_resolution.as_str(), + backend = %backend, + "conversation created by agent" + ); + + let persisted_workspace = created + .extra + .get("workspace") + .and_then(Value::as_str) + .map(str::to_owned) + .unwrap_or(workspace); + Ok(ConversationCreateResponse { + id: created.id, + name: created.name, + workspace: persisted_workspace, + assistant: created.assistant.map(|assistant| ConversationCreateAssistant { + id: assistant.id, + name: assistant.name, + backend: assistant.backend, + }), + }) + } + + /// "Omitted" branch: same assistant definition (via the caller's snapshot) + /// or, for snapshot-less rows, the caller's `type` plus the legacy + /// `extra.{backend, agent_id, agent_source}` triple that `create` still + /// accepts. aionrs copies `row.model` as-is. + async fn inherit_plan( + &self, + user_id: &str, + caller: &ConversationRow, + ) -> Result { + let caller_type: AgentType = string_to_enum(&caller.r#type).map_err(ConversationCreateError::transport)?; + let (model, model_resolution) = if caller_type == AgentType::Aionrs { + let model = provider_model_from_conversation_row(caller); + ( + Some(model).filter(|m| !m.provider_id.is_empty()), + ModelResolution::Inherited, + ) + } else { + (None, ModelResolution::NotRequired) + }; + + let snapshot = self + .conversation_repo() + .get_assistant_snapshot(user_id, &caller.id) + .await + .map_err(ConversationCreateError::transport)?; + // A snapshot whose definition has since been deleted would make `create` + // fail with "Either `type` or `assistant.id` is required"; fall back to + // the legacy triple so the new conversation still mirrors the caller. + let snapshot_assistant_id = match snapshot { + Some(snapshot) => match self.assistant_definition_repo() { + Some(definition_repo) => definition_repo + .get_by_assistant_id_for_user(user_id, &snapshot.assistant_id) + .await + .map_err(ConversationCreateError::transport)? + .map(|_| snapshot.assistant_id), + None => None, + }, + None => None, + }; + + if let Some(assistant_id) = snapshot_assistant_id { + return Ok(CreatePlan { + r#type: None, + assistant_id: Some(assistant_id), + model, + legacy_triple: Map::new(), + inheritance: Inheritance::Snapshot, + model_resolution, + }); + } + + let caller_extra: Value = serde_json::from_str(&caller.extra).unwrap_or(Value::Null); + let mut legacy_triple = Map::new(); + for key in ["backend", "agent_id", "agent_source"] { + if let Some(value) = caller_extra.get(key).and_then(Value::as_str).filter(|v| !v.is_empty()) { + legacy_triple.insert(key.to_owned(), Value::String(value.to_owned())); + } + } + Ok(CreatePlan { + r#type: Some(caller_type), + assistant_id: None, + model, + legacy_triple, + inheritance: Inheritance::LegacyTriple, + model_resolution, + }) + } + + /// "Explicit" branch. Filled in by the next task. + async fn override_plan(&self, _user_id: &str, _assistant_id: &str) -> Result { + Err(ConversationCreateError::TransportUnavailable { + reason: "assistant override not implemented".to_owned(), + }) + } +} + +/// `create`'s own workspace check is the only error we translate by kind; every +/// other failure inside `create` is an infrastructure problem from the agent's +/// point of view, so it surfaces as `transport_unavailable` with the message. +fn map_create_error(error: ConversationError, workspace: &str) -> ConversationCreateError { + match error { + ConversationError::WorkspacePathUnavailable { path } => ConversationCreateError::WorkspaceUnavailable { path }, + ConversationError::WorkspacePathRuntimeUnavailable { .. } => ConversationCreateError::WorkspaceUnavailable { + path: workspace.to_owned(), + }, + other => ConversationCreateError::transport(other), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The status/code pairing is a wire contract the CLI and every bad-path + /// test assert on, so it is pinned here rather than re-derived per route. + #[test] + fn every_error_maps_to_the_status_and_code_the_spec_pins() { + let cases: Vec<(ConversationCreateError, ConversationToolErrorCode, u16)> = vec![ + ( + ConversationCreateError::CallerIsTeam { id: "c".into() }, + ConversationToolErrorCode::CallerIsTeam, + 403, + ), + ( + ConversationCreateError::WorkspaceNotAbsolute { path: "p".into() }, + ConversationToolErrorCode::WorkspaceNotAbsolute, + 422, + ), + ( + ConversationCreateError::WorkspaceUnavailable { path: "p".into() }, + ConversationToolErrorCode::WorkspaceUnavailable, + 422, + ), + ( + ConversationCreateError::AssistantNotFound { id: "a".into() }, + ConversationToolErrorCode::AssistantNotFound, + 404, + ), + ( + ConversationCreateError::AssistantDisabled { id: "a".into() }, + ConversationToolErrorCode::AssistantDisabled, + 422, + ), + ( + ConversationCreateError::AssistantModelUnresolved { + assistant_id: "a".into(), + model_id: Some("m".into()), + }, + ConversationToolErrorCode::AssistantModelUnresolved, + 422, + ), + ( + ConversationCreateError::SchemaValidation { reason: "r".into() }, + ConversationToolErrorCode::SchemaValidationFailed, + 400, + ), + ( + ConversationCreateError::TransportUnavailable { reason: "r".into() }, + ConversationToolErrorCode::TransportUnavailable, + 503, + ), + ]; + for (error, code, status) in cases { + assert_eq!(error.code(), code, "{error}"); + assert_eq!(error.http_status(), status, "{error}"); + } + } + + #[test] + fn model_unresolved_message_names_the_model_only_when_there_is_one() { + let with = ConversationCreateError::AssistantModelUnresolved { + assistant_id: "a".into(), + model_id: Some("gpt-x".into()), + }; + assert!(with.to_string().contains("`gpt-x`"), "{with}"); + let without = ConversationCreateError::AssistantModelUnresolved { + assistant_id: "a".into(), + model_id: None, + }; + assert!(!without.to_string().contains('`'), "{without}"); + } +} diff --git a/crates/aionui-conversation/src/service.rs b/crates/aionui-conversation/src/service.rs index e37b2a119..d12c5df5c 100644 --- a/crates/aionui-conversation/src/service.rs +++ b/crates/aionui-conversation/src/service.rs @@ -39,9 +39,9 @@ use aionui_db::models::{ use aionui_db::{ AgentBindingResolution, ConversationFilters, ConversationRowUpdate, CreateAcpSessionParams, IAcpSessionRepository, IAgentMetadataRepository, IAssistantDefinitionRepository, IAssistantOverlayRepository, - IAssistantPreferenceRepository, IConversationRepository, IMcpServerRepository, MessagePageCursor, - MessagePageDirection, MessagePageParams, SaveRuntimeStateParams, UpsertConversationAssistantSnapshotParams, - resolve_agent_binding_from_rows, + IAssistantPreferenceRepository, IConversationRepository, IMcpServerRepository, IProviderRepository, + MessagePageCursor, MessagePageDirection, MessagePageParams, SaveRuntimeStateParams, + UpsertConversationAssistantSnapshotParams, resolve_agent_binding_from_rows, }; use aionui_extension::AssistantRuleDispatcher; use aionui_mcp::{AcpMcpCapabilities, parse_acp_mcp_capabilities}; @@ -72,7 +72,7 @@ const LEGACY_CONVERSATION_ARCHIVED_MESSAGE: &str = const DEPRECATED_AGENT_TYPE_MESSAGE: &str = "This agent type is no longer supported for new conversations."; #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)] -struct AssistantConversationOverrides { +pub(crate) struct AssistantConversationOverrides { #[serde(default)] model: Option, #[serde(default)] @@ -101,9 +101,9 @@ impl From for AssistantConversationOverri } #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -struct AssistantSnapshotResolvedDefaults { +pub(crate) struct AssistantSnapshotResolvedDefaults { #[serde(default)] - model: Option, + pub(crate) model: Option, #[serde(default)] permission: Option, #[serde(default)] @@ -136,12 +136,12 @@ struct AssistantSnapshotRules { } #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -struct AssistantSnapshot { +pub(crate) struct AssistantSnapshot { assistant_definition_id: String, assistant_id: String, assistant_source: String, #[serde(default)] - name: String, + pub(crate) name: String, #[serde(default)] avatar_type: String, #[serde(default)] @@ -151,13 +151,13 @@ struct AssistantSnapshot { #[serde(default, deserialize_with = "deserialize_string_or_null")] agent_source: String, #[serde(default, alias = "agent_backend", deserialize_with = "deserialize_string_or_null")] - runtime_backend: String, + pub(crate) runtime_backend: String, #[serde(default = "default_assistant_snapshot_agent_type")] - agent_type: AgentType, + pub(crate) agent_type: AgentType, rules: AssistantSnapshotRules, #[serde(default)] default_modes: AssistantSnapshotDefaultModes, - resolved_defaults: AssistantSnapshotResolvedDefaults, + pub(crate) resolved_defaults: AssistantSnapshotResolvedDefaults, created_at: i64, } @@ -329,6 +329,9 @@ pub struct ConversationService { assistant_definition_repo: Arc>>>, assistant_state_repo: Arc>>>, assistant_preference_repo: Arc>>>, + /// Only the agent-facing `conversation create` path reads this, to match an + /// aionrs assistant's default model to one of the user's providers. + provider_repo: Arc>>>, assistant_dispatcher: Arc>>>, agent_availability_feedback: Arc>>>, /// Project-bind side branch (optional). `None` → binding is a no-op, so @@ -414,6 +417,7 @@ impl ConversationService { assistant_definition_repo: Arc::new(RwLock::new(None)), assistant_state_repo: Arc::new(RwLock::new(None)), assistant_preference_repo: Arc::new(RwLock::new(None)), + provider_repo: Arc::new(RwLock::new(None)), assistant_dispatcher: Arc::new(RwLock::new(None)), agent_availability_feedback: Arc::new(RwLock::new(None)), project_service: Arc::new(RwLock::new(None)), @@ -607,6 +611,12 @@ impl ConversationService { } } + pub fn with_provider_repo(&self, repo: Arc) { + if let Ok(mut guard) = self.provider_repo.write() { + *guard = Some(repo); + } + } + pub fn with_assistant_dispatcher(&self, dispatcher: Arc) { if let Ok(mut guard) = self.assistant_dispatcher.write() { *guard = Some(dispatcher); @@ -743,27 +753,33 @@ impl ConversationService { auto_provisioned_workspace_to_delete(&self.workspace_root, row, conversation_id) } - fn assistant_definition_repo(&self) -> Option> { + pub(crate) fn assistant_definition_repo(&self) -> Option> { self.assistant_definition_repo .read() .ok() .and_then(|guard| guard.as_ref().cloned()) } - fn assistant_state_repo(&self) -> Option> { + pub(crate) fn assistant_state_repo(&self) -> Option> { self.assistant_state_repo .read() .ok() .and_then(|guard| guard.as_ref().cloned()) } - fn assistant_preference_repo(&self) -> Option> { + pub(crate) fn assistant_preference_repo(&self) -> Option> { self.assistant_preference_repo .read() .ok() .and_then(|guard| guard.as_ref().cloned()) } + // Read by the assistant-override branch of `runtime_create` (next task). + #[allow(dead_code)] + pub(crate) fn provider_repo(&self) -> Option> { + self.provider_repo.read().ok().and_then(|guard| guard.as_ref().cloned()) + } + fn assistant_dispatcher(&self) -> Option> { self.assistant_dispatcher .read() @@ -1634,7 +1650,7 @@ impl ConversationService { Ok(resolve_agent_binding_from_rows(&rows, value)) } - async fn resolve_assistant_snapshot( + pub(crate) async fn resolve_assistant_snapshot( &self, user_id: &str, assistant_id: &str, diff --git a/crates/aionui-conversation/src/service_test.rs b/crates/aionui-conversation/src/service_test.rs index 5602acbf0..9b991c095 100644 --- a/crates/aionui-conversation/src/service_test.rs +++ b/crates/aionui-conversation/src/service_test.rs @@ -57,6 +57,9 @@ use crate::{ConversationAgentTurnRequest, ConversationAgentTurnStatus, Conversat #[path = "service_test/acp_error_recovery_test.rs"] mod acp_error_recovery_test; +#[path = "service_test/runtime_create_test.rs"] +mod runtime_create_test; + #[derive(Clone, Debug)] struct RecordedViewSync { user_id: String, diff --git a/crates/aionui-conversation/src/service_test/runtime_create_test.rs b/crates/aionui-conversation/src/service_test/runtime_create_test.rs new file mode 100644 index 000000000..ae70cdcc0 --- /dev/null +++ b/crates/aionui-conversation/src/service_test/runtime_create_test.rs @@ -0,0 +1,275 @@ +//! `ConversationService::create_for_conversation_helper` — the inherit branch +//! and the validation order. The assistant-override branch lives in the same +//! file. + +use super::*; +use crate::ConversationCreateError; +use aionui_api_types::{ConversationCreateRequest, ConversationToolErrorCode}; +use aionui_db::UpsertConversationAssistantSnapshotParams; +use aionui_db::models::ConversationAssistantSnapshotRow; + +const USER: &str = "user_1"; + +fn create_req(name: &str, workspace: Option<&str>, assistant_id: Option<&str>) -> ConversationCreateRequest { + ConversationCreateRequest { + name: name.to_owned(), + workspace: workspace.map(str::to_owned), + assistant_id: assistant_id.map(str::to_owned), + } +} + +/// A caller row in the shape the frontend leaves behind: `type` + legacy +/// `extra.{backend, agent_id, agent_source}` and NO assistant snapshot. +async fn insert_caller( + repo: &Arc, + id: &str, + agent_type: &str, + extra: serde_json::Value, + model: Option<&str>, +) { + repo.create(&ConversationRow { + id: id.to_owned(), + user_id: USER.to_owned(), + name: "caller".to_owned(), + r#type: agent_type.to_owned(), + extra: extra.to_string(), + model: model.map(str::to_owned), + status: Some("finished".to_owned()), + source: Some("aionui".to_owned()), + channel_chat_id: None, + pinned: false, + pinned_at: None, + created_at: 1, + updated_at: 1, + project_id: None, + folder_id: None, + name_source: None, + }) + .await + .unwrap(); +} + +async fn snapshot_of(repo: &Arc, conversation_id: &str) -> Option { + repo.get_assistant_snapshot(USER, conversation_id).await.unwrap() +} + +// ── Inherit branch ────────────────────────────────────────────────── + +#[tokio::test] +async fn inherits_workspace_type_and_legacy_triple_when_the_caller_has_no_snapshot() { + let (svc, broadcaster, repo) = make_service_with_mock_task_manager(Arc::new(MockTaskManager::new())); + let workspace = ensure_test_workspace_path(); + insert_caller( + &repo, + "caller-legacy", + "acp", + json!({ "workspace": workspace, "backend": "claude", "agent_id": "2d23ff1c", "agent_source": "builtin" }), + None, + ) + .await; + broadcaster.take_events(); + + let created = svc + .create_for_conversation_helper(USER, "caller-legacy", &create_req(" 重构鉴权模块 ", None, None)) + .await + .unwrap(); + + assert_eq!(created.name, "重构鉴权模块", "name is trimmed"); + assert_eq!(created.workspace, workspace); + assert!( + created.assistant.is_none(), + "legacy triple carries no assistant identity" + ); + let row = repo.get(USER, &created.id).await.unwrap().unwrap(); + assert_eq!(row.r#type, "acp"); + assert_eq!(row.source.as_deref(), Some("aionui")); + assert!(row.name_source.is_none(), "agent-given names stay overwritable"); + let extra: serde_json::Value = serde_json::from_str(&row.extra).unwrap(); + assert_eq!(extra["workspace"], json!(workspace)); + assert_eq!(extra["backend"], json!("claude")); + assert_eq!(extra["agent_id"], json!("2d23ff1c")); + assert_eq!(extra["agent_source"], json!("builtin")); + assert!( + extra.get("custom_workspace").is_none(), + "request-only toggle must not persist" + ); + assert!(extra.get("teamId").is_none()); + + let events = broadcaster.take_events(); + let list_changed: Vec<_> = events.iter().filter(|e| e.name == "conversation.listChanged").collect(); + assert_eq!(list_changed.len(), 1); + assert_eq!(list_changed[0].data["action"], json!("created")); + assert_eq!(list_changed[0].data["user_id"], json!(USER)); + assert_eq!(list_changed[0].data["conversation_id"], json!(created.id)); +} + +#[tokio::test] +async fn inherits_the_assistant_snapshot_and_the_aionrs_model_verbatim() { + let (svc, _broadcaster, repo, definition_repo, _overlay_repo, _preference_repo) = + make_service_with_mock_task_manager_and_assistant_support(Arc::new(MockTaskManager::new())).await; + upsert_test_assistant_definition( + &definition_repo, + "def-aionrs", + "asst-aionrs", + "632f31d2", + "auto", + "auto", + ) + .await; + let workspace = ensure_test_workspace_path(); + let caller_model = json!({ "provider_id": "prov-1", "model": "model-a", "use_model": "model-a" }).to_string(); + insert_caller( + &repo, + "caller-snap", + "aionrs", + json!({ "workspace": workspace }), + Some(&caller_model), + ) + .await; + repo.upsert_assistant_snapshot( + USER, + &UpsertConversationAssistantSnapshotParams { + conversation_id: "caller-snap", + assistant_definition_id: "def-aionrs", + assistant_id: "asst-aionrs", + assistant_source: "builtin", + agent_id: "632f31d2", + rules_content: "", + default_model_mode: "auto", + resolved_model_id: Some("model-a"), + default_permission_mode: "auto", + resolved_permission_value: None, + default_thought_level_mode: "auto", + resolved_thought_level_value: None, + default_skills_mode: "auto", + resolved_skill_ids: "[]", + resolved_disabled_builtin_skill_ids: "[]", + default_mcps_mode: "auto", + resolved_mcp_ids: "[]", + }, + ) + .await + .unwrap(); + + let created = svc + .create_for_conversation_helper(USER, "caller-snap", &create_req("子任务", None, None)) + .await + .unwrap(); + + let assistant = created.assistant.expect("snapshot branch reports the assistant"); + assert_eq!(assistant.id, "asst-aionrs"); + let row = repo.get(USER, &created.id).await.unwrap().unwrap(); + assert_eq!(row.r#type, "aionrs"); + let model: ProviderWithModel = serde_json::from_str(row.model.as_deref().unwrap()).unwrap(); + assert_eq!(model.provider_id, "prov-1"); + assert_eq!(model.model, "model-a"); + let snapshot = snapshot_of(&repo, &created.id) + .await + .expect("new conversation gets its own snapshot"); + assert_eq!(snapshot.assistant_id, "asst-aionrs"); +} + +#[tokio::test] +async fn an_explicit_workspace_is_used_instead_of_the_callers() { + let (svc, _broadcaster, repo) = make_service_with_mock_task_manager(Arc::new(MockTaskManager::new())); + insert_caller( + &repo, + "caller-ws", + "acp", + json!({ "workspace": ensure_test_workspace_path(), "backend": "claude" }), + None, + ) + .await; + let other = unique_test_workspace_path("runtime-create-explicit"); + let other_str = other.to_string_lossy().to_string(); + + let created = svc + .create_for_conversation_helper(USER, "caller-ws", &create_req("x", Some(&other_str), None)) + .await + .unwrap(); + + assert_eq!(created.workspace, other_str); + let row = repo.get(USER, &created.id).await.unwrap().unwrap(); + let extra: serde_json::Value = serde_json::from_str(&row.extra).unwrap(); + assert_eq!( + extra["workspace"], + json!(other_str), + "no temp workspace was auto-provisioned" + ); +} + +// ── Validation order / bad paths ───────────────────────────────────── + +fn assert_code( + result: Result, + code: ConversationToolErrorCode, + status: u16, +) { + let error = result.expect_err("expected a rejection"); + assert_eq!(error.code(), code, "{error}"); + assert_eq!(error.http_status(), status, "{error}"); +} + +#[tokio::test] +async fn a_missing_caller_row_is_transport_unavailable() { + let (svc, _broadcaster, _repo) = make_service_with_mock_task_manager(Arc::new(MockTaskManager::new())); + let result = svc + .create_for_conversation_helper(USER, "gone", &create_req("x", None, None)) + .await; + assert_code(result, ConversationToolErrorCode::TransportUnavailable, 503); +} + +#[tokio::test] +async fn a_team_caller_is_refused_before_any_other_check() { + let (svc, _broadcaster, repo) = make_service_with_mock_task_manager(Arc::new(MockTaskManager::new())); + insert_caller(&repo, "caller-team", "acp", json!({ "teamId": "team-1" }), None).await; + // Blank name AND relative workspace would each fail later; team wins. + let result = svc + .create_for_conversation_helper(USER, "caller-team", &create_req(" ", Some("relative"), None)) + .await; + assert_code(result, ConversationToolErrorCode::CallerIsTeam, 403); + assert_eq!(repo.rows.lock().unwrap().len(), 1, "nothing was persisted"); +} + +#[tokio::test] +async fn a_blank_name_is_schema_validation_failed() { + let (svc, _broadcaster, repo) = make_service_with_mock_task_manager(Arc::new(MockTaskManager::new())); + insert_caller( + &repo, + "caller-a", + "acp", + json!({ "workspace": ensure_test_workspace_path() }), + None, + ) + .await; + let result = svc + .create_for_conversation_helper(USER, "caller-a", &create_req(" \t ", None, None)) + .await; + assert_code(result, ConversationToolErrorCode::SchemaValidationFailed, 400); +} + +#[tokio::test] +async fn a_relative_workspace_is_rejected_locally_and_a_missing_absolute_one_by_create() { + let (svc, _broadcaster, repo) = make_service_with_mock_task_manager(Arc::new(MockTaskManager::new())); + insert_caller( + &repo, + "caller-b", + "acp", + json!({ "workspace": ensure_test_workspace_path() }), + None, + ) + .await; + + let relative = svc + .create_for_conversation_helper(USER, "caller-b", &create_req("x", Some("src/lib"), None)) + .await; + assert_code(relative, ConversationToolErrorCode::WorkspaceNotAbsolute, 422); + + let missing = std::env::temp_dir().join("aionui-runtime-create-does-not-exist-9f2c"); + let missing_str = missing.to_string_lossy().to_string(); + let unavailable = svc + .create_for_conversation_helper(USER, "caller-b", &create_req("x", Some(&missing_str), None)) + .await; + assert_code(unavailable, ConversationToolErrorCode::WorkspaceUnavailable, 422); + assert_eq!(repo.rows.lock().unwrap().len(), 1, "nothing was persisted"); +} From fd8deddc676280499ddd0c6f62031c6b152ee6ab Mon Sep 17 00:00:00 2001 From: zynx <> Date: Tue, 8 Sep 2026 16:07:09 +0800 Subject: [PATCH 3/6] feat(conversation): resolve explicit assistant overrides with aionrs provider matching --- .../aionui-conversation/src/runtime_create.rs | 124 ++++++++- crates/aionui-conversation/src/service.rs | 2 - .../src/service_test/runtime_create_test.rs | 256 ++++++++++++++++++ 3 files changed, 372 insertions(+), 10 deletions(-) diff --git a/crates/aionui-conversation/src/runtime_create.rs b/crates/aionui-conversation/src/runtime_create.rs index 4a98df718..68c05e9c0 100644 --- a/crates/aionui-conversation/src/runtime_create.rs +++ b/crates/aionui-conversation/src/runtime_create.rs @@ -99,8 +99,6 @@ impl ConversationCreateError { pub(crate) enum Inheritance { Snapshot, LegacyTriple, - // Constructed by the assistant-override branch (next task). - #[allow(dead_code)] AssistantOverride, } @@ -118,8 +116,6 @@ impl Inheritance { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum ModelResolution { Inherited, - // Constructed by the assistant-override branch (next task). - #[allow(dead_code)] ProviderMatch, NotRequired, } @@ -370,12 +366,124 @@ impl ConversationService { }) } - /// "Explicit" branch. Filled in by the next task. - async fn override_plan(&self, _user_id: &str, _assistant_id: &str) -> Result { - Err(ConversationCreateError::TransportUnavailable { - reason: "assistant override not implemented".to_owned(), + /// "Explicit" branch: the definition must exist and be enabled; aionrs + /// assistants additionally need their default model matched to one of the + /// user's providers — NO fallback to the caller's model, which may belong + /// to a provider the chosen assistant was never meant to use. + async fn override_plan(&self, user_id: &str, assistant_id: &str) -> Result { + let (Some(definition_repo), Some(state_repo)) = (self.assistant_definition_repo(), self.assistant_state_repo()) + else { + return Err(ConversationCreateError::TransportUnavailable { + reason: "assistant repositories are not configured".to_owned(), + }); + }; + + let definition = definition_repo + .get_by_assistant_id_for_user(user_id, assistant_id) + .await + .map_err(ConversationCreateError::transport)? + .ok_or_else(|| ConversationCreateError::AssistantNotFound { + id: assistant_id.to_owned(), + })?; + + // Overlay row absent ⇒ enabled, the same reading `aionui-assistant`'s + // projection applies. + let overlay = state_repo + .get_for_user(user_id, &definition.id) + .await + .map_err(ConversationCreateError::transport)?; + if !overlay.as_ref().is_none_or(|row| row.enabled) { + return Err(ConversationCreateError::AssistantDisabled { + id: assistant_id.to_owned(), + }); + } + + // Reuse the exact model/backend resolution `create` will run again, so + // the pre-check and the persisted snapshot cannot disagree. + let snapshot = self + .resolve_assistant_snapshot( + user_id, + assistant_id, + None, + &crate::service::AssistantConversationOverrides::default(), + &Value::Null, + ) + .await + .map_err(ConversationCreateError::transport)? + .ok_or_else(|| ConversationCreateError::AssistantNotFound { + id: assistant_id.to_owned(), + })?; + + let (model, model_resolution) = if snapshot.agent_type == AgentType::Aionrs { + let model_id = snapshot.resolved_defaults.model.clone().ok_or_else(|| { + ConversationCreateError::AssistantModelUnresolved { + assistant_id: assistant_id.to_owned(), + model_id: None, + } + })?; + let provider_id = self + .match_provider_for_model(user_id, &model_id) + .await? + .ok_or_else(|| { + warn!( + assistant_id, + "aionrs assistant default model is not offered by any enabled provider" + ); + ConversationCreateError::AssistantModelUnresolved { + assistant_id: assistant_id.to_owned(), + model_id: Some(model_id.clone()), + } + })?; + ( + Some(ProviderWithModel { + provider_id, + model: model_id.clone(), + use_model: Some(model_id), + }), + ModelResolution::ProviderMatch, + ) + } else { + (None, ModelResolution::NotRequired) + }; + + Ok(CreatePlan { + r#type: None, + assistant_id: Some(assistant_id.to_owned()), + model, + legacy_triple: Map::new(), + inheritance: Inheritance::AssistantOverride, + model_resolution, }) } + + /// First ENABLED provider whose `models` JSON array lists `model_id` — the + /// same rule as team provisioning's `resolve_provider_for_model` and the + /// picker's `modelList[0]` default. `None` when no provider matches. + async fn match_provider_for_model( + &self, + user_id: &str, + model_id: &str, + ) -> Result, ConversationCreateError> { + let Some(provider_repo) = self.provider_repo() else { + return Err(ConversationCreateError::TransportUnavailable { + reason: "provider repository is not configured".to_owned(), + }); + }; + let providers = provider_repo + .list(user_id) + .await + .map_err(ConversationCreateError::transport)?; + Ok(providers + .into_iter() + .filter(|provider| provider.enabled) + .find(|provider| { + serde_json::from_str::>(&provider.models) + .unwrap_or_default() + .iter() + .any(|candidate| candidate == model_id) + }) + .map(|provider| provider.id)) + } } /// `create`'s own workspace check is the only error we translate by kind; every diff --git a/crates/aionui-conversation/src/service.rs b/crates/aionui-conversation/src/service.rs index d12c5df5c..601850df1 100644 --- a/crates/aionui-conversation/src/service.rs +++ b/crates/aionui-conversation/src/service.rs @@ -774,8 +774,6 @@ impl ConversationService { .and_then(|guard| guard.as_ref().cloned()) } - // Read by the assistant-override branch of `runtime_create` (next task). - #[allow(dead_code)] pub(crate) fn provider_repo(&self) -> Option> { self.provider_repo.read().ok().and_then(|guard| guard.as_ref().cloned()) } diff --git a/crates/aionui-conversation/src/service_test/runtime_create_test.rs b/crates/aionui-conversation/src/service_test/runtime_create_test.rs index ae70cdcc0..2d0e28ef8 100644 --- a/crates/aionui-conversation/src/service_test/runtime_create_test.rs +++ b/crates/aionui-conversation/src/service_test/runtime_create_test.rs @@ -273,3 +273,259 @@ async fn a_relative_workspace_is_rejected_locally_and_a_missing_absolute_one_by_ assert_code(unavailable, ConversationToolErrorCode::WorkspaceUnavailable, 422); assert_eq!(repo.rows.lock().unwrap().len(), 1, "nothing was persisted"); } + +// ── Assistant override branch ──────────────────────────────────────── + +use aionui_db::{CreateProviderParams, IProviderRepository, SqliteProviderRepository, UpsertAssistantOverlayParams}; + +/// An assistant whose default model is FIXED to `model`, bound to `agent_id`. +async fn upsert_assistant_with_fixed_model( + repo: &SqliteAssistantDefinitionRepository, + definition_id: &str, + assistant_id: &str, + agent_id: &str, + model: Option<&str>, +) { + // A user-owned definition: `upsert` (no user) would file it under the + // default user, invisible to `get_by_assistant_id_for_user(USER, ..)`. + repo.upsert_for_user( + USER, + &UpsertAssistantDefinitionParams { + id: definition_id, + assistant_id, + source: "user", + owner_type: "user", + source_ref: None, + name: assistant_id, + name_i18n: "{}", + description: None, + description_i18n: "{}", + avatar_type: "emoji", + avatar_value: Some("🧪"), + agent_id, + rule_resource_type: "none", + rule_resource_ref: None, + recommended_prompts: "[]", + recommended_prompts_i18n: "{}", + default_model_mode: if model.is_some() { "fixed" } else { "auto" }, + default_model_value: model, + default_permission_mode: "auto", + default_permission_value: None, + default_thought_level_mode: "auto", + default_thought_level_value: None, + default_skills_mode: "auto", + default_skill_ids: "[]", + custom_skill_names: "[]", + default_disabled_builtin_skill_ids: "[]", + default_mcps_mode: "auto", + default_mcp_ids: "[]", + }, + ) + .await + .unwrap(); +} + +/// A provider repo over its own in-memory DB (the assistant helper does not +/// expose its pool). `models` is the JSON array the row stores. +async fn provider_repo_with(models_by_provider: &[(&str, &str, bool)]) -> Arc { + let db = init_database_memory().await.unwrap(); + seed_test_user(db.pool(), USER).await; + let repo = SqliteProviderRepository::new(db.pool().clone()); + for (id, models, enabled) in models_by_provider { + repo.create(CreateProviderParams { + id: Some(id), + user_id: USER, + platform: "openai", + name: id, + base_url: "https://example.invalid", + api_key_encrypted: "enc", + models, + enabled: *enabled, + capabilities: "[]", + context_limit: None, + model_protocols: None, + model_enabled: None, + model_health: None, + model_settings: "{}", + bedrock_config: None, + is_full_url: false, + }) + .await + .unwrap(); + } + Arc::new(repo) +} + +#[tokio::test] +async fn an_explicit_claude_assistant_overrides_the_callers_backend() { + let (svc, _broadcaster, repo, definition_repo, _overlay_repo, _preference_repo) = + make_service_with_mock_task_manager_and_assistant_support(Arc::new(MockTaskManager::new())).await; + upsert_assistant_with_fixed_model(&definition_repo, "def-claude", "asst-claude", "2d23ff1c", None).await; + // Caller is an aionrs conversation; the new one must NOT inherit that. + let caller_model = json!({ "provider_id": "prov-1", "model": "model-a", "use_model": "model-a" }).to_string(); + insert_caller( + &repo, + "caller-x", + "aionrs", + json!({ "workspace": ensure_test_workspace_path() }), + Some(&caller_model), + ) + .await; + + let created = svc + .create_for_conversation_helper(USER, "caller-x", &create_req("review", None, Some("asst-claude"))) + .await + .unwrap(); + + let assistant = created.assistant.expect("override reports the chosen assistant"); + assert_eq!(assistant.id, "asst-claude"); + assert_eq!(assistant.backend, "claude"); + let row = repo.get(USER, &created.id).await.unwrap().unwrap(); + assert_eq!(row.r#type, "acp"); + assert!(row.model.is_none(), "non-aionrs backends carry no top-level model"); + let extra: serde_json::Value = serde_json::from_str(&row.extra).unwrap(); + assert_eq!(extra["backend"], json!("claude")); + assert_eq!(extra["agent_id"], json!("2d23ff1c")); + assert_eq!( + snapshot_of(&repo, &created.id).await.unwrap().assistant_id, + "asst-claude" + ); +} + +#[tokio::test] +async fn an_explicit_aionrs_assistant_gets_the_first_enabled_provider_that_offers_its_model() { + let (svc, _broadcaster, repo, definition_repo, _overlay_repo, _preference_repo) = + make_service_with_mock_task_manager_and_assistant_support(Arc::new(MockTaskManager::new())).await; + upsert_assistant_with_fixed_model(&definition_repo, "def-rs", "asst-rs", "632f31d2", Some("model-b")).await; + svc.with_provider_repo( + provider_repo_with(&[ + ("prov-disabled", r#"["model-b"]"#, false), + ("prov-other", r#"["model-z"]"#, true), + ("prov-match", r#"["model-a","model-b"]"#, true), + ("prov-second-match", r#"["model-b"]"#, true), + ]) + .await, + ); + insert_caller( + &repo, + "caller-y", + "acp", + json!({ "workspace": ensure_test_workspace_path(), "backend": "claude" }), + None, + ) + .await; + + let created = svc + .create_for_conversation_helper(USER, "caller-y", &create_req("rs", None, Some("asst-rs"))) + .await + .unwrap(); + + let row = repo.get(USER, &created.id).await.unwrap().unwrap(); + assert_eq!(row.r#type, "aionrs"); + let model: ProviderWithModel = serde_json::from_str(row.model.as_deref().unwrap()).unwrap(); + assert_eq!( + model.provider_id, "prov-match", + "first ENABLED provider listing the model wins" + ); + assert_eq!(model.model, "model-b"); + assert_eq!(model.use_model.as_deref(), Some("model-b")); +} + +#[tokio::test] +async fn an_unknown_assistant_id_is_404_and_a_disabled_one_is_422() { + let (svc, _broadcaster, repo, definition_repo, overlay_repo, _preference_repo) = + make_service_with_mock_task_manager_and_assistant_support(Arc::new(MockTaskManager::new())).await; + upsert_assistant_with_fixed_model(&definition_repo, "def-off", "asst-off", "2d23ff1c", None).await; + overlay_repo + .upsert_for_user( + USER, + &UpsertAssistantOverlayParams { + assistant_definition_id: "def-off", + enabled: false, + sort_order: 0, + agent_id_override: None, + last_used_at: None, + }, + ) + .await + .unwrap(); + insert_caller( + &repo, + "caller-z", + "acp", + json!({ "workspace": ensure_test_workspace_path() }), + None, + ) + .await; + + let missing = svc + .create_for_conversation_helper(USER, "caller-z", &create_req("x", None, Some("asst-nope"))) + .await; + assert_code(missing, ConversationToolErrorCode::AssistantNotFound, 404); + + let disabled = svc + .create_for_conversation_helper(USER, "caller-z", &create_req("x", None, Some("asst-off"))) + .await; + assert_code(disabled, ConversationToolErrorCode::AssistantDisabled, 422); + assert_eq!(repo.rows.lock().unwrap().len(), 1, "nothing was persisted"); +} + +#[tokio::test] +async fn an_aionrs_assistant_whose_model_no_provider_offers_is_422_and_persists_nothing() { + let (svc, _broadcaster, repo, definition_repo, _overlay_repo, _preference_repo) = + make_service_with_mock_task_manager_and_assistant_support(Arc::new(MockTaskManager::new())).await; + upsert_assistant_with_fixed_model( + &definition_repo, + "def-orphan", + "asst-orphan", + "632f31d2", + Some("model-nowhere"), + ) + .await; + svc.with_provider_repo(provider_repo_with(&[("prov-1", r#"["model-a"]"#, true)]).await); + // The caller HAS a usable aionrs model; it must NOT be used as a fallback. + let caller_model = json!({ "provider_id": "prov-1", "model": "model-a", "use_model": "model-a" }).to_string(); + insert_caller( + &repo, + "caller-w", + "aionrs", + json!({ "workspace": ensure_test_workspace_path() }), + Some(&caller_model), + ) + .await; + + let result = svc + .create_for_conversation_helper(USER, "caller-w", &create_req("x", None, Some("asst-orphan"))) + .await; + + let error = result.expect_err("no provider offers model-nowhere"); + assert_eq!(error.code(), ConversationToolErrorCode::AssistantModelUnresolved); + assert_eq!(error.http_status(), 422); + assert!( + error.to_string().contains("model-nowhere"), + "message names the model for the agent: {error}" + ); + assert_eq!(repo.rows.lock().unwrap().len(), 1, "no half-built conversation"); + assert!(snapshot_of(&repo, "caller-w").await.is_none(), "caller untouched"); +} + +#[tokio::test] +async fn an_aionrs_assistant_in_auto_mode_with_no_preference_is_model_unresolved() { + let (svc, _broadcaster, repo, definition_repo, _overlay_repo, _preference_repo) = + make_service_with_mock_task_manager_and_assistant_support(Arc::new(MockTaskManager::new())).await; + upsert_assistant_with_fixed_model(&definition_repo, "def-auto", "asst-auto", "632f31d2", None).await; + svc.with_provider_repo(provider_repo_with(&[("prov-1", r#"["model-a"]"#, true)]).await); + insert_caller( + &repo, + "caller-v", + "acp", + json!({ "workspace": ensure_test_workspace_path() }), + None, + ) + .await; + + let result = svc + .create_for_conversation_helper(USER, "caller-v", &create_req("x", None, Some("asst-auto"))) + .await; + assert_code(result, ConversationToolErrorCode::AssistantModelUnresolved, 422); +} From 29fc093b7e1579dcaef02c7d52e5e57d590ec850 Mon Sep 17 00:00:00 2001 From: zynx <> Date: Tue, 8 Sep 2026 16:13:16 +0800 Subject: [PATCH 4/6] feat(conversation): expose POST /api/runtime/conversations/create behind runtime-token auth --- Cargo.lock | 1 + crates/aionui-app/src/router/routes.rs | 11 +- crates/aionui-app/src/services.rs | 1 + .../tests/conversation_runtime_routes_e2e.rs | 133 ++++++ crates/aionui-conversation/Cargo.toml | 1 + crates/aionui-conversation/src/lib.rs | 2 + .../aionui-conversation/src/runtime_routes.rs | 140 ++++++ .../tests/runtime_create_routes.rs | 425 ++++++++++++++++++ 8 files changed, 713 insertions(+), 1 deletion(-) create mode 100644 crates/aionui-app/tests/conversation_runtime_routes_e2e.rs create mode 100644 crates/aionui-conversation/src/runtime_routes.rs create mode 100644 crates/aionui-conversation/tests/runtime_create_routes.rs diff --git a/Cargo.lock b/Cargo.lock index 93a14371c..42e99e2ae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -600,6 +600,7 @@ dependencies = [ "tempfile", "thiserror 2.0.18", "tokio", + "tower", "tracing", "tracing-subscriber", ] diff --git a/crates/aionui-app/src/router/routes.rs b/crates/aionui-app/src/router/routes.rs index c7f5db104..e67514c72 100644 --- a/crates/aionui-app/src/router/routes.rs +++ b/crates/aionui-app/src/router/routes.rs @@ -27,7 +27,9 @@ use aionui_channel::channel_routes; #[cfg(feature = "weixin")] use aionui_channel::weixin_login_route; use aionui_common::ApiErrorLogContext; -use aionui_conversation::{conversation_ops_routes, conversation_routes}; +use aionui_conversation::{ + ConversationRuntimeRouterState, conversation_ops_routes, conversation_routes, conversation_runtime_routes, +}; use aionui_cron::cron_routes; use aionui_extension::{extension_routes, hub_routes, skill_routes}; use aionui_file::file_routes; @@ -369,6 +371,12 @@ pub fn create_router_with_all_state(services: &AppServices, states: ModuleStates // Runtime routes authenticate on their own token header — deliberately NOT // behind auth_middleware, same as runtime_team_tools. let session_message_runtime = session_message_routes(states.session_message.clone()); + // Agent-facing `conversation create`. Same runtime-token self-authentication + // as the two groups above, so it is also mounted after the CSRF layer. + let conversation_runtime = conversation_runtime_routes(ConversationRuntimeRouterState { + service: services.conversation_service.clone(), + runtime_token_service: services.runtime_token_service.clone(), + }); // Channel A. Same runtime-token self-authentication: the caller is an agent // process holding a conversation-scoped token, not a browser session. let skill_runtime = skill_runtime_routes(states.skill_runtime); @@ -428,6 +436,7 @@ pub fn create_router_with_all_state(services: &AppServices, states: ModuleStates .merge(ws_routes) .merge(runtime_team_tools) .merge(session_message_runtime) + .merge(conversation_runtime) .merge(office_proxy) .merge(public_assets) .layer(middleware::from_fn(security_headers_middleware)); diff --git a/crates/aionui-app/src/services.rs b/crates/aionui-app/src/services.rs index f5a4c6b37..92abbd573 100644 --- a/crates/aionui-app/src/services.rs +++ b/crates/aionui-app/src/services.rs @@ -511,6 +511,7 @@ fn build_conversation_service(deps: ConversationServiceDeps<'_>) -> Conversation service.with_assistant_preference_repo(Arc::new(SqliteAssistantPreferenceRepository::new( deps.database.pool().clone(), ))); + service.with_provider_repo(Arc::new(SqliteProviderRepository::new(deps.database.pool().clone()))); if let Some(hook) = deps.task_manager_delete_hook { service.with_delete_hook(hook); } diff --git a/crates/aionui-app/tests/conversation_runtime_routes_e2e.rs b/crates/aionui-app/tests/conversation_runtime_routes_e2e.rs new file mode 100644 index 000000000..3e7181255 --- /dev/null +++ b/crates/aionui-app/tests/conversation_runtime_routes_e2e.rs @@ -0,0 +1,133 @@ +//! The agent-facing create route is reachable through the REAL app router — +//! mounted outside auth/CSRF, wired to the shared `ConversationService` (so it +//! sees the provider/assistant repos `AppServices` injects). + +mod common; + +use aionui_ai_agent::{RuntimeTokenScope, TEAM_RUNTIME_TOKEN_SESSION_GENERATION}; +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use common::{body_json, build_app}; +use tower::ServiceExt; + +const USER: &str = "system_default_user"; +const CALLER: &str = "conv-agent-create-caller"; + +async fn insert_caller(services: &aionui_app::AppServices, id: &str, extra: &str) { + sqlx::query( + "INSERT INTO conversations (id, user_id, name, type, extra, status, created_at, updated_at) + VALUES (?, ?, 'caller', 'acp', ?, 'finished', 1, 1)", + ) + .bind(id) + .bind(USER) + .bind(extra) + .execute(services.database.pool()) + .await + .unwrap(); +} + +fn create_request(conversation_id: &str, token: &str, body: &str) -> Request { + Request::builder() + .method("POST") + .uri("/api/runtime/conversations/create") + .header("content-type", "application/json") + .header("x-aionui-user-id", USER) + .header("x-aionui-conversation-id", conversation_id) + .header("x-aionui-runtime-token", token) + .body(Body::from(body.to_owned())) + .unwrap() +} + +#[tokio::test] +async fn runtime_create_is_mounted_and_creates_without_a_csrf_token() { + let (app, services) = build_app().await; + let workspace = std::env::temp_dir().join("aionui-app-runtime-create-e2e"); + std::fs::create_dir_all(&workspace).unwrap(); + insert_caller( + &services, + CALLER, + &serde_json::json!({ "workspace": workspace, "backend": "claude" }).to_string(), + ) + .await; + let token = services + .runtime_token_service + .issue( + USER, + CALLER, + TEAM_RUNTIME_TOKEN_SESSION_GENERATION, + [RuntimeTokenScope::ConversationHelper], + ) + .token; + + let response = app + .clone() + .oneshot(create_request(CALLER, &token, r#"{"name":"子任务"}"#)) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let envelope = body_json(response).await; + assert_eq!(envelope["success"], serde_json::json!(true)); + assert_eq!( + envelope["data"]["workspace"], + serde_json::json!(workspace.to_string_lossy()) + ); + let new_id = envelope["data"]["id"].as_str().unwrap().to_owned(); + let (count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM conversations WHERE id = ? AND user_id = ?") + .bind(&new_id) + .bind(USER) + .fetch_one(services.database.pool()) + .await + .unwrap(); + assert_eq!(count, 1); + services.database.close().await; +} + +#[tokio::test] +async fn runtime_create_refuses_a_team_caller_through_the_real_router() { + let (app, services) = build_app().await; + insert_caller(&services, "conv-team-caller", r#"{"teamId":"team-1"}"#).await; + let token = services + .runtime_token_service + .issue( + USER, + "conv-team-caller", + TEAM_RUNTIME_TOKEN_SESSION_GENERATION, + [RuntimeTokenScope::ConversationHelper], + ) + .token; + + let response = app + .oneshot(create_request("conv-team-caller", &token, r#"{"name":"x"}"#)) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert_eq!( + body_json(response).await["error"]["code"], + serde_json::json!("caller_is_team") + ); + services.database.close().await; +} + +#[tokio::test] +async fn runtime_create_without_a_token_is_401_not_a_redirect_into_user_auth() { + let (app, services) = build_app().await; + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/runtime/conversations/create") + .header("content-type", "application/json") + .body(Body::from(r#"{"name":"x"}"#)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + assert_eq!( + body_json(response).await["error"]["code"], + serde_json::json!("runtime_auth_failed") + ); + services.database.close().await; +} diff --git a/crates/aionui-conversation/Cargo.toml b/crates/aionui-conversation/Cargo.toml index e981cd7a4..4bd4c56b4 100644 --- a/crates/aionui-conversation/Cargo.toml +++ b/crates/aionui-conversation/Cargo.toml @@ -27,6 +27,7 @@ thiserror.workspace = true [dev-dependencies] tokio = { workspace = true, features = ["test-util"] } +tower.workspace = true tempfile.workspace = true tracing-subscriber.workspace = true sqlx.workspace = true diff --git a/crates/aionui-conversation/src/lib.rs b/crates/aionui-conversation/src/lib.rs index 2b740cec4..4895497b2 100644 --- a/crates/aionui-conversation/src/lib.rs +++ b/crates/aionui-conversation/src/lib.rs @@ -14,6 +14,7 @@ pub mod routes_aux; mod runtime_completion; pub mod runtime_create; mod runtime_persistence; +pub mod runtime_routes; pub mod runtime_state; pub mod service; mod service_ops; @@ -36,6 +37,7 @@ pub use response_middleware::{MessageMiddleware, MiddlewareResult, strip_think_t pub use routes::conversation_routes; pub use routes_aux::conversation_ops_routes; pub use runtime_create::ConversationCreateError; +pub use runtime_routes::{ConversationRuntimeRouterState, conversation_runtime_routes}; pub use service::is_temp_session_workspace; pub use service::{ ConversationAgentTurnOutcome, ConversationAgentTurnRequest, ConversationAgentTurnStarted, diff --git a/crates/aionui-conversation/src/runtime_routes.rs b/crates/aionui-conversation/src/runtime_routes.rs new file mode 100644 index 000000000..2b42627ec --- /dev/null +++ b/crates/aionui-conversation/src/runtime_routes.rs @@ -0,0 +1,140 @@ +//! `POST /api/runtime/conversations/create` — the agent-facing create route. +//! +//! Authenticates on the three `x-aionui-*` headers itself, exactly like +//! `aionui-session-message/src/routes.rs`, and is therefore mounted OUTSIDE the +//! ordinary auth and CSRF middleware in `aionui-app`. The caller conversation is +//! the token-bound header — the body cannot name a different caller. + +use std::sync::Arc; + +use aionui_ai_agent::{RuntimeTokenScope, RuntimeTokenService, TEAM_RUNTIME_TOKEN_SESSION_GENERATION}; +use aionui_api_types::{ + ConversationCliEnvelope, ConversationCreateRequest, ConversationCreateResponse, ConversationToolErrorCode, + ConversationToolErrorPayload, +}; +use axum::extract::State; +use axum::extract::rejection::JsonRejection; +use axum::http::{HeaderMap, StatusCode}; +use axum::routing::post; +use axum::{Json, Router}; +use tracing::warn; + +use crate::runtime_create::ConversationCreateError; +use crate::service::ConversationService; + +const HEADER_USER_ID: &str = "x-aionui-user-id"; +const HEADER_CONVERSATION_ID: &str = "x-aionui-conversation-id"; +const HEADER_RUNTIME_TOKEN: &str = "x-aionui-runtime-token"; +const COMMAND: &str = "conversation create"; + +/// Separate from `ConversationRouterState` so the ordinary conversation routes +/// keep their shape and this one carries only what it needs. +#[derive(Clone)] +pub struct ConversationRuntimeRouterState { + pub service: ConversationService, + pub runtime_token_service: Arc, +} + +pub fn conversation_runtime_routes(state: ConversationRuntimeRouterState) -> Router { + Router::new() + .route("/api/runtime/conversations/create", post(create)) + .with_state(state) +} + +struct RuntimeCaller { + user_id: String, + conversation_id: String, +} + +fn runtime_caller(state: &ConversationRuntimeRouterState, headers: &HeaderMap) -> Option { + let user_id = required_header(headers, HEADER_USER_ID)?; + let conversation_id = required_header(headers, HEADER_CONVERSATION_ID)?; + let token = required_header(headers, HEADER_RUNTIME_TOKEN)?; + state + .runtime_token_service + .validate( + Some(&token), + &user_id, + &conversation_id, + RuntimeTokenScope::ConversationHelper, + // Every conversation's helper token is issued with this constant; + // despite the prefix it is not team-specific. + TEAM_RUNTIME_TOKEN_SESSION_GENERATION, + ) + .ok()?; + Some(RuntimeCaller { + user_id, + conversation_id, + }) +} + +fn required_header(headers: &HeaderMap, name: &'static str) -> Option { + headers + .get(name) + .and_then(|value| value.to_str().ok()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_owned) +} + +type Reply = (StatusCode, Json>); + +async fn create( + State(state): State, + headers: HeaderMap, + body: Result, JsonRejection>, +) -> Reply { + let Some(caller) = runtime_caller(&state, &headers) else { + // No header values are logged — the token must never reach the logs. + warn!( + outcome = "rejected", + error_code = "runtime_auth_failed", + "agent conversation create refused" + ); + return failure( + StatusCode::UNAUTHORIZED, + ConversationToolErrorPayload::new(ConversationToolErrorCode::RuntimeAuthFailed, "runtime auth failed"), + ); + }; + // Body parsing is deliberately AFTER auth so an unauthenticated caller + // learns nothing about the schema; `deny_unknown_fields` on the request + // type turns stray fields into this same 400. + let request = match body { + Ok(Json(request)) => request, + Err(rejection) => { + return failure( + StatusCode::BAD_REQUEST, + ConversationToolErrorPayload::new( + ConversationToolErrorCode::SchemaValidationFailed, + format!("request body does not match the schema: {}", rejection.body_text()), + ), + ); + } + }; + match state + .service + .create_for_conversation_helper(&caller.user_id, &caller.conversation_id, &request) + .await + { + Ok(data) => ( + StatusCode::OK, + Json(ConversationCliEnvelope::success(data, Some(COMMAND.to_owned()))), + ), + Err(error) => envelope_failure(error), + } +} + +fn envelope_failure(error: ConversationCreateError) -> Reply { + let status = StatusCode::from_u16(error.http_status()).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR); + failure( + status, + ConversationToolErrorPayload::new(error.code(), error.to_string()), + ) +} + +fn failure(status: StatusCode, error: ConversationToolErrorPayload) -> Reply { + ( + status, + Json(ConversationCliEnvelope::failure(error, Some(COMMAND.to_owned()))), + ) +} diff --git a/crates/aionui-conversation/tests/runtime_create_routes.rs b/crates/aionui-conversation/tests/runtime_create_routes.rs new file mode 100644 index 000000000..3a78ee239 --- /dev/null +++ b/crates/aionui-conversation/tests/runtime_create_routes.rs @@ -0,0 +1,425 @@ +//! Route-level contract for `POST /api/runtime/conversations/create`: +//! runtime-token auth, envelope error codes + HTTP statuses, and the happy path +//! through a real in-memory DB and a real `ConversationService`. + +use std::sync::{Arc, Mutex}; + +use aionui_ai_agent::agent_task::AgentInstance; +use aionui_ai_agent::types::BuildTaskOptions; +use aionui_ai_agent::{AgentError, IWorkerTaskManager, RuntimeTokenScope, RuntimeTokenService}; +use aionui_api_types::WebSocketMessage; +use aionui_common::{AgentKillReason, TimestampMs}; +use aionui_conversation::skill_resolver::{ResolvedAgentSkill, SkillResolver}; +use aionui_conversation::{ConversationRuntimeRouterState, ConversationService, conversation_runtime_routes}; +use aionui_db::models::ConversationRow; +use aionui_db::{ + IConversationRepository, SqliteAssistantDefinitionRepository, SqliteAssistantOverlayRepository, + SqliteAssistantPreferenceRepository, SqliteConversationRepository, init_database_memory, +}; +use aionui_realtime::EventBroadcaster; +use axum::Router; +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use tower::ServiceExt; + +const USER: &str = "user_1"; +const OTHER_USER: &str = "user_2"; +const HEADER_USER_ID: &str = "x-aionui-user-id"; +const HEADER_CONVERSATION_ID: &str = "x-aionui-conversation-id"; +const HEADER_RUNTIME_TOKEN: &str = "x-aionui-runtime-token"; + +#[derive(Default)] +struct RecordingBroadcaster { + events: Mutex>>, +} + +impl RecordingBroadcaster { + fn events(&self) -> Vec> { + self.events.lock().unwrap().clone() + } +} + +impl EventBroadcaster for RecordingBroadcaster { + fn broadcast(&self, event: WebSocketMessage) { + self.events.lock().unwrap().push(event); + } +} + +struct NoSkills; + +#[async_trait::async_trait] +impl SkillResolver for NoSkills { + async fn auto_inject_names(&self) -> Vec { + Vec::new() + } + + async fn resolve_skills(&self, _names: &[String]) -> Vec { + Vec::new() + } +} + +/// `create` never builds an agent, so nothing here is reachable; it exists only +/// because `ConversationService::new` takes a task manager. +struct NoopTaskManager; + +#[async_trait::async_trait] +impl IWorkerTaskManager for NoopTaskManager { + fn get_task(&self, _: &str) -> Option { + None + } + + async fn get_or_build_task(&self, _: &str, _: BuildTaskOptions) -> Result { + Err(AgentError::internal("noop")) + } + + fn kill(&self, _: &str, _: Option) -> Result<(), AgentError> { + Ok(()) + } + + fn kill_and_wait( + &self, + _: &str, + _: Option, + ) -> std::pin::Pin + Send>> { + Box::pin(std::future::ready(())) + } + + async fn clear(&self) {} + + fn active_count(&self) -> usize { + 0 + } + + fn collect_idle(&self, _: TimestampMs) -> Vec { + Vec::new() + } +} + +struct Ctx { + router: Router, + repo: Arc, + broadcaster: Arc, + tokens: Arc, + workspace: String, +} + +async fn setup() -> Ctx { + let db = init_database_memory().await.unwrap(); + for user in [USER, OTHER_USER] { + sqlx::query( + "INSERT INTO users (id, user_type, username, password_hash, status, session_generation, created_at, updated_at) \ + VALUES (?, 'local', ?, 'hash', 'active', 0, 1, 1)", + ) + .bind(user) + .bind(user) + .execute(db.pool()) + .await + .unwrap(); + } + let pool = db.pool().clone(); + // Leaked so the shared in-memory pool outlives the test (same as the + // session-message harness). + std::mem::forget(db); + + let repo = Arc::new(SqliteConversationRepository::new(pool.clone())); + let broadcaster = Arc::new(RecordingBroadcaster::default()); + let task_manager: Arc = Arc::new(NoopTaskManager); + let service = ConversationService::new( + std::env::temp_dir().join("aionui-runtime-create-routes-root"), + broadcaster.clone(), + Arc::new(NoSkills), + task_manager, + repo.clone(), + Arc::new(aionui_db::SqliteAgentMetadataRepository::new(pool.clone())), + Arc::new(aionui_db::SqliteAcpSessionRepository::new(pool.clone())), + ); + // Assistant repos so an unknown `assistant_id` answers with the contract + // code (404) rather than "repositories not configured". + service.with_assistant_definition_repo(Arc::new(SqliteAssistantDefinitionRepository::new(pool.clone()))); + service.with_assistant_state_repo(Arc::new(SqliteAssistantOverlayRepository::new(pool.clone()))); + service.with_assistant_preference_repo(Arc::new(SqliteAssistantPreferenceRepository::new(pool.clone()))); + + let tokens = Arc::new(RuntimeTokenService::new()); + let router = conversation_runtime_routes(ConversationRuntimeRouterState { + service, + runtime_token_service: tokens.clone(), + }); + let workspace = std::env::temp_dir().join("aionui-runtime-create-routes-workspace"); + std::fs::create_dir_all(&workspace).unwrap(); + Ctx { + router, + repo, + broadcaster, + tokens, + workspace: workspace.to_string_lossy().into_owned(), + } +} + +impl Ctx { + async fn insert_row(&self, user_id: &str, id: &str, extra: serde_json::Value) { + self.repo + .create(&ConversationRow { + id: id.to_owned(), + user_id: user_id.to_owned(), + name: "caller".to_owned(), + r#type: "acp".to_owned(), + extra: extra.to_string(), + model: None, + status: Some("finished".to_owned()), + source: Some("aionui".to_owned()), + channel_chat_id: None, + pinned: false, + pinned_at: None, + created_at: 1, + updated_at: 1, + project_id: None, + folder_id: None, + name_source: None, + }) + .await + .unwrap(); + } + + async fn caller(&self, id: &str) { + self.insert_row( + USER, + id, + serde_json::json!({ "workspace": self.workspace, "backend": "claude" }), + ) + .await; + } + + fn mint(&self, user_id: &str, conversation_id: &str) -> String { + self.tokens + .issue( + user_id, + conversation_id, + aionui_ai_agent::TEAM_RUNTIME_TOKEN_SESSION_GENERATION, + [RuntimeTokenScope::ConversationHelper], + ) + .token + } +} + +fn create_request(user_id: &str, conversation_id: &str, token: Option<&str>, body: &str) -> Request { + // Deliberately no `x-csrf-token`: the runtime channel is mounted outside + // the CSRF layer in aionui-app, and this router has none either. + let mut builder = Request::builder() + .method("POST") + .uri("/api/runtime/conversations/create") + .header("content-type", "application/json") + .header(HEADER_USER_ID, user_id) + .header(HEADER_CONVERSATION_ID, conversation_id); + if let Some(token) = token { + builder = builder.header(HEADER_RUNTIME_TOKEN, token); + } + builder.body(Body::from(body.to_owned())).unwrap() +} + +async fn json_body(response: axum::response::Response) -> serde_json::Value { + let bytes = axum::body::to_bytes(response.into_body(), 1024 * 1024).await.unwrap(); + serde_json::from_slice(&bytes).unwrap() +} + +async fn call(ctx: &Ctx, request: Request) -> (StatusCode, serde_json::Value) { + let response = ctx.router.clone().oneshot(request).await.unwrap(); + let status = response.status(); + (status, json_body(response).await) +} + +// ── Security ──────────────────────────────────────────────────────── + +#[tokio::test] +async fn create_without_a_runtime_token_is_401() { + let ctx = setup().await; + ctx.caller("conv_a").await; + let (status, envelope) = call(&ctx, create_request(USER, "conv_a", None, r#"{"name":"x"}"#)).await; + assert_eq!(status, StatusCode::UNAUTHORIZED); + assert_eq!(envelope["success"], serde_json::json!(false)); + assert_eq!(envelope["error"]["code"], serde_json::json!("runtime_auth_failed")); + assert_eq!(envelope["meta"]["command"], serde_json::json!("conversation create")); +} + +#[tokio::test] +async fn a_forged_user_header_cannot_create_for_another_user() { + let ctx = setup().await; + ctx.caller("conv_a").await; + let token = ctx.mint(USER, "conv_a"); + let (status, envelope) = call( + &ctx, + create_request(OTHER_USER, "conv_a", Some(&token), r#"{"name":"x"}"#), + ) + .await; + assert_eq!(status, StatusCode::UNAUTHORIZED); + assert_eq!(envelope["error"]["code"], serde_json::json!("runtime_auth_failed")); + assert_eq!( + ctx.repo.list_all_conversation_ids().await.unwrap().len(), + 1, + "nothing created" + ); +} + +// ── Happy path ────────────────────────────────────────────────────── + +#[tokio::test] +async fn create_inherits_the_callers_workspace_and_broadcasts_once() { + let ctx = setup().await; + ctx.caller("conv_a").await; + let token = ctx.mint(USER, "conv_a"); + + let (status, envelope) = call( + &ctx, + create_request(USER, "conv_a", Some(&token), r#"{"name":"重构鉴权模块"}"#), + ) + .await; + + assert_eq!(status, StatusCode::OK, "{envelope}"); + assert_eq!(envelope["success"], serde_json::json!(true)); + assert_eq!(envelope["meta"]["schema_version"], serde_json::json!(1)); + assert_eq!(envelope["meta"]["command"], serde_json::json!("conversation create")); + assert_eq!(envelope["data"]["name"], serde_json::json!("重构鉴权模块")); + assert_eq!(envelope["data"]["workspace"], serde_json::json!(ctx.workspace)); + let new_id = envelope["data"]["id"].as_str().unwrap(); + let row = ctx.repo.get(USER, new_id).await.unwrap().expect("row persisted"); + assert_eq!(row.source.as_deref(), Some("aionui")); + assert!(row.name_source.is_none()); + let extra: serde_json::Value = serde_json::from_str(&row.extra).unwrap(); + assert_eq!(extra["backend"], serde_json::json!("claude")); + + let list_changed: Vec<_> = ctx + .broadcaster + .events() + .into_iter() + .filter(|event| event.name == "conversation.listChanged") + .collect(); + assert_eq!(list_changed.len(), 1); + assert_eq!(list_changed[0].data["action"], serde_json::json!("created")); + assert_eq!(list_changed[0].data["user_id"], serde_json::json!(USER)); +} + +#[tokio::test] +async fn create_with_an_explicit_workspace_persists_that_path() { + let ctx = setup().await; + ctx.caller("conv_a").await; + let token = ctx.mint(USER, "conv_a"); + let other = std::env::temp_dir().join("aionui-runtime-create-routes-other"); + std::fs::create_dir_all(&other).unwrap(); + let other = other.to_string_lossy().into_owned(); + let body = serde_json::json!({ "name": "x", "workspace": other }).to_string(); + + let (status, envelope) = call(&ctx, create_request(USER, "conv_a", Some(&token), &body)).await; + + assert_eq!(status, StatusCode::OK, "{envelope}"); + assert_eq!(envelope["data"]["workspace"], serde_json::json!(other)); +} + +// ── Bad paths (code + status) ──────────────────────────────────────── + +async fn assert_rejected(ctx: &Ctx, conversation_id: &str, body: &str, status: StatusCode, code: &str) { + let token = ctx.mint(USER, conversation_id); + let (got_status, envelope) = call(ctx, create_request(USER, conversation_id, Some(&token), body)).await; + assert_eq!(got_status, status, "{envelope}"); + assert_eq!(envelope["success"], serde_json::json!(false)); + assert_eq!(envelope["error"]["code"], serde_json::json!(code), "{envelope}"); + assert!(envelope.get("data").is_none(), "{envelope}"); +} + +#[tokio::test] +async fn a_team_caller_is_403_caller_is_team() { + let ctx = setup().await; + ctx.insert_row(USER, "conv_team", serde_json::json!({ "teamId": "team-1" })) + .await; + assert_rejected( + &ctx, + "conv_team", + r#"{"name":"x"}"#, + StatusCode::FORBIDDEN, + "caller_is_team", + ) + .await; +} + +#[tokio::test] +async fn a_blank_name_is_400_schema_validation_failed() { + let ctx = setup().await; + ctx.caller("conv_a").await; + assert_rejected( + &ctx, + "conv_a", + r#"{"name":" "}"#, + StatusCode::BAD_REQUEST, + "schema_validation_failed", + ) + .await; +} + +#[tokio::test] +async fn an_unknown_body_field_or_malformed_json_is_400_schema_validation_failed() { + let ctx = setup().await; + ctx.caller("conv_a").await; + assert_rejected( + &ctx, + "conv_a", + r#"{"name":"x","files":[]}"#, + StatusCode::BAD_REQUEST, + "schema_validation_failed", + ) + .await; + assert_rejected( + &ctx, + "conv_a", + r#"not json"#, + StatusCode::BAD_REQUEST, + "schema_validation_failed", + ) + .await; +} + +#[tokio::test] +async fn workspace_errors_are_422_with_distinct_codes() { + let ctx = setup().await; + ctx.caller("conv_a").await; + assert_rejected( + &ctx, + "conv_a", + r#"{"name":"x","workspace":"src/lib"}"#, + StatusCode::UNPROCESSABLE_ENTITY, + "workspace_not_absolute", + ) + .await; + let missing = std::env::temp_dir().join("aionui-runtime-create-routes-missing-7d1e"); + let body = serde_json::json!({ "name": "x", "workspace": missing }).to_string(); + assert_rejected( + &ctx, + "conv_a", + &body, + StatusCode::UNPROCESSABLE_ENTITY, + "workspace_unavailable", + ) + .await; +} + +#[tokio::test] +async fn an_unknown_assistant_is_404_assistant_not_found() { + let ctx = setup().await; + ctx.caller("conv_a").await; + assert_rejected( + &ctx, + "conv_a", + r#"{"name":"x","assistant_id":"nope"}"#, + StatusCode::NOT_FOUND, + "assistant_not_found", + ) + .await; +} + +#[tokio::test] +async fn a_deleted_caller_is_503_transport_unavailable() { + let ctx = setup().await; + ctx.caller("conv_a").await; + let token = ctx.mint(USER, "conv_a"); + ctx.repo.delete(USER, "conv_a").await.unwrap(); + let (status, envelope) = call(&ctx, create_request(USER, "conv_a", Some(&token), r#"{"name":"x"}"#)).await; + assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE, "{envelope}"); + assert_eq!(envelope["error"]["code"], serde_json::json!("transport_unavailable")); +} From 6578c5209db00c79b7b8bffd04770fde45f640ce Mon Sep 17 00:00:00 2001 From: zynx <> Date: Tue, 8 Sep 2026 16:20:48 +0800 Subject: [PATCH 5/6] feat(cli): add aioncore conversation {capabilities,create} for agent-driven conversation creation --- crates/aionui-app/src/cli.rs | 74 ++++- .../src/commands/cmd_capabilities.rs | 20 +- .../src/commands/cmd_conversation.rs | 307 ++++++++++++++++++ .../src/commands/conversation_capabilities.rs | 111 +++++++ crates/aionui-app/src/commands/mod.rs | 3 + crates/aionui-app/src/main.rs | 1 + .../aionui-app/tests/conversation_cli_e2e.rs | 183 +++++++++++ 7 files changed, 696 insertions(+), 3 deletions(-) create mode 100644 crates/aionui-app/src/commands/cmd_conversation.rs create mode 100644 crates/aionui-app/src/commands/conversation_capabilities.rs create mode 100644 crates/aionui-app/tests/conversation_cli_e2e.rs diff --git a/crates/aionui-app/src/cli.rs b/crates/aionui-app/src/cli.rs index ea39878b0..040fcdfaa 100644 --- a/crates/aionui-app/src/cli.rs +++ b/crates/aionui-app/src/cli.rs @@ -117,6 +117,9 @@ pub(crate) enum Command { /// Cross-session messaging: list deliverable conversations and deliver a /// message to one of them. Session(SessionArgs), + /// Agent-facing conversation CLI: create a new conversation for this user + /// that inherits (or overrides) the current conversation's setup. + Conversation(ConversationArgs), /// Agent-facing read-only runtime CLI for THIS conversation's skills. /// Channel A of skill delivery: a normal tool call instead of the /// `[LOAD_SKILL]` text-protocol round trip. @@ -160,6 +163,7 @@ impl Command { Self::Diagnose(_) => "diagnose", Self::Team(_) => "team", Self::Session(_) => "session", + Self::Conversation(_) => "conversation", Self::Skills(_) => "skills", Self::AntigravityHook => "antigravity-hook", Self::McpTeamStdio => "mcp-team-stdio", @@ -244,6 +248,20 @@ pub(crate) enum SessionCommand { Unknown(Vec), } +#[derive(Args, Debug, Clone)] +pub(crate) struct ConversationArgs { + #[command(subcommand)] + pub command: ConversationCommand, +} + +#[derive(Subcommand, Debug, Clone)] +pub(crate) enum ConversationCommand { + Capabilities, + Create, + #[command(external_subcommand)] + Unknown(Vec), +} + #[derive(Args, Debug, Clone)] pub(crate) struct SkillsArgs { #[command(subcommand)] @@ -819,8 +837,9 @@ mod tests { use clap::error::ErrorKind; use super::{ - Cli, Command, ConfigArgs, ConfigCommand, ManagedResourcesModeArg, PrepareManagedResourcesArgs, SecretArgs, - SecretCommand, SessionCommand, TeamCommand, UserArgs, UserCommand, UserStatusArgs, + Cli, Command, ConfigArgs, ConfigCommand, ConversationCommand, ManagedResourcesModeArg, + PrepareManagedResourcesArgs, SecretArgs, SecretCommand, SessionCommand, TeamCommand, UserArgs, UserCommand, + UserStatusArgs, }; #[test] @@ -1102,6 +1121,57 @@ mod tests { ); } + fn parse_conversation_command(argv: &[&str]) -> Option { + let cli = Cli::try_parse_from(argv).ok()?; + let Some(Command::Conversation(args)) = cli.command else { + return None; + }; + match args.command { + ConversationCommand::Unknown(_) => None, + command => Some(command), + } + } + + /// Every tool in the conversation registry advertises a `cli_command`, and + /// that path is printed by `conversation capabilities` and copied into the + /// auto-inject skill. A registry entry with no wired subcommand sends + /// agents at a command that can only fail. + #[test] + fn every_registry_tool_has_a_wired_conversation_cli_subcommand() { + for tool in aionui_api_types::conversation_tool_descriptors() { + let mut argv = vec!["aioncore", "conversation"]; + argv.extend(tool.cli_command.iter().map(String::as_str)); + assert!( + parse_conversation_command(&argv).is_some(), + "`{}` is advertised by tool {} but is not wired into ConversationCommand", + argv[1..].join(" "), + tool.name + ); + } + } + + /// The reverse direction: every wired data subcommand has a descriptor, so + /// `capabilities` and the skill can describe it. + #[test] + fn every_wired_conversation_subcommand_has_a_registry_descriptor() { + // `create` is the only data subcommand today; add a line per new one. + assert!(matches!( + parse_conversation_command(&["aioncore", "conversation", "create"]), + Some(ConversationCommand::Create) + )); + assert!( + aionui_api_types::tool_name_for_conversation_cli_path(&["create".to_owned()]).is_some(), + "`conversation create` is wired but has no descriptor" + ); + } + + #[test] + fn conversation_cli_accepts_capabilities_and_reports_unknown_paths() { + assert!(parse_conversation_command(&["aioncore", "conversation", "capabilities"]).is_some()); + assert!(parse_conversation_command(&["aioncore", "conversation", "definitely-not-a-command"]).is_none()); + assert!(parse_conversation_command(&["aioncore", "conversation"]).is_none()); + } + /// Every tool in the shared Team registry advertises a `cli_command`, and /// that path is printed by `team capabilities`, `team help`, and the CLI /// transport prompt. A tool present in the registry but missing from diff --git a/crates/aionui-app/src/commands/cmd_capabilities.rs b/crates/aionui-app/src/commands/cmd_capabilities.rs index 8cf80e767..d6ea407a2 100644 --- a/crates/aionui-app/src/commands/cmd_capabilities.rs +++ b/crates/aionui-app/src/commands/cmd_capabilities.rs @@ -122,6 +122,22 @@ fn data() -> Value { "per_user_feature_switch": "list and send-message answer feature_disabled while the user has cross-session messaging switched off; capabilities stays available because it reads no conversation data" } }, + { + "name": "conversation", + "mode": "conversation-create", + "description": "Create a new conversation for this user that inherits (or overrides) this conversation's workspace and assistant. Does not send, open, or switch to it.", + "contract": "agent-facing-conversation-cli", + "contract_command": "conversation capabilities", + "invocation": "aioncore conversation capabilities", + "runtime_required": ["AIONUI_BASE_URL", "AIONUI_CONVERSATION_ID", "AIONUI_USER_ID", "AIONUI_RUNTIME_TOKEN"], + "runtime_free_commands": ["conversation capabilities"], + "safety": { + "can_write": true, + "runtime_token_required_for_context_and_call": true, + "does_not_accept_identity_authority_from_stdin": true, + "refuses_team_callers": true + } + }, { "name": "skills", "mode": "read-only", @@ -190,7 +206,8 @@ mod tests { use super::*; use crate::cli::Cli; use crate::commands::{ - config_capabilities, diagnose_capabilities, session_capabilities, skills_capabilities, team_capabilities, + config_capabilities, conversation_capabilities, diagnose_capabilities, session_capabilities, + skills_capabilities, team_capabilities, }; /// `capabilities` is its own entrypoint — `data()` declares it under @@ -272,6 +289,7 @@ mod tests { ("diagnose", diagnose_capabilities::data()), ("team", team_capabilities::data()), ("session", session_capabilities::data()), + ("conversation", conversation_capabilities::data()), ("skills", skills_capabilities::data()), ] { let entry = domains diff --git a/crates/aionui-app/src/commands/cmd_conversation.rs b/crates/aionui-app/src/commands/cmd_conversation.rs new file mode 100644 index 000000000..d8c60d67d --- /dev/null +++ b/crates/aionui-app/src/commands/cmd_conversation.rs @@ -0,0 +1,307 @@ +//! `aioncore conversation` — the agent-facing conversation CLI. +//! +//! Shaped after `cmd_session.rs` and deliberately NOT sharing code with it: the +//! two command families have different envelope and error-code types, and a +//! generic layer to save a few dozen lines is not worth it until a third family +//! appears. + +use std::ffi::OsString; +use std::io::{self, Read, Write}; +use std::process::ExitCode; + +use aionui_api_types::{ + ConversationCliEnvelope, ConversationToolErrorCode, ConversationToolErrorPayload, ConversationToolName, +}; +use serde_json::{Value, json}; + +use crate::cli::{ConversationArgs, ConversationCommand}; +use crate::commands::conversation_capabilities; + +const ENV_BASE_URL: &str = "AIONUI_BASE_URL"; +const ENV_USER_ID: &str = "AIONUI_USER_ID"; +const ENV_CONVERSATION_ID: &str = "AIONUI_CONVERSATION_ID"; +const ENV_RUNTIME_TOKEN: &str = "AIONUI_RUNTIME_TOKEN"; + +pub(crate) async fn run_conversation(args: ConversationArgs) -> ExitCode { + match run_conversation_inner(args).await { + Ok(()) => ExitCode::SUCCESS, + Err(code) => code, + } +} + +async fn run_conversation_inner(args: ConversationArgs) -> Result<(), ExitCode> { + match args.command { + // Static contract; needs no runtime env. + ConversationCommand::Capabilities => print_json(&ConversationCliEnvelope::success( + conversation_capabilities::data(), + Some("conversation capabilities".to_owned()), + )), + ConversationCommand::Create => create().await, + ConversationCommand::Unknown(path) => { + Err(unknown_command("conversation", path, "unknown conversation command")) + } + } +} + +async fn create() -> Result<(), ExitCode> { + let command = "conversation create"; + let env = runtime_env(command)?; + let body = read_stdin_json_object(command, ConversationToolName::ConversationCreate)?; + let url = format!( + "{}/api/runtime/conversations/create", + env.base_url.trim_end_matches('/') + ); + let response = reqwest::Client::new() + .post(url) + .headers(env.headers(command)?) + .json(&body) + .send() + .await + .map_err(|error| runtime_error(command, "CONVERSATION_CLI_HTTP_BRIDGE_FAILED", error.to_string()))?; + print_response(command, response).await +} + +struct RuntimeEnv { + base_url: String, + user_id: String, + conversation_id: String, + runtime_token: String, +} + +impl RuntimeEnv { + /// Fallible on purpose: a malformed `AIONUI_*` value must come back as an + /// envelope, not a panic (same reasoning as `cmd_session.rs`). + fn headers(&self, command: &str) -> Result { + let mut headers = reqwest::header::HeaderMap::new(); + for (name, value) in [ + ("x-aionui-user-id", &self.user_id), + ("x-aionui-conversation-id", &self.conversation_id), + ("x-aionui-runtime-token", &self.runtime_token), + ] { + let parsed = value.parse().map_err(|_| { + // The NAME only — a token must never reach stdout or stderr. + runtime_error( + command, + "CONVERSATION_CLI_HEADER_INVALID", + format!("environment variable for {name} is not a valid header value"), + ) + })?; + headers.insert(name, parsed); + } + Ok(headers) + } +} + +fn runtime_env(command: &str) -> Result { + Ok(RuntimeEnv { + base_url: required_env(command, ENV_BASE_URL)?, + user_id: required_env(command, ENV_USER_ID)?, + conversation_id: required_env(command, ENV_CONVERSATION_ID)?, + runtime_token: required_env(command, ENV_RUNTIME_TOKEN)?, + }) +} + +fn required_env(command: &str, name: &'static str) -> Result { + std::env::var(name).map_err(|_| { + print_failure( + command, + "CONVERSATION_CLI_ENV_MISSING", + ConversationToolErrorPayload::new( + ConversationToolErrorCode::TransportUnavailable, + format!("missing required environment variable: {name}"), + ), + ) + }) +} + +fn read_stdin_json_object(command: &str, tool: ConversationToolName) -> Result { + let mut input = String::new(); + io::stdin().read_to_string(&mut input).map_err(|error| { + print_failure( + command, + "CONVERSATION_CLI_STDIN_READ_FAILED", + ConversationToolErrorPayload::new(ConversationToolErrorCode::SchemaValidationFailed, error.to_string()), + ) + })?; + let value = if input.trim().is_empty() { + json!({}) + } else { + serde_json::from_str(&input).map_err(|error| { + print_failure( + command, + "CONVERSATION_CLI_STDIN_JSON_INVALID", + ConversationToolErrorPayload::new(ConversationToolErrorCode::SchemaValidationFailed, error.to_string()), + ) + })? + }; + validate_against_descriptor(command, tool, value) +} + +/// Validate stdin against the registry descriptor before spending a round trip, +/// so a typo comes back as `schema_validation_failed` locally. +fn validate_against_descriptor(command: &str, tool: ConversationToolName, value: Value) -> Result { + let Some(object) = value.as_object() else { + return Err(print_failure( + command, + "CONVERSATION_CLI_SCHEMA_VALIDATION_FAILED", + ConversationToolErrorPayload::new( + ConversationToolErrorCode::SchemaValidationFailed, + "stdin JSON must be an object", + ), + )); + }; + let descriptor = + aionui_api_types::conversation_tool_descriptor(tool.as_str()).expect("descriptor for canonical tool"); + let properties = descriptor.input_schema["properties"] + .as_object() + .cloned() + .unwrap_or_default(); + for key in object.keys() { + if !properties.contains_key(key) { + return Err(print_failure( + command, + "CONVERSATION_CLI_SCHEMA_VALIDATION_FAILED", + ConversationToolErrorPayload::new( + ConversationToolErrorCode::SchemaValidationFailed, + format!("unknown stdin field: {key}"), + ) + .with_details(json!({ "expected_schema": descriptor.input_schema })), + )); + } + } + if let Some(required) = descriptor.input_schema["required"].as_array() { + for key in required.iter().filter_map(Value::as_str) { + if !object.contains_key(key) { + return Err(print_failure( + command, + "CONVERSATION_CLI_SCHEMA_VALIDATION_FAILED", + ConversationToolErrorPayload::new( + ConversationToolErrorCode::SchemaValidationFailed, + format!("missing required stdin field: {key}"), + ) + .with_details(json!({ "expected_schema": descriptor.input_schema })), + )); + } + } + } + Ok(value) +} + +async fn print_response(command: &str, response: reqwest::Response) -> Result<(), ExitCode> { + let status = response.status(); + let text = response + .text() + .await + .map_err(|error| runtime_error(command, "CONVERSATION_CLI_HTTP_RESPONSE_FAILED", error.to_string()))?; + if !status.is_success() { + eprintln!( + "CONVERSATION_CLI_HTTP_STATUS_ERROR command={command} status={status}: runtime bridge returned non-success status" + ); + println!("{text}"); + return Err(ExitCode::from(3)); + } + println!("{text}"); + Ok(()) +} + +fn runtime_error(command: &str, code: &'static str, message: String) -> ExitCode { + print_failure( + command, + code, + ConversationToolErrorPayload::new(ConversationToolErrorCode::TransportUnavailable, message), + ) +} + +fn unknown_command(prefix: &str, path: Vec, message: &'static str) -> ExitCode { + let suffix = path + .into_iter() + .map(|part| part.to_string_lossy().into_owned()) + .collect::>() + .join(" "); + let command = if suffix.is_empty() { + prefix.to_owned() + } else { + format!("{prefix} {suffix}") + }; + print_failure( + &command, + "CONVERSATION_CLI_UNKNOWN_COMMAND", + ConversationToolErrorPayload::new(ConversationToolErrorCode::SchemaValidationFailed, message), + ) +} + +fn print_failure(command: &str, stderr_code: &'static str, error: ConversationToolErrorPayload) -> ExitCode { + eprintln!("{stderr_code} command={command}: {}", error.message); + let _ = print_json(&ConversationCliEnvelope::::failure( + error, + Some(command.to_owned()), + )); + ExitCode::from(2) +} + +fn print_json(value: &T) -> Result<(), ExitCode> { + let rendered = serde_json::to_string_pretty(value).map_err(|_| ExitCode::from(1))?; + let mut stdout = io::stdout(); + stdout + .write_all(rendered.as_bytes()) + .and_then(|_| stdout.write_all(b"\n")) + .map_err(|_| ExitCode::from(1)) +} + +#[cfg(test)] +mod tests { + use aionui_api_types::tool_name_for_conversation_cli_path; + + use super::*; + + fn env(user_id: &str, conversation_id: &str, runtime_token: &str) -> RuntimeEnv { + RuntimeEnv { + base_url: "http://127.0.0.1:1".to_owned(), + user_id: user_id.to_owned(), + conversation_id: conversation_id.to_owned(), + runtime_token: runtime_token.to_owned(), + } + } + + #[test] + fn well_formed_runtime_env_produces_all_three_headers() { + let headers = env("user_1", "conv_1", "tok_1").headers("conversation create").unwrap(); + assert_eq!(headers.get("x-aionui-user-id").unwrap(), "user_1"); + assert_eq!(headers.get("x-aionui-conversation-id").unwrap(), "conv_1"); + assert_eq!(headers.get("x-aionui-runtime-token").unwrap(), "tok_1"); + } + + #[test] + fn a_malformed_env_value_yields_an_exit_code_instead_of_panicking() { + for broken in ["bad\nvalue", "bad\rvalue", "bad\0value"] { + assert!(env(broken, "conv_1", "tok_1").headers("conversation create").is_err()); + assert!(env("user_1", broken, "tok_1").headers("conversation create").is_err()); + assert!(env("user_1", "conv_1", broken).headers("conversation create").is_err()); + } + } + + #[test] + fn create_requires_name_and_rejects_unknown_fields_and_non_objects() { + let tool = ConversationToolName::ConversationCreate; + assert!(validate_against_descriptor("conversation create", tool, json!({ "workspace": "/tmp" })).is_err()); + assert!(validate_against_descriptor("conversation create", tool, json!({ "name": "x", "files": [] })).is_err()); + assert!(validate_against_descriptor("conversation create", tool, json!(["name"])).is_err()); + assert!(validate_against_descriptor("conversation create", tool, json!({ "name": "x" })).is_ok()); + assert!( + validate_against_descriptor( + "conversation create", + tool, + json!({ "name": "x", "workspace": "/abs", "assistant_id": "asst" }) + ) + .is_ok() + ); + } + + #[test] + fn the_cli_path_this_file_hard_codes_is_the_registrys() { + assert_eq!( + tool_name_for_conversation_cli_path(&["create".to_owned()]), + Some(ConversationToolName::ConversationCreate) + ); + } +} diff --git a/crates/aionui-app/src/commands/conversation_capabilities.rs b/crates/aionui-app/src/commands/conversation_capabilities.rs new file mode 100644 index 000000000..fbd34e077 --- /dev/null +++ b/crates/aionui-app/src/commands/conversation_capabilities.rs @@ -0,0 +1,111 @@ +use aionui_api_types::{CONVERSATION_TOOLS_SCHEMA_VERSION, conversation_tool_descriptors}; +use serde_json::{Value, json}; + +pub(crate) fn data() -> Value { + let tools = conversation_tool_descriptors() + .into_iter() + .map(|tool| { + json!({ + "name": tool.name, + "cli_command": tool.cli_command, + "description": tool.description, + "when": tool.when, + "input_summary": tool.input_summary, + "stdin_json_schema": tool.input_schema, + }) + }) + .collect::>(); + json!({ + "schema_version": CONVERSATION_TOOLS_SCHEMA_VERSION, + "contract": "agent-facing-conversation-cli", + "commands": { + "capabilities": { "runtime_env_required": [] }, + "create": { "runtime_env_required": ["AIONUI_BASE_URL", "AIONUI_USER_ID", "AIONUI_CONVERSATION_ID", "AIONUI_RUNTIME_TOKEN"] } + }, + "output_envelope": { + "success": "boolean", + "data": "object when success=true: { id, name, workspace, assistant? { id, name, backend } }", + "error": "object when success=false: { code, message, details? }", + "meta": { "schema_version": CONVERSATION_TOOLS_SCHEMA_VERSION } + }, + "semantics": { + "synchronous": "when success=true the conversation exists and can immediately be addressed by id", + "does_not": ["send a first message", "open the conversation", "switch the user's current conversation"], + "inheritance": "workspace and assistant default to THIS conversation's; the new conversation is a clean instance of the same assistant, not a copy of this conversation's runtime state" + }, + "tools": tools, + "errors": [ + "caller_is_team", + "workspace_not_absolute", + "workspace_unavailable", + "assistant_not_found", + "assistant_disabled", + "assistant_model_unresolved", + "runtime_auth_failed", + "schema_validation_failed", + "transport_unavailable" + ] + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn capabilities_lists_every_registry_tool_and_its_cli_path() { + let data = data(); + let tools = data["tools"].as_array().unwrap(); + assert_eq!(tools.len(), conversation_tool_descriptors().len()); + for descriptor in conversation_tool_descriptors() { + let entry = tools + .iter() + .find(|tool| tool["name"] == json!(descriptor.name)) + .unwrap_or_else(|| panic!("{} missing from capabilities", descriptor.name)); + assert_eq!( + entry["cli_command"], + serde_json::to_value(&descriptor.cli_command).unwrap() + ); + assert!(entry["stdin_json_schema"].is_object(), "{}", descriptor.name); + let command = descriptor.cli_command.join(" "); + assert!( + data["commands"][&command].is_object(), + "`{command}` has no runtime_env entry" + ); + } + } + + #[test] + fn capabilities_declares_that_it_needs_no_runtime_env() { + assert_eq!(data()["commands"]["capabilities"]["runtime_env_required"], json!([])); + } + + #[test] + fn every_error_code_the_service_can_return_is_documented() { + use aionui_api_types::ConversationToolErrorCode as Code; + let documented: Vec = data()["errors"] + .as_array() + .unwrap() + .iter() + .map(|value| value.as_str().unwrap().to_owned()) + .collect(); + for code in [ + Code::CallerIsTeam, + Code::WorkspaceNotAbsolute, + Code::WorkspaceUnavailable, + Code::AssistantNotFound, + Code::AssistantDisabled, + Code::AssistantModelUnresolved, + Code::RuntimeAuthFailed, + Code::SchemaValidationFailed, + Code::TransportUnavailable, + ] { + assert!( + documented.contains(&code.as_str().to_owned()), + "{} is undocumented", + code.as_str() + ); + } + assert_eq!(documented.len(), 9, "no stray codes: {documented:?}"); + } +} diff --git a/crates/aionui-app/src/commands/mod.rs b/crates/aionui-app/src/commands/mod.rs index 36b4f8c15..fb3019411 100644 --- a/crates/aionui-app/src/commands/mod.rs +++ b/crates/aionui-app/src/commands/mod.rs @@ -6,6 +6,7 @@ pub(crate) mod cmd_antigravity_hook; pub(crate) mod cmd_capabilities; pub(crate) mod cmd_config; +pub(crate) mod cmd_conversation; pub(crate) mod cmd_diagnose; pub(crate) mod cmd_doctor; pub(crate) mod cmd_prepare_managed_resources; @@ -17,6 +18,7 @@ pub(crate) mod cmd_team; pub(crate) mod cmd_team_stdio; pub(crate) mod cmd_user; pub(crate) mod config_capabilities; +pub(crate) mod conversation_capabilities; pub(crate) mod diagnose_capabilities; pub(crate) mod error; pub(crate) mod session_capabilities; @@ -26,6 +28,7 @@ pub(crate) mod team_capabilities; pub(crate) use cmd_antigravity_hook::run_antigravity_hook; pub(crate) use cmd_capabilities::run_capabilities; pub(crate) use cmd_config::run_config; +pub(crate) use cmd_conversation::run_conversation; pub(crate) use cmd_diagnose::run_diagnose; pub(crate) use cmd_doctor::run_doctor; pub(crate) use cmd_prepare_managed_resources::run_prepare_managed_resources; diff --git a/crates/aionui-app/src/main.rs b/crates/aionui-app/src/main.rs index e7d76e2d3..ae3fc72a2 100644 --- a/crates/aionui-app/src/main.rs +++ b/crates/aionui-app/src/main.rs @@ -83,6 +83,7 @@ async fn async_main(merged_path: String, cli: Cli) -> Result Ok(commands::run_diagnose(args).await), Some(Command::Team(args)) => Ok(commands::run_team(args).await), Some(Command::Session(args)) => Ok(commands::run_session(args).await), + Some(Command::Conversation(args)) => Ok(commands::run_conversation(args).await), Some(Command::Skills(args)) => Ok(commands::run_skills(args).await), Some(Command::AntigravityHook) => Ok(commands::run_antigravity_hook().await), Some(Command::McpTeamStdio) => Ok(commands::run_team_stdio().await), diff --git a/crates/aionui-app/tests/conversation_cli_e2e.rs b/crates/aionui-app/tests/conversation_cli_e2e.rs new file mode 100644 index 000000000..be86904bc --- /dev/null +++ b/crates/aionui-app/tests/conversation_cli_e2e.rs @@ -0,0 +1,183 @@ +//! E2E for the agent-facing `aioncore conversation` CLI: the real binary against +//! the real app router served on a loopback port. Covers the success path, the +//! `caller_is_team` rejection (exit 3 + envelope), and the runtime-free +//! `capabilities` / local-contract errors (exit 2). + +mod common; + +use std::process::Stdio; + +use aionui_ai_agent::{RuntimeTokenScope, TEAM_RUNTIME_TOKEN_SESSION_GENERATION}; +use common::build_app; +use tokio::io::AsyncWriteExt; +use tokio::net::TcpListener; +use tokio::process::Command; + +const USER: &str = "system_default_user"; + +fn conversation_command() -> Command { + let mut command = Command::new(env!("CARGO_BIN_EXE_aioncore")); + command.arg("conversation"); + command +} + +async fn serve(app: axum::Router) -> (String, tokio::task::JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let handle = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + (format!("http://{addr}"), handle) +} + +async fn insert_caller(services: &aionui_app::AppServices, id: &str, extra: &str) { + sqlx::query( + "INSERT INTO conversations (id, user_id, name, type, extra, status, created_at, updated_at) + VALUES (?, ?, 'caller', 'acp', ?, 'finished', 1, 1)", + ) + .bind(id) + .bind(USER) + .bind(extra) + .execute(services.database.pool()) + .await + .unwrap(); +} + +async fn run_create(base_url: &str, conversation_id: &str, token: &str, stdin: &str) -> std::process::Output { + let mut child = conversation_command() + .arg("create") + .env("AIONUI_BASE_URL", base_url) + .env("AIONUI_USER_ID", USER) + .env("AIONUI_CONVERSATION_ID", conversation_id) + .env("AIONUI_RUNTIME_TOKEN", token) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + child.stdin.as_mut().unwrap().write_all(stdin.as_bytes()).await.unwrap(); + drop(child.stdin.take()); + child.wait_with_output().await.unwrap() +} + +#[tokio::test] +async fn create_through_the_binary_returns_the_new_conversation_id() { + let (app, services) = build_app().await; + let workspace = std::env::temp_dir().join("aionui-conversation-cli-e2e"); + std::fs::create_dir_all(&workspace).unwrap(); + insert_caller( + &services, + "conv-cli-caller", + &serde_json::json!({ "workspace": workspace, "backend": "claude" }).to_string(), + ) + .await; + let token = services + .runtime_token_service + .issue( + USER, + "conv-cli-caller", + TEAM_RUNTIME_TOKEN_SESSION_GENERATION, + [RuntimeTokenScope::ConversationHelper], + ) + .token; + let (base_url, handle) = serve(app).await; + + let output = run_create(&base_url, "conv-cli-caller", &token, r#"{ "name": "重构鉴权模块" }"#).await; + handle.abort(); + + assert!( + output.status.success(), + "stdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let stdout: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(stdout["success"], true); + assert_eq!(stdout["meta"]["command"], "conversation create"); + assert_eq!(stdout["data"]["name"], "重构鉴权模块"); + assert_eq!( + stdout["data"]["workspace"], + serde_json::json!(workspace.to_string_lossy()) + ); + assert!(stdout["data"]["id"].as_str().is_some_and(|id| !id.is_empty())); + services.database.close().await; +} + +#[tokio::test] +async fn a_team_caller_gets_exit_3_and_the_caller_is_team_envelope() { + let (app, services) = build_app().await; + insert_caller(&services, "conv-cli-team", r#"{"teamId":"team-1"}"#).await; + let token = services + .runtime_token_service + .issue( + USER, + "conv-cli-team", + TEAM_RUNTIME_TOKEN_SESSION_GENERATION, + [RuntimeTokenScope::ConversationHelper], + ) + .token; + let (base_url, handle) = serve(app).await; + + let output = run_create(&base_url, "conv-cli-team", &token, r#"{ "name": "x" }"#).await; + handle.abort(); + + assert_eq!(output.status.code(), Some(3)); + let stdout: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(stdout["success"], false); + assert_eq!(stdout["error"]["code"], "caller_is_team"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("CONVERSATION_CLI_HTTP_STATUS_ERROR command=conversation create"), + "{stderr}" + ); + assert!(!stderr.contains(&token), "the token must never reach stderr"); + services.database.close().await; +} + +#[tokio::test] +async fn capabilities_needs_no_runtime_env() { + let output = conversation_command() + .arg("capabilities") + .env_remove("AIONUI_BASE_URL") + .env_remove("AIONUI_USER_ID") + .env_remove("AIONUI_CONVERSATION_ID") + .env_remove("AIONUI_RUNTIME_TOKEN") + .output() + .await + .unwrap(); + assert!(output.status.success()); + assert!(output.stderr.is_empty()); + let stdout: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(stdout["data"]["contract"], "agent-facing-conversation-cli"); + assert_eq!(stdout["data"]["tools"][0]["cli_command"], serde_json::json!(["create"])); +} + +#[tokio::test] +async fn missing_runtime_env_is_exit_2_transport_unavailable() { + let output = conversation_command() + .arg("create") + .env_remove("AIONUI_BASE_URL") + .env_remove("AIONUI_USER_ID") + .env_remove("AIONUI_CONVERSATION_ID") + .env_remove("AIONUI_RUNTIME_TOKEN") + .stdin(Stdio::null()) + .output() + .await + .unwrap(); + assert_eq!(output.status.code(), Some(2)); + let stdout: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(stdout["error"]["code"], "transport_unavailable"); + assert!( + String::from_utf8_lossy(&output.stderr).starts_with("CONVERSATION_CLI_ENV_MISSING command=conversation create") + ); +} + +#[tokio::test] +async fn an_unknown_subcommand_is_a_structured_envelope_not_clap_help() { + let output = conversation_command().arg("delete").output().await.unwrap(); + assert_eq!(output.status.code(), Some(2)); + let stdout: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(stdout["error"]["code"], "schema_validation_failed"); + assert_eq!(stdout["meta"]["command"], "conversation delete"); + assert!(!String::from_utf8_lossy(&output.stderr).contains("Usage:")); +} From c3fcd2bdc5d02a788405f176bb345d9ca8efa244 Mon Sep 17 00:00:00 2001 From: zynx <> Date: Tue, 8 Sep 2026 16:22:11 +0800 Subject: [PATCH 6/6] feat(skills): add conversation-create auto-inject skill --- .../auto-inject/conversation-create/SKILL.md | 57 +++++++ crates/aionui-app/tests/conversation_skill.rs | 159 ++++++++++++++++++ 2 files changed, 216 insertions(+) create mode 100644 crates/aionui-app/assets/builtin-skills/auto-inject/conversation-create/SKILL.md create mode 100644 crates/aionui-app/tests/conversation_skill.rs diff --git a/crates/aionui-app/assets/builtin-skills/auto-inject/conversation-create/SKILL.md b/crates/aionui-app/assets/builtin-skills/auto-inject/conversation-create/SKILL.md new file mode 100644 index 000000000..58ade6164 --- /dev/null +++ b/crates/aionui-app/assets/builtin-skills/auto-inject/conversation-create/SKILL.md @@ -0,0 +1,57 @@ +--- +name: conversation-create +description: Create a new conversation for this user with the bundled aioncore CLI. Use when the user asks to open, start, or spin up a new conversation. +--- + +# Conversation Create Skill + +Create a new conversation for this user with the bundled agent-facing CLI. By +default the new conversation reuses this conversation's working directory and +assistant; you can point it at another directory or another assistant instead. + +## Rules + +1. Use this ONLY when the user asked for a new conversation. Never create one + on your own initiative. +2. `name` is required — pick a short name that describes the task, in the + user's language. +3. Omit `workspace` to reuse this conversation's directory; pass an absolute + path of an existing directory to use another one. +4. Omit `assistant_id` to reuse this conversation's assistant. To use another + assistant, get its id from `"$AIONUI_HELPER_BIN" config assistants list` + first. +5. Never pass, inline, export, echo, or set any `AIONUI_...` environment + variable. Call `"$AIONUI_HELPER_BIN" conversation ...` directly and pass the + payload through a stdin heredoc. Do not write payload JSON files to disk. +6. If this conversation belongs to a team, do not use this skill. +7. Creating does NOT send anything to the new conversation, does NOT open it, + and does NOT switch the user's current conversation. Report exactly what + happened: "created" — nothing more. +8. On failure, report the error from stderr/stdout in plain prose; never claim + the conversation was created. + +## Creating a conversation + +```bash +"$AIONUI_HELPER_BIN" conversation create <<'JSON' +{ + "name": "重构鉴权模块", + "workspace": "/absolute/path/to/repo", + "assistant_id": "asst_xxx" +} +JSON +``` + +Only `name` is required; drop `workspace` / `assistant_id` to inherit. + +## Result + +`data.id` is the new conversation's id; `data.name` and `data.workspace` echo +what was persisted; `data.assistant` (when present) is `{ id, name, backend }`. +When `success` is `true` the conversation already exists. + +## Exact schemas + +```bash +"$AIONUI_HELPER_BIN" conversation capabilities +``` diff --git a/crates/aionui-app/tests/conversation_skill.rs b/crates/aionui-app/tests/conversation_skill.rs new file mode 100644 index 000000000..16e8b2bf2 --- /dev/null +++ b/crates/aionui-app/tests/conversation_skill.rs @@ -0,0 +1,159 @@ +//! Content guard for the `conversation-create` auto-inject skill. +//! +//! The skill is the only channel through which an ordinary conversation's agent +//! learns `aioncore conversation` exists. If its body drifts from the wired CLI +//! the feature is unreachable while every unit test stays green — so the +//! commands and rules it documents are asserted here. + +use std::path::PathBuf; + +fn skill_body() -> String { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("assets/builtin-skills/auto-inject/conversation-create/SKILL.md"); + std::fs::read_to_string(&path).unwrap_or_else(|error| panic!("read {}: {error}", path.display())) +} + +/// Whitespace-collapsed body, so prose assertions test what the skill SAYS +/// rather than where a line happened to wrap. +fn skill_prose() -> String { + skill_body().split_whitespace().collect::>().join(" ") +} + +#[test] +fn the_skill_declares_the_name_the_resolver_will_discover() { + let body = skill_body(); + assert!(body.starts_with("---\n"), "front matter is required"); + assert!(body.contains("name: conversation-create"), "{body}"); + assert!(body.contains("description:"), "{body}"); +} + +#[test] +fn the_skill_stays_within_sixty_lines() { + let lines = skill_body().lines().count(); + assert!(lines <= 60, "skill is {lines} lines; the design caps it at 60"); +} + +/// Every subcommand the registry advertises must appear in the body: the skill +/// has to be self-contained enough to create without running `capabilities`. +#[test] +fn the_skill_documents_every_wired_conversation_subcommand() { + let body = skill_body(); + for descriptor in aionui_api_types::conversation_tool_descriptors() { + let command = format!( + "\"$AIONUI_HELPER_BIN\" conversation {}", + descriptor.cli_command.join(" ") + ); + assert!( + body.contains(&command), + "the skill must document `{command}` (advertised by {})", + descriptor.name + ); + } +} + +#[test] +fn the_skill_carries_a_copyable_create_example_with_every_field() { + let body = skill_body(); + assert!(body.contains("<<'JSON'"), "{body}"); + assert!(body.contains("\"name\":"), "{body}"); + assert!(body.contains("\"workspace\":"), "{body}"); + assert!(body.contains("\"assistant_id\":"), "{body}"); +} + +#[test] +fn the_skill_states_the_non_negotiable_rules() { + let body = skill_prose(); + // Rule 1 — only on request. + assert!(body.contains("Never create one on your own initiative"), "{body}"); + // Rule 2 — name required, in the user's language. + assert!(body.contains("`name` is required"), "{body}"); + assert!(body.contains("in the user's language"), "{body}"); + // Rules 3/4 — omit to inherit; assistant ids come from the config family. + assert!( + body.contains("Omit `workspace` to reuse this conversation's directory"), + "{body}" + ); + assert!( + body.contains("Omit `assistant_id` to reuse this conversation's assistant"), + "{body}" + ); + assert!(body.contains("\"$AIONUI_HELPER_BIN\" config assistants list"), "{body}"); + // Rule 5 — never touch AIONUI_* env vars. + assert!( + body.contains("Never pass, inline, export, echo, or set any `AIONUI_...` environment variable"), + "{body}" + ); + // Rule 6 — team conversations are out of scope. + assert!( + body.contains("If this conversation belongs to a team, do not use this skill"), + "{body}" + ); + // Rule 7 — creating is only creating. + assert!( + body.contains("does NOT send anything to the new conversation"), + "{body}" + ); + assert!(body.contains("does NOT open it"), "{body}"); + assert!( + body.contains("does NOT switch the user's current conversation"), + "{body}" + ); + // Rule 8 — honest failure reporting. + assert!(body.contains("never claim the conversation was created"), "{body}"); +} + +/// The two skills must not reference each other; composition is the agent's +/// own reasoning. +#[test] +fn the_skill_does_not_reference_the_session_message_family() { + let body = skill_body(); + for forbidden in [ + "session send-message", + "session-message", + "session capabilities", + "@@", + "[[AION_SESSION", + ] { + assert!(!body.contains(forbidden), "the skill must not mention {forbidden}"); + } +} + +#[test] +fn the_skill_points_at_capabilities_only_as_a_fallback() { + let body = skill_body(); + let create_at = body + .find("\n\"$AIONUI_HELPER_BIN\" conversation create") + .expect("the skill must carry a copyable create command"); + let capabilities_at = body + .find("\n\"$AIONUI_HELPER_BIN\" conversation capabilities") + .expect("the skill must carry a copyable capabilities fallback command"); + assert!( + create_at < capabilities_at, + "create must precede the capabilities fallback" + ); +} + +#[test] +fn the_skill_does_not_suggest_writing_payload_files_or_foreign_tooling() { + let body = skill_body(); + for forbidden in ["python3", "aionui_api.py", "curl", "lsof", "netstat"] { + assert!(!body.contains(forbidden), "the skill must not mention {forbidden}"); + } +} + +/// The session-message skill is deliberately frozen: this feature must not +/// have touched it. +#[test] +fn the_session_message_skill_still_does_not_mention_conversation_create() { + let path = + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("assets/builtin-skills/auto-inject/session-message/SKILL.md"); + let body = std::fs::read_to_string(path).unwrap(); + assert!( + !body.contains("conversation create"), + "session-message/SKILL.md must stay untouched" + ); + assert!( + !body.contains("conversation-create"), + "session-message/SKILL.md must stay untouched" + ); +}