feat: add declarative Feature chat invocations and typed composers

This commit is contained in:
2026-10-05 18:44:41 +09:00
parent cc97987482
commit f7783b3d2a
69 changed files with 10065 additions and 657 deletions
+1
View File
@@ -14,6 +14,7 @@ json-schema = ["dep:schemars"]
schemars = { workspace = true, optional = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
thiserror = { workspace = true }
sha2.workspace = true
tokio = { workspace = true, features = ["io-util"], optional = true }
ts-rs = { version = "12.0.1", optional = true }
+949
View File
@@ -0,0 +1,949 @@
//! Declarative chat Feature invocation contract.
//!
//! The input spelling is deliberately bounded and unambiguous:
//! `/name(arg=value, "positional value")`. Parentheses terminate arguments,
//! so following prose is never consumed. Strings use JSON-style quoting and
//! escaping. Unquoted values are limited to booleans, integers, enum-like words,
//! and paths without comma/parenthesis/whitespace. This module parses input for
//! clients and validates the structured payload again at the host boundary.
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
/// UTF-8 byte bound shared by input validation and durable recovery keys.
pub const MAX_FEATURE_INVOCATION_ID_BYTES: usize = 128;
/// Installed descriptors must fit a public completion entry without truncation.
pub const MAX_FEATURE_INVOCATION_DESCRIPTOR_BYTES: usize = 32 * 1024;
/// Source-qualified identity of one invocation contribution, for example
/// `builtin:attachments/attach`. Display names and aliases are never authority.
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct FeatureInvocationIdentity(pub String);
impl fmt::Display for FeatureInvocationIdentity {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum FeatureInvocationSyntax {
/// `/name(value, key=value)` with JSON-style quoted strings and `)` as the
/// explicit boundary before following natural language.
Parenthesized,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum InvocationArgumentType {
String,
Integer,
Boolean,
Enum {
values: Vec<String>,
},
/// A Worker-readable path. This does not grant filesystem authority.
WorkerFile,
/// A resource selected and staged by the client. It must never be resolved
/// as a path on the Worker host.
ClientFile,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum InvocationCompletion {
None,
Static {
values: Vec<String>,
},
WorkerFile,
ClientFile,
/// Host-owned provider selected by source-qualified id. Metadata lookup is
/// side-effect free; implementations never cross this wire boundary.
Provider {
provider: String,
},
}
impl Default for InvocationCompletion {
fn default() -> Self {
Self::None
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct InvocationArgumentDescriptor {
pub name: String,
/// Zero-based positional index. `None` means named-only. A positional
/// argument may also be supplied by name; this makes chip editing stable.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub position: Option<u16>,
#[serde(default)]
pub required: bool,
pub value_type: InvocationArgumentType,
#[serde(default)]
pub completion: InvocationCompletion,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum InvocationClientAdapter {
/// The client stages a local file and replaces the draft invocation with an
/// `UploadedFile` chip. Local paths are not sent to or persisted by Worker.
Attachment,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct FeatureInvocationDescriptor {
pub identity: FeatureInvocationIdentity,
pub name: String,
#[serde(default)]
pub aliases: Vec<String>,
pub display_name: String,
pub description: String,
pub syntax: FeatureInvocationSyntax,
#[serde(default)]
pub arguments: Vec<InvocationArgumentDescriptor>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub client_adapter: Option<InvocationClientAdapter>,
}
impl FeatureInvocationDescriptor {
pub fn usage(&self) -> String {
let mut usage = format!("/{}(", self.name);
for (index, argument) in self.arguments.iter().enumerate() {
if index > 0 {
usage.push_str(", ");
}
if argument.position.is_none() {
usage.push_str(&argument.name);
usage.push('=');
}
let marker = match &argument.value_type {
InvocationArgumentType::String => "text",
InvocationArgumentType::Integer => "integer",
InvocationArgumentType::Boolean => "true|false",
InvocationArgumentType::Enum { .. } => "choice",
InvocationArgumentType::WorkerFile => "worker-path",
InvocationArgumentType::ClientFile => "local-file",
};
if argument.required {
usage.push('<');
usage.push_str(marker);
usage.push('>');
} else {
usage.push('[');
usage.push_str(marker);
usage.push(']');
}
}
usage.push(')');
usage
}
pub fn validate(&self) -> Result<(), InvocationValidationError> {
const MAX_PUBLIC_NAMES: usize = 16;
const MAX_ARGUMENTS: usize = 32;
const MAX_METADATA_BYTES: usize = 4096;
validate_invocation_name(&self.name)?;
if self.aliases.len() > MAX_PUBLIC_NAMES || self.arguments.len() > MAX_ARGUMENTS {
return Err(InvocationValidationError::InvalidDescriptor(
"invocation descriptor exceeds collection limits".into(),
));
}
if self.display_name.len() > MAX_METADATA_BYTES
|| self.description.len() > MAX_METADATA_BYTES
{
return Err(InvocationValidationError::InvalidDescriptor(
"invocation metadata exceeds byte limits".into(),
));
}
if self.identity.0.trim().is_empty()
|| !self.identity.0.contains(':')
|| self.identity.0.len() > 256
{
return Err(InvocationValidationError::InvalidDescriptor(
"invocation identity must be source-qualified".into(),
));
}
let mut names = BTreeSet::new();
let mut positions = BTreeSet::new();
for name in std::iter::once(&self.name).chain(self.aliases.iter()) {
validate_invocation_name(name)?;
if !names.insert(name) {
return Err(InvocationValidationError::InvalidDescriptor(format!(
"duplicate invocation name or alias `{name}`"
)));
}
}
let mut argument_names = BTreeSet::new();
for argument in &self.arguments {
validate_argument_name(&argument.name)?;
if !argument_names.insert(argument.name.as_str()) {
return Err(InvocationValidationError::InvalidDescriptor(format!(
"duplicate argument `{}`",
argument.name
)));
}
if let Some(position) = argument.position
&& !positions.insert(position)
{
return Err(InvocationValidationError::InvalidDescriptor(format!(
"duplicate positional index `{position}`"
)));
}
if argument
.description
.as_ref()
.is_some_and(|value| value.len() > MAX_METADATA_BYTES)
{
return Err(InvocationValidationError::InvalidDescriptor(format!(
"argument `{}` description exceeds byte limits",
argument.name
)));
}
if let InvocationCompletion::Provider { provider } = &argument.completion
&& (provider.trim().is_empty() || provider.len() > 256)
{
return Err(InvocationValidationError::InvalidDescriptor(format!(
"argument `{}` has an invalid completion provider",
argument.name
)));
}
if matches!(&argument.value_type, InvocationArgumentType::Enum { values }
if values.len() > 256 || values.iter().any(|value| value.len() > MAX_METADATA_BYTES))
|| matches!(&argument.completion, InvocationCompletion::Static { values }
if values.len() > 256 || values.iter().any(|value| value.len() > MAX_METADATA_BYTES))
{
return Err(InvocationValidationError::InvalidDescriptor(format!(
"argument `{}` completion values exceed limits",
argument.name
)));
}
if matches!(&argument.value_type, InvocationArgumentType::Enum { values } if values.is_empty())
{
return Err(InvocationValidationError::InvalidDescriptor(format!(
"enum argument `{}` has no values",
argument.name
)));
}
}
for (expected, actual) in positions.iter().copied().enumerate() {
if actual as usize != expected {
return Err(InvocationValidationError::InvalidDescriptor(
"positional argument indices must be contiguous from zero".into(),
));
}
}
if serde_json::to_vec(self).map_or(true, |encoded| {
encoded.len() > MAX_FEATURE_INVOCATION_DESCRIPTOR_BYTES
}) {
return Err(InvocationValidationError::InvalidDescriptor(
"invocation descriptor exceeds the public completion byte limit".into(),
));
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
pub enum InvocationValue {
String(String),
Integer(i32),
Boolean(bool),
}
impl InvocationValue {
pub fn display_input(&self) -> String {
match self {
Self::String(value) => quote_invocation_string(value),
Self::Integer(value) => value.to_string(),
Self::Boolean(value) => value.to_string(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct InvocationArgumentValue {
pub name: String,
pub value: InvocationValue,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct FeatureInvocation {
/// Stable client-generated id retained across queueing and transport retry.
pub invocation_id: String,
pub identity: FeatureInvocationIdentity,
/// Selected public name, retained for exact draft/history restoration. Host
/// resolution is always by `identity`.
pub name: String,
pub arguments: Vec<InvocationArgumentValue>,
}
impl FeatureInvocation {
pub fn display_input(&self) -> String {
let mut input = format!("/{}(", self.name);
for (index, argument) in self.arguments.iter().enumerate() {
if index > 0 {
input.push_str(", ");
}
input.push_str(&argument.name);
input.push('=');
input.push_str(&argument.value.display_input());
}
input.push(')');
input
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum FeatureInvocationStatus {
Succeeded,
Failed,
OutcomeUnknown,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct FeatureInvocationResult {
pub invocation_id: String,
pub identity: FeatureInvocationIdentity,
pub status: FeatureInvocationStatus,
pub message: String,
/// Bounded model-visible context produced by the handler. Credentials and
/// executable implementation details must not be included.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParsedFeatureInvocation {
pub invocation: FeatureInvocation,
/// UTF-8 byte offset immediately after the closing parenthesis.
pub end: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum InvocationValidationError {
#[error("invalid invocation descriptor: {0}")]
InvalidDescriptor(String),
#[error("incomplete invocation at byte {0}")]
Incomplete(usize),
#[error("unknown argument `{0}`")]
UnknownArgument(String),
#[error("duplicate argument `{0}`")]
DuplicateArgument(String),
#[error("missing required argument `{0}`")]
MissingRequired(String),
#[error("too many positional arguments")]
TooManyPositional,
#[error("invalid value for `{argument}`: {message}")]
InvalidValue { argument: String, message: String },
#[error("invocation identity does not match descriptor")]
IdentityMismatch,
#[error("invocation name does not match descriptor")]
NameMismatch,
#[error("invalid invocation syntax at byte {offset}: {message}")]
Syntax { offset: usize, message: String },
}
pub fn validate_feature_invocation(
descriptor: &FeatureInvocationDescriptor,
invocation: &FeatureInvocation,
) -> Result<(), InvocationValidationError> {
descriptor.validate()?;
if invocation.identity != descriptor.identity {
return Err(InvocationValidationError::IdentityMismatch);
}
if invocation.name != descriptor.name && !descriptor.aliases.contains(&invocation.name) {
return Err(InvocationValidationError::NameMismatch);
}
if invocation.invocation_id.trim().is_empty()
|| invocation.invocation_id.len() > MAX_FEATURE_INVOCATION_ID_BYTES
{
return Err(InvocationValidationError::InvalidValue {
argument: "invocation_id".into(),
message: "must be nonempty and at most 128 UTF-8 bytes".into(),
});
}
if invocation.arguments.len() > 32 {
return Err(InvocationValidationError::InvalidValue {
argument: "arguments".into(),
message: "too many arguments".into(),
});
}
let mut values = BTreeMap::new();
for argument in &invocation.arguments {
if values
.insert(argument.name.as_str(), &argument.value)
.is_some()
{
return Err(InvocationValidationError::DuplicateArgument(
argument.name.clone(),
));
}
}
for name in values.keys() {
if !descriptor
.arguments
.iter()
.any(|argument| argument.name == **name)
{
return Err(InvocationValidationError::UnknownArgument(
(*name).to_string(),
));
}
}
for argument in &descriptor.arguments {
match values.get(argument.name.as_str()) {
Some(value) => validate_value(argument, value)?,
None if argument.required => {
return Err(InvocationValidationError::MissingRequired(
argument.name.clone(),
));
}
None => {}
}
}
Ok(())
}
/// Parse one complete parenthesized invocation starting at `start`. Callers
/// decide when input becomes a typed chip; parsing alone never executes it.
pub fn parse_feature_invocation(
input: &str,
start: usize,
descriptor: &FeatureInvocationDescriptor,
invocation_id: impl Into<String>,
) -> Result<ParsedFeatureInvocation, InvocationValidationError> {
descriptor.validate()?;
if !input.is_char_boundary(start) || input.as_bytes().get(start) != Some(&b'/') {
return Err(InvocationValidationError::Syntax {
offset: start,
message: "expected `/`".into(),
});
}
let mut cursor = start + 1;
let name_start = cursor;
while let Some(byte) = input.as_bytes().get(cursor) {
if byte.is_ascii_alphanumeric() || matches!(*byte, b'-' | b'_') {
cursor += 1;
} else {
break;
}
}
let name = &input[name_start..cursor];
if name != descriptor.name && !descriptor.aliases.iter().any(|alias| alias == name) {
return Err(InvocationValidationError::NameMismatch);
}
skip_ws(input, &mut cursor);
if input.as_bytes().get(cursor) != Some(&b'(') {
return Err(InvocationValidationError::Incomplete(cursor));
}
cursor += 1;
let positional = descriptor
.arguments
.iter()
.filter_map(|argument| argument.position.map(|position| (position, argument)))
.collect::<BTreeMap<_, _>>();
let mut next_position = 0u16;
let mut parsed = Vec::<InvocationArgumentValue>::new();
loop {
skip_ws(input, &mut cursor);
match input.as_bytes().get(cursor) {
Some(b')') => {
cursor += 1;
break;
}
None => return Err(InvocationValidationError::Incomplete(cursor)),
_ => {}
}
let token_start = cursor;
let possible_name = parse_identifier(input, &mut cursor);
let after_name = cursor;
skip_ws(input, &mut cursor);
let (argument, raw_value) = if !possible_name.is_empty()
&& input.as_bytes().get(cursor) == Some(&b'=')
{
cursor += 1;
skip_ws(input, &mut cursor);
let argument = descriptor
.arguments
.iter()
.find(|argument| argument.name == possible_name)
.ok_or_else(|| InvocationValidationError::UnknownArgument(possible_name.into()))?;
(argument, parse_raw_value(input, &mut cursor)?)
} else {
cursor = token_start;
let argument = positional
.get(&next_position)
.copied()
.ok_or(InvocationValidationError::TooManyPositional)?;
next_position += 1;
(argument, parse_raw_value(input, &mut cursor)?)
};
if parsed.iter().any(|value| value.name == argument.name) {
return Err(InvocationValidationError::DuplicateArgument(
argument.name.clone(),
));
}
let value = coerce_value(argument, raw_value)?;
parsed.push(InvocationArgumentValue {
name: argument.name.clone(),
value,
});
skip_ws(input, &mut cursor);
match input.as_bytes().get(cursor) {
Some(b',') => cursor += 1,
Some(b')') => {}
None => return Err(InvocationValidationError::Incomplete(cursor)),
_ => {
return Err(InvocationValidationError::Syntax {
offset: after_name.max(cursor),
message: "expected `,` or `)`".into(),
});
}
}
}
let invocation = FeatureInvocation {
invocation_id: invocation_id.into(),
identity: descriptor.identity.clone(),
name: name.to_string(),
arguments: parsed,
};
validate_feature_invocation(descriptor, &invocation)?;
Ok(ParsedFeatureInvocation {
invocation,
end: cursor,
})
}
#[derive(Debug)]
enum RawValue {
Quoted(String),
Bare(String),
}
fn parse_raw_value(input: &str, cursor: &mut usize) -> Result<RawValue, InvocationValidationError> {
if input.as_bytes().get(*cursor) == Some(&b'"') {
let start = *cursor;
*cursor += 1;
let mut escaped = false;
loop {
let Some(ch) = input[*cursor..].chars().next() else {
return Err(InvocationValidationError::Incomplete(*cursor));
};
*cursor += ch.len_utf8();
if ch == '"' && !escaped {
let value =
serde_json::from_str::<String>(&input[start..*cursor]).map_err(|error| {
InvocationValidationError::Syntax {
offset: start,
message: error.to_string(),
}
})?;
return Ok(RawValue::Quoted(value));
}
escaped = ch == '\\' && !escaped;
}
}
let start = *cursor;
while let Some(ch) = input[*cursor..].chars().next() {
if ch == ',' || ch == ')' || ch.is_whitespace() {
break;
}
*cursor += ch.len_utf8();
}
if *cursor == start {
return Err(InvocationValidationError::Syntax {
offset: start,
message: "expected argument value".into(),
});
}
Ok(RawValue::Bare(input[start..*cursor].to_string()))
}
fn coerce_value(
descriptor: &InvocationArgumentDescriptor,
raw: RawValue,
) -> Result<InvocationValue, InvocationValidationError> {
let text = match raw {
RawValue::Quoted(value) | RawValue::Bare(value) => value,
};
let invalid = |message: &str| InvocationValidationError::InvalidValue {
argument: descriptor.name.clone(),
message: message.into(),
};
match &descriptor.value_type {
InvocationArgumentType::String
| InvocationArgumentType::WorkerFile
| InvocationArgumentType::ClientFile => Ok(InvocationValue::String(text)),
InvocationArgumentType::Integer => {
let digits = text.strip_prefix('-').unwrap_or(&text);
if digits.is_empty() || !digits.bytes().all(|byte| byte.is_ascii_digit()) {
return Err(invalid("expected a decimal integer"));
}
text.parse::<i32>()
.map(InvocationValue::Integer)
.map_err(|_| invalid("expected a signed 32-bit integer"))
}
InvocationArgumentType::Boolean => match text.as_str() {
"true" => Ok(InvocationValue::Boolean(true)),
"false" => Ok(InvocationValue::Boolean(false)),
_ => Err(invalid("expected `true` or `false`")),
},
InvocationArgumentType::Enum { values } => {
if values.contains(&text) {
Ok(InvocationValue::String(text))
} else {
Err(invalid("value is not in the declared enum"))
}
}
}
}
fn validate_value(
descriptor: &InvocationArgumentDescriptor,
value: &InvocationValue,
) -> Result<(), InvocationValidationError> {
let valid = match (&descriptor.value_type, value) {
(
InvocationArgumentType::String
| InvocationArgumentType::WorkerFile
| InvocationArgumentType::ClientFile,
InvocationValue::String(value),
) => value.len() <= 16 * 1024,
(InvocationArgumentType::Integer, InvocationValue::Integer(_)) => true,
(InvocationArgumentType::Boolean, InvocationValue::Boolean(_)) => true,
(InvocationArgumentType::Enum { values }, InvocationValue::String(value)) => {
values.contains(value)
}
_ => false,
};
if valid {
Ok(())
} else {
Err(InvocationValidationError::InvalidValue {
argument: descriptor.name.clone(),
message: "structured value does not match the declared type".into(),
})
}
}
fn validate_invocation_name(name: &str) -> Result<(), InvocationValidationError> {
if name.is_empty()
|| !name
.bytes()
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
|| !name.as_bytes()[0].is_ascii_lowercase()
{
return Err(InvocationValidationError::InvalidDescriptor(format!(
"invalid invocation name `{name}`"
)));
}
Ok(())
}
fn validate_argument_name(name: &str) -> Result<(), InvocationValidationError> {
if name.is_empty()
|| !name
.bytes()
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_')
|| !name.as_bytes()[0].is_ascii_lowercase()
{
return Err(InvocationValidationError::InvalidDescriptor(format!(
"invalid argument name `{name}`"
)));
}
Ok(())
}
fn parse_identifier<'a>(input: &'a str, cursor: &mut usize) -> &'a str {
let start = *cursor;
while let Some(byte) = input.as_bytes().get(*cursor) {
if byte.is_ascii_lowercase() || byte.is_ascii_digit() || *byte == b'_' {
*cursor += 1;
} else {
break;
}
}
&input[start..*cursor]
}
fn skip_ws(input: &str, cursor: &mut usize) {
while let Some(ch) = input[*cursor..].chars().next() {
if !ch.is_whitespace() {
break;
}
*cursor += ch.len_utf8();
}
}
pub fn quote_invocation_string(value: &str) -> String {
// Keep the spelling identical to Web's JSON.stringify, including control
// characters and JSON unicode escapes accepted by both common parsers.
serde_json::to_string(value).expect("serializing a string cannot fail")
}
#[cfg(test)]
mod tests {
use super::*;
fn descriptor() -> FeatureInvocationDescriptor {
FeatureInvocationDescriptor {
identity: FeatureInvocationIdentity("builtin:test/run".into()),
name: "run".into(),
aliases: vec!["execute".into()],
display_name: "Run".into(),
description: "test invocation".into(),
syntax: FeatureInvocationSyntax::Parenthesized,
arguments: vec![
InvocationArgumentDescriptor {
name: "path".into(),
position: Some(0),
required: true,
value_type: InvocationArgumentType::String,
completion: InvocationCompletion::WorkerFile,
description: None,
},
InvocationArgumentDescriptor {
name: "mode".into(),
position: None,
required: false,
value_type: InvocationArgumentType::Enum {
values: vec!["fast".into(), "safe".into()],
},
completion: InvocationCompletion::Static {
values: vec!["fast".into(), "safe".into()],
},
description: None,
},
],
client_adapter: None,
}
}
#[test]
fn parses_unicode_quoted_paths_and_stops_before_natural_language() {
let input = "/run(\"資料/my file / x\", mode=safe) この後は本文";
let parsed = parse_feature_invocation(input, 0, &descriptor(), "invoke-1").unwrap();
assert_eq!(&input[parsed.end..], " この後は本文");
assert_eq!(
parsed.invocation.arguments,
vec![
InvocationArgumentValue {
name: "path".into(),
value: InvocationValue::String("資料/my file / x".into()),
},
InvocationArgumentValue {
name: "mode".into(),
value: InvocationValue::String("safe".into()),
},
]
);
}
#[test]
fn reports_incomplete_unknown_duplicate_missing_and_type_errors() {
assert!(matches!(
parse_feature_invocation("/run(\"x\"", 0, &descriptor(), "1"),
Err(InvocationValidationError::Incomplete(_))
));
assert!(matches!(
parse_feature_invocation("/run(path=x, nope=y)", 0, &descriptor(), "1"),
Err(InvocationValidationError::UnknownArgument(name)) if name == "nope"
));
assert!(matches!(
parse_feature_invocation("/run(path=x, path=y)", 0, &descriptor(), "1"),
Err(InvocationValidationError::DuplicateArgument(name)) if name == "path"
));
assert!(matches!(
parse_feature_invocation("/run(mode=fast)", 0, &descriptor(), "1"),
Err(InvocationValidationError::MissingRequired(name)) if name == "path"
));
assert!(matches!(
parse_feature_invocation("/run(x, mode=unknown)", 0, &descriptor(), "1"),
Err(InvocationValidationError::InvalidValue { argument, .. }) if argument == "mode"
));
}
#[test]
fn descriptors_cannot_install_with_undiscoverable_oversized_metadata() {
let mut descriptor = descriptor();
descriptor.arguments[0].completion = InvocationCompletion::Static {
values: vec!["a".repeat(1024); 40],
};
assert!(
matches!(descriptor.validate(), Err(InvocationValidationError::InvalidDescriptor(message)) if message.contains("completion byte limit"))
);
}
#[test]
fn client_and_recovery_invocation_key_bound_is_shared() {
let descriptor = descriptor();
assert!(
parse_feature_invocation(
"/run(x)",
0,
&descriptor,
"a".repeat(MAX_FEATURE_INVOCATION_ID_BYTES)
)
.is_ok()
);
assert!(
parse_feature_invocation(
"/run(x)",
0,
&descriptor,
"a".repeat(MAX_FEATURE_INVOCATION_ID_BYTES + 1)
)
.is_err()
);
assert!(parse_feature_invocation("/run(x)", 0, &descriptor, "界".repeat(43)).is_err());
}
#[test]
fn structured_payload_validation_does_not_trust_client_identity_or_types() {
let descriptor = descriptor();
let original = parse_feature_invocation("/run(path=x)", 0, &descriptor, "stable")
.unwrap()
.invocation;
let mut changed = original.clone();
changed.identity = FeatureInvocationIdentity("builtin:other/run".into());
assert_eq!(
validate_feature_invocation(&descriptor, &changed),
Err(InvocationValidationError::IdentityMismatch)
);
changed = original.clone();
changed.name = "unregistered".into();
assert_eq!(
validate_feature_invocation(&descriptor, &changed),
Err(InvocationValidationError::NameMismatch)
);
changed = original.clone();
changed.arguments[0].value = InvocationValue::Boolean(true);
assert!(matches!(
validate_feature_invocation(&descriptor, &changed),
Err(InvocationValidationError::InvalidValue { .. })
));
changed = original.clone();
changed.arguments.push(changed.arguments[0].clone());
assert!(matches!(
validate_feature_invocation(&descriptor, &changed),
Err(InvocationValidationError::DuplicateArgument(_))
));
changed = original;
changed.invocation_id.clear();
assert!(validate_feature_invocation(&descriptor, &changed).is_err());
}
#[test]
fn integer_boolean_and_utf8_offsets_use_the_common_typed_contract() {
let mut descriptor = descriptor();
descriptor.arguments[0].value_type = InvocationArgumentType::Integer;
descriptor.arguments[1].value_type = InvocationArgumentType::Boolean;
let parsed = parse_feature_invocation(
"本文 /execute(-2147483648, mode=true) 後",
"本文 ".len(),
&descriptor,
"typed",
)
.unwrap();
assert_eq!(
parsed.invocation.arguments[0].value,
InvocationValue::Integer(i32::MIN)
);
assert_eq!(
parsed.invocation.arguments[1].value,
InvocationValue::Boolean(true)
);
for invalid in ["/run(2147483648)", "/run(1.0)", "/run(1, mode=yes)"] {
assert!(parse_feature_invocation(invalid, 0, &descriptor, "typed").is_err());
}
assert!(parse_feature_invocation("本文 /run(1)", 1, &descriptor, "typed").is_err());
assert!(parse_feature_invocation("/run(1)", usize::MAX, &descriptor, "typed").is_err());
}
#[test]
fn json_escapes_and_multiple_invocations_have_explicit_boundaries() {
let input = r#"/run("\u8cc7\u6599\/a\b\f", mode=safe) prose /execute("second") end"#;
let first = parse_feature_invocation(input, 0, &descriptor(), "first").unwrap();
assert_eq!(
first.invocation.arguments[0].value,
InvocationValue::String("資料/a\u{8}\u{c}".into())
);
let next = input[first.end..].find("/execute").unwrap() + first.end;
let second = parse_feature_invocation(input, next, &descriptor(), "second").unwrap();
assert_eq!(&input[second.end..], " end");
assert!(matches!(
parse_feature_invocation(r#"/run("\uD800")"#, 0, &descriptor(), "bad"),
Err(InvocationValidationError::Syntax { .. })
));
let value = "controls \u{0}\u{8}\u{c}";
let spelling = format!("/run({})", quote_invocation_string(value));
assert_eq!(
parse_feature_invocation(&spelling, 0, &descriptor(), "control")
.unwrap()
.invocation
.arguments[0]
.value,
InvocationValue::String(value.into())
);
}
#[test]
fn typed_segment_round_trips_without_flattening_identity_or_arguments() {
let parsed =
parse_feature_invocation("/run(\"x y\", mode=fast)", 0, &descriptor(), "1").unwrap();
let segment = crate::Segment::FeatureInvoke {
invocation: parsed.invocation,
};
let json = serde_json::to_string(&segment).unwrap();
assert!(json.contains("\"kind\":\"feature_invoke\""));
assert!(json.contains("builtin:test/run"));
assert_eq!(
serde_json::from_str::<crate::Segment>(&json).unwrap(),
segment
);
}
#[test]
fn quoting_round_trips_spaces_unicode_slashes_and_escapes() {
let value = "a b/界\\\"\n";
let input = format!("/run({})", quote_invocation_string(value));
let parsed = parse_feature_invocation(&input, 0, &descriptor(), "1").unwrap();
assert_eq!(
parsed.invocation.arguments[0].value,
InvocationValue::String(value.into())
);
}
}
+133 -10
View File
@@ -1,4 +1,5 @@
pub mod identity;
pub mod invocation;
#[cfg(feature = "stream")]
pub mod stream;
pub mod subscription;
@@ -10,6 +11,14 @@ use std::path::PathBuf;
use serde::{Deserialize, Serialize};
pub use identity::{WorkerId, WorkerIdParseError};
pub use invocation::{
FeatureInvocation, FeatureInvocationDescriptor, FeatureInvocationIdentity,
FeatureInvocationResult, FeatureInvocationStatus, FeatureInvocationSyntax,
InvocationArgumentDescriptor, InvocationArgumentType, InvocationArgumentValue,
InvocationClientAdapter, InvocationCompletion, InvocationValidationError, InvocationValue,
ParsedFeatureInvocation, parse_feature_invocation, quote_invocation_string,
validate_feature_invocation,
};
/// Allocate an opaque idempotency key for one client Submit request.
pub fn new_submission_request_id() -> String {
@@ -62,6 +71,17 @@ impl Default for AuthenticatedInputSource {
}
}
// Tracked methods may carry serialized transport provenance, but wire values
// never grant authority. Keep this field recognized under fail-closed decoding
// while replacing it with UntrustedWire, including malformed/forged values.
fn ignore_wire_input_source<'de, D>(deserializer: D) -> Result<AuthenticatedInputSource, D::Error>
where
D: serde::Deserializer<'de>,
{
let _ = serde::de::IgnoredAny::deserialize(deserializer)?;
Ok(AuthenticatedInputSource::UntrustedWire)
}
impl AuthenticatedInputSource {
pub fn namespace(&self) -> String {
match self {
@@ -224,7 +244,12 @@ impl From<WorkerStatus> for WorkerStateSnapshot {
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(tag = "method", content = "params", rename_all = "snake_case")]
#[serde(
tag = "method",
content = "params",
rename_all = "snake_case",
deny_unknown_fields
)]
pub enum Method {
/// Durably accept typed input for immediate activation or the session FIFO.
/// Running or Paused Workers queue Submit for a later turn; use Notify for
@@ -244,7 +269,7 @@ pub enum Method {
SubmitTracked {
submission_request_id: String,
input: Vec<Segment>,
#[serde(skip_deserializing, default)]
#[serde(default, deserialize_with = "ignore_wire_input_source")]
source: AuthenticatedInputSource,
},
/// Agent-controlled input: accept only at an Idle controller boundary, never
@@ -254,7 +279,7 @@ pub enum Method {
SubmitIfIdle {
submission_request_id: String,
input: Vec<Segment>,
#[serde(skip_deserializing, default)]
#[serde(default, deserialize_with = "ignore_wire_input_source")]
source: AuthenticatedInputSource,
},
/// Human-readable text injected into the target Worker's LLM context as a
@@ -272,7 +297,7 @@ pub enum Method {
NotifyTracked {
notification_request_id: String,
message: String,
#[serde(skip_deserializing, default)]
#[serde(default, deserialize_with = "ignore_wire_input_source")]
source: AuthenticatedInputSource,
},
/// Typed lifecycle report from a child Worker to its direct parent.
@@ -336,6 +361,12 @@ pub enum Method {
ListCompletions {
kind: CompletionKind,
prefix: String,
/// Client query generation. Echoed verbatim so identical-prefix ABA
/// responses cannot cross edits, target switches, or permission changes.
#[serde(default, skip_serializing_if = "Option::is_none")]
request_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
context: Option<CompletionContext>,
},
/// List Workers visible to this Worker from durable Worker state and the spawned-child
/// registry. This is not a host-wide Worker universe query.
@@ -609,6 +640,10 @@ pub enum Segment {
/// `[Dir: <path>]` listings; the flattened user text keeps the literal
/// `@<path>` placeholder either way.
FileRef { path: String },
/// Explicitly selected, source-qualified Feature invocation. The host never
/// promotes free text to this variant and validates it against the installed
/// declaration before execution.
FeatureInvoke { invocation: FeatureInvocation },
/// Source-qualified request for the host to start a Flow before committing
/// this user input. Runtime resolves it through Workspace authority and
/// replaces it with the entered state's instructions before Worker-side
@@ -677,6 +712,9 @@ impl Segment {
out.push('@');
out.push_str(path);
}
Segment::FeatureInvoke { invocation } => {
out.push_str(&invocation.display_input());
}
Segment::Flow { selector } => {
out.push_str("[Flow: ");
out.push_str(selector);
@@ -1348,6 +1386,12 @@ pub enum Event {
/// wired up.
Completions {
kind: CompletionKind,
#[serde(default)]
prefix: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
request_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
context: Option<CompletionContext>,
entries: Vec<CompletionEntry>,
},
/// Reply to `Method::ListRewindTargets`. Clients should only open a picker
@@ -1460,25 +1504,42 @@ pub enum AlertSource {
/// Kind of completion requested by `Method::ListCompletions`.
///
/// Mirrors the completion prefix sigil: `@` → `File`.
/// `File` is the existing `@` path lane. `Feature` lists installed chat
/// invocations. `FeatureArgument` resolves a declared argument using the
/// source-qualified [`CompletionContext`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum CompletionKind {
File,
Feature,
FeatureArgument,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct CompletionContext {
pub invocation: FeatureInvocationIdentity,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub argument: Option<String>,
}
/// One completion candidate for a prefix query.
///
/// `value` is a path (file kind).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct CompletionEntry {
pub value: String,
#[serde(default)]
pub is_dir: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub usage: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub invocation: Option<FeatureInvocationDescriptor>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -2114,6 +2175,27 @@ mod tests {
);
}
#[test]
fn notify_rejects_typed_intent_instead_of_silently_discarding_it() {
for method in ["notify", "notify_tracked"] {
for key in ["input", "segments", "invocation"] {
let mut params =
serde_json::json!({"notification_request_id":"request", "message":"advisory"});
params[key] = serde_json::json!([{ "kind":"feature_invoke", "invocation": { "identity":"builtin:test/run" } }]);
assert!(
serde_json::from_value::<Method>(
serde_json::json!({"method":method,"params":params})
)
.is_err()
);
}
}
// Text is still text: no decoding or Host scan promotes it to an invoke.
assert!(
matches!(serde_json::from_value::<Method>(serde_json::json!({"method":"notify", "params":{"notification_request_id":"text","message":"/run()"}})).unwrap(), Method::Notify { message, .. } if message == "/run()")
);
}
#[test]
fn segment_unknown_variant_decodes_as_unknown() {
// A future client sends a segment kind this Worker has never heard of.
@@ -2352,6 +2434,8 @@ mod tests {
let method = Method::ListCompletions {
kind: CompletionKind::File,
prefix: "src/".into(),
request_id: None,
context: None,
};
let json = serde_json::to_string(&method).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
@@ -2361,7 +2445,7 @@ mod tests {
let decoded: Method = serde_json::from_str(&json).unwrap();
match decoded {
Method::ListCompletions { kind, prefix } => {
Method::ListCompletions { kind, prefix, .. } => {
assert_eq!(kind, CompletionKind::File);
assert_eq!(prefix, "src/");
}
@@ -2369,13 +2453,52 @@ mod tests {
}
}
#[test]
fn completion_generation_round_trips_for_identical_query_correlation() {
let context = CompletionContext {
invocation: FeatureInvocationIdentity("builtin:test/run".into()),
argument: Some("mode".into()),
};
for request_id in ["query-1", "query-2"] {
let method = Method::ListCompletions {
kind: CompletionKind::FeatureArgument,
prefix: "s".into(),
request_id: Some(request_id.into()),
context: Some(context.clone()),
};
let decoded: Method =
serde_json::from_str(&serde_json::to_string(&method).unwrap()).unwrap();
assert!(
matches!(decoded, Method::ListCompletions { request_id: Some(id), context: Some(restored), .. } if id == request_id && restored == context)
);
let event = Event::Completions {
kind: CompletionKind::FeatureArgument,
prefix: "s".into(),
request_id: Some(request_id.into()),
context: Some(context.clone()),
entries: Vec::new(),
};
let decoded: Event =
serde_json::from_str(&serde_json::to_string(&event).unwrap()).unwrap();
assert!(
matches!(decoded, Event::Completions { request_id: Some(id), context: Some(restored), .. } if id == request_id && restored == context)
);
}
}
#[test]
fn event_completions_format_and_default_is_dir() {
let event = Event::Completions {
kind: CompletionKind::File,
prefix: "src/".into(),
request_id: None,
context: None,
entries: vec![CompletionEntry {
value: "src/main.rs".into(),
is_dir: false,
description: None,
usage: None,
invocation: None,
}],
};
let json = serde_json::to_string(&event).unwrap();
@@ -2389,7 +2512,7 @@ mod tests {
r#"{"event":"completions","data":{"kind":"file","entries":[{"value":"main.rs"}]}}"#;
let decoded: Event = serde_json::from_str(inbound).unwrap();
match decoded {
Event::Completions { kind, entries } => {
Event::Completions { kind, entries, .. } => {
assert_eq!(kind, CompletionKind::File);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].value, "main.rs");
+2
View File
@@ -1197,6 +1197,8 @@ mod tests {
method: crate::Method::ListCompletions {
kind: crate::CompletionKind::File,
prefix: "src/".to_string(),
request_id: None,
context: None,
},
},
));
+29 -13
View File
@@ -6,20 +6,23 @@ use ts_rs::{Config, TS};
use crate::{
Alert, AlertLevel, AlertSource, CommandEvent, CommandSnapshot, CommandStatus, CommandStream,
CommandStreamSlice, CompactionLifecycle, CompactionLifecycleState, CompactionPhase,
CompactionTrigger, CompletionEntry, CompletionKind, ContextTokenSource, ContextUsage,
ErrorCode, Event, Greeting, InFlightBlock, InFlightCompaction, InFlightSnapshot,
CompactionTrigger, CompletionContext, CompletionEntry, CompletionKind, ContextTokenSource,
ContextUsage, ErrorCode, Event, FeatureInvocation, FeatureInvocationDescriptor,
FeatureInvocationIdentity, FeatureInvocationResult, FeatureInvocationStatus,
FeatureInvocationSyntax, Greeting, InFlightBlock, InFlightCompaction, InFlightSnapshot,
InFlightToolCallState, InternalWorkerKind, InternalWorkerRef, InternalWorkerSnapshot,
InvokeKind, MemoryWorkerEvent, Method, PasteArtifactAvailability, PasteArtifactMediaType,
PasteArtifactRef, PendingSubmissionSummary, PendingSubmissionsSnapshot, Permission,
ReasoningConfig, RewindSummary, RewindTarget, RewindTargetId, RunFailureKind, RunResult,
RunResumeSource, RunYieldReason, ScopeRule, Segment, SessionContentPart,
SessionConversationTurn, SessionEntryProvenance, SessionHistoryLineageBoundary,
SessionHistoryPage, SessionMessageRole, SessionSnapshot, SessionSnapshotEntry,
SessionSnapshotEntryData, SessionToolAttachment, SubmissionDisposition, SymlinkPolicy,
ToolResultDisposition, TurnResult, UploadedFileAvailability, UploadedFileRef, WorkerBusyState,
WorkerCommandAcknowledgement, WorkerCommandDisposition, WorkerCommandEnvelope,
WorkerCommandKind, WorkerEvent, WorkerMaintenanceState, WorkerRunState, WorkerState,
WorkerStateSnapshot, WorkerStatus,
InvocationArgumentDescriptor, InvocationArgumentType, InvocationArgumentValue,
InvocationClientAdapter, InvocationCompletion, InvocationValue, InvokeKind, MemoryWorkerEvent,
Method, PasteArtifactAvailability, PasteArtifactMediaType, PasteArtifactRef,
PendingSubmissionSummary, PendingSubmissionsSnapshot, Permission, ReasoningConfig,
RewindSummary, RewindTarget, RewindTargetId, RunFailureKind, RunResult, RunResumeSource,
RunYieldReason, ScopeRule, Segment, SessionContentPart, SessionConversationTurn,
SessionEntryProvenance, SessionHistoryLineageBoundary, SessionHistoryPage, SessionMessageRole,
SessionSnapshot, SessionSnapshotEntry, SessionSnapshotEntryData, SessionToolAttachment,
SubmissionDisposition, SymlinkPolicy, ToolResultDisposition, TurnResult,
UploadedFileAvailability, UploadedFileRef, WorkerBusyState, WorkerCommandAcknowledgement,
WorkerCommandDisposition, WorkerCommandEnvelope, WorkerCommandKind, WorkerEvent,
WorkerMaintenanceState, WorkerRunState, WorkerState, WorkerStateSnapshot, WorkerStatus,
subscription::{
EventSubscriptionSelector, MAX_CORRELATION_ID_BYTES, MAX_REJECTION_MESSAGE_BYTES,
MAX_RESOURCE_ID_BYTES, MAX_SUBSCRIPTION_COLLECTION_ITEMS,
@@ -64,6 +67,19 @@ pub fn generated_protocol_types() -> String {
push_decl::<AlertLevel>(&cfg, &mut output);
push_decl::<AlertSource>(&cfg, &mut output);
push_decl::<CompletionKind>(&cfg, &mut output);
push_decl::<CompletionContext>(&cfg, &mut output);
push_decl::<FeatureInvocationIdentity>(&cfg, &mut output);
push_decl::<FeatureInvocationSyntax>(&cfg, &mut output);
push_decl::<InvocationArgumentType>(&cfg, &mut output);
push_decl::<InvocationCompletion>(&cfg, &mut output);
push_decl::<InvocationArgumentDescriptor>(&cfg, &mut output);
push_decl::<InvocationClientAdapter>(&cfg, &mut output);
push_decl::<FeatureInvocationDescriptor>(&cfg, &mut output);
push_decl::<InvocationValue>(&cfg, &mut output);
push_decl::<InvocationArgumentValue>(&cfg, &mut output);
push_decl::<FeatureInvocation>(&cfg, &mut output);
push_decl::<FeatureInvocationStatus>(&cfg, &mut output);
push_decl::<FeatureInvocationResult>(&cfg, &mut output);
push_decl::<WorkerStatus>(&cfg, &mut output);
push_decl::<WorkerCommandEnvelope>(&cfg, &mut output);
push_decl::<WorkerCommandKind>(&cfg, &mut output);
+17
View File
@@ -567,6 +567,8 @@ pub struct CompletionRequest {
pub kind: CompletionKind,
#[serde(default)]
pub prefix: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context: Option<protocol::CompletionContext>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
@@ -1202,6 +1204,21 @@ pub const REMAINING_RUNTIME_ROUTES: &[RemainingRuntimeRoute] = &[
mod tests {
use super::*;
#[test]
fn feature_argument_completion_wire_preserves_source_and_argument() {
let json = serde_json::json!({"kind":"feature_argument", "prefix":"資料/", "context": {"invocation":"builtin:test/prepare", "argument":"path"}});
let request: CompletionRequest = serde_json::from_value(json.clone()).unwrap();
assert_eq!(request.kind, CompletionKind::FeatureArgument);
assert_eq!(
request.context.as_ref().unwrap().argument.as_deref(),
Some("path")
);
assert_eq!(serde_json::to_value(request).unwrap(), json);
let legacy: CompletionRequest =
serde_json::from_value(serde_json::json!({"kind":"file"})).unwrap();
assert!(legacy.context.is_none());
}
#[derive(Clone)]
struct RoundTripService;
+45 -4
View File
@@ -8233,7 +8233,7 @@ pub struct RuntimeWorkerInputRequest {
pub kind: Option<String>,
pub content: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub segments: Option<Vec<serde_json::Value>>,
pub segments: Option<Vec<protocol::Segment>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
@@ -8268,9 +8268,11 @@ pub struct RuntimeWorkerLifecycleResult {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct RuntimeWorkerCompletionsRequest {
pub kind: serde_json::Value,
pub kind: protocol::CompletionKind,
#[serde(default)]
pub prefix: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context: Option<protocol::CompletionContext>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
@@ -8278,9 +8280,9 @@ pub struct RuntimeWorkerCompletionsRequest {
pub struct RuntimeWorkerCompletionsResult {
pub runtime_id: String,
pub worker_id: String,
pub kind: serde_json::Value,
pub kind: protocol::CompletionKind,
pub prefix: String,
pub entries: Vec<serde_json::Value>,
pub entries: Vec<protocol::CompletionEntry>,
#[serde(default)]
pub diagnostics: Vec<Diagnostic>,
}
@@ -12661,6 +12663,45 @@ mod tests {
assert!(serde_json::from_value::<CompanionTranscriptItem>(private_item).is_err());
}
#[test]
fn structured_invocation_input_round_trips_through_typed_api() {
let json = serde_json::json!({"kind":"user", "content":"", "segments":[{"kind":"feature_invoke", "invocation":{
"invocation_id":"stable-invoke", "identity":"builtin:test/prepare", "name":"prepare",
"arguments":[{"name":"path", "value":{"kind":"string","value":"資料/a b"}}]
}}]});
let request: RuntimeWorkerInputRequest = serde_json::from_value(json.clone()).unwrap();
assert!(
matches!(&request.segments.as_ref().unwrap()[0], protocol::Segment::FeatureInvoke { invocation }
if invocation.identity.0 == "builtin:test/prepare" && invocation.invocation_id == "stable-invoke")
);
assert_eq!(serde_json::to_value(request).unwrap(), json);
let mut invalid = json;
invalid["segments"][0]["invocation"]["arguments"][0]["value"] =
serde_json::json!({"kind":"integer","value":2147483648_i64});
assert!(serde_json::from_value::<RuntimeWorkerInputRequest>(invalid).is_err());
}
#[test]
fn feature_completion_contract_is_typed_and_preserves_argument_context() {
let json = serde_json::json!({"kind":"feature_argument", "prefix":"資料/", "context":{"invocation":"builtin:test/prepare","argument":"path"}});
let request: RuntimeWorkerCompletionsRequest =
serde_json::from_value(json.clone()).unwrap();
assert_eq!(request.kind, protocol::CompletionKind::FeatureArgument);
assert_eq!(serde_json::to_value(request).unwrap(), json);
assert!(
serde_json::from_value::<RuntimeWorkerCompletionsRequest>(
serde_json::json!({"kind":"unknown"})
)
.is_err()
);
assert!(
serde_json::from_value::<RuntimeWorkerCompletionsRequest>(
serde_json::json!({"kind":"feature_argument", "context":{"invocation":123}})
)
.is_err()
);
}
#[test]
fn generated_companion_api_contract_is_current() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
@@ -1227,8 +1227,16 @@ fn json_content_schema<'a>(
}
fn schema_reference<'a>(value: &'a Value, context: &str) -> Result<&'a str, GenerationError> {
const ALLOWED_REFERENCE_SIBLINGS: &[&str] =
&["$ref", "$comment", "deprecated", "description", "title"];
// `default` is a JSON Schema annotation, not a constraint or a replacement
// for the referenced type. Schemars emits it for defaulted enum fields.
const ALLOWED_REFERENCE_SIBLINGS: &[&str] = &[
"$ref",
"$comment",
"default",
"deprecated",
"description",
"title",
];
let schema = object(value, context)?;
if let Some(keyword) = schema
.keys()
@@ -2023,6 +2031,27 @@ mod tests {
assert!(error.to_string().contains("ambiguous anyOf"));
}
#[test]
fn reference_default_annotations_do_not_replace_the_referenced_type() {
let value = serde_json::json!({"$ref":"#/components/schemas/InvocationCompletion", "default":{"kind":"none"}});
assert_eq!(
schema_reference(&value, "completion").unwrap(),
"#/components/schemas/InvocationCompletion"
);
let output = generate_runtime_typescript(
&crate::canonical_openapi_document()
.unwrap()
.to_json()
.unwrap(),
)
.unwrap();
assert!(output.contains("completion?: InvocationCompletion"));
assert!(output.contains("export type FeatureInvocation ="));
assert!(output.contains(
"export type CompletionKind = \"file\" | \"feature\" | \"feature_argument\""
));
}
#[test]
fn generator_rejects_lossy_reference_siblings() {
let mut document: Value = serde_json::from_str(OPENAPI).unwrap();
+33
View File
@@ -123,6 +123,14 @@ pub enum SystemItem {
prompt_provenance: Option<PromptRenderProvenance>,
},
/// Result of an explicitly selected chat Feature invocation. The typed
/// result is retained for clients; `body` is the exact bounded context made
/// visible to the Worker model.
FeatureInvocationResult {
result: protocol::FeatureInvocationResult,
body: String,
},
/// `@<path>` file reference resolution. `body` is the rendered
/// LLM-context text (`[File: <path>]\n…` for regular files,
/// `[Dir: <path>]\n…` for directory listings, possibly with a
@@ -184,6 +192,7 @@ impl SystemItem {
match self {
SystemItem::Notification { body, .. } => body.clone(),
SystemItem::WorkerEvent { body, .. } => body.clone(),
SystemItem::FeatureInvocationResult { body, .. } => body.clone(),
SystemItem::FileAttachment { body, .. } => body.clone(),
SystemItem::SkillActivation { body, .. } => body.clone(),
SystemItem::LegacyKnowledgeIgnored { .. } => String::new(),
@@ -208,6 +217,7 @@ impl SystemItem {
match self {
SystemItem::Notification { .. } => "notification",
SystemItem::WorkerEvent { .. } => "worker_event",
SystemItem::FeatureInvocationResult { .. } => "feature_invocation_result",
SystemItem::FileAttachment { .. } => "file_attachment",
SystemItem::SkillActivation { .. } => "skill_activation",
SystemItem::LegacyKnowledgeIgnored { .. } => "legacy_knowledge_ignored",
@@ -363,6 +373,29 @@ mod tests {
}
}
#[test]
fn invocation_result_retains_typed_outcome_and_exact_context() {
let result = protocol::FeatureInvocationResult {
invocation_id: "stable-invoke".into(),
identity: protocol::FeatureInvocationIdentity("builtin:test/prepare".into()),
status: protocol::FeatureInvocationStatus::OutcomeUnknown,
message: "operation may have committed".into(),
context: None,
};
let body = "Outcome unknown; do not retry automatically";
let item = SystemItem::FeatureInvocationResult {
result: result.clone(),
body: body.into(),
};
let restored: SystemItem =
serde_json::from_str(&serde_json::to_string(&item).unwrap()).unwrap();
assert_eq!(restored.history_text(), body);
assert_eq!(restored.kind_label(), "feature_invocation_result");
assert!(
matches!(restored, SystemItem::FeatureInvocationResult { result: saved, .. } if saved == result)
);
}
#[test]
fn round_trip_worker_event() {
let item = SystemItem::WorkerEvent {
@@ -606,6 +606,70 @@ fn rewound_fork_preserves_uploaded_file_segments_in_snapshot() {
)));
}
#[test]
fn feature_invocations_survive_replay_snapshot_and_rewind_without_reparsing() {
let (_dir, store) = make_store();
let config = RequestConfig::default();
let (sid, segid) = session_store::create_segment(
&store,
SegmentStartState {
system_prompt: Some("System prompt"),
config: &config,
history: Vec::new(),
user_segments: Vec::new(),
},
)
.unwrap();
let segments = vec![
Segment::Text {
content: "before ".into(),
},
Segment::FeatureInvoke {
invocation: protocol::FeatureInvocation {
invocation_id: "stable-request-1".into(),
identity: protocol::FeatureInvocationIdentity("builtin:test/prepare".into()),
name: "prepare".into(),
arguments: vec![protocol::InvocationArgumentValue {
name: "path".into(),
value: protocol::InvocationValue::String("資料/a b".into()),
}],
},
},
Segment::Text {
content: " after /prepare(unselected)".into(),
},
];
session_store::save_user_input(
&store,
sid,
segid,
segments.clone(),
annotated(&[Item::user_message(Segment::flatten_to_text(&segments))]),
)
.unwrap();
session_store::save_turn_end(&store, sid, segid, 1).unwrap();
let entries = store.read_all(sid, segid).unwrap();
assert_eq!(
collect_state(&entries).user_segments,
vec![segments.clone()]
);
let fork_id = session_store::fork_at(&store, sid, segid, 1).unwrap();
let fork_entries = store.read_all(sid, fork_id).unwrap();
assert_eq!(
collect_state(&fork_entries).user_segments,
vec![segments.clone()]
);
let snapshot = session_store::public_snapshot::project_session_snapshot(sid, &fork_entries);
assert!(snapshot.entries.iter().any(|entry| matches!(
&entry.data, SessionSnapshotEntryData::UserInput { segments: restored } if restored == &segments
)));
// Original history remains append-only and retains the same exact typed intent.
assert_eq!(
serde_json::to_value(store.read_all(sid, segid).unwrap()).unwrap(),
serde_json::to_value(entries).unwrap()
);
}
#[tokio::test]
async fn session_config_changed_logged() {
let (_dir, store) = make_store();
+30
View File
@@ -28,3 +28,33 @@ The TUI should display committed events and Worker snapshots rather than inventi
- [`../../docs/design/context-history.md`](../../docs/design/context-history.md)
- [`../../docs/design/worker-session-state.md`](../../docs/design/worker-session-state.md)
## Feature invocation editing
`/` discovers enabled Feature declarations with descriptions and usage. Choose a
name or alias with Tab/Enter, complete its parenthesized arguments, and press
Enter to confirm that selected occurrence as a typed chip. A later Enter sends
the message. Raw slash text and paste content are never dispatched as commands.
Use Alt+Enter beside a Feature chip to rediscover its declaration and reopen its
arguments; Backspace/Delete removes a chip atomically. Typed chips survive local
history, draft restoration, transport retry, and rewind.
Every completion and chip-re-edit query carries a fresh `request_id`. Replies
must echo that nonce and match the active query context, composer edit/cursor
revision, authority snapshot generation, and Worker target. Identical-prefix ABA
edits, cancelled/reopened queries, permission snapshots, and Worker/view switches
cannot reuse older replies or candidates. Uncorrelated replies are ignored, even
for File queries where the wire field remains optional for legacy callers.
The declared attachment adapter stages a client-local file in place, replacing
its staging chip with an `UploadedFile` reference when ready. Local file argument
completion runs on the client, not Worker. Alt+Enter beside a failed staging chip
retries with the same upload ID; deleting an unsent chip cancels only its owned
staging resource. Accepted, restored, or history attachments are never deleted by
composer cleanup. Staging blocks Send/Notify and history navigation until it is
ready, retried, or removed. There is no raw `/attach` or `/clear-attachments`
command lane.
Attachment staging remains Backend-only: the existing Standalone transport has
no client-local file-upload staging API. The TUI reports that limitation and
preserves the draft invocation; it never falls back to raw path dispatch.
+815 -45
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+654 -27
View File
@@ -115,12 +115,31 @@ impl FlowRefAtom {
}
}
#[derive(Debug, Clone)]
pub struct FeatureInvokeAtom {
pub invocation: protocol::FeatureInvocation,
}
impl FeatureInvokeAtom {
pub fn label(&self) -> String {
self.invocation.display_input()
}
}
#[derive(Debug, Clone)]
pub enum Atom {
Char(char),
Unknown,
AttachmentStage {
id: String,
file_name: String,
failed: bool,
},
Paste(PasteRef),
PasteArtifact(protocol::PasteArtifactRef),
UploadedFile(protocol::UploadedFileRef),
FileRef(FileRefAtom),
FeatureInvoke(FeatureInvokeAtom),
FlowRef(FlowRefAtom),
}
@@ -130,6 +149,23 @@ impl Atom {
fn chip(&self) -> Option<(Style, String)> {
match self {
Atom::Char(_) => None,
Atom::Unknown => Some((
Style::default().fg(Color::Red),
"[unknown input segment]".into(),
)),
Atom::AttachmentStage {
file_name, failed, ..
} => Some((
Style::default().fg(Color::Cyan),
format!(
"[Attached {file_name} | {}]",
if *failed {
"failed; Alt+Enter retries"
} else {
"uploading"
}
),
)),
Atom::Paste(p) => Some((Style::default().fg(Color::Magenta), p.label())),
Atom::PasteArtifact(artifact) => Some((
Style::default().fg(Color::Magenta),
@@ -143,7 +179,17 @@ impl Atom {
artifact.created_at_ms
),
)),
Atom::UploadedFile(file) => Some((
Style::default().fg(Color::Cyan),
format!(
"[Attached {} | {} bytes, ready]",
file.file_name, file.byte_len
),
)),
Atom::FileRef(r) => Some((Style::default().fg(Color::Cyan), r.label())),
Atom::FeatureInvoke(invocation) => {
Some((Style::default().fg(Color::Yellow), invocation.label()))
}
Atom::FlowRef(r) => Some((Style::default().fg(Color::Yellow), r.label())),
}
}
@@ -173,9 +219,14 @@ enum WordKind {
fn atom_class(atom: &Atom) -> AtomClass {
match atom {
Atom::Char(c) => char_class(*c),
Atom::Paste(_) | Atom::PasteArtifact(_) | Atom::FileRef(_) | Atom::FlowRef(_) => {
AtomClass::Chip
}
Atom::Unknown
| Atom::AttachmentStage { .. }
| Atom::Paste(_)
| Atom::PasteArtifact(_)
| Atom::UploadedFile(_)
| Atom::FileRef(_)
| Atom::FeatureInvoke(_)
| Atom::FlowRef(_) => AtomClass::Chip,
}
}
@@ -195,12 +246,53 @@ fn char_class(c: char) -> AtomClass {
}
}
fn first_unquoted_char(value: &str, target: char) -> Option<usize> {
unquoted_char_indices(value).find_map(|(index, ch)| (ch == target).then_some(index))
}
fn last_unquoted_char(value: &str, target: char) -> Option<usize> {
unquoted_char_indices(value)
.filter_map(|(index, ch)| (ch == target).then_some(index))
.last()
}
fn unquoted_char_indices(value: &str) -> impl Iterator<Item = (usize, char)> + '_ {
let mut quoted = false;
let mut escaped = false;
value.char_indices().filter(move |(_, ch)| {
if !escaped && *ch == '"' {
quoted = !quoted;
escaped = false;
return false;
}
let visible = !quoted;
escaped = quoted && !escaped && *ch == '\\';
if *ch != '\\' {
escaped = false;
}
visible
})
}
#[derive(Clone)]
struct SelectedInvocation {
start: usize,
descriptor: protocol::FeatureInvocationDescriptor,
invocation_id: String,
}
pub struct InputBuffer {
/// Monotonic semantic edit/cursor revision, including ABA changes.
revision: u64,
atoms: Vec<Atom>,
selected_invocations: Vec<SelectedInvocation>,
removed_attachment_stages: Vec<String>,
/// Insertion point in `0..=atoms.len()`.
cursor: usize,
/// Top wrapped row of the visible composer viewport.
scroll_offset: usize,
/// Uploaded draft chips removed by explicit editing and awaiting client-side cleanup.
removed_uploaded_files: Vec<protocol::UploadedFileRef>,
/// Monotonic counter reused across the TUI process lifetime.
next_paste_id: u32,
}
@@ -208,20 +300,34 @@ pub struct InputBuffer {
impl Default for InputBuffer {
fn default() -> Self {
Self {
revision: 0,
atoms: Vec::new(),
selected_invocations: Vec::new(),
removed_attachment_stages: Vec::new(),
cursor: 0,
scroll_offset: 0,
removed_uploaded_files: Vec::new(),
next_paste_id: 1,
}
}
}
impl InputBuffer {
pub fn revision(&self) -> u64 {
self.revision
}
fn bump_revision(&mut self) {
self.revision = self.revision.wrapping_add(1);
}
pub fn new() -> Self {
Self::default()
}
pub fn clear(&mut self) {
self.bump_revision();
self.selected_invocations.clear();
self.atoms.clear();
self.cursor = 0;
self.scroll_offset = 0;
@@ -243,6 +349,8 @@ impl InputBuffer {
/// by [`submit_segments`](Self::submit_segments), preserving typed chips
/// and placing the cursor at the end of the restored input.
pub fn replace_with_segments(&mut self, segments: &[protocol::Segment]) {
self.bump_revision();
self.selected_invocations.clear();
self.atoms.clear();
for segment in segments {
match segment {
@@ -267,24 +375,24 @@ impl InputBuffer {
self.atoms.push(Atom::PasteArtifact(artifact.clone()));
}
protocol::Segment::UploadedFile { file } => {
self.atoms.extend(
format!("[Attached file: {}]", file.file_name)
.chars()
.map(Atom::Char),
);
self.atoms.push(Atom::UploadedFile(file.clone()));
}
protocol::Segment::FileRef { path } => {
self.atoms
.push(Atom::FileRef(FileRefAtom { path: path.clone() }));
}
protocol::Segment::FeatureInvoke { invocation } => {
self.atoms.push(Atom::FeatureInvoke(FeatureInvokeAtom {
invocation: invocation.clone(),
}));
}
protocol::Segment::Flow { selector } => {
self.atoms.push(Atom::FlowRef(FlowRefAtom {
selector: selector.clone(),
}));
}
protocol::Segment::Unknown => {
self.atoms
.extend("[unknown input segment]".chars().map(Atom::Char));
self.atoms.push(Atom::Unknown);
}
}
}
@@ -292,6 +400,8 @@ impl InputBuffer {
}
pub fn insert_char(&mut self, c: char) {
self.bump_revision();
self.adjust_selection(self.cursor, self.cursor, 1);
self.atoms.insert(self.cursor, Atom::Char(c));
self.cursor += 1;
}
@@ -307,6 +417,10 @@ impl InputBuffer {
for atom in &self.atoms {
match atom {
Atom::Char(c) => text.push(*c),
Atom::Unknown => text.push_str("[unknown input segment]"),
Atom::AttachmentStage { file_name, .. } => {
text.push_str(&format!("[Staging {file_name}]"))
}
Atom::Paste(paste) => text.push_str(&paste.content),
Atom::PasteArtifact(artifact) => {
text.push_str(&protocol::Segment::flatten_to_text(&[
@@ -315,7 +429,11 @@ impl InputBuffer {
},
]))
}
Atom::UploadedFile(file) => {
text.push_str(&format!("[Attached file: {}]", file.file_name));
}
Atom::FileRef(file) => text.push_str(&file.path),
Atom::FeatureInvoke(invocation) => text.push_str(&invocation.label()),
Atom::FlowRef(flow) => text.push_str(&flow.selector),
}
}
@@ -327,6 +445,7 @@ impl InputBuffer {
}
pub fn insert_paste(&mut self, content: String) {
self.bump_revision();
let measurement = measure_paste(&content);
if measurement.presentation() == PastePresentation::Text {
self.insert_str(&content);
@@ -335,6 +454,7 @@ impl InputBuffer {
let id = self.next_paste_id;
self.next_paste_id = self.next_paste_id.wrapping_add(1);
self.adjust_selection(self.cursor, self.cursor, 1);
self.atoms.insert(
self.cursor,
Atom::Paste(PasteRef {
@@ -347,14 +467,236 @@ impl InputBuffer {
self.cursor += 1;
}
#[cfg(test)]
pub fn insert_uploaded_file(&mut self, file: protocol::UploadedFileRef) {
self.bump_revision();
self.adjust_selection(self.cursor, self.cursor, 1);
self.atoms.insert(self.cursor, Atom::UploadedFile(file));
self.cursor += 1;
}
// Selections belong to an exact occurrence, never to all text matching a name.
fn adjust_selection(&mut self, start: usize, end: usize, inserted: usize) {
self.selected_invocations.retain_mut(|selection| {
if start < end && (start..end).contains(&selection.start) {
return false;
}
if selection.start >= end {
selection.start = selection.start - (end - start) + inserted;
}
true
});
}
pub fn select_feature_invocation(
&mut self,
start: usize,
descriptor: protocol::FeatureInvocationDescriptor,
) {
self.bump_revision();
self.selected_invocations
.retain(|selection| selection.start != start);
self.selected_invocations.push(SelectedInvocation {
start,
descriptor,
invocation_id: protocol::new_submission_request_id(),
});
}
pub fn has_selected_feature_invocations(&self) -> bool {
!self.selected_invocations.is_empty()
}
pub fn retry_attachment_stage(&mut self, id: &str) {
self.bump_revision();
for atom in &mut self.atoms {
if let Atom::AttachmentStage {
id: stage_id,
failed,
..
} = atom
{
if stage_id == id {
*failed = false;
}
}
}
}
pub fn finalize_feature_invocations(&mut self) -> Result<(), String> {
// Validate first so invalid arguments cannot partially chip a draft.
let mut parsed = Vec::new();
for selection in &self.selected_invocations {
let text = self.atoms[selection.start..]
.iter()
.take_while(|atom| matches!(atom, Atom::Char(_)))
.filter_map(|atom| {
if let Atom::Char(ch) = atom {
Some(*ch)
} else {
None
}
})
.collect::<String>();
let invocation = protocol::parse_feature_invocation(
&text,
0,
&selection.descriptor,
selection.invocation_id.clone(),
)
.map_err(|error| error.to_string())?;
parsed.push((
selection.start,
text[..invocation.end].chars().count(),
invocation.invocation,
));
}
parsed.sort_by_key(|(start, _, _)| std::cmp::Reverse(*start));
for (start, count, invocation) in parsed {
self.replace_atoms(
start,
start + count,
vec![Atom::FeatureInvoke(FeatureInvokeAtom { invocation })],
);
}
Ok(())
}
fn replace_atoms(&mut self, start: usize, end: usize, atoms: Vec<Atom>) {
self.bump_revision();
let inserted = atoms.len();
self.adjust_selection(start, end, inserted);
let removed = self.atoms.splice(start..end, atoms).collect::<Vec<_>>();
for atom in removed {
self.record_removed_atom(atom);
}
if self.cursor >= end {
self.cursor = self.cursor - (end - start) + inserted;
} else if self.cursor > start {
self.cursor = start + inserted;
}
}
pub fn adjacent_feature_invocation(&self) -> Option<protocol::FeatureInvocation> {
[self.cursor.checked_sub(1), Some(self.cursor)]
.into_iter()
.flatten()
.find_map(|index| match self.atoms.get(index) {
Some(Atom::FeatureInvoke(atom)) => Some(atom.invocation.clone()),
_ => None,
})
}
pub fn edit_feature_invocation(
&mut self,
id: &str,
descriptor: protocol::FeatureInvocationDescriptor,
) -> bool {
let Some((index, invocation)) =
self.atoms
.iter()
.enumerate()
.find_map(|(index, atom)| match atom {
Atom::FeatureInvoke(atom) if atom.invocation.invocation_id == id => {
Some((index, atom.invocation.clone()))
}
_ => None,
})
else {
return false;
};
if invocation.identity != descriptor.identity {
return false;
}
let text = invocation.display_input();
self.replace_atoms(index, index + 1, text.chars().map(Atom::Char).collect());
self.cursor = index + text.chars().count() - 1;
self.selected_invocations.push(SelectedInvocation {
start: index,
descriptor,
invocation_id: invocation.invocation_id,
});
true
}
pub fn stage_attachment(&mut self, invocation_id: &str, id: String, file_name: String) -> bool {
let Some(index) = self.atoms.iter().position(|atom| matches!(atom, Atom::FeatureInvoke(atom) if atom.invocation.invocation_id == invocation_id)) else { return false; };
self.replace_atoms(
index,
index + 1,
vec![Atom::AttachmentStage {
id,
file_name,
failed: false,
}],
);
true
}
pub fn has_attachment_stages(&self) -> bool {
self.atoms
.iter()
.any(|atom| matches!(atom, Atom::AttachmentStage { .. }))
}
pub fn attachment_stage_ids(&self) -> Vec<String> {
self.atoms
.iter()
.filter_map(|atom| {
if let Atom::AttachmentStage { id, .. } = atom {
Some(id.clone())
} else {
None
}
})
.collect()
}
pub fn adjacent_failed_attachment(&self) -> Option<String> {
[self.cursor.checked_sub(1), Some(self.cursor)]
.into_iter()
.flatten()
.find_map(|index| match self.atoms.get(index) {
Some(Atom::AttachmentStage {
id, failed: true, ..
}) => Some(id.clone()),
_ => None,
})
}
pub fn finish_attachment_stage(
&mut self,
id: &str,
file: Option<protocol::UploadedFileRef>,
) -> bool {
self.bump_revision();
let Some(index) = self.atoms.iter().position(
|atom| matches!(atom, Atom::AttachmentStage { id: stage_id, .. } if stage_id == id),
) else {
return false;
};
if let Some(file) = file {
self.atoms[index] = Atom::UploadedFile(file);
} else if let Atom::AttachmentStage { failed, .. } = &mut self.atoms[index] {
*failed = true;
}
true
}
pub fn take_removed_attachment_stages(&mut self) -> Vec<String> {
std::mem::take(&mut self.removed_attachment_stages)
}
/// Replace `atoms[start..self.cursor]` (the in-flight `@<typed>` /
/// active `@<typed>` file token) with the corresponding chip atom
/// and place the cursor right after the chip. Used by the completion
/// confirm path.
pub fn replace_with_file_ref(&mut self, start: usize, path: String) {
self.atoms.drain(start..self.cursor);
self.atoms
.insert(start, Atom::FileRef(FileRefAtom { path }));
self.replace_atoms(
start,
self.cursor,
vec![Atom::FileRef(FileRefAtom { path })],
);
self.cursor = start + 1;
}
@@ -363,14 +705,177 @@ impl InputBuffer {
/// completion path: the popup-selected entry is inserted as raw
/// text (not a chip) so the user can keep typing — e.g. drill into
/// a directory whose value ends with `/`.
pub fn replace_with_text_at(&mut self, start: usize, text: &str) {
self.atoms.drain(start..self.cursor);
let mut idx = start;
for c in text.chars() {
self.atoms.insert(idx, Atom::Char(c));
idx += 1;
pub fn char_at(&self, index: usize) -> Option<char> {
match self.atoms.get(index) {
Some(Atom::Char(ch)) => Some(*ch),
_ => None,
}
self.cursor = idx;
}
pub fn replace_with_text_at(&mut self, start: usize, text: &str) {
self.replace_atoms(start, self.cursor, text.chars().map(Atom::Char).collect());
self.cursor = start + text.chars().count();
}
pub fn can_complete_argument_name(&self, prefix_start: usize) -> bool {
let mut before = prefix_start;
while self.char_at(before).is_some_and(char::is_whitespace) {
let Some(previous) = before.checked_sub(1) else {
return false;
};
before = previous;
}
matches!(self.char_at(before), Some('(' | ','))
}
pub fn replace_feature_name_completion(&mut self, start: usize, text: &str) {
let mut end = self.cursor;
while self
.char_at(end)
.is_some_and(|ch| ch.is_ascii_alphanumeric() || ch == '-')
{
end += 1;
}
if self.char_at(end) == Some('(') {
end += 1;
}
self.replace_atoms(start, end, text.chars().map(Atom::Char).collect());
self.cursor = start + text.chars().count();
}
pub fn replace_argument_completion(&mut self, start: usize, text: &str) {
// Replace the whole value including an existing closing quote when editing its middle.
let mut end = self.cursor;
let quoted = self.char_at(start) == Some('"');
let mut escaped = false;
while let Some(ch) = self.char_at(end) {
if !quoted && (ch == ',' || ch == ')' || ch.is_whitespace()) {
break;
}
end += 1;
if quoted && !escaped && ch == '"' {
break;
}
escaped = ch == '\\' && !escaped;
if ch != '\\' {
escaped = false;
}
}
if quoted && self.char_at(end.saturating_sub(1)) != Some('"') {
end = self.cursor;
}
self.replace_atoms(start, end, text.chars().map(Atom::Char).collect());
self.cursor = start + text.chars().count();
}
pub fn pending_feature_argument_completion(
&self,
descriptors: &[protocol::FeatureInvocationDescriptor],
) -> Option<(
protocol::CompletionKind,
usize,
String,
protocol::CompletionContext,
)> {
if self.cursor == 0 {
return None;
}
let selection = self
.selected_invocations
.iter()
.filter(|selected| selected.start < self.cursor)
.max_by_key(|selected| selected.start)?;
let run_start = selection.start;
let descriptor = &selection.descriptor;
if !descriptors
.iter()
.any(|candidate| candidate.identity == descriptor.identity)
{
return None;
}
let invocation = self.atoms[run_start..self.cursor]
.iter()
.map(|atom| match atom {
Atom::Char(ch) => Some(*ch),
_ => None,
})
.collect::<Option<String>>()?;
let open = invocation.find('(')?;
let arguments = &invocation[open + 1..];
if first_unquoted_char(arguments, ')').is_some() {
return None;
}
let current_start = last_unquoted_char(arguments, ',').map_or(0, |index| index + 1);
let current = &arguments[current_start..];
let leading = current.len() - current.trim_start().len();
let current = &current[leading..];
let (argument, prefix_offset, mut prefix) =
if let Some(equal) = first_unquoted_char(current, '=') {
let name = current[..equal].trim();
let mut value = &current[equal + 1..];
let mut offset = equal + 1;
let value_leading = value.len() - value.trim_start().len();
value = value.trim_start();
offset += value_leading;
if let Some(unquoted) = value.strip_prefix('"') {
value = unquoted;
offset += 1;
}
(Some(name.to_string()), offset, value.to_string())
} else {
let mut previous_start = 0;
let mut position = 0;
for comma in unquoted_char_indices(&arguments[..current_start])
.filter_map(|(index, ch)| (ch == ',').then_some(index))
{
if first_unquoted_char(&arguments[previous_start..comma], '=').is_none() {
position += 1;
}
previous_start = comma + 1;
}
let positional = descriptor
.arguments
.iter()
.find(|argument| argument.position == Some(position));
if (current.is_empty() && positional.is_none())
|| (current.bytes().all(|byte| {
byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_'
}) && positional.is_none())
{
(None, 0, current.to_string())
} else {
let mut value = current;
let mut offset = 0;
if let Some(unquoted) = value.strip_prefix('"') {
value = unquoted;
offset = 1;
}
(
positional.map(|argument| argument.name.clone()),
offset,
value.to_string(),
)
}
};
if prefix_offset > 0 && current[..prefix_offset].ends_with('"') {
if serde_json::from_str::<String>(&format!("\"{}", prefix)).is_ok() {
return None;
}
prefix = serde_json::from_str::<String>(&format!("\"{}\"", prefix)).ok()?;
}
let prefix_start = run_start
+ invocation[..open + 1].chars().count()
+ arguments[..current_start + leading].chars().count()
+ current[..prefix_offset].chars().count();
Some((
protocol::CompletionKind::FeatureArgument,
prefix_start.saturating_sub(1),
prefix,
protocol::CompletionContext {
invocation: descriptor.identity.clone(),
argument,
},
))
}
/// If the cursor is currently inside a `@<typed>` /
@@ -398,7 +903,9 @@ impl InputBuffer {
}
let kind = match c {
'@' => Some(protocol::CompletionKind::File),
'/' if !typed.contains(['(', ')', '=', ',']) => {
Some(protocol::CompletionKind::Feature)
}
_ => None,
};
if let Some(k) = kind {
@@ -422,17 +929,29 @@ impl InputBuffer {
None
}
pub fn take_removed_uploaded_files(&mut self) -> Vec<protocol::UploadedFileRef> {
std::mem::take(&mut self.removed_uploaded_files)
}
fn record_removed_atom(&mut self, atom: Atom) {
match atom {
Atom::UploadedFile(file) => self.removed_uploaded_files.push(file),
Atom::AttachmentStage { id, .. } => self.removed_attachment_stages.push(id),
_ => {}
}
}
pub fn delete_before(&mut self) {
if self.cursor == 0 {
return;
}
self.cursor -= 1;
self.atoms.remove(self.cursor);
self.replace_atoms(self.cursor, self.cursor + 1, Vec::new());
}
pub fn delete_after(&mut self) {
if self.cursor < self.atoms.len() {
self.atoms.remove(self.cursor);
self.replace_atoms(self.cursor, self.cursor + 1, Vec::new());
}
}
@@ -442,14 +961,16 @@ impl InputBuffer {
let end = self.cursor;
self.move_word_left();
let start = self.cursor;
self.atoms.drain(start..end);
self.replace_atoms(start, end, Vec::new());
}
pub fn move_left(&mut self) {
self.bump_revision();
self.cursor = self.cursor.saturating_sub(1);
}
pub fn move_right(&mut self) {
self.bump_revision();
self.cursor = (self.cursor + 1).min(self.atoms.len());
}
@@ -457,6 +978,7 @@ impl InputBuffer {
/// atoms sharing the same [`AtomClass`] — so `Word(Hiragana)` next to
/// `Word(Han)` are separate blocks, and a `Paste` atom is its own block.
pub fn move_word_left(&mut self) {
self.bump_revision();
while self.cursor > 0 && atom_class(&self.atoms[self.cursor - 1]) == AtomClass::Sep {
self.cursor -= 1;
}
@@ -471,6 +993,7 @@ impl InputBuffer {
/// Move forward by one word. Mirror of [`move_word_left`].
pub fn move_word_right(&mut self) {
self.bump_revision();
while self.cursor < self.atoms.len()
&& atom_class(&self.atoms[self.cursor]) == AtomClass::Sep
{
@@ -486,6 +1009,7 @@ impl InputBuffer {
}
pub fn move_start(&mut self) {
self.bump_revision();
self.cursor = 0;
}
@@ -534,11 +1058,13 @@ impl InputBuffer {
}
pub fn move_home(&mut self) {
self.bump_revision();
let (ranges, line, _) = self.logical_line_and_col();
self.cursor = ranges[line].0;
}
pub fn move_end(&mut self) {
self.bump_revision();
let (ranges, line, _) = self.logical_line_and_col();
self.cursor = ranges[line].1;
}
@@ -546,6 +1072,7 @@ impl InputBuffer {
/// Move one logical line up, preserving column (atom count from
/// current line start). No-op if already on the first line.
pub fn move_up(&mut self) {
self.bump_revision();
let (ranges, line, col) = self.logical_line_and_col();
if line == 0 {
return;
@@ -556,6 +1083,7 @@ impl InputBuffer {
/// Move one logical line down, preserving column.
pub fn move_down(&mut self) {
self.bump_revision();
let (ranges, line, col) = self.logical_line_and_col();
let Some(&(start, end)) = ranges.get(line + 1) else {
return;
@@ -580,6 +1108,12 @@ impl InputBuffer {
for a in &self.atoms {
match a {
Atom::Char(c) => buf.push(*c),
Atom::Unknown => {
flush_text(&mut buf, &mut out);
out.push(protocol::Segment::Unknown);
}
// Staging is client-owned and never a submit payload.
Atom::AttachmentStage { .. } => {}
Atom::Paste(p) => {
flush_text(&mut buf, &mut out);
out.push(protocol::Segment::Paste {
@@ -595,12 +1129,22 @@ impl InputBuffer {
artifact: artifact.clone(),
});
}
Atom::UploadedFile(file) => {
flush_text(&mut buf, &mut out);
out.push(protocol::Segment::UploadedFile { file: file.clone() });
}
Atom::FileRef(r) => {
flush_text(&mut buf, &mut out);
out.push(protocol::Segment::FileRef {
path: r.path.clone(),
});
}
Atom::FeatureInvoke(invocation) => {
flush_text(&mut buf, &mut out);
out.push(protocol::Segment::FeatureInvoke {
invocation: invocation.invocation.clone(),
});
}
Atom::FlowRef(r) => {
flush_text(&mut buf, &mut out);
out.push(protocol::Segment::Flow {
@@ -1531,9 +2075,14 @@ mod word_motion_tests {
for a in &buf.atoms {
match a {
Atom::Char(c) => out.push(*c),
Atom::Paste(_) | Atom::PasteArtifact(_) | Atom::FileRef(_) | Atom::FlowRef(_) => {
out.push_str("<P>")
}
Atom::Unknown
| Atom::AttachmentStage { .. }
| Atom::Paste(_)
| Atom::PasteArtifact(_)
| Atom::UploadedFile(_)
| Atom::FileRef(_)
| Atom::FeatureInvoke(_)
| Atom::FlowRef(_) => out.push_str("<P>"),
}
}
out
@@ -1621,3 +2170,81 @@ mod word_motion_tests {
assert_eq!(cursor(&buf), 7); // after "った"
}
}
#[cfg(test)]
mod feature_invocation_input_tests {
use super::*;
use protocol::{
FeatureInvocationDescriptor, FeatureInvocationIdentity, FeatureInvocationSyntax,
InvocationArgumentDescriptor, InvocationArgumentType, InvocationCompletion, Segment,
UploadedFileAvailability, UploadedFileRef,
};
fn descriptor() -> FeatureInvocationDescriptor {
FeatureInvocationDescriptor {
identity: FeatureInvocationIdentity("builtin:test/run".into()),
name: "run".into(),
aliases: Vec::new(),
display_name: "Run".into(),
description: "test".into(),
syntax: FeatureInvocationSyntax::Parenthesized,
arguments: vec![InvocationArgumentDescriptor {
name: "path".into(),
position: Some(0),
required: true,
value_type: InvocationArgumentType::String,
completion: InvocationCompletion::None,
description: None,
}],
client_adapter: None,
}
}
#[test]
fn selected_invocations_become_typed_atoms_and_preserve_following_prose() {
let mut input = InputBuffer::new();
input.insert_str("before /run(\"資料/a b\") after /run(\"x/y\") done");
input.select_feature_invocation(7, descriptor());
input.select_feature_invocation(
"before /run(\"資料/a b\") after ".chars().count(),
descriptor(),
);
input.finalize_feature_invocations().unwrap();
let segments = input.submit_segments();
assert_eq!(
segments
.iter()
.filter(|segment| matches!(segment, Segment::FeatureInvoke { .. }))
.count(),
2
);
assert!(matches!(segments.last(), Some(Segment::Text { content }) if content == " done"));
let mut unselected = InputBuffer::new();
unselected.insert_str("/run(\"x\")");
unselected.finalize_feature_invocations().unwrap();
assert!(matches!(
unselected.submit_segments().as_slice(),
[Segment::Text { content }] if content == "/run(\"x\")"
));
}
#[test]
fn deleting_an_uploaded_file_chip_reports_exact_staged_resource() {
let file = UploadedFileRef {
artifact_id: "artifact-1".into(),
file_name: "report.md".into(),
media_type: "text/markdown".into(),
created_at_ms: 1,
availability: UploadedFileAvailability::Available,
byte_len: 4,
sha256: "a".repeat(64),
source_entry_id: None,
};
let mut input = InputBuffer::new();
input.insert_uploaded_file(file.clone());
input.delete_before();
assert_eq!(input.take_removed_uploaded_files(), vec![file]);
assert!(input.take_removed_uploaded_files().is_empty());
}
}
+653
View File
@@ -0,0 +1,653 @@
//! T-696 client contract regressions, exercised through real composer selection.
use crate::{app::App, input::InputBuffer};
use protocol::*;
pub(crate) fn descriptor() -> FeatureInvocationDescriptor {
FeatureInvocationDescriptor {
identity: FeatureInvocationIdentity("plugin:test/run".into()),
name: "run".into(),
aliases: vec!["execute".into()],
display_name: "Run".into(),
description: "Run a declared action".into(),
syntax: FeatureInvocationSyntax::Parenthesized,
arguments: vec![
InvocationArgumentDescriptor {
name: "path".into(),
position: Some(0),
required: true,
value_type: InvocationArgumentType::WorkerFile,
completion: InvocationCompletion::WorkerFile,
description: None,
},
InvocationArgumentDescriptor {
name: "mode".into(),
position: None,
required: false,
value_type: InvocationArgumentType::Enum {
values: vec!["safe".into(), "fast".into()],
},
completion: InvocationCompletion::Static {
values: vec!["safe".into(), "fast".into()],
},
description: None,
},
InvocationArgumentDescriptor {
name: "count".into(),
position: None,
required: false,
value_type: InvocationArgumentType::Integer,
completion: InvocationCompletion::None,
description: None,
},
InvocationArgumentDescriptor {
name: "force".into(),
position: None,
required: false,
value_type: InvocationArgumentType::Boolean,
completion: InvocationCompletion::None,
description: None,
},
],
client_adapter: None,
}
}
pub(crate) fn attachment_descriptor() -> FeatureInvocationDescriptor {
let mut descriptor = descriptor();
// No hardcoded builtin identity, public name, or argument name in adapter dispatch.
descriptor.identity = FeatureInvocationIdentity("plugin:files/add".into());
descriptor.name = "add-file".into();
descriptor.aliases.clear();
descriptor.arguments.truncate(1);
descriptor.arguments[0].name = "resource".into();
descriptor.arguments[0].value_type = InvocationArgumentType::ClientFile;
descriptor.arguments[0].completion = InvocationCompletion::ClientFile;
descriptor.client_adapter = Some(InvocationClientAdapter::Attachment);
descriptor
}
pub(crate) fn uploaded_file() -> UploadedFileRef {
UploadedFileRef {
artifact_id: "artifact-owned".into(),
file_name: "資料 a.txt".into(),
media_type: "text/plain".into(),
created_at_ms: 1,
availability: UploadedFileAvailability::Available,
byte_len: 4,
sha256: "a".repeat(64),
source_entry_id: None,
}
}
pub(crate) fn select(
app: &mut App,
descriptor: FeatureInvocationDescriptor,
name: &str,
) -> Option<Method> {
app.insert_char('/');
assert!(matches!(
app.refresh_completion(),
Some(Method::ListCompletions {
kind: CompletionKind::Feature,
..
})
));
app.handle_worker_event(Event::Completions {
request_id: app.completion_request_id(),
kind: CompletionKind::Feature,
prefix: String::new(),
context: None,
entries: vec![CompletionEntry {
value: name.into(),
invocation: Some(descriptor),
..Default::default()
}],
});
app.apply_completion_text()
}
fn complete(app: &mut App, value: &str, is_dir: bool) -> Option<Method> {
let state = app.completion.as_ref().expect("argument completion");
app.handle_worker_event(Event::Completions {
request_id: app.completion_request_id(),
kind: state.kind,
prefix: state.prefix.clone(),
context: state.context.clone(),
entries: vec![CompletionEntry {
value: value.into(),
is_dir,
..Default::default()
}],
});
app.apply_completion_text()
}
#[test]
fn feature_invocation_discovery_alias_selection_and_confirmation_are_occurrence_scoped() {
let mut app = App::new("test".into());
app.input.insert_str("説明 ");
assert!(
matches!(select(&mut app, descriptor(), "execute"), Some(Method::ListCompletions {
kind: CompletionKind::FeatureArgument, context: Some(CompletionContext { argument: Some(argument), .. }), ..
}) if argument == "path")
);
app.input
.insert_str("\"資料/a b\") after /execute(\"not selected\")");
assert!(
app.submit_input().is_none(),
"first Enter confirms typed chips, not Send"
);
let segments = app.input.submit_segments();
assert!(
matches!(&segments[1], Segment::FeatureInvoke { invocation } if invocation.identity == descriptor().identity && invocation.name == "execute")
);
assert!(
matches!(segments.last(), Some(Segment::Text { content }) if content == " after /execute(\"not selected\")")
);
assert!(matches!(app.submit_input(), Some(Method::Submit { input, .. }) if input == segments));
}
#[test]
fn feature_invocation_raw_text_paste_and_unknown_payloads_do_not_dispatch() {
let mut app = App::new("test".into());
app.input
.insert_str("/run(x) /attach(/tmp/a.txt) /clear-attachments");
assert!(
matches!(app.submit_input(), Some(Method::Submit { input, .. }) if matches!(input.as_slice(), [Segment::Text { .. }]))
);
let unknown: Segment =
serde_json::from_str(r#"{"kind":"future_invocation","identity":"plugin:test/run"}"#)
.unwrap();
app.input.replace_with_segments(&[unknown]);
assert_eq!(app.input.submit_segments(), vec![Segment::Unknown]);
let mut input = InputBuffer::new();
input.insert_paste("/run(x) ".repeat(20));
input.finalize_feature_invocations().unwrap();
assert!(matches!(
input.submit_segments().as_slice(),
[Segment::Paste { .. }]
));
}
#[test]
fn feature_invocation_argument_insertion_quotes_unicode_spaces_escapes_and_keeps_prose() {
let mut app = App::new("test".into());
app.input.insert_str("界 ");
select(&mut app, descriptor(), "run");
let value = "資料/a b/quote\"slash\\end";
complete(&mut app, value, false);
app.input.insert_str(", mode=sa");
app.refresh_completion();
complete(&mut app, "safe", false);
app.input.insert_str(", count=1");
app.refresh_completion();
complete(&mut app, "12", false);
app.input.insert_str(", force=f");
app.refresh_completion();
complete(&mut app, "false", false);
app.input.insert_str(") trailing prose");
assert!(app.submit_input().is_none());
let segments = app.input.submit_segments();
let Segment::FeatureInvoke { invocation } = &segments[1] else {
panic!("typed chip expected")
};
assert_eq!(
invocation.arguments[0].value,
InvocationValue::String(value.into())
);
assert_eq!(invocation.arguments[2].value, InvocationValue::Integer(12));
assert_eq!(
invocation.arguments[3].value,
InvocationValue::Boolean(false)
);
assert!(
matches!(segments.last(), Some(Segment::Text { content }) if content == " trailing prose")
);
}
#[test]
fn feature_invocation_argument_names_and_directory_drill_in_are_safe() {
let mut app = App::new("test".into());
select(&mut app, descriptor(), "run");
assert!(
matches!(complete(&mut app, "資料 dir", true), Some(Method::ListCompletions { prefix, .. }) if prefix == "資料 dir/")
);
complete(&mut app, "資料 dir/file name.txt", false);
app.input.insert_str(", mo");
app.refresh_completion();
assert!(
app.completion
.as_ref()
.unwrap()
.context
.as_ref()
.unwrap()
.argument
.is_none()
);
assert!(
matches!(complete(&mut app, "mode=", false), Some(Method::ListCompletions { context: Some(CompletionContext { argument: Some(argument), .. }), .. }) if argument == "mode")
);
complete(&mut app, "fast", false);
app.input.insert_char(')');
assert!(app.submit_input().is_none());
assert!(matches!(
app.input.submit_segments().as_slice(),
[Segment::FeatureInvoke { .. }]
));
}
#[test]
fn feature_invocation_completion_replaces_middle_value_without_suffix_corruption() {
let mut input = InputBuffer::new();
input.insert_str("界 /run(path= \"資料/old name\", mode=safe) prose");
input.select_feature_invocation(2, descriptor());
input.move_home();
for _ in 0.."界 /run(path= \"資料/ol".chars().count() {
input.move_right();
}
let (_, before, prefix, context) = input
.pending_feature_argument_completion(&[descriptor()])
.unwrap();
assert_eq!(prefix, "資料/ol");
assert_eq!(context.argument.as_deref(), Some("path"));
let start = before; // preceding atom is the opening quote
input.replace_argument_completion(start, &quote_invocation_string("資料/new name"));
assert_eq!(
input.plain_text(),
"界 /run(path= \"資料/new name\", mode=safe) prose"
);
input.finalize_feature_invocations().unwrap();
assert!(
matches!(input.submit_segments().last(), Some(Segment::Text { content }) if content == " prose")
);
}
#[test]
fn feature_invocation_validation_is_atomic_for_multiple_selected_calls() {
for invalid in [
"/run()",
"/run(x, nope=y)",
"/run(x, mode=unknown)",
"/run(x, count=oops)",
"/run(x, force=yes)",
"/run(x, path=y)",
"/run(\"unfinished",
] {
let mut input = InputBuffer::new();
input.insert_str("/run(ok) then ");
let second = input.plain_text().chars().count();
input.insert_str(invalid);
input.select_feature_invocation(0, descriptor());
input.select_feature_invocation(second, descriptor());
let before = input.submit_segments();
assert!(input.finalize_feature_invocations().is_err(), "{invalid}");
assert_eq!(input.submit_segments(), before);
}
}
#[test]
fn feature_invocation_selection_tracks_cursor_edits_and_does_not_survive_deleting_its_sigil() {
let mut input = InputBuffer::new();
input.insert_str("/run(x)");
input.select_feature_invocation(0, descriptor());
input.move_home();
input.insert_str("prefix ");
input.finalize_feature_invocations().unwrap();
assert!(matches!(
&input.submit_segments()[1],
Segment::FeatureInvoke { .. }
));
input.replace_with_segments(&[Segment::text("/run(y)")]);
input.select_feature_invocation(0, descriptor());
input.move_home();
input.delete_after();
input.insert_char('/');
input.finalize_feature_invocations().unwrap();
assert_eq!(input.submit_segments(), vec![Segment::text("/run(y)")]);
}
#[test]
fn feature_invocation_chip_reedit_preserves_identity_id_and_typed_arguments() {
let parsed = parse_feature_invocation(
"/execute(\"a b\", mode=safe, count=2)",
0,
&descriptor(),
"stable-id",
)
.unwrap()
.invocation;
let segments = vec![
Segment::FeatureInvoke {
invocation: parsed.clone(),
},
Segment::UploadedFile {
file: uploaded_file(),
},
Segment::Flow {
selector: "review".into(),
},
Segment::FileRef {
path: "src/".into(),
},
];
let mut input = InputBuffer::new();
input.replace_with_segments(&segments);
assert_eq!(input.submit_segments(), segments);
assert!(input.edit_feature_invocation("stable-id", descriptor()));
input.finalize_feature_invocations().unwrap();
assert_eq!(input.submit_segments(), segments);
assert!(!input.edit_feature_invocation("stable-id", attachment_descriptor()));
}
#[test]
fn feature_invocation_reedit_unknown_disabled_or_stale_keeps_original_chip() {
let invocation = parse_feature_invocation("/run(x)", 0, &descriptor(), "id")
.unwrap()
.invocation;
let mut app = App::new("test".into());
let segments = vec![Segment::FeatureInvoke { invocation }];
app.input.replace_with_segments(&segments);
assert!(matches!(
app.edit_adjacent_feature_invocation(),
Some(Method::ListCompletions { .. })
));
app.handle_worker_event(Event::Completions {
request_id: app.completion_request_id(),
kind: CompletionKind::Feature,
prefix: "run".into(),
context: None,
entries: Vec::new(),
});
assert_eq!(app.input.submit_segments(), segments);
app.edit_adjacent_feature_invocation();
app.input.move_home();
app.refresh_completion();
app.handle_worker_event(Event::Completions {
request_id: app.completion_request_id(),
kind: CompletionKind::Feature,
prefix: "run".into(),
context: None,
entries: vec![CompletionEntry {
value: "run".into(),
invocation: Some(descriptor()),
..Default::default()
}],
});
assert_eq!(app.input.submit_segments(), segments);
}
#[test]
fn feature_invocation_generic_attachment_adapter_never_records_or_sends_local_path() {
let mut app = App::new("test".into());
select(&mut app, attachment_descriptor(), "add-file");
app.input.insert_str("\"/tmp/資料 a.txt\") keep this prose");
assert!(app.submit_input().is_none());
assert_eq!(app.input_history_len(), 0);
let adapters = app.attachment_invocations();
assert_eq!(adapters.len(), 1);
assert_eq!(adapters[0].1.to_str().unwrap(), "/tmp/資料 a.txt");
app.input
.stage_attachment(&adapters[0].0, "staging-id".into(), "資料 a.txt".into());
assert!(app.submit_input().is_none());
assert!(app.submit_notify_input().is_none());
app.input
.finish_attachment_stage("staging-id", Some(uploaded_file()));
let Method::Submit { input, .. } = app.submit_input().unwrap() else {
panic!("submit expected")
};
assert!(matches!(&input[0], Segment::UploadedFile { .. }));
assert!(!serde_json::to_string(&input).unwrap().contains("/tmp/"));
assert!(
matches!(input.last(), Some(Segment::Text { content }) if content == " keep this prose")
);
}
#[test]
fn feature_invocation_staging_deletion_and_late_completion_do_not_resurrect_chips() {
for delete in [0, 1, 2] {
let invocation = parse_feature_invocation("/run(x)", 0, &descriptor(), "invoke")
.unwrap()
.invocation;
let mut input = InputBuffer::new();
input.replace_with_segments(&[Segment::FeatureInvoke { invocation }]);
input.stage_attachment("invoke", "stage".into(), "a.txt".into());
match delete {
0 => input.delete_before(),
1 => {
input.move_left();
input.delete_after();
}
_ => input.delete_word_before(),
}
assert_eq!(input.take_removed_attachment_stages(), vec!["stage"]);
assert!(!input.finish_attachment_stage("stage", Some(uploaded_file())));
assert!(input.is_empty());
}
}
#[test]
fn feature_invocation_typed_history_draft_transport_and_rewind_restore_without_flattening() {
let invocation = parse_feature_invocation(
"/run(\"資料/a b\", mode=safe)",
0,
&descriptor(),
"stable-id",
)
.unwrap()
.invocation;
let segments = vec![
Segment::text("before "),
Segment::FeatureInvoke { invocation },
Segment::UploadedFile {
file: uploaded_file(),
},
Segment::Paste {
id: 2,
chars: 4,
lines: 1,
content: "data".into(),
},
Segment::FileRef {
path: "src/".into(),
},
Segment::Flow {
selector: "review".into(),
},
];
let mut app = App::new("test".into());
app.input.replace_with_segments(&segments);
let method = app.submit_input().unwrap();
app.restore_unsent_run(&method);
assert_eq!(app.input.submit_segments(), segments);
app.input.clear();
let draft = vec![
Segment::text("draft "),
Segment::UploadedFile {
file: uploaded_file(),
},
];
app.input.replace_with_segments(&draft);
app.input.move_home();
assert!(app.browse_input_history_older());
assert_eq!(app.input.submit_segments(), segments);
assert!(app.browse_input_history_newer());
assert_eq!(app.input.submit_segments(), draft);
app.input.clear();
app.handle_worker_event(Event::RewindApplied {
session: SessionSnapshot {
entries: vec![],
pending_submissions: PendingSubmissionsSnapshot::default(),
},
input: segments.clone(),
summary: RewindSummary {
truncated_to_entries: 1,
discarded_entries: 2,
tool_side_effect_warning: false,
},
});
assert_eq!(app.input.submit_segments(), segments);
}
#[test]
fn feature_invocation_stale_argument_context_and_unvalidated_discovery_entries_are_ignored() {
let mut app = App::new("test".into());
select(&mut app, descriptor(), "run");
let state = app.completion.as_ref().unwrap();
let prefix = state.prefix.clone();
let mut context = state.context.clone().unwrap();
context.invocation = FeatureInvocationIdentity("plugin:other/run".into());
app.handle_worker_event(Event::Completions {
request_id: app.completion_request_id(),
kind: CompletionKind::FeatureArgument,
prefix,
context: Some(context),
entries: vec![CompletionEntry {
value: "unsafe".into(),
..Default::default()
}],
});
assert!(app.completion.as_ref().unwrap().entries.is_empty());
let mut app = App::new("test".into());
app.insert_char('/');
app.refresh_completion();
let mut invalid = descriptor();
invalid.identity = FeatureInvocationIdentity("unqualified".into());
app.handle_worker_event(Event::Completions {
request_id: app.completion_request_id(),
kind: CompletionKind::Feature,
prefix: String::new(),
context: None,
entries: vec![CompletionEntry {
value: "run".into(),
invocation: Some(invalid),
..Default::default()
}],
});
assert!(app.apply_completion_text().is_none());
assert_eq!(app.input.plain_text(), "/");
}
#[test]
fn feature_invocation_multiple_selected_calls_preserve_prose_and_source_qualified_identity() {
let mut app = App::new("test".into());
select(&mut app, descriptor(), "run");
app.input.insert_str("x) prose ");
let mut other = descriptor();
other.identity = FeatureInvocationIdentity("plugin:other/second".into());
other.name = "second".into();
other.aliases.clear();
select(&mut app, other.clone(), "second");
app.input.insert_str("\"y z\") done");
assert!(app.submit_input().is_none());
let segments = app.input.submit_segments();
assert!(
matches!(&segments[0], Segment::FeatureInvoke { invocation } if invocation.identity == descriptor().identity)
);
assert!(matches!(&segments[1], Segment::Text { content } if content == " prose "));
assert!(
matches!(&segments[2], Segment::FeatureInvoke { invocation } if invocation.identity == other.identity)
);
assert!(matches!(&segments[3], Segment::Text { content } if content == " done"));
}
#[test]
fn feature_invocation_range_replacement_reports_each_removed_upload_and_stage() {
let invocation = parse_feature_invocation("/run(x)", 0, &descriptor(), "id")
.unwrap()
.invocation;
let mut input = InputBuffer::new();
input.replace_with_segments(&[
Segment::UploadedFile {
file: uploaded_file(),
},
Segment::text(" "),
Segment::FeatureInvoke { invocation },
]);
input.stage_attachment("id", "stage".into(), "a.txt".into());
input.replace_with_text_at(0, "replacement");
assert_eq!(input.take_removed_uploaded_files(), vec![uploaded_file()]);
assert_eq!(input.take_removed_attachment_stages(), vec!["stage"]);
assert_eq!(input.plain_text(), "replacement");
}
#[test]
fn feature_invocation_argument_positions_ignore_previous_named_arguments() {
let mut input = InputBuffer::new();
input.insert_str("/run(mode=safe, \"資料/a");
input.select_feature_invocation(0, descriptor());
let (_, _, prefix, context) = input
.pending_feature_argument_completion(&[descriptor()])
.unwrap();
assert_eq!(context.argument.as_deref(), Some("path"));
assert_eq!(prefix, "資料/a");
}
#[test]
fn feature_invocation_name_completion_replaces_suffix_at_middle_cursor() {
let mut app = App::new("test".into());
app.input.insert_str("/run() prose");
app.input.move_home();
app.input.move_right();
app.input.move_right();
app.refresh_completion();
app.handle_worker_event(Event::Completions {
request_id: app.completion_request_id(),
kind: CompletionKind::Feature,
prefix: "r".into(),
context: None,
entries: vec![CompletionEntry {
value: "run".into(),
invocation: Some(descriptor()),
..Default::default()
}],
});
app.apply_completion_text();
assert_eq!(app.input.plain_text(), "/run() prose");
}
#[test]
fn feature_invocation_discovery_expands_aliases_from_host_metadata() {
let mut app = App::new("test".into());
app.input.insert_str("/exe");
app.refresh_completion();
app.handle_worker_event(Event::Completions {
request_id: app.completion_request_id(),
kind: CompletionKind::Feature,
prefix: "exe".into(),
context: None,
entries: vec![CompletionEntry {
value: "run".into(),
invocation: Some(descriptor()),
..Default::default()
}],
});
assert_eq!(app.completion.as_ref().unwrap().entries[0].value, "execute");
app.apply_completion_text();
assert_eq!(app.input.plain_text(), "/execute(");
}
#[test]
fn feature_invocation_named_arguments_are_suggested_alongside_positional_values() {
let mut app = App::new("test".into());
select(&mut app, descriptor(), "run");
app.input.insert_str("pa");
app.refresh_completion();
let state = app.completion.as_ref().unwrap();
app.handle_worker_event(Event::Completions {
request_id: app.completion_request_id(),
kind: state.kind,
prefix: state.prefix.clone(),
context: state.context.clone(),
entries: Vec::new(),
});
assert_eq!(app.completion.as_ref().unwrap().entries[0].value, "path=");
app.apply_completion_text();
assert_eq!(app.input.plain_text(), "/run(path=");
app.input.insert_str("\"資料/path\")");
assert!(app.submit_input().is_none());
assert!(matches!(
app.input.submit_segments().as_slice(),
[Segment::FeatureInvoke { .. }]
));
}
+2
View File
@@ -13,6 +13,8 @@ mod console;
mod e2e_observer;
mod inline_terminal;
mod input;
#[cfg(test)]
mod invocation_tests;
pub mod keys;
mod markdown;
mod scroll;
+46 -8
View File
@@ -366,6 +366,23 @@ fn task_status_mark(status: TaskStatus) -> (&'static str, Style) {
/// taller than that single row); `Clear` blanks the cells first so
/// underlying text doesn't bleed through. The popup width matches the
/// widest visible label, capped at the input-area width.
fn completion_entry_label(entry: &CompletionEntry) -> String {
if let Some(invocation) = &entry.invocation {
format!(
"/{} — {} — {}",
entry.value,
invocation.description,
invocation.usage()
)
} else {
let suffix = if entry.is_dir { "/" } else { "" };
match &entry.description {
Some(description) => format!("{}{suffix} — {description}", entry.value),
None => format!("{}{suffix}", entry.value),
}
}
}
fn draw_completion_popup(frame: &mut Frame, state: &CompletionState, input_area: Rect) {
let entries = &state.entries;
if entries.is_empty() || input_area.y == 0 {
@@ -380,16 +397,10 @@ fn draw_completion_popup(frame: &mut Frame, state: &CompletionState, input_area:
};
let view_end = (view_start + visible).min(entries.len());
let label_for = |entry: &CompletionEntry| {
let mut s = entry.value.clone();
if entry.is_dir {
s.push('/');
}
s
};
let label_for = |entry: &CompletionEntry| completion_entry_label(entry);
let max_label = entries[view_start..view_end]
.iter()
.map(|e| label_for(e).chars().count() as u16)
.map(|e| label_for(e).width().min(u16::MAX as usize) as u16)
.max()
.unwrap_or(0);
let popup_w = max_label.saturating_add(2).min(input_area.width).max(1);
@@ -1341,6 +1352,10 @@ fn chip_span_for(seg: &Segment, fallback: Style) -> (Style, String) {
),
),
Segment::FileRef { path } => (Style::default().fg(Color::Cyan), format!("@{path}")),
Segment::FeatureInvoke { invocation } => (
Style::default().fg(Color::Yellow),
invocation.display_input(),
),
Segment::Flow { selector } => (
Style::default().fg(Color::Yellow),
format!("[Flow: {selector}]"),
@@ -1375,6 +1390,7 @@ fn segment_display_text(seg: &Segment) -> String {
file.availability.as_str()
),
Segment::FileRef { path } => format!("@{path}"),
Segment::FeatureInvoke { invocation } => invocation.display_input(),
Segment::Flow { selector } => format!("[Flow: {selector}]"),
Segment::Unknown => "[unknown segment]".to_owned(),
}
@@ -2186,6 +2202,28 @@ mod tests {
});
}
#[test]
fn feature_invocation_completion_labels_include_alias_description_usage_and_argument_help() {
let descriptor = crate::invocation_tests::descriptor();
let entry = CompletionEntry {
value: "execute".into(),
invocation: Some(descriptor.clone()),
..Default::default()
};
let label = completion_entry_label(&entry);
assert!(label.starts_with("/execute"));
assert!(label.contains(&descriptor.description));
assert!(label.contains(&descriptor.usage()));
assert_eq!(
completion_entry_label(&CompletionEntry {
value: "mode=".into(),
description: Some("execution mode".into()),
..Default::default()
}),
"mode= — execution mode"
);
}
#[test]
fn run_status_line_matches_console_metrics_and_spinner_frame() {
let now = Instant::now();
+4 -1
View File
@@ -720,6 +720,7 @@ pub trait WorkerExecutionBackend: Send + Sync + 'static {
_handle: &WorkerExecutionHandle,
_kind: protocol::CompletionKind,
_prefix: &str,
_context: Option<&protocol::CompletionContext>,
) -> Vec<protocol::CompletionEntry> {
Vec::new()
}
@@ -942,8 +943,10 @@ impl WorkerExecutionBackendRef {
handle: &WorkerExecutionHandle,
kind: protocol::CompletionKind,
prefix: &str,
context: Option<&protocol::CompletionContext>,
) -> Vec<protocol::CompletionEntry> {
self.backend.worker_completions(handle, kind, prefix)
self.backend
.worker_completions(handle, kind, prefix, context)
}
pub(crate) fn stop_worker_operation(
+9 -3
View File
@@ -526,6 +526,8 @@ pub struct RuntimeHttpWorkerCompletionsRequest {
pub kind: protocol::CompletionKind,
#[serde(default)]
pub prefix: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context: Option<protocol::CompletionContext>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
@@ -1865,10 +1867,14 @@ async fn worker_completions(
&worker_ref,
request.kind,
&request.prefix,
request.context.as_ref(),
),
None => state.runtime.worker_completions(
&worker_ref,
request.kind,
&request.prefix,
request.context.as_ref(),
),
None => state
.runtime
.worker_completions(&worker_ref, request.kind, &request.prefix),
}
.map_err(RuntimeHttpRestError::runtime)?;
Ok(Json(RuntimeHttpWorkerCompletionsResponse {
@@ -562,15 +562,19 @@ impl runtime_api::RuntimeApi for RuntimeManagementApi {
let entries = match auth_workspace_scope(&self.state, auth_extension().as_ref())
.map_err(api_error)?
{
Some(scope) => {
self.state
.runtime
.worker_completions_scoped(&scope, &worker_ref, kind, &prefix)
}
None => self
.state
.runtime
.worker_completions(&worker_ref, kind, &prefix),
Some(scope) => self.state.runtime.worker_completions_scoped(
&scope,
&worker_ref,
kind,
&prefix,
value.context.as_ref(),
),
None => self.state.runtime.worker_completions(
&worker_ref,
kind,
&prefix,
value.context.as_ref(),
),
}
.map_err(RuntimeHttpRestError::runtime)
.map_err(api_error)?;
+167 -6
View File
@@ -2430,9 +2430,10 @@ impl Runtime {
worker_ref: &WorkerRef,
kind: protocol::CompletionKind,
prefix: &str,
context: Option<&protocol::CompletionContext>,
) -> Result<Vec<protocol::CompletionEntry>, RuntimeError> {
self.ensure_worker_in_workspace(scope, worker_ref)?;
self.worker_completions(worker_ref, kind, prefix)
self.worker_completions(worker_ref, kind, prefix, context)
}
/// Return live completion entries for the Worker composer.
@@ -2441,6 +2442,7 @@ impl Runtime {
worker_ref: &WorkerRef,
kind: protocol::CompletionKind,
prefix: &str,
context: Option<&protocol::CompletionContext>,
) -> Result<Vec<protocol::CompletionEntry>, RuntimeError> {
let (backend, handle) = {
let state = self.lock()?;
@@ -2457,7 +2459,7 @@ impl Runtime {
let Some((backend, handle)) = backend.zip(handle) else {
return Ok(Vec::new());
};
Ok(backend.worker_completions(&handle, kind, prefix))
Ok(backend.worker_completions(&handle, kind, prefix, context))
}
/// Accept a protocol method through a workspace-scoped Runtime authorization context.
@@ -2482,9 +2484,21 @@ impl Runtime {
worker_ref: &WorkerRef,
method: Method,
) -> Result<Vec<Event>, RuntimeError> {
if let Method::ListCompletions { kind, prefix } = method {
let entries = self.worker_completions(worker_ref, kind, &prefix)?;
return Ok(vec![Event::Completions { kind, entries }]);
if let Method::ListCompletions {
kind,
prefix,
request_id,
context,
} = method
{
let entries = self.worker_completions(worker_ref, kind, &prefix, context.as_ref())?;
return Ok(vec![Event::Completions {
kind,
prefix,
request_id,
context,
entries,
}]);
}
if matches!(&method, Method::Shutdown { .. }) {
self.stop_worker(worker_ref, Some("worker protocol shutdown".to_string()))?;
@@ -5490,7 +5504,29 @@ fn validate_create_workspace_scope(
Ok(())
}
fn validate_worker_input(input: &WorkerInput) -> Result<(), RuntimeError> {
pub(crate) fn validate_worker_input(input: &WorkerInput) -> Result<(), RuntimeError> {
if input.segments.as_ref().is_some_and(|segments| {
segments
.iter()
.any(|segment| matches!(segment, protocol::Segment::Unknown))
}) {
return Err(RuntimeError::InvalidRequest(
"unsupported typed input must not be discarded; use a compatible client and host"
.into(),
));
}
if !matches!(
input.kind,
WorkerInputKind::User | WorkerInputKind::UserIfIdle
) && input.segments.as_ref().is_some_and(|segments| {
segments
.iter()
.any(|segment| !matches!(segment, protocol::Segment::Text { .. }))
}) {
return Err(RuntimeError::InvalidRequest(
"Notify and control inputs cannot carry typed submission intent; use Submit".into(),
));
}
let has_segments = input
.segments
.as_ref()
@@ -6500,6 +6536,13 @@ mod tests {
workspace_config_results: Mutex<Vec<WorkspaceConfigFetchResult>>,
contexts: Mutex<BTreeMap<WorkerId, WorkerExecutionContext>>,
dispatched_inputs: Mutex<Vec<WorkerInput>>,
completion_queries: Mutex<
Vec<(
protocol::CompletionKind,
String,
Option<protocol::CompletionContext>,
)>,
>,
repository_accesses: Mutex<Vec<WorkingDirectoryRepositoryAccessRequest>>,
repository_access_available: AtomicBool,
working_directory_requests: Mutex<Vec<WorkingDirectoryRequest>>,
@@ -6677,6 +6720,23 @@ mod tests {
}
}
fn worker_completions(
&self,
_handle: &WorkerExecutionHandle,
kind: protocol::CompletionKind,
prefix: &str,
context: Option<&protocol::CompletionContext>,
) -> Vec<protocol::CompletionEntry> {
self.completion_queries
.lock()
.unwrap()
.push((kind, prefix.into(), context.cloned()));
vec![protocol::CompletionEntry {
value: "資料/a b".into(),
..Default::default()
}]
}
fn dispatch_input(
&self,
_handle: &WorkerExecutionHandle,
@@ -6816,6 +6876,107 @@ mod tests {
.block_on(subscription.recv())
}
#[test]
fn unknown_typed_intent_is_rejected_before_backend_dispatch() {
let (runtime, backend) = runtime_and_backend();
let worker = runtime
.create_worker(task_request("unsupported input"))
.unwrap();
let mut input = WorkerInput::user("normal prose");
input.segments = Some(vec![
protocol::Segment::text("normal prose"),
protocol::Segment::Unknown,
]);
assert!(matches!(
runtime.send_input(&worker.worker_ref, input),
Err(RuntimeError::InvalidRequest(_))
));
assert!(backend.dispatched_inputs.lock().unwrap().is_empty());
}
#[test]
fn notify_rejects_typed_feature_intent_before_backend_dispatch() {
let (runtime, backend) = runtime_and_backend();
let worker = runtime.create_worker(task_request("notification")).unwrap();
let invoke = protocol::Segment::FeatureInvoke {
invocation: protocol::FeatureInvocation {
invocation_id: "not-executed".into(),
identity: protocol::FeatureInvocationIdentity("builtin:test/run".into()),
name: "run".into(),
arguments: Vec::new(),
},
};
let mut input = WorkerInput::user("/run()");
input.kind = WorkerInputKind::Notify;
input.segments = Some(vec![invoke]);
assert!(matches!(
runtime.send_input(&worker.worker_ref, input),
Err(RuntimeError::InvalidRequest(_))
));
assert!(backend.dispatched_inputs.lock().unwrap().is_empty());
let mut text = WorkerInput::user("/run() advisory text");
text.kind = WorkerInputKind::Notify;
assert!(
runtime
.send_input(&worker.worker_ref, text)
.unwrap()
.notification
.is_some()
);
assert_eq!(backend.dispatched_inputs.lock().unwrap().len(), 1);
}
#[test]
fn feature_argument_completions_preserve_context_and_workspace_authority() {
let (runtime, backend) = runtime_and_backend();
let owner = scope("workspace-a", "server-a");
let worker = runtime
.create_worker_scoped(&owner, scoped_task_request("completion", "workspace-a"))
.unwrap();
let context = protocol::CompletionContext {
invocation: protocol::FeatureInvocationIdentity("builtin:test/prepare".into()),
argument: Some("path".into()),
};
let events = runtime
.send_protocol_method_scoped(
&owner,
&worker.worker_ref,
Method::ListCompletions {
kind: protocol::CompletionKind::FeatureArgument,
prefix: "資料/".into(),
request_id: Some("query-current".into()),
context: Some(context.clone()),
},
)
.unwrap();
assert!(
matches!(&events[0], Event::Completions { kind: protocol::CompletionKind::FeatureArgument, prefix, request_id: Some(request_id), context: Some(restored), entries }
if request_id == "query-current" && prefix == "資料/" && restored == &context && entries[0].value == "資料/a b")
);
assert_eq!(
*backend.completion_queries.lock().unwrap(),
vec![(
protocol::CompletionKind::FeatureArgument,
"資料/".into(),
Some(context.clone())
)]
);
let outsider = scope("workspace-b", "server-b");
assert!(
runtime
.worker_completions_scoped(
&outsider,
&worker.worker_ref,
protocol::CompletionKind::FeatureArgument,
"",
Some(&context)
)
.is_err()
);
assert_eq!(backend.completion_queries.lock().unwrap().len(), 1);
assert!(backend.dispatched_inputs.lock().unwrap().is_empty());
}
#[test]
fn runtime_worker_subscription_has_gap_free_snapshot_and_live_updates() {
let runtime = runtime_with_backend();
+10 -1
View File
@@ -3067,6 +3067,12 @@ where
handle: &WorkerExecutionHandle,
input: WorkerInput,
) -> WorkerExecutionResult {
if let Err(error) = crate::runtime::validate_worker_input(&input) {
return WorkerExecutionResult::rejected(
WorkerExecutionOperation::Input,
error.to_string(),
);
}
let (worker, worker_state, _workspace_client) = match self.get_execution(handle) {
Ok(execution) => execution,
Err(mut result) => {
@@ -3367,6 +3373,7 @@ where
handle: &WorkerExecutionHandle,
kind: protocol::CompletionKind,
prefix: &str,
context: Option<&protocol::CompletionContext>,
) -> Vec<protocol::CompletionEntry> {
if handle.backend_id() != self.backend_id() {
return Vec::new();
@@ -3377,7 +3384,9 @@ where
workers
.get(handle.worker_ref())
.map(|execution| {
futures::executor::block_on(execution.handle.completion_entries(kind, prefix))
futures::executor::block_on(
execution.handle.completion_entries(kind, prefix, context),
)
})
.unwrap_or_default()
}
+59
View File
@@ -184,6 +184,7 @@ impl WorkerHandle {
&self,
kind: protocol::CompletionKind,
prefix: &str,
context: Option<&protocol::CompletionContext>,
) -> Vec<protocol::CompletionEntry> {
match kind {
protocol::CompletionKind::File => {
@@ -196,9 +197,62 @@ impl WorkerHandle {
.map(|candidate| protocol::CompletionEntry {
value: candidate.path,
is_dir: candidate.is_dir,
description: None,
usage: None,
invocation: None,
})
.collect()
}
protocol::CompletionKind::Feature => self
.shared_state
.feature_invocations()
.feature_completions(prefix),
protocol::CompletionKind::FeatureArgument => {
let Some(context) = context else {
return Vec::new();
};
let registry = self.shared_state.feature_invocations();
let Some(argument) = context.argument.as_deref() else {
return registry.argument_name_completions(&context.invocation, prefix);
};
let worker_file = registry
.descriptor(&context.invocation)
.and_then(|descriptor| {
descriptor
.arguments
.iter()
.find(|candidate| candidate.name == argument)
})
.is_some_and(|descriptor| {
matches!(
descriptor.completion,
protocol::InvocationCompletion::WorkerFile
)
});
if worker_file {
let Some(view) = self.shared_state.fs_view() else {
return Vec::new();
};
let candidates = view.list_file_completions(prefix).await;
if registry.descriptor(&context.invocation).is_none() {
return Vec::new();
}
return crate::feature::invocation::bounded_completion_entries(
candidates
.into_iter()
.map(|candidate| protocol::CompletionEntry {
value: candidate.path,
is_dir: candidate.is_dir,
description: None,
usage: None,
invocation: None,
}),
);
}
registry
.argument_completions(&context.invocation, argument, prefix)
.await
}
}
}
@@ -846,6 +900,7 @@ impl WorkerController {
manifest_toml.clone(),
greeting,
));
shared_state.set_feature_invocations(worker.feature_invocations());
let usage_state = shared_state.clone();
let usage_events = working_event_tx.clone();
worker.engine_mut().on_usage(move |event| {
@@ -1327,6 +1382,10 @@ where
let worker_enabled = feature_config.worker.enabled;
let sub_worker_enabled = feature_config.sub_worker.enabled;
let mut feature_registry = FeatureRegistryBuilder::new();
if !backend_job_profile {
feature_registry
.add_module(crate::feature::builtin::chat_invocation::AttachmentInvocationFeature);
}
if backend_job_profile {
let workspace_client = worker.workspace_client_handle();
if !workspace_client.is_available() || workspace_client.workspace_id().is_none() {
+56
View File
@@ -20,6 +20,7 @@ use agen::Engine;
use agen::llm_client::client::LlmClient;
use agen::state::Mutable;
use agen::tool::ToolDefinition;
use protocol::FeatureInvocationDescriptor;
use serde::{Deserialize, Serialize};
use thiserror::Error;
@@ -623,6 +624,9 @@ pub struct FeatureDescriptor {
pub provides_services: Vec<ServiceDeclaration>,
pub requires_services: Vec<ServiceRequirement>,
pub protocol_providers: Vec<ProtocolProviderDeclaration>,
/// Declarative, user-selected chat invocations contributed by this Feature.
/// Metadata is public; handlers are registered separately during install.
pub chat_invocations: Vec<FeatureInvocationDescriptor>,
}
impl FeatureDescriptor {
@@ -640,6 +644,7 @@ impl FeatureDescriptor {
provides_services: Vec::new(),
requires_services: Vec::new(),
protocol_providers: Vec::new(),
chat_invocations: Vec::new(),
}
}
@@ -682,6 +687,11 @@ impl FeatureDescriptor {
self.protocol_providers.push(provider);
self
}
pub fn with_chat_invocation(mut self, invocation: FeatureInvocationDescriptor) -> Self {
self.chat_invocations.push(invocation);
self
}
}
/// Feature module contribution boundary.
@@ -739,6 +749,7 @@ pub enum FeatureContributionKind {
BackgroundTask,
Service,
ProtocolProvider,
ChatInvocation,
Notification,
Alert,
Diagnostic,
@@ -765,6 +776,7 @@ pub struct FeatureInstallReport {
pub provided_services: Vec<ServiceDeclaration>,
pub resolved_service_requirements: Vec<ServiceRequirement>,
pub protocol_providers: Vec<ProtocolProviderLifecycleDiagnostic>,
pub installed_chat_invocations: Vec<FeatureInvocationDescriptor>,
pub skipped: Vec<SkippedContribution>,
pub diagnostics: Vec<FeatureDiagnostic>,
}
@@ -782,6 +794,7 @@ impl FeatureInstallReport {
provided_services: Vec::new(),
resolved_service_requirements: Vec::new(),
protocol_providers: Vec::new(),
installed_chat_invocations: Vec::new(),
skipped: Vec::new(),
diagnostics: Vec::new(),
}
@@ -794,6 +807,7 @@ impl FeatureInstallReport {
self.installed_instructions.clear();
self.declared_background_tasks.clear();
self.provided_services.clear();
self.installed_chat_invocations.clear();
}
fn mark_skipped(
@@ -819,6 +833,7 @@ struct FeatureContributionDeclarations {
provided_services: HashSet<(ServiceId, String)>,
required_services: HashSet<ServiceId>,
protocol_providers: HashSet<ProviderId>,
chat_invocations: Vec<FeatureInvocationDescriptor>,
}
impl FeatureContributionDeclarations {
@@ -859,6 +874,7 @@ impl FeatureContributionDeclarations {
.iter()
.map(|provider| provider.id.clone())
.collect(),
chat_invocations: descriptor.chat_invocations.clone(),
}
}
@@ -1527,6 +1543,7 @@ pub struct FeatureInstallContext<'a> {
hook_builder: &'a mut HookRegistryBuilder,
background_task_builder: &'a mut FeatureBackgroundTaskRegistryBuilder,
service_registry: &'a mut FeatureServiceRegistry,
invocation_registry: &'a mut FeatureInvocationRegistry,
report: &'a mut FeatureInstallReport,
}
@@ -1591,6 +1608,15 @@ impl FeatureInstallContext<'_> {
}
}
pub fn chat_invocations(&mut self) -> InvocationContributionRegistrar<'_> {
InvocationContributionRegistrar {
feature_id: self.feature_id,
declarations: &self.declarations.chat_invocations,
registry: self.invocation_registry,
installed: &mut self.report.installed_chat_invocations,
}
}
pub fn diagnostics(&mut self) -> FeatureDiagnosticSink<'_> {
FeatureDiagnosticSink {
report: self.report,
@@ -1604,6 +1630,7 @@ pub struct FeatureRegistryInstallReport {
pub reports: Vec<FeatureInstallReport>,
pub services: FeatureServiceRegistry,
pub background_tasks: FeatureBackgroundTaskRegistry,
pub chat_invocations: FeatureInvocationRegistry,
pub plan_error: Option<FeaturePlanError>,
}
@@ -2026,11 +2053,13 @@ impl FeatureRegistryBuilder {
reports,
services: FeatureServiceRegistry::default(),
background_tasks: FeatureBackgroundTaskRegistry::default(),
chat_invocations: FeatureInvocationRegistry::default(),
plan_error: Some(error),
};
}
};
let mut service_registry = FeatureServiceRegistry::default();
let mut invocation_registry = FeatureInvocationRegistry::default();
let mut background_task_builder = FeatureBackgroundTaskRegistryBuilder::default();
let mut reports = Vec::with_capacity(plan.ordered_indices.len());
let install_hook_checkpoint = hook_builder.checkpoint();
@@ -2055,6 +2084,7 @@ impl FeatureRegistryBuilder {
let hook_checkpoint = hook_builder.checkpoint();
let background_checkpoint = background_task_builder.checkpoint();
let service_checkpoint = service_registry.clone();
let invocation_checkpoint = invocation_registry.clone();
let tool_checkpoint = pending_tools.len();
let installed_tool_checkpoint = installed_tool_names.clone();
@@ -2102,6 +2132,7 @@ impl FeatureRegistryBuilder {
hook_builder,
background_task_builder: &mut background_task_builder,
service_registry: &mut service_registry,
invocation_registry: &mut invocation_registry,
report: &mut report,
};
module.install(&mut context)
@@ -2113,6 +2144,7 @@ impl FeatureRegistryBuilder {
hook_builder.rollback_to(hook_checkpoint);
background_task_builder.rollback_to(&background_checkpoint);
service_registry = service_checkpoint.clone();
invocation_registry = invocation_checkpoint.clone();
pending_tools.truncate(tool_checkpoint);
installed_tool_names = installed_tool_checkpoint.clone();
report.clear_installed_contributions();
@@ -2138,6 +2170,14 @@ impl FeatureRegistryBuilder {
)));
}
}
for invocation in &descriptor.chat_invocations {
if !report.installed_chat_invocations.contains(invocation) {
report.diagnostics.push(FeatureDiagnostic::error(format!(
"feature `{}` declared chat invocation `{}` but did not register an executable handler",
descriptor.id, invocation.identity
)));
}
}
if report
.diagnostics
.iter()
@@ -2146,6 +2186,7 @@ impl FeatureRegistryBuilder {
hook_builder.rollback_to(hook_checkpoint);
background_task_builder.rollback_to(&background_checkpoint);
service_registry = service_checkpoint.clone();
invocation_registry = invocation_checkpoint.clone();
pending_tools.truncate(tool_checkpoint);
installed_tool_names = installed_tool_checkpoint.clone();
report.clear_installed_contributions();
@@ -2176,6 +2217,7 @@ impl FeatureRegistryBuilder {
reports,
services: FeatureServiceRegistry::default(),
background_tasks: FeatureBackgroundTaskRegistry::default(),
chat_invocations: FeatureInvocationRegistry::default(),
plan_error: None,
}
} else {
@@ -2183,6 +2225,7 @@ impl FeatureRegistryBuilder {
reports,
services: service_registry,
background_tasks: background_task_builder.build(),
chat_invocations: invocation_registry,
plan_error: None,
}
}
@@ -2225,11 +2268,24 @@ pub enum FeatureInstallError {
first_feature: String,
duplicate_feature: String,
},
#[error("duplicate invocation identity `{identity}`")]
DuplicateInvocationIdentity { identity: String },
#[error("duplicate invocation name `{name}` from `{duplicate}`; first registered by `{first}`")]
DuplicateInvocationName {
name: String,
first: String,
duplicate: String,
},
#[error("feature install failed: {0}")]
Install(String),
}
pub mod background;
pub mod invocation;
pub use invocation::{
FeatureInvocationCompletionProvider, FeatureInvocationContext, FeatureInvocationHandler,
FeatureInvocationHandlerError, FeatureInvocationRegistry, InvocationContributionRegistrar,
};
pub mod builtin;
pub mod mcp;
pub(crate) mod session;
+1
View File
@@ -5,6 +5,7 @@
//! an external plugin-loading surface.
pub mod backend_job_result;
pub mod chat_invocation;
pub mod flow_transition;
pub mod manage_workdir;
pub mod manage_worker;
@@ -0,0 +1,72 @@
//! Builtin metadata for client-local attachment staging.
//!
//! `/attach` uses the common invocation declaration and parser, but its path is
//! intentionally consumed by the client adapter. The Worker accepts only the
//! resulting immutable `UploadedFile` segment and never resolves a client path.
use async_trait::async_trait;
use protocol::{
FeatureInvocation, FeatureInvocationDescriptor, FeatureInvocationIdentity,
FeatureInvocationResult, FeatureInvocationSyntax, InvocationArgumentDescriptor,
InvocationArgumentType, InvocationClientAdapter, InvocationCompletion,
};
use crate::feature::{
FeatureDescriptor, FeatureInstallContext, FeatureInstallError, FeatureInvocationContext,
FeatureInvocationHandler, FeatureInvocationHandlerError, FeatureModule,
};
pub const ATTACH_FEATURE_ID: &str = "attachments";
pub const ATTACH_INVOCATION_ID: &str = "builtin:attachments/attach";
pub fn attach_invocation_descriptor() -> FeatureInvocationDescriptor {
FeatureInvocationDescriptor {
identity: FeatureInvocationIdentity(ATTACH_INVOCATION_ID.into()),
name: "attach".into(),
aliases: Vec::new(),
display_name: "Attach file".into(),
description: "Stage a client-local file as an editable attachment chip.".into(),
syntax: FeatureInvocationSyntax::Parenthesized,
arguments: vec![InvocationArgumentDescriptor {
name: "file".into(),
position: Some(0),
required: true,
value_type: InvocationArgumentType::ClientFile,
completion: InvocationCompletion::ClientFile,
description: Some("File available to this client".into()),
}],
client_adapter: Some(InvocationClientAdapter::Attachment),
}
}
pub struct AttachmentInvocationFeature;
impl FeatureModule for AttachmentInvocationFeature {
fn descriptor(&self) -> FeatureDescriptor {
FeatureDescriptor::builtin(ATTACH_FEATURE_ID, "Attachments")
.with_description("Client-local attachment staging and typed uploaded-file input")
.with_chat_invocation(attach_invocation_descriptor())
}
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
context.chat_invocations().register(
attach_invocation_descriptor(),
AttachmentMustBeStagedByClient,
)
}
}
struct AttachmentMustBeStagedByClient;
#[async_trait]
impl FeatureInvocationHandler for AttachmentMustBeStagedByClient {
async fn invoke(
&self,
_context: FeatureInvocationContext,
_invocation: &FeatureInvocation,
) -> Result<FeatureInvocationResult, FeatureInvocationHandlerError> {
Err(FeatureInvocationHandlerError::failed(
"attachment invocation reached the Worker without client-local upload staging",
))
}
}
+966
View File
@@ -0,0 +1,966 @@
use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use std::sync::Arc;
use async_trait::async_trait;
use protocol::{
CompletionEntry, FeatureInvocation, FeatureInvocationDescriptor, FeatureInvocationIdentity,
FeatureInvocationResult, InvocationCompletion, InvocationValue, validate_feature_invocation,
};
use super::{FeatureId, FeatureInstallError};
pub(crate) const MAX_INVOCATION_MESSAGE_BYTES: usize = 4 * 1024;
pub(crate) const MAX_INVOCATION_CONTEXT_BYTES: usize = 16 * 1024;
const MAX_COMPLETION_ENTRIES: usize = 64;
const MAX_COMPLETION_VALUE_BYTES: usize = 1024;
const MAX_COMPLETION_DESCRIPTION_BYTES: usize = 2 * 1024;
const MAX_COMPLETION_USAGE_BYTES: usize = 4 * 1024;
const MAX_INVOCATION_DESCRIPTOR_BYTES: usize =
protocol::invocation::MAX_FEATURE_INVOCATION_DESCRIPTOR_BYTES;
const MAX_COMPLETION_RESPONSE_BYTES: usize = 64 * 1024;
pub(crate) fn invocation_result_is_bounded(result: &FeatureInvocationResult) -> bool {
result.message.len() <= MAX_INVOCATION_MESSAGE_BYTES
&& result
.context
.as_ref()
.is_none_or(|context| context.len() <= MAX_INVOCATION_CONTEXT_BYTES)
}
fn truncate_display(text: &mut String, max_bytes: usize) {
let mut end = text.len().min(max_bytes);
while !text.is_char_boundary(end) {
end -= 1;
}
text.truncate(end);
}
/// Values are never truncated: doing so could silently select a different
/// operation. Display-only text may be shortened; oversized metadata is dropped.
pub(crate) fn bounded_completion_entries(
entries: impl IntoIterator<Item = CompletionEntry>,
) -> Vec<CompletionEntry> {
let mut result = Vec::new();
let mut bytes = 2; // JSON array delimiters.
for mut entry in entries.into_iter().take(MAX_COMPLETION_ENTRIES * 4) {
if entry.value.len() > MAX_COMPLETION_VALUE_BYTES {
continue;
}
if let Some(description) = &mut entry.description {
truncate_display(description, MAX_COMPLETION_DESCRIPTION_BYTES);
}
if let Some(usage) = &mut entry.usage {
truncate_display(usage, MAX_COMPLETION_USAGE_BYTES);
}
if entry.invocation.as_ref().is_some_and(|descriptor| {
serde_json::to_vec(descriptor).map_or(true, |encoded| {
encoded.len() > MAX_INVOCATION_DESCRIPTOR_BYTES
})
}) {
continue;
}
let Ok(encoded) = serde_json::to_vec(&entry) else {
continue;
};
let additional = encoded.len() + usize::from(!result.is_empty());
if bytes + additional > MAX_COMPLETION_RESPONSE_BYTES {
continue;
}
bytes += additional;
result.push(entry);
if result.len() == MAX_COMPLETION_ENTRIES {
break;
}
}
result
}
/// Host-owned execution context. `invocation_id` is the idempotency key a
/// handler must use when its operation has external side effects. Workspace,
/// credential, and permission authority remain captured by the handler itself.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FeatureInvocationContext {
pub invocation_id: String,
}
#[async_trait]
pub trait FeatureInvocationHandler: Send + Sync {
/// Side-effect-free runtime availability/permission check. Installation is
/// necessary but not sufficient: discovery and execution both consult this.
fn is_available(&self) -> bool {
true
}
/// Side-effect-free semantic and authority validation. Installed handlers
/// retain Workspace/credential/permission authority; request arguments never
/// supply it. The Worker calls this before persisting an execution start.
fn validate(
&self,
_invocation: &FeatureInvocation,
) -> Result<(), FeatureInvocationHandlerError> {
Ok(())
}
async fn invoke(
&self,
context: FeatureInvocationContext,
invocation: &FeatureInvocation,
) -> Result<FeatureInvocationResult, FeatureInvocationHandlerError>;
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("{message}")]
pub struct FeatureInvocationHandlerError {
pub message: String,
/// True only when the handler cannot determine whether its business-side
/// effect committed. The Worker never automatically retries this outcome.
pub outcome_unknown: bool,
}
impl FeatureInvocationHandlerError {
pub fn failed(message: impl Into<String>) -> Self {
Self {
message: message.into(),
outcome_unknown: false,
}
}
pub fn outcome_unknown(message: impl Into<String>) -> Self {
Self {
message: message.into(),
outcome_unknown: true,
}
}
}
#[async_trait]
pub trait FeatureInvocationCompletionProvider: Send + Sync {
/// The provider selector comes only from installed host metadata, never
/// from completion-request input. Providers retain their own scoped authority.
async fn complete(
&self,
completion: &InvocationCompletion,
argument: &str,
prefix: &str,
) -> Vec<CompletionEntry>;
}
#[derive(Clone)]
struct RegisteredInvocation {
descriptor: FeatureInvocationDescriptor,
handler: Arc<dyn FeatureInvocationHandler>,
completion_provider: Option<Arc<dyn FeatureInvocationCompletionProvider>>,
}
/// Installed invocation metadata and executable handlers. Equality/debug expose
/// only public descriptors; implementation objects and credentials never leak.
#[derive(Clone, Default)]
pub struct FeatureInvocationRegistry {
entries: BTreeMap<FeatureInvocationIdentity, RegisteredInvocation>,
names: BTreeMap<String, FeatureInvocationIdentity>,
}
impl fmt::Debug for FeatureInvocationRegistry {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("FeatureInvocationRegistry")
.field("descriptors", &self.descriptors())
.finish()
}
}
impl PartialEq for FeatureInvocationRegistry {
fn eq(&self, other: &Self) -> bool {
self.descriptors() == other.descriptors()
}
}
impl Eq for FeatureInvocationRegistry {}
impl FeatureInvocationRegistry {
pub fn descriptors(&self) -> Vec<FeatureInvocationDescriptor> {
self.entries
.values()
.filter(|entry| entry.handler.is_available())
.map(|entry| entry.descriptor.clone())
.collect()
}
pub fn descriptor(
&self,
identity: &FeatureInvocationIdentity,
) -> Option<&FeatureInvocationDescriptor> {
self.entries
.get(identity)
.filter(|entry| entry.handler.is_available())
.map(|entry| &entry.descriptor)
}
pub fn feature_completions(&self, prefix: &str) -> Vec<CompletionEntry> {
bounded_completion_entries(
self.entries
.values()
.filter(|entry| entry.handler.is_available())
.filter(|entry| {
entry.descriptor.name.starts_with(prefix)
|| entry
.descriptor
.aliases
.iter()
.any(|alias| alias.starts_with(prefix))
})
.map(|entry| CompletionEntry {
value: entry.descriptor.name.clone(),
is_dir: false,
description: Some(entry.descriptor.description.clone()),
usage: Some(entry.descriptor.usage()),
invocation: Some(entry.descriptor.clone()),
}),
)
}
pub fn argument_name_completions(
&self,
identity: &FeatureInvocationIdentity,
prefix: &str,
) -> Vec<CompletionEntry> {
bounded_completion_entries(
self.entries
.get(identity)
.filter(|entry| entry.handler.is_available())
.into_iter()
.flat_map(|entry| entry.descriptor.arguments.iter())
.filter(|argument| argument.name.starts_with(prefix))
.map(|argument| CompletionEntry {
value: format!("{}=", argument.name),
is_dir: false,
description: argument.description.clone(),
usage: None,
invocation: None,
}),
)
}
pub async fn argument_completions(
&self,
identity: &FeatureInvocationIdentity,
argument: &str,
prefix: &str,
) -> Vec<CompletionEntry> {
let Some(entry) = self
.entries
.get(identity)
.filter(|entry| entry.handler.is_available())
else {
return Vec::new();
};
let Some(descriptor) = entry
.descriptor
.arguments
.iter()
.find(|candidate| candidate.name == argument)
else {
return Vec::new();
};
let strings = match &descriptor.completion {
InvocationCompletion::None => match &descriptor.value_type {
protocol::InvocationArgumentType::Boolean => ["true", "false"]
.into_iter()
.filter(|value| value.starts_with(prefix))
.map(str::to_owned)
.collect(),
protocol::InvocationArgumentType::Enum { values } => values
.iter()
.filter(|value| value.starts_with(prefix))
.cloned()
.collect(),
_ => Vec::new(),
},
InvocationCompletion::WorkerFile | InvocationCompletion::ClientFile => Vec::new(),
InvocationCompletion::Static { values } => values
.iter()
.filter(|value| value.starts_with(prefix))
.cloned()
.collect(),
InvocationCompletion::Provider { .. } => {
return match &entry.completion_provider {
Some(provider) => {
let entries = provider
.complete(&descriptor.completion, argument, prefix)
.await;
if !entry.handler.is_available() {
return Vec::new();
}
bounded_completion_entries(entries.into_iter().map(|mut candidate| {
// Argument providers contribute values/display text,
// never new executable invocation metadata.
candidate.invocation = None;
candidate
}))
}
None => Vec::new(),
};
}
};
bounded_completion_entries(strings.into_iter().map(|value| CompletionEntry {
value,
is_dir: false,
description: descriptor.description.clone(),
usage: None,
invocation: None,
}))
}
pub(crate) fn validate_metadata(
&self,
invocation: &FeatureInvocation,
) -> Result<(), FeatureInvocationHandlerError> {
let entry = self
.entries
.get(&invocation.identity)
.filter(|entry| entry.handler.is_available())
.ok_or_else(|| {
FeatureInvocationHandlerError::failed(
"Feature invocation is not enabled or currently available",
)
})?;
if entry.descriptor.client_adapter.is_some() {
return Err(FeatureInvocationHandlerError::failed(
"client-local invocation must be staged by the client, not executed by the Worker",
));
}
validate_feature_invocation(&entry.descriptor, invocation).map_err(|error| {
let mut message = error.to_string();
truncate_display(&mut message, MAX_INVOCATION_MESSAGE_BYTES);
FeatureInvocationHandlerError::failed(message)
})
}
pub fn validate(
&self,
invocation: &FeatureInvocation,
) -> Result<(), FeatureInvocationHandlerError> {
self.validate_metadata(invocation)?;
self.entries[&invocation.identity]
.handler
.validate(invocation)
.map_err(|mut error| {
// Validation is side-effect-free; bound its diagnostic before
// any start, without claiming an uncertain business outcome.
truncate_display(&mut error.message, MAX_INVOCATION_MESSAGE_BYTES);
error
})
}
pub async fn invoke(
&self,
invocation: &FeatureInvocation,
) -> Result<FeatureInvocationResult, FeatureInvocationHandlerError> {
self.validate(invocation)?;
let entry = &self.entries[&invocation.identity];
let result = entry
.handler
.invoke(
FeatureInvocationContext {
invocation_id: invocation.invocation_id.clone(),
},
invocation,
)
.await
.map_err(|error| {
if error.message.len() > MAX_INVOCATION_MESSAGE_BYTES {
FeatureInvocationHandlerError::outcome_unknown(
"handler error exceeded the result byte limit; do not retry automatically",
)
} else {
error
}
})?;
if !invocation_result_is_bounded(&result) {
return Err(FeatureInvocationHandlerError::outcome_unknown(
"handler result exceeded the message/context byte limit; do not retry automatically",
));
}
if result.invocation_id != invocation.invocation_id
|| result.identity != invocation.identity
{
return Err(FeatureInvocationHandlerError::outcome_unknown(
"handler returned a result for a different invocation; do not retry automatically",
));
}
if result.status != protocol::FeatureInvocationStatus::Succeeded && result.context.is_some()
{
return Err(FeatureInvocationHandlerError::outcome_unknown(
"handler returned preparation context without a successful result",
));
}
if result.status == protocol::FeatureInvocationStatus::Succeeded
&& !entry.handler.is_available()
{
return Err(FeatureInvocationHandlerError::failed(
"handler reported success but the invocation is no longer available; preparation context withheld; do not retry automatically",
));
}
Ok(result)
}
pub(crate) fn register(
&mut self,
feature_id: FeatureId,
descriptor: FeatureInvocationDescriptor,
handler: Arc<dyn FeatureInvocationHandler>,
completion_provider: Option<Arc<dyn FeatureInvocationCompletionProvider>>,
) -> Result<(), FeatureInstallError> {
if descriptor.identity.0.len() > 256
|| serde_json::to_vec(&descriptor).map_or(true, |encoded| {
encoded.len() > MAX_INVOCATION_DESCRIPTOR_BYTES
})
{
return Err(FeatureInstallError::InvalidDescriptor(
"invocation metadata exceeds the host byte limit".into(),
));
}
descriptor
.validate()
.map_err(|error| FeatureInstallError::InvalidDescriptor(error.to_string()))?;
let expected_prefix = format!("{feature_id}/");
if !descriptor.identity.0.starts_with(&expected_prefix) {
return Err(FeatureInstallError::InvalidDescriptor(format!(
"invocation identity `{}` must be qualified by feature `{feature_id}`",
descriptor.identity
)));
}
if self.entries.contains_key(&descriptor.identity) {
return Err(FeatureInstallError::DuplicateInvocationIdentity {
identity: descriptor.identity.to_string(),
});
}
let public_names = std::iter::once(descriptor.name.as_str())
.chain(descriptor.aliases.iter().map(String::as_str))
.collect::<BTreeSet<_>>();
for name in &public_names {
if let Some(existing) = self.names.get(*name) {
return Err(FeatureInstallError::DuplicateInvocationName {
name: (*name).to_string(),
first: existing.to_string(),
duplicate: descriptor.identity.to_string(),
});
}
}
for name in public_names {
self.names
.insert(name.to_string(), descriptor.identity.clone());
}
self.entries.insert(
descriptor.identity.clone(),
RegisteredInvocation {
descriptor,
handler,
completion_provider,
},
);
Ok(())
}
pub fn argument_value<'a>(
invocation: &'a FeatureInvocation,
name: &str,
) -> Option<&'a InvocationValue> {
invocation
.arguments
.iter()
.find(|argument| argument.name == name)
.map(|argument| &argument.value)
}
}
pub struct InvocationContributionRegistrar<'a> {
pub(super) feature_id: &'a FeatureId,
pub(super) declarations: &'a [FeatureInvocationDescriptor],
pub(super) registry: &'a mut FeatureInvocationRegistry,
pub(super) installed: &'a mut Vec<FeatureInvocationDescriptor>,
}
impl InvocationContributionRegistrar<'_> {
pub fn register<H>(
&mut self,
descriptor: FeatureInvocationDescriptor,
handler: H,
) -> Result<(), FeatureInstallError>
where
H: FeatureInvocationHandler + 'static,
{
self.register_with_provider(descriptor, Arc::new(handler), None)
}
pub fn register_with_completion<H, P>(
&mut self,
descriptor: FeatureInvocationDescriptor,
handler: H,
provider: P,
) -> Result<(), FeatureInstallError>
where
H: FeatureInvocationHandler + 'static,
P: FeatureInvocationCompletionProvider + 'static,
{
self.register_with_provider(descriptor, Arc::new(handler), Some(Arc::new(provider)))
}
fn register_with_provider(
&mut self,
descriptor: FeatureInvocationDescriptor,
handler: Arc<dyn FeatureInvocationHandler>,
provider: Option<Arc<dyn FeatureInvocationCompletionProvider>>,
) -> Result<(), FeatureInstallError> {
if provider.is_none()
&& descriptor.arguments.iter().any(|argument| {
matches!(argument.completion, InvocationCompletion::Provider { .. })
})
{
return Err(FeatureInstallError::InvalidDescriptor(format!(
"invocation `{}` declares a completion provider but did not register one",
descriptor.identity
)));
}
if !self.declarations.contains(&descriptor) {
return Err(FeatureInstallError::UndeclaredContribution {
kind: super::FeatureContributionKind::ChatInvocation,
name: descriptor.identity.to_string(),
feature: self.feature_id.to_string(),
});
}
self.registry.register(
self.feature_id.clone(),
descriptor.clone(),
handler,
provider,
)?;
self.installed.push(descriptor);
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use protocol::{
FeatureInvocationStatus, FeatureInvocationSyntax, InvocationArgumentDescriptor,
InvocationArgumentType, InvocationArgumentValue,
};
use std::sync::atomic::{AtomicUsize, Ordering};
fn descriptor(feature: &str, name: &str) -> FeatureInvocationDescriptor {
FeatureInvocationDescriptor {
identity: FeatureInvocationIdentity(format!("{feature}/{name}")),
name: name.into(),
aliases: Vec::new(),
display_name: name.into(),
description: format!("invoke {name}"),
syntax: FeatureInvocationSyntax::Parenthesized,
arguments: vec![InvocationArgumentDescriptor {
name: "value".into(),
position: Some(0),
required: true,
value_type: InvocationArgumentType::String,
completion: InvocationCompletion::Static {
values: vec!["alpha".into(), "beta".into()],
},
description: Some("selected value".into()),
}],
client_adapter: None,
}
}
struct CountingHandler(Arc<AtomicUsize>);
#[async_trait]
impl FeatureInvocationHandler for CountingHandler {
async fn invoke(
&self,
_context: FeatureInvocationContext,
invocation: &FeatureInvocation,
) -> Result<FeatureInvocationResult, FeatureInvocationHandlerError> {
self.0.fetch_add(1, Ordering::SeqCst);
Ok(FeatureInvocationResult {
invocation_id: invocation.invocation_id.clone(),
identity: invocation.identity.clone(),
status: FeatureInvocationStatus::Succeeded,
message: "done".into(),
context: Some("prepared".into()),
})
}
}
fn invocation(descriptor: &FeatureInvocationDescriptor) -> FeatureInvocation {
FeatureInvocation {
invocation_id: "invoke-1".into(),
identity: descriptor.identity.clone(),
name: descriptor.name.clone(),
arguments: vec![InvocationArgumentValue {
name: "value".into(),
value: InvocationValue::String("alpha".into()),
}],
}
}
#[tokio::test]
async fn installed_registry_validates_before_calling_handler_and_returns_context() {
let calls = Arc::new(AtomicUsize::new(0));
let descriptor = descriptor("builtin:test", "run");
let mut registry = FeatureInvocationRegistry::default();
registry
.register(
FeatureId::builtin("test"),
descriptor.clone(),
Arc::new(CountingHandler(calls.clone())),
None,
)
.unwrap();
let result = registry.invoke(&invocation(&descriptor)).await.unwrap();
assert_eq!(result.status, FeatureInvocationStatus::Succeeded);
assert_eq!(result.context.as_deref(), Some("prepared"));
assert_eq!(calls.load(Ordering::SeqCst), 1);
let mut malformed = invocation(&descriptor);
malformed.arguments.clear();
assert!(registry.invoke(&malformed).await.is_err());
assert_eq!(calls.load(Ordering::SeqCst), 1);
}
#[test]
fn registration_rejects_public_name_collisions_across_source_qualified_features() {
let calls = Arc::new(AtomicUsize::new(0));
let mut registry = FeatureInvocationRegistry::default();
registry
.register(
FeatureId::builtin("first"),
descriptor("builtin:first", "run"),
Arc::new(CountingHandler(calls.clone())),
None,
)
.unwrap();
let error = registry
.register(
FeatureId::builtin("second"),
descriptor("builtin:second", "run"),
Arc::new(CountingHandler(calls)),
None,
)
.unwrap_err();
assert!(matches!(
error,
FeatureInstallError::DuplicateInvocationName { .. }
));
}
#[tokio::test]
async fn client_local_adapter_metadata_cannot_authorize_worker_execution() {
let calls = Arc::new(AtomicUsize::new(0));
let descriptor = crate::feature::builtin::chat_invocation::attach_invocation_descriptor();
let mut registry = FeatureInvocationRegistry::default();
registry
.register(
FeatureId::builtin("attachments"),
descriptor.clone(),
Arc::new(CountingHandler(calls.clone())),
None,
)
.unwrap();
let invocation = FeatureInvocation {
invocation_id: "client-path".into(),
identity: descriptor.identity,
name: descriptor.name,
arguments: vec![InvocationArgumentValue {
name: "file".into(),
value: InvocationValue::String("/client/private/file".into()),
}],
};
assert!(registry.invoke(&invocation).await.is_err());
assert_eq!(calls.load(Ordering::SeqCst), 0);
}
struct CountingCompletion(Arc<AtomicUsize>);
#[async_trait]
impl FeatureInvocationCompletionProvider for CountingCompletion {
async fn complete(
&self,
_completion: &InvocationCompletion,
_argument: &str,
_prefix: &str,
) -> Vec<CompletionEntry> {
self.0.fetch_add(1, Ordering::SeqCst);
Vec::new()
}
}
#[tokio::test]
async fn completion_dispatch_uses_installed_argument_metadata_not_provider_presence() {
let calls = Arc::new(AtomicUsize::new(0));
let provider_calls = Arc::new(AtomicUsize::new(0));
let descriptor = descriptor("builtin:test", "run");
let mut registry = FeatureInvocationRegistry::default();
registry
.register(
FeatureId::builtin("test"),
descriptor.clone(),
Arc::new(CountingHandler(calls.clone())),
Some(Arc::new(CountingCompletion(provider_calls.clone()))),
)
.unwrap();
assert_eq!(
registry
.argument_completions(&descriptor.identity, "value", "a")
.await[0]
.value,
"alpha"
);
assert!(
registry
.argument_completions(
&FeatureInvocationIdentity("foreign:feature/run".into()),
"value",
""
)
.await
.is_empty()
);
assert!(
registry
.argument_completions(&descriptor.identity, "credential", "")
.await
.is_empty()
);
assert_eq!(provider_calls.load(Ordering::SeqCst), 0);
assert_eq!(calls.load(Ordering::SeqCst), 0);
}
struct AvailabilityHandler {
available: Arc<std::sync::atomic::AtomicBool>,
calls: Arc<AtomicUsize>,
}
#[async_trait]
impl FeatureInvocationHandler for AvailabilityHandler {
fn is_available(&self) -> bool {
self.available.load(Ordering::SeqCst)
}
async fn invoke(
&self,
context: FeatureInvocationContext,
invocation: &FeatureInvocation,
) -> Result<FeatureInvocationResult, FeatureInvocationHandlerError> {
CountingHandler(self.calls.clone())
.invoke(context, invocation)
.await
}
}
struct TestProvider {
entries: Vec<CompletionEntry>,
revoke: Option<Arc<std::sync::atomic::AtomicBool>>,
}
#[async_trait]
impl FeatureInvocationCompletionProvider for TestProvider {
async fn complete(
&self,
_completion: &InvocationCompletion,
_argument: &str,
_prefix: &str,
) -> Vec<CompletionEntry> {
if let Some(available) = &self.revoke {
available.store(false, Ordering::SeqCst);
}
self.entries.clone()
}
}
#[tokio::test]
async fn provider_output_is_bounded_and_cannot_inject_invocation_metadata() {
let available = Arc::new(std::sync::atomic::AtomicBool::new(true));
let mut descriptor = descriptor("builtin:test", "run");
descriptor.arguments[0].completion = InvocationCompletion::Provider {
provider: "choices".into(),
};
let entries = (0..128)
.map(|index| CompletionEntry {
value: format!("value-{index}"),
is_dir: false,
description: Some("é".repeat(MAX_COMPLETION_DESCRIPTION_BYTES)),
usage: Some("usage".repeat(MAX_COMPLETION_USAGE_BYTES)),
invocation: Some(descriptor.clone()),
})
.chain([CompletionEntry {
value: "x".repeat(MAX_COMPLETION_VALUE_BYTES + 1),
is_dir: false,
description: None,
usage: None,
invocation: None,
}])
.collect();
let mut registry = FeatureInvocationRegistry::default();
registry
.register(
FeatureId::builtin("test"),
descriptor.clone(),
Arc::new(AvailabilityHandler {
available,
calls: Arc::new(AtomicUsize::new(0)),
}),
Some(Arc::new(TestProvider {
entries,
revoke: None,
})),
)
.unwrap();
let candidates = registry
.argument_completions(&descriptor.identity, "value", "")
.await;
assert!(!candidates.is_empty());
assert!(candidates.len() <= MAX_COMPLETION_ENTRIES);
assert!(serde_json::to_vec(&candidates).unwrap().len() <= MAX_COMPLETION_RESPONSE_BYTES);
assert!(candidates.iter().all(|candidate| {
candidate.value.len() <= MAX_COMPLETION_VALUE_BYTES
&& candidate.invocation.is_none()
&& candidate.description.as_ref().unwrap().len() <= MAX_COMPLETION_DESCRIPTION_BYTES
&& candidate.usage.as_ref().unwrap().len() <= MAX_COMPLETION_USAGE_BYTES
}));
let simple = (0..128).map(|index| CompletionEntry {
value: format!("value-{index}"),
is_dir: false,
description: None,
usage: None,
invocation: None,
});
assert_eq!(
bounded_completion_entries(simple).len(),
MAX_COMPLETION_ENTRIES
);
assert!(
bounded_completion_entries([CompletionEntry {
value: "x".repeat(MAX_COMPLETION_VALUE_BYTES + 1),
is_dir: false,
description: None,
usage: None,
invocation: None,
}])
.is_empty()
);
}
#[tokio::test]
async fn provider_revocation_during_completion_discards_its_output() {
let available = Arc::new(std::sync::atomic::AtomicBool::new(true));
let calls = Arc::new(AtomicUsize::new(0));
let mut descriptor = descriptor("builtin:test", "run");
descriptor.arguments[0].completion = InvocationCompletion::Provider {
provider: "choices".into(),
};
let mut registry = FeatureInvocationRegistry::default();
registry
.register(
FeatureId::builtin("test"),
descriptor.clone(),
Arc::new(AvailabilityHandler {
available: available.clone(),
calls: calls.clone(),
}),
Some(Arc::new(TestProvider {
entries: vec![CompletionEntry {
value: "private".into(),
is_dir: false,
description: None,
usage: None,
invocation: None,
}],
revoke: Some(available),
})),
)
.unwrap();
assert!(
registry
.argument_completions(&descriptor.identity, "value", "")
.await
.is_empty()
);
assert!(registry.descriptors().is_empty());
assert!(registry.invoke(&invocation(&descriptor)).await.is_err());
assert_eq!(calls.load(Ordering::SeqCst), 0);
}
#[tokio::test]
async fn enum_and_boolean_completions_derive_from_type_schema_when_no_provider_declared() {
let calls = Arc::new(AtomicUsize::new(0));
for (value_type, prefix, expected) in [
(InvocationArgumentType::Boolean, "f", "false"),
(
InvocationArgumentType::Enum {
values: vec!["read".into(), "write".into()],
},
"w",
"write",
),
] {
let mut descriptor = descriptor("builtin:test", "run");
descriptor.arguments[0].value_type = value_type;
descriptor.arguments[0].completion = InvocationCompletion::None;
let mut registry = FeatureInvocationRegistry::default();
registry
.register(
FeatureId::builtin("test"),
descriptor.clone(),
Arc::new(CountingHandler(calls.clone())),
None,
)
.unwrap();
let candidates = registry
.argument_completions(&descriptor.identity, "value", prefix)
.await;
assert_eq!(candidates.len(), 1);
assert_eq!(candidates[0].value, expected);
}
assert_eq!(calls.load(Ordering::SeqCst), 0);
}
#[test]
fn oversized_installed_invocation_metadata_is_rejected() {
let mut descriptor = descriptor("builtin:test", "run");
descriptor.description = "x".repeat(MAX_INVOCATION_DESCRIPTOR_BYTES);
let mut registry = FeatureInvocationRegistry::default();
assert!(
registry
.register(
FeatureId::builtin("test"),
descriptor,
Arc::new(CountingHandler(Arc::new(AtomicUsize::new(0)))),
None,
)
.is_err()
);
assert!(registry.descriptors().is_empty());
}
#[tokio::test]
async fn metadata_and_argument_completion_are_side_effect_free() {
let calls = Arc::new(AtomicUsize::new(0));
let descriptor = descriptor("builtin:test", "run");
let mut registry = FeatureInvocationRegistry::default();
registry
.register(
FeatureId::builtin("test"),
descriptor.clone(),
Arc::new(CountingHandler(calls.clone())),
None,
)
.unwrap();
assert_eq!(registry.feature_completions("ru")[0].value, "run");
assert_eq!(
registry
.argument_completions(&descriptor.identity, "value", "a")
.await[0]
.value,
"alpha"
);
assert_eq!(calls.load(Ordering::SeqCst), 0);
}
}
+20 -2
View File
@@ -399,8 +399,23 @@ impl Interceptor<SessionHistoryMetadata> for WorkerInterceptor {
.lock()
.expect("pending_attachments poisoned"),
);
// Preparations can commit before Engine receives the user prompt. The
// annotation queue alone does not materialize their model-visible items:
// return them through the normal prompt lifecycle, without recommitting.
// The annotator consumes their original IDs/provenance as Engine appends.
let mut prepared_items = self
.pending_committed_history
.lock()
.expect("pending committed history poisoned")
.iter()
.map(|entry| entry.item.clone())
.collect::<Vec<_>>();
Ok(if extras.is_empty() {
PromptAction::Continue
if prepared_items.is_empty() {
PromptAction::Continue
} else {
PromptAction::ContinueWith(prepared_items)
}
} else {
// Commit the typed system items first, then hand the
// matching `Item::system_message`s to the worker. Sync
@@ -410,7 +425,10 @@ impl Interceptor<SessionHistoryMetadata> for WorkerInterceptor {
self.attach_prompt_provenance(&mut extras);
let items: Vec<Item> = extras.iter().map(SystemItem::to_history_item).collect();
match self.commit_system_items(&extras) {
Ok(()) => PromptAction::ContinueWith(items),
Ok(()) => {
prepared_items.extend(items);
PromptAction::ContinueWith(prepared_items)
}
Err(error) => PromptAction::Cancel(format!("session persistence failed: {error}")),
}
})
+16 -3
View File
@@ -121,9 +121,22 @@ pub async fn dispatch_worker_protocol_method(
method: Method,
) -> Option<Event> {
match method {
Method::ListCompletions { kind, prefix } => {
let entries = handle.completion_entries(kind, &prefix).await;
Some(Event::Completions { kind, entries })
Method::ListCompletions {
request_id,
kind,
prefix,
context,
} => {
let entries = handle
.completion_entries(kind, &prefix, context.as_ref())
.await;
Some(Event::Completions {
request_id,
kind,
prefix,
context,
entries,
})
}
method => {
let _ = handle.send(method).await;
+16
View File
@@ -48,6 +48,7 @@ pub struct WorkerSharedState {
/// `ListCompletions` queries without going through the controller. It is
/// unset only in unit tests that construct `WorkerSharedState` directly.
fs_view: OnceLock<WorkerFsView>,
feature_invocations: RwLock<crate::feature::FeatureInvocationRegistry>,
flow_transition_enabled: AtomicBool,
}
@@ -66,6 +67,7 @@ impl WorkerSharedState {
state: RwLock::new(WorkerStateSnapshot::initial()),
accepted_commands: RwLock::new(VecDeque::new()),
fs_view: OnceLock::new(),
feature_invocations: RwLock::new(crate::feature::FeatureInvocationRegistry::default()),
flow_transition_enabled: AtomicBool::new(false),
}
}
@@ -82,6 +84,20 @@ impl WorkerSharedState {
self.fs_view.get()
}
pub fn feature_invocations(&self) -> crate::feature::FeatureInvocationRegistry {
self.feature_invocations
.read()
.expect("feature invocation registry lock poisoned")
.clone()
}
pub fn set_feature_invocations(&self, registry: crate::feature::FeatureInvocationRegistry) {
*self
.feature_invocations
.write()
.expect("feature invocation registry lock poisoned") = registry;
}
pub fn enable_flow_transition(&self) {
self.flow_transition_enabled.store(true, Ordering::Release);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,581 @@
//! Installed host-feature execution, not lexical slash-command dispatch.
use std::pin::Pin;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use agen::Engine;
use agen::llm_client::event::{Event as LlmEvent, ResponseStatus, StatusEvent};
use agen::llm_client::{ClientError, LlmClient, Request};
use async_trait::async_trait;
use futures::Stream;
use protocol::{
FeatureInvocation, FeatureInvocationDescriptor, FeatureInvocationIdentity,
FeatureInvocationResult, FeatureInvocationStatus, FeatureInvocationSyntax,
InvocationArgumentDescriptor, InvocationArgumentType, InvocationArgumentValue,
InvocationCompletion, InvocationValue, Segment,
};
use session_store::{CombinedStore, FsStore, FsWorkerStore, LogEntry, SystemItem};
use worker::feature::{
FeatureDescriptor, FeatureInstallContext, FeatureInstallError, FeatureInvocationContext,
FeatureInvocationHandler, FeatureInvocationHandlerError, FeatureModule, FeatureRegistryBuilder,
};
use worker::{Worker, WorkerFilesystemAuthority, WorkerManifest, WorkerWorkspaceContext};
#[derive(Clone)]
struct Trace {
calls: Arc<Mutex<Vec<String>>>,
requests: Arc<Mutex<Vec<Request>>>,
available: Arc<AtomicBool>,
}
impl Default for Trace {
fn default() -> Self {
Self {
calls: Arc::default(),
requests: Arc::default(),
available: Arc::new(AtomicBool::new(true)),
}
}
}
#[async_trait]
impl LlmClient for Trace {
fn clone_boxed(&self) -> Box<dyn LlmClient> {
Box::new(self.clone())
}
async fn stream(
&self,
request: Request,
) -> Result<Pin<Box<dyn Stream<Item = Result<LlmEvent, ClientError>> + Send>>, ClientError>
{
self.calls.lock().unwrap().push("llm".into());
self.requests.lock().unwrap().push(request);
Ok(Box::pin(futures::stream::iter(vec![
Ok(LlmEvent::text_block_start(0)),
Ok(LlmEvent::text_delta(0, "done")),
Ok(LlmEvent::text_block_stop(0, None)),
Ok(LlmEvent::Status(StatusEvent {
status: ResponseStatus::Completed,
})),
])))
}
}
fn descriptor() -> FeatureInvocationDescriptor {
FeatureInvocationDescriptor {
identity: FeatureInvocationIdentity("builtin:fake/prepare".into()),
name: "prepare".into(),
aliases: vec!["prep".into()],
display_name: "Prepare".into(),
description: "Prepare test context".into(),
syntax: FeatureInvocationSyntax::Parenthesized,
arguments: vec![InvocationArgumentDescriptor {
name: "value".into(),
position: Some(0),
required: true,
value_type: InvocationArgumentType::String,
completion: InvocationCompletion::None,
description: None,
}],
client_adapter: None,
}
}
#[derive(Clone)]
struct FakeFeature(Trace);
impl FeatureModule for FakeFeature {
fn descriptor(&self) -> FeatureDescriptor {
FeatureDescriptor::builtin("fake", "Fake").with_chat_invocation(descriptor())
}
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
context
.chat_invocations()
.register(descriptor(), self.clone())
}
}
#[async_trait]
impl FeatureInvocationHandler for FakeFeature {
fn is_available(&self) -> bool {
self.0.available.load(Ordering::SeqCst)
}
fn validate(
&self,
invocation: &FeatureInvocation,
) -> Result<(), FeatureInvocationHandlerError> {
if invocation.arguments[0].value == InvocationValue::String("denied".into()) {
return Err(FeatureInvocationHandlerError::failed(
"host permission denied",
));
}
Ok(())
}
async fn invoke(
&self,
context: FeatureInvocationContext,
invocation: &FeatureInvocation,
) -> Result<FeatureInvocationResult, FeatureInvocationHandlerError> {
assert_eq!(context.invocation_id, invocation.invocation_id);
let InvocationValue::String(value) = &invocation.arguments[0].value else {
panic!("validated string")
};
self.0.calls.lock().unwrap().push(value.clone());
match value.as_str() {
"fail" => {
return Err(FeatureInvocationHandlerError::failed(
"test preparation failed",
));
}
"unknown" => {
return Err(FeatureInvocationHandlerError::outcome_unknown(
"test effect uncertain",
));
}
"revoke-during-effect" => self.0.available.store(false, Ordering::SeqCst),
"large-error" => return Err(FeatureInvocationHandlerError::failed("x".repeat(4097))),
_ => {}
}
Ok(FeatureInvocationResult {
invocation_id: if value == "wrong" {
"wrong-id".into()
} else {
invocation.invocation_id.clone()
},
identity: invocation.identity.clone(),
status: FeatureInvocationStatus::Succeeded,
message: match value.as_str() {
"large-message" => "é".repeat(2049),
"limit-message" => "x".repeat(4096),
_ => "prepared".into(),
},
context: Some(match value.as_str() {
"large-context" => "é".repeat(8193),
"limit-context" => "x".repeat(16384),
_ => format!("trusted-preparation-{value}"),
}),
})
}
}
fn invoke(id: &str, value: &str) -> Segment {
Segment::FeatureInvoke {
invocation: FeatureInvocation {
invocation_id: id.into(),
identity: descriptor().identity,
name: "prepare".into(),
arguments: vec![InvocationArgumentValue {
name: "value".into(),
value: InvocationValue::String(value.into()),
}],
},
}
}
type TestStore = CombinedStore<FsStore, FsWorkerStore>;
async fn fixture(trace: Trace) -> (tempfile::TempDir, Worker<Trace, TestStore>) {
let dir = tempfile::tempdir().unwrap();
let manifest = WorkerManifest::from_toml(
r#"
[worker]
name = "invocation-test"
[model]
scheme = "anthropic"
model_id = "test-model"
[engine]
max_tokens = 100
[scope]
allow = []
"#,
)
.unwrap();
let store = CombinedStore::new(
FsStore::new(dir.path().join("sessions")).unwrap(),
FsWorkerStore::new(dir.path().join("workers")).unwrap(),
);
let mut worker = Worker::new(
manifest,
Engine::<_, agen::state::Mutable, worker::SessionHistoryMetadata>::new_annotated(
trace.clone(),
),
store,
WorkerWorkspaceContext::no_workspace(),
WorkerFilesystemAuthority::None,
manifest::Scope::empty(),
)
.await
.unwrap();
let report =
worker.install_features(FeatureRegistryBuilder::new().with_module(FakeFeature(trace)));
assert!(!report.has_errors(), "{report:?}");
(dir, worker)
}
fn results(worker: &Worker<Trace, TestStore>) -> Vec<FeatureInvocationResult> {
worker
.sink()
.subscribe_with_snapshot()
.0
.into_iter()
.filter_map(|entry| match entry {
LogEntry::AnnotatedSystemItem { entry, .. } => match entry.item {
SystemItem::FeatureInvocationResult { result, .. } => Some(result),
_ => None,
},
_ => None,
})
.collect()
}
#[derive(Clone)]
struct TracePromptHook {
trace: Trace,
cancel: bool,
}
#[async_trait]
impl worker::hook::Hook<worker::hook::OnPromptSubmit> for TracePromptHook {
async fn call(
&self,
_input: &worker::hook::PromptSubmitInfo,
) -> Result<worker::hook::HookPromptAction, worker::hook::HookError> {
self.trace.calls.lock().unwrap().push("prompt-hook".into());
Ok(if self.cancel {
worker::hook::HookPromptAction::Cancel("test cancellation".into())
} else {
worker::hook::HookPromptAction::Continue
})
}
}
#[tokio::test]
async fn installed_feature_invocations_execute_in_order_before_model_and_context_is_visible() {
let trace = Trace::default();
let (_dir, mut worker) = fixture(trace.clone()).await;
worker.add_on_prompt_submit_hook(TracePromptHook {
trace: trace.clone(),
cancel: false,
});
worker
.run(vec![
invoke("one", "first"),
Segment::text("explain"),
invoke("two", "second"),
])
.await
.unwrap();
assert_eq!(
*trace.calls.lock().unwrap(),
["first", "second", "prompt-hook", "llm"]
);
let requests = trace.requests.lock().unwrap();
let request = format!("{:?}", requests[0]);
assert!(request.contains("trusted-preparation-first"));
assert!(request.contains("trusted-preparation-second"));
assert!(request.find("trusted-preparation-first") < request.find("trusted-preparation-second"));
assert_eq!(results(&worker).len(), 2);
// Preparation results must be model-visible in the same order as replay.
let history = worker.history();
let first = history
.iter()
.position(|item| {
item.as_text()
.is_some_and(|text| text.contains("trusted-preparation-first"))
})
.unwrap();
let second = history
.iter()
.position(|item| {
item.as_text()
.is_some_and(|text| text.contains("trusted-preparation-second"))
})
.unwrap();
assert!(first < second);
}
#[tokio::test]
async fn feature_failure_fences_later_invocations_model_resume_and_notifications() {
for value in ["fail", "unknown", "wrong"] {
let trace = Trace::default();
let (_dir, mut worker) = fixture(trace.clone()).await;
assert!(
worker
.run(vec![
invoke("one", "first"),
invoke("two", value),
invoke("three", "never")
])
.await
.is_err()
);
assert_eq!(*trace.calls.lock().unwrap(), ["first", value]);
let results = results(&worker);
assert_eq!(results.len(), 3);
assert_eq!(results[0].status, FeatureInvocationStatus::Succeeded);
assert_eq!(
results[1].status,
if value == "fail" {
FeatureInvocationStatus::Failed
} else {
FeatureInvocationStatus::OutcomeUnknown
}
);
assert_eq!(results[1].invocation_id, "two");
assert!(results[1].context.is_none());
assert_eq!(results[2].status, FeatureInvocationStatus::Failed);
assert!(worker.resume().await.is_err());
assert!(
worker
.run_for_notification(protocol::InvokeKind::Notify)
.await
.is_err()
);
assert!(trace.requests.lock().unwrap().is_empty());
}
}
#[tokio::test]
async fn feature_replay_reuses_result_and_rejects_id_payload_changes() {
let trace = Trace::default();
let (_dir, mut worker) = fixture(trace.clone()).await;
worker.run(vec![invoke("same", "first")]).await.unwrap();
worker.run(vec![invoke("same", "first")]).await.unwrap();
assert_eq!(*trace.calls.lock().unwrap(), ["first", "llm", "llm"]);
assert!(worker.run(vec![invoke("same", "different")]).await.is_err());
assert_eq!(*trace.calls.lock().unwrap(), ["first", "llm", "llm"]);
assert_eq!(
results(&worker).last().unwrap().status,
FeatureInvocationStatus::Failed
);
}
#[tokio::test]
async fn feature_success_replay_does_not_resurrect_disabled_installed_capability() {
let trace = Trace::default();
let (_dir, mut worker) = fixture(trace.clone()).await;
worker.run(vec![invoke("same", "first")]).await.unwrap();
assert!(
!worker
.install_features(FeatureRegistryBuilder::new())
.has_errors()
);
assert!(
worker
.feature_invocations()
.feature_completions("")
.is_empty()
);
assert!(worker.run(vec![invoke("same", "first")]).await.is_err());
assert_eq!(*trace.calls.lock().unwrap(), ["first", "llm"]);
assert_eq!(
results(&worker).last().unwrap().status,
FeatureInvocationStatus::Failed
);
}
#[tokio::test]
async fn feature_invocation_text_is_not_executable_and_duplicate_ids_are_rejected_before_effects() {
let trace = Trace::default();
let (_dir, mut worker) = fixture(trace.clone()).await;
worker
.run(vec![Segment::text("/prepare(first) quoted /prep(fail)")])
.await
.unwrap();
assert_eq!(*trace.calls.lock().unwrap(), ["llm"]);
assert!(results(&worker).is_empty());
assert!(
worker
.run(vec![invoke("same", "first"), invoke("same", "second")])
.await
.is_err()
);
assert_eq!(*trace.calls.lock().unwrap(), ["llm"]);
}
#[tokio::test]
async fn feature_handler_validation_rejects_semantics_and_forged_authority_before_effects() {
let trace = Trace::default();
let (_dir, mut worker) = fixture(trace.clone()).await;
assert!(worker.run(vec![invoke("denied", "denied")]).await.is_err());
let mut forged = invoke("forged", "first");
if let Segment::FeatureInvoke { invocation } = &mut forged {
invocation.arguments.push(InvocationArgumentValue {
name: "workspace_id".into(),
value: InvocationValue::String("foreign".into()),
});
}
assert!(worker.run(vec![forged]).await.is_err());
assert!(trace.calls.lock().unwrap().is_empty());
assert_eq!(results(&worker).len(), 2);
assert!(
results(&worker)
.iter()
.all(|result| result.status == FeatureInvocationStatus::Failed)
);
}
#[tokio::test]
async fn feature_prompt_hook_cancellation_does_not_rollback_durable_business_preparation() {
let trace = Trace::default();
let (_dir, mut worker) = fixture(trace.clone()).await;
worker.add_on_prompt_submit_hook(TracePromptHook {
trace: trace.clone(),
cancel: true,
});
let _ = worker.run(vec![invoke("one", "first")]).await;
assert_eq!(*trace.calls.lock().unwrap(), ["first", "prompt-hook"]);
assert_eq!(results(&worker).len(), 1);
assert!(worker.history().iter().any(|item| {
item.as_text()
.is_some_and(|text| text.contains("trusted-preparation-first"))
}));
let _ = worker.run(vec![invoke("one", "first")]).await;
assert_eq!(
*trace.calls.lock().unwrap(),
["first", "prompt-hook", "prompt-hook"]
);
}
#[tokio::test]
async fn feature_unknown_input_rejected_before_effects_commit_or_model() {
let trace = Trace::default();
let (_dir, mut worker) = fixture(trace.clone()).await;
let unknown: Segment = serde_json::from_value(serde_json::json!({
"kind": "future_side_effect", "args": { "target": "important" }
}))
.unwrap();
assert!(matches!(unknown, Segment::Unknown));
let before = worker.sink().subscribe_with_snapshot().0.len();
assert!(
worker
.run(vec![
invoke("one", "first"),
unknown,
Segment::text("explain")
])
.await
.is_err()
);
assert_eq!(worker.sink().subscribe_with_snapshot().0.len(), before);
assert!(trace.calls.lock().unwrap().is_empty());
}
#[tokio::test]
async fn feature_oversized_results_become_bounded_unknown_and_do_not_repeat_effects() {
for value in ["large-message", "large-context", "large-error"] {
let trace = Trace::default();
let (_dir, mut worker) = fixture(trace.clone()).await;
assert!(
worker
.run(vec![invoke("large", value), invoke("later", "never")])
.await
.is_err()
);
assert_eq!(*trace.calls.lock().unwrap(), [value]);
for result in results(&worker) {
assert!(result.message.len() <= 4096);
assert!(result.context.is_none());
}
assert_eq!(
results(&worker)[0].status,
FeatureInvocationStatus::OutcomeUnknown
);
assert!(worker.run(vec![invoke("large", value)]).await.is_err());
assert!(worker.resume().await.is_err());
assert_eq!(*trace.calls.lock().unwrap(), [value]);
}
for value in ["limit-message", "limit-context"] {
let trace = Trace::default();
let (_dir, mut worker) = fixture(trace.clone()).await;
worker.run(vec![invoke("limit", value)]).await.unwrap();
worker.run(vec![invoke("limit", value)]).await.unwrap();
assert_eq!(*trace.calls.lock().unwrap(), [value, "llm", "llm"]);
}
}
#[tokio::test]
async fn feature_dynamic_permission_revocation_hides_discovery_and_fences_execution_and_replay() {
let trace = Trace::default();
let (_dir, mut worker) = fixture(trace.clone()).await;
let registry = worker.feature_invocations();
assert_eq!(registry.descriptors().len(), 1);
worker.run(vec![invoke("same", "first")]).await.unwrap();
trace.available.store(false, Ordering::SeqCst);
assert!(registry.descriptors().is_empty());
assert!(registry.descriptor(&descriptor().identity).is_none());
assert!(registry.feature_completions("").is_empty());
assert!(
registry
.argument_name_completions(&descriptor().identity, "")
.is_empty()
);
assert!(
registry
.argument_completions(&descriptor().identity, "value", "")
.await
.is_empty()
);
assert!(worker.run(vec![invoke("same", "first")]).await.is_err());
assert!(worker.run(vec![invoke("new", "second")]).await.is_err());
assert!(worker.resume().await.is_err());
assert_eq!(*trace.calls.lock().unwrap(), ["first", "llm"]);
trace.available.store(true, Ordering::SeqCst);
assert_eq!(registry.descriptors().len(), 1);
worker.run(vec![invoke("same", "first")]).await.unwrap();
assert_eq!(*trace.calls.lock().unwrap(), ["first", "llm", "llm"]);
}
#[tokio::test]
async fn feature_revocation_during_effect_withholds_preparation_without_retrying_committed_effect()
{
let trace = Trace::default();
let (_dir, mut worker) = fixture(trace.clone()).await;
assert!(
worker
.run(vec![invoke("same", "revoke-during-effect")])
.await
.is_err()
);
assert_eq!(*trace.calls.lock().unwrap(), ["revoke-during-effect"]);
let result = &results(&worker)[0];
assert_eq!(result.status, FeatureInvocationStatus::Failed);
assert!(result.context.is_none());
assert!(result.message.contains("reported success"));
trace.available.store(true, Ordering::SeqCst);
assert!(
worker
.run(vec![invoke("same", "revoke-during-effect")])
.await
.is_err()
);
assert_eq!(*trace.calls.lock().unwrap(), ["revoke-during-effect"]);
}
#[test]
fn notify_rejects_typed_side_effect_message_payloads() {
let methods = [
protocol::Method::Notify {
notification_request_id: "n-1".into(),
message: "/prepare(first)".into(),
},
protocol::Method::NotifyTracked {
notification_request_id: "n-1".into(),
message: "/prepare(first)".into(),
source: protocol::AuthenticatedInputSource::UntrustedWire,
},
];
for method in methods {
// Notify accepts an advisory string, not typed input or invocation
// objects. Ordinary text spelling an invocation remains advisory text.
let mut request = serde_json::to_value(method).unwrap();
assert!(serde_json::from_value::<protocol::Method>(request.clone()).is_ok());
let mut with_input = request.clone();
with_input["params"]["input"] = serde_json::json!([invoke("one", "first")]);
assert!(
serde_json::from_value::<protocol::Method>(with_input).is_err(),
"Notify must reject typed input rather than silently discard it"
);
request["params"]["message"] = serde_json::json!([invoke("one", "first")]);
assert!(serde_json::from_value::<protocol::Method>(request).is_err());
}
}
+48 -4
View File
@@ -735,6 +735,8 @@ pub struct WorkerCompletionsRequest {
pub kind: protocol::CompletionKind,
#[serde(default)]
pub prefix: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context: Option<protocol::CompletionContext>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@@ -3333,10 +3335,12 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
)],
};
};
match self
.runtime
.worker_completions(&worker_ref, request.kind, &request.prefix)
{
match self.runtime.worker_completions(
&worker_ref,
request.kind,
&request.prefix,
request.context.as_ref(),
) {
Ok(entries) => WorkerCompletionsResult {
worker: RuntimeWorkerRef::new(self.runtime_id.clone(), worker_id.to_string()),
kind: request.kind,
@@ -5415,6 +5419,7 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
let request = runtime_api::CompletionRequest {
kind: request.kind,
prefix: request.prefix,
context: request.context,
};
let failure_kind = request.kind;
let failure_prefix = request.prefix.clone();
@@ -6711,6 +6716,45 @@ mod tests {
}
}
#[test]
fn feature_invocation_and_completion_context_survive_runtime_contract() {
let segments = vec![
protocol::Segment::FeatureInvoke {
invocation: protocol::FeatureInvocation {
invocation_id: "stable-request".into(),
identity: protocol::FeatureInvocationIdentity("builtin:test/prepare".into()),
name: "prepare".into(),
arguments: vec![protocol::InvocationArgumentValue {
name: "path".into(),
value: protocol::InvocationValue::String("資料/a b".into()),
}],
},
},
protocol::Segment::text(" subsequent prose"),
];
let input = EmbeddedWorkerInput {
kind: EmbeddedWorkerInputKind::User,
content: protocol::Segment::flatten_to_text(&segments),
submission_request_id: Some("submit-request".into()),
segments: Some(segments),
};
let wire: runtime_api::WorkerInput = runtime_contract_convert(input.clone()).unwrap();
let restored: EmbeddedWorkerInput = runtime_contract_convert(wire).unwrap();
assert_eq!(restored, input);
let request = WorkerCompletionsRequest {
kind: protocol::CompletionKind::FeatureArgument,
prefix: "資料/".into(),
context: Some(protocol::CompletionContext {
invocation: protocol::FeatureInvocationIdentity("builtin:test/prepare".into()),
argument: Some("path".into()),
}),
};
let wire: runtime_api::CompletionRequest =
runtime_contract_convert(request.clone()).unwrap();
let restored: WorkerCompletionsRequest = runtime_contract_convert(wire).unwrap();
assert_eq!(restored, request);
}
#[test]
fn remote_profile_source_uses_workspace_config_archive_reference() {
let request = embedded_spawn_request();
+9 -25
View File
@@ -6304,23 +6304,11 @@ fn worker_input_request_from_api(
)));
}
};
let segments = request
.segments
.map(|segments| {
segments
.into_iter()
.map(|segment| {
serde_json::from_value::<Segment>(segment)
.map_err(|error| invalid_contract_value("Worker input segment", error))
})
.collect::<Result<Vec<_>>>()
})
.transpose()?;
Ok(WorkerInputRequest {
kind,
content: request.content,
submission_request_id: None,
segments,
segments: request.segments,
})
}
@@ -6339,9 +6327,9 @@ fn worker_completions_request_from_api(
request: server_api::RuntimeWorkerCompletionsRequest,
) -> Result<WorkerCompletionsRequest> {
Ok(WorkerCompletionsRequest {
kind: serde_json::from_value(request.kind)
.map_err(|error| invalid_contract_value("Worker completion kind", error))?,
kind: request.kind,
prefix: request.prefix,
context: request.context,
})
}
@@ -6437,17 +6425,9 @@ fn worker_completions_result_to_api(
Ok(server_api::RuntimeWorkerCompletionsResult {
runtime_id: result.worker.runtime_id,
worker_id: result.worker.worker_id,
kind: serde_json::to_value(result.kind)
.map_err(|error| invalid_contract_value("Worker completion kind", error))?,
kind: result.kind,
prefix: result.prefix,
entries: result
.entries
.into_iter()
.map(|entry| {
serde_json::to_value(entry)
.map_err(|error| invalid_contract_value("Worker completion entry", error))
})
.collect::<Result<Vec<_>>>()?,
entries: result.entries,
diagnostics: result.diagnostics.into_iter().map(Into::into).collect(),
})
}
@@ -54822,6 +54802,8 @@ mod tests {
serde_json::to_string(&protocol::Method::ListCompletions {
kind: protocol::CompletionKind::File,
prefix: String::new(),
request_id: None,
context: None,
})
.unwrap()
.into(),
@@ -55301,6 +55283,8 @@ mod tests {
method: protocol::Method::ListCompletions {
kind: protocol::CompletionKind::File,
prefix: String::new(),
request_id: None,
context: None,
},
},
),