Merge commit '5670c20a6a50077985168269dfc15a6776c66ba6' into work/T-699-checkout-projection
This commit is contained in:
@@ -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 }
|
||||
|
||||
@@ -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
@@ -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");
|
||||
|
||||
@@ -1197,6 +1197,8 @@ mod tests {
|
||||
method: crate::Method::ListCompletions {
|
||||
kind: crate::CompletionKind::File,
|
||||
prefix: "src/".to_string(),
|
||||
request_id: None,
|
||||
context: None,
|
||||
},
|
||||
},
|
||||
));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -567,12 +567,20 @@ pub struct CompletionRequest {
|
||||
pub kind: CompletionKind,
|
||||
#[serde(default)]
|
||||
pub prefix: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub context: Option<protocol::CompletionContext>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub request_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
pub struct CompletionResponse {
|
||||
pub kind: CompletionKind,
|
||||
pub prefix: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub context: Option<protocol::CompletionContext>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub request_id: Option<String>,
|
||||
pub entries: Vec<CompletionEntry>,
|
||||
}
|
||||
|
||||
@@ -1202,6 +1210,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;
|
||||
|
||||
|
||||
@@ -8371,7 +8371,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)]
|
||||
@@ -8406,9 +8406,13 @@ 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>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub request_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
|
||||
@@ -8416,9 +8420,13 @@ 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>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub context: Option<protocol::CompletionContext>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub request_id: Option<String>,
|
||||
pub entries: Vec<protocol::CompletionEntry>,
|
||||
#[serde(default)]
|
||||
pub diagnostics: Vec<Diagnostic>,
|
||||
}
|
||||
@@ -12933,6 +12941,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();
|
||||
|
||||
@@ -383,8 +383,11 @@ impl Store for FsStore {
|
||||
content.push_str(&serde_json::to_string(entry)?);
|
||||
content.push('\n');
|
||||
}
|
||||
fs::write(&path, content.as_bytes())?;
|
||||
Ok(())
|
||||
let _guard = self
|
||||
.append_lock
|
||||
.lock()
|
||||
.map_err(|_| std::io::Error::other("session store append lock was poisoned"))?;
|
||||
crate::worker_session_store::atomic_write_bytes(&path, content.as_bytes())
|
||||
}
|
||||
|
||||
fn exists(&self, session_id: SessionId, segment_id: SegmentId) -> Result<bool, StoreError> {
|
||||
|
||||
@@ -110,7 +110,14 @@ pub trait Store: Send + Sync {
|
||||
/// segment ID without its session ID (e.g. legacy `--session <UUID>`).
|
||||
fn lookup_session_of(&self, segment_id: SegmentId) -> Result<Option<SessionId>, StoreError>;
|
||||
|
||||
/// Create a new segment within `session_id`, with initial entries.
|
||||
/// Create or atomically replace a segment within `session_id` with all entries.
|
||||
///
|
||||
/// Readers and crash recovery must observe either the previous complete log
|
||||
/// or the complete replacement, never a truncated prefix or partial record.
|
||||
/// Failure before replacement leaves the old log intact; an error after the
|
||||
/// atomic commit (for example a durability sync failure) may leave the complete
|
||||
/// replacement. Filesystem implementations must sync replacement bytes before
|
||||
/// rename and the containing directory afterward before reporting success.
|
||||
fn create_segment(
|
||||
&self,
|
||||
session_id: SessionId,
|
||||
|
||||
@@ -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
|
||||
@@ -193,6 +201,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(),
|
||||
@@ -218,6 +227,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",
|
||||
@@ -393,6 +403,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 {
|
||||
|
||||
@@ -1166,7 +1166,7 @@ fn atomic_write_json<T: Serialize>(path: &Path, value: &T) -> Result<(), StoreEr
|
||||
atomic_write_bytes(path, &bytes)
|
||||
}
|
||||
|
||||
fn atomic_write_bytes(path: &Path, bytes: &[u8]) -> Result<(), StoreError> {
|
||||
pub(crate) fn atomic_write_bytes(path: &Path, bytes: &[u8]) -> Result<(), StoreError> {
|
||||
let parent = path
|
||||
.parent()
|
||||
.ok_or_else(|| std::io::Error::other("Worker Session path has no parent"))?;
|
||||
|
||||
@@ -115,6 +115,100 @@ fn create_segment_writes_all_entries() {
|
||||
assert_eq!(state.session_id, Some(sid));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_segment_replacement_keeps_open_old_log_and_commits_complete_new_log() {
|
||||
use std::io::Read;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let store = FsStore::new(dir.path()).unwrap();
|
||||
let sid = new_session_id();
|
||||
let segid = new_segment_id();
|
||||
let old = vec![
|
||||
nil_session_start(1, sid),
|
||||
LogEntry::TurnEnd {
|
||||
ts: 2,
|
||||
turn_count: 1,
|
||||
},
|
||||
];
|
||||
store.create_segment(sid, segid, &old).unwrap();
|
||||
let path = dir
|
||||
.path()
|
||||
.join(sid.to_string())
|
||||
.join(format!("{segid}.jsonl"));
|
||||
let old_bytes = std::fs::read(&path).unwrap();
|
||||
let mut old_reader = std::fs::File::open(&path).unwrap();
|
||||
let new = vec![
|
||||
nil_session_start(1, sid),
|
||||
LogEntry::Extension {
|
||||
ts: 3,
|
||||
domain: "recovery-checkpoint".into(),
|
||||
payload: serde_json::json!({"receipt": "retained"}),
|
||||
},
|
||||
];
|
||||
store.create_segment(sid, segid, &new).unwrap();
|
||||
let mut retained_inode = Vec::new();
|
||||
old_reader.read_to_end(&mut retained_inode).unwrap();
|
||||
// An in-place fs::write would truncate the inode held by this reader.
|
||||
assert_eq!(retained_inode, old_bytes);
|
||||
let reopened = FsStore::new(dir.path()).unwrap();
|
||||
assert_eq!(
|
||||
serde_json::to_value(reopened.read_all(sid, segid).unwrap()).unwrap(),
|
||||
serde_json::to_value(&new).unwrap()
|
||||
);
|
||||
assert_eq!(reopened.read_entry_count(sid, segid).unwrap(), new.len());
|
||||
assert_eq!(
|
||||
std::fs::read_dir(path.parent().unwrap()).unwrap().count(),
|
||||
1,
|
||||
"successful replacement must not leave temporary files"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_segment_atomic_replacement_readers_only_observe_complete_old_or_new() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let store = FsStore::new(dir.path()).unwrap();
|
||||
let sid = new_session_id();
|
||||
let segid = new_segment_id();
|
||||
let old = vec![
|
||||
nil_session_start(1, sid),
|
||||
LogEntry::Extension {
|
||||
ts: 2,
|
||||
domain: "old-evidence".into(),
|
||||
payload: serde_json::json!({"result": "old"}),
|
||||
},
|
||||
];
|
||||
let new = vec![
|
||||
nil_session_start(1, sid),
|
||||
LogEntry::Extension {
|
||||
ts: 3,
|
||||
domain: "new-checkpoint".into(),
|
||||
payload: serde_json::json!({"result": "new"}),
|
||||
},
|
||||
];
|
||||
store.create_segment(sid, segid, &old).unwrap();
|
||||
let old_json = serde_json::to_value(&old).unwrap();
|
||||
let new_json = serde_json::to_value(&new).unwrap();
|
||||
let barrier = std::sync::Arc::new(std::sync::Barrier::new(2));
|
||||
let reader_store = store.clone();
|
||||
let reader_barrier = barrier.clone();
|
||||
let reader = std::thread::spawn(move || {
|
||||
reader_barrier.wait();
|
||||
for _ in 0..1000 {
|
||||
let observed =
|
||||
serde_json::to_value(reader_store.read_all(sid, segid).unwrap()).unwrap();
|
||||
assert!(
|
||||
observed == old_json || observed == new_json,
|
||||
"replacement must never expose a partial log: {observed}"
|
||||
);
|
||||
}
|
||||
});
|
||||
barrier.wait();
|
||||
for _ in 0..50 {
|
||||
store.create_segment(sid, segid, &new).unwrap();
|
||||
store.create_segment(sid, segid, &old).unwrap();
|
||||
}
|
||||
reader.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_sessions_and_segments() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+792
-272
File diff suppressed because it is too large
Load Diff
+654
-27
@@ -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 = ¤t[leading..];
|
||||
let (argument, prefix_offset, mut prefix) =
|
||||
if let Some(equal) = first_unquoted_char(current, '=') {
|
||||
let name = current[..equal].trim();
|
||||
let mut value = ¤t[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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, "e_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 { .. }]
|
||||
));
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -521,19 +521,8 @@ pub struct RuntimeHttpUploadedFileDeleteResponse {
|
||||
pub deleted: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RuntimeHttpWorkerCompletionsRequest {
|
||||
pub kind: protocol::CompletionKind,
|
||||
#[serde(default)]
|
||||
pub prefix: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RuntimeHttpWorkerCompletionsResponse {
|
||||
pub kind: protocol::CompletionKind,
|
||||
pub prefix: String,
|
||||
pub entries: Vec<protocol::CompletionEntry>,
|
||||
}
|
||||
pub type RuntimeHttpWorkerCompletionsRequest = runtime_api::CompletionRequest;
|
||||
pub type RuntimeHttpWorkerCompletionsResponse = runtime_api::CompletionResponse;
|
||||
|
||||
/// Worker lifecycle request body used by stop/cancel endpoints.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -1865,15 +1854,21 @@ 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 {
|
||||
kind: request.kind,
|
||||
prefix: request.prefix,
|
||||
context: request.context,
|
||||
request_id: request.request_id,
|
||||
entries,
|
||||
}))
|
||||
}
|
||||
@@ -3511,6 +3506,56 @@ mod tests {
|
||||
serde_json::from_slice(&body).unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rest_feature_completions_echo_each_nonce_and_qualified_context() {
|
||||
let runtime =
|
||||
Runtime::with_execution_backend(RuntimeOptions::default(), Arc::new(AcceptingBackend))
|
||||
.unwrap();
|
||||
runtime
|
||||
.store_config_bundle(test_bundle(ProfileSelector::Builtin(
|
||||
"builtin:coder".into(),
|
||||
)))
|
||||
.unwrap();
|
||||
let token = "completion-token";
|
||||
let app = runtime_http_router(runtime, token.into());
|
||||
let created = authed_json_request(
|
||||
app.clone(),
|
||||
Method::POST,
|
||||
"/v1/workers",
|
||||
token,
|
||||
&task_request("completion"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(created.status(), StatusCode::OK);
|
||||
let created: RuntimeHttpWorkerResponse = read_json(created).await;
|
||||
let context = protocol::CompletionContext {
|
||||
invocation: protocol::FeatureInvocationIdentity("builtin:test/prepare".into()),
|
||||
argument: Some("path".into()),
|
||||
};
|
||||
for nonce in ["first", "second", "first"] {
|
||||
let request = runtime_api::CompletionRequest {
|
||||
kind: protocol::CompletionKind::FeatureArgument,
|
||||
prefix: "資料/".into(),
|
||||
context: Some(context.clone()),
|
||||
request_id: Some(nonce.into()),
|
||||
};
|
||||
let response = authed_json_request(
|
||||
app.clone(),
|
||||
Method::POST,
|
||||
&format!("/v1/workers/{}/completions", created.worker.worker_id),
|
||||
token,
|
||||
&request,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let response: runtime_api::CompletionResponse = read_json(response).await;
|
||||
assert_eq!(response.kind, request.kind);
|
||||
assert_eq!(response.prefix, request.prefix);
|
||||
assert_eq!(response.context, request.context);
|
||||
assert_eq!(response.request_id, request.request_id);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rest_command_api_delegates_to_runtime() {
|
||||
let runtime =
|
||||
|
||||
@@ -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)?;
|
||||
@@ -578,6 +582,8 @@ impl runtime_api::RuntimeApi for RuntimeManagementApi {
|
||||
Ok(runtime_api::CompletionResponse {
|
||||
kind,
|
||||
prefix,
|
||||
context: value.context,
|
||||
request_id: value.request_id,
|
||||
entries,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -435,11 +489,29 @@ fn stage_pending_notifications<St: Store + Clone>(
|
||||
count
|
||||
}
|
||||
|
||||
/// The durable input append is the activation boundary. Clear live state in
|
||||
/// the same poll, before invocation preparation can checkpoint it again; the
|
||||
/// oneshot only publishes the already-completed transition to the Controller.
|
||||
pub(crate) fn input_committed_hook<St: Store + Clone>(
|
||||
pending: crate::worker::PendingSubmissionHandle<St>,
|
||||
submission_id: String,
|
||||
sender: oneshot::Sender<()>,
|
||||
) -> impl FnOnce() {
|
||||
move || {
|
||||
pending.finish_activation(&submission_id);
|
||||
let _ = sender.send(());
|
||||
}
|
||||
}
|
||||
|
||||
fn notification_coalesce_remaining<St: Store + Clone>(
|
||||
pending_submissions: &crate::worker::PendingSubmissionHandle<St>,
|
||||
notify_buffer: &NotifyBuffer,
|
||||
delay: Duration,
|
||||
can_schedule_run: bool,
|
||||
) -> Option<Duration> {
|
||||
if !can_schedule_run {
|
||||
return None;
|
||||
}
|
||||
let Some(accepted_at_ms) = pending_submissions.oldest_pending_notification_accepted_at_ms()
|
||||
else {
|
||||
return notify_buffer
|
||||
@@ -846,6 +918,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 +1400,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() {
|
||||
@@ -2119,9 +2196,11 @@ async fn controller_loop<C, St>(
|
||||
submission.input,
|
||||
vec![extension],
|
||||
submission.provenance,
|
||||
move || {
|
||||
let _ = input_commit_tx.send(());
|
||||
},
|
||||
input_committed_hook(
|
||||
pending_submissions.clone(),
|
||||
committed_submission.submission_id.clone(),
|
||||
input_commit_tx,
|
||||
),
|
||||
),
|
||||
&mut method_rx,
|
||||
&working_event_tx,
|
||||
@@ -2178,10 +2257,6 @@ async fn controller_loop<C, St>(
|
||||
.await
|
||||
}
|
||||
};
|
||||
if notify_buffer.is_empty() {
|
||||
pending_submissions.finish_notification_batch();
|
||||
}
|
||||
|
||||
if !shutdown && may_drain_pending && new_status == WorkerStatus::Idle {
|
||||
match prepare_pending_run(&pending_submissions, ¬ify_buffer, None, false) {
|
||||
Ok(Some(next)) => {
|
||||
@@ -2230,12 +2305,16 @@ async fn controller_loop<C, St>(
|
||||
continue;
|
||||
}
|
||||
|
||||
// An expired Notify deadline must not repeatedly schedule a run that
|
||||
// cannot cross the invocation fence. Keep the accepted notification for
|
||||
// a fresh input/recovery boundary instead of retrying business effects.
|
||||
let notification_delay = (shared_state.catalog_status() == WorkerStatus::Idle)
|
||||
.then(|| {
|
||||
notification_coalesce_remaining(
|
||||
&pending_submissions,
|
||||
¬ify_buffer,
|
||||
notification_coalesce_delay,
|
||||
worker.can_schedule_notification_run(),
|
||||
)
|
||||
})
|
||||
.flatten();
|
||||
@@ -3909,6 +3988,40 @@ mod tests {
|
||||
assert!(notify_buffer.has_notification_pending());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invocation_fence_suspends_expired_notification_deadline_without_dropping_input() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let pending = crate::worker::PendingSubmissionHandle::for_test(temp.path());
|
||||
let buffer = NotifyBuffer::new();
|
||||
pending
|
||||
.accept_notification("held".into(), "held".into())
|
||||
.unwrap();
|
||||
let before = pending.persisted_entries_for_test().len();
|
||||
assert_eq!(
|
||||
notification_coalesce_remaining(&pending, &buffer, Duration::ZERO, true),
|
||||
Some(Duration::ZERO)
|
||||
);
|
||||
for _ in 0..4 {
|
||||
assert_eq!(
|
||||
notification_coalesce_remaining(&pending, &buffer, Duration::ZERO, false),
|
||||
None
|
||||
);
|
||||
}
|
||||
assert_eq!(pending.persisted_entries_for_test().len(), before);
|
||||
assert_eq!(pending.snapshot().notification_count, 1);
|
||||
// A busy-time staged notification is equally retained behind the fence.
|
||||
stage_pending_notifications(&pending, &buffer);
|
||||
assert_eq!(
|
||||
notification_coalesce_remaining(&pending, &buffer, Duration::ZERO, false),
|
||||
None
|
||||
);
|
||||
assert!(buffer.has_notification_pending());
|
||||
assert_eq!(
|
||||
notification_coalesce_remaining(&pending, &buffer, Duration::ZERO, true),
|
||||
Some(Duration::ZERO)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restored_mixed_activations_preserve_global_fifo_order() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,967 @@
|
||||
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
|
||||
/// and before reusing successful preparation context on replay or resume.
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,9 @@ use crate::hook::{
|
||||
use crate::ipc::notify_buffer::{NotifyBuffer, build_system_item_with_provenance};
|
||||
use crate::prompt::catalog::PromptCatalog;
|
||||
use crate::session_history::SessionHistoryMetadata;
|
||||
use crate::worker::{SystemItemCommitter, SystemPromptContributionSource};
|
||||
use crate::worker::{
|
||||
DurableNotificationCommitter, SystemItemCommitter, SystemPromptContributionSource,
|
||||
};
|
||||
use agen::HistoryEntry;
|
||||
use agen::token_counter::total_tokens;
|
||||
|
||||
@@ -109,6 +111,7 @@ pub(crate) struct WorkerInterceptor {
|
||||
/// worker. `None` in tests / `Worker::new` paths where no writer is
|
||||
/// attached.
|
||||
log_writer: Option<Arc<dyn SystemItemCommitter>>,
|
||||
notification_committer: Option<Arc<dyn DurableNotificationCommitter>>,
|
||||
resident_context_source: Option<Arc<dyn SystemPromptContributionSource>>,
|
||||
pending_committed_history: Arc<Mutex<VecDeque<HistoryEntry<SessionHistoryMetadata>>>>,
|
||||
/// Next turn index assigned by `on_prompt_submit`.
|
||||
@@ -164,6 +167,7 @@ impl WorkerInterceptor {
|
||||
prompts,
|
||||
prompt_workspace_id: None,
|
||||
log_writer,
|
||||
notification_committer: None,
|
||||
resident_context_source: None,
|
||||
pending_committed_history,
|
||||
next_turn_index: AtomicUsize::new(0),
|
||||
@@ -171,6 +175,14 @@ impl WorkerInterceptor {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn with_notification_committer(
|
||||
mut self,
|
||||
committer: Option<Arc<dyn DurableNotificationCommitter>>,
|
||||
) -> Self {
|
||||
self.notification_committer = committer;
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn with_usage_tracker(mut self, usage_tracker: Arc<UsageTracker>) -> Self {
|
||||
self.usage_tracker = Some(usage_tracker);
|
||||
self
|
||||
@@ -427,8 +439,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
|
||||
@@ -438,7 +465,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}")),
|
||||
}
|
||||
})
|
||||
@@ -462,19 +492,10 @@ impl Interceptor<SessionHistoryMetadata> for WorkerInterceptor {
|
||||
projection_digest: projection.catalog_digest.clone(),
|
||||
logical_name: "internal.notify_wrapper".to_string(),
|
||||
};
|
||||
let mut system_items: Vec<(
|
||||
SystemItem,
|
||||
Vec<session_store::SessionExtension>,
|
||||
Option<session_store::LoggedSessionHistoryOrigin>,
|
||||
)> = Vec::with_capacity(drained.len());
|
||||
let mut items: Vec<Item> = Vec::with_capacity(drained.len());
|
||||
let mut system_items = Vec::with_capacity(drained.len());
|
||||
for entry in &drained {
|
||||
let system_item = match build_system_item_with_provenance(
|
||||
entry,
|
||||
&prompts,
|
||||
Some(provenance.clone()),
|
||||
) {
|
||||
Ok(system_item) => system_item,
|
||||
match build_system_item_with_provenance(entry, &prompts, Some(provenance.clone())) {
|
||||
Ok(item) => system_items.push(item),
|
||||
Err(error) => {
|
||||
self.pending_notifies.requeue_front(drained);
|
||||
return Err(InterceptorError::new(
|
||||
@@ -482,16 +503,50 @@ impl Interceptor<SessionHistoryMetadata> for WorkerInterceptor {
|
||||
format!("failed to render notify_wrapper: {error}"),
|
||||
));
|
||||
}
|
||||
};
|
||||
items.push(system_item.to_history_item());
|
||||
system_items.push((system_item, entry.extensions(), entry.history_provenance()));
|
||||
}
|
||||
}
|
||||
if let Err(error) = self.commit_system_items_with_extensions(&system_items) {
|
||||
self.pending_notifies.requeue_front(drained);
|
||||
return Err(InterceptorError::new(
|
||||
InterceptorErrorCategory::Dependency,
|
||||
format!("session persistence failed: {error}"),
|
||||
));
|
||||
let mut items = Vec::with_capacity(drained.len());
|
||||
let mut remaining = drained.into_iter();
|
||||
for item in system_items {
|
||||
let entry = remaining.next().expect("rendered notification must exist");
|
||||
let committed = if let Some(identity) = entry.notification_commit() {
|
||||
match self.notification_committer.as_ref() {
|
||||
Some(committer) => {
|
||||
committer.commit_notification(identity, item, entry.history_provenance())
|
||||
}
|
||||
None => Err(session_store::StoreError::Io(std::io::Error::other(
|
||||
"durable notification commit authority is missing",
|
||||
))),
|
||||
}
|
||||
} else {
|
||||
self.commit_system_items_with_extensions(&[(
|
||||
item.clone(),
|
||||
Vec::new(),
|
||||
entry.history_provenance(),
|
||||
)])
|
||||
.map(|()| {
|
||||
items.push(item.to_history_item());
|
||||
None
|
||||
})
|
||||
};
|
||||
match committed {
|
||||
Ok(Some(history)) => {
|
||||
items.push(history.item.clone());
|
||||
self.pending_committed_history
|
||||
.lock()
|
||||
.expect("pending committed history poisoned")
|
||||
.push_back(history);
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(error) => {
|
||||
self.pending_notifies
|
||||
.requeue_front(std::iter::once(entry).chain(remaining).collect());
|
||||
return Err(InterceptorError::new(
|
||||
InterceptorErrorCategory::Dependency,
|
||||
format!("session persistence failed: {error}"),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
@@ -24,8 +24,9 @@
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use crate::worker::NotificationCommitIdentity;
|
||||
use protocol::WorkerEvent;
|
||||
use session_store::{LoggedSessionHistoryOrigin, SessionExtension, SystemItem};
|
||||
use session_store::{LoggedSessionHistoryOrigin, SystemItem};
|
||||
|
||||
use crate::prompt::catalog::{CatalogError, PromptCatalog};
|
||||
|
||||
@@ -39,7 +40,7 @@ use crate::prompt::catalog::{CatalogError, PromptCatalog};
|
||||
pub enum PendingNotify {
|
||||
Notify {
|
||||
message: String,
|
||||
extensions: Vec<SessionExtension>,
|
||||
notification_commit: Option<NotificationCommitIdentity>,
|
||||
history_provenance: Option<LoggedSessionHistoryOrigin>,
|
||||
},
|
||||
WorkerEvent {
|
||||
@@ -48,10 +49,13 @@ pub enum PendingNotify {
|
||||
}
|
||||
|
||||
impl PendingNotify {
|
||||
pub(crate) fn extensions(&self) -> Vec<SessionExtension> {
|
||||
pub(crate) fn notification_commit(&self) -> Option<&NotificationCommitIdentity> {
|
||||
match self {
|
||||
PendingNotify::Notify { extensions, .. } => extensions.clone(),
|
||||
PendingNotify::WorkerEvent { .. } => Vec::new(),
|
||||
PendingNotify::Notify {
|
||||
notification_commit,
|
||||
..
|
||||
} => notification_commit.as_ref(),
|
||||
PendingNotify::WorkerEvent { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,7 +89,7 @@ impl NotifyBuffer {
|
||||
pub fn push_notify(&self, message: String) {
|
||||
self.push_entry(PendingNotify::Notify {
|
||||
message,
|
||||
extensions: Vec::new(),
|
||||
notification_commit: None,
|
||||
history_provenance: None,
|
||||
});
|
||||
}
|
||||
@@ -94,11 +98,11 @@ impl NotifyBuffer {
|
||||
&self,
|
||||
message: String,
|
||||
history_provenance: LoggedSessionHistoryOrigin,
|
||||
extension: SessionExtension,
|
||||
identity: NotificationCommitIdentity,
|
||||
) {
|
||||
self.push_entry(PendingNotify::Notify {
|
||||
message,
|
||||
extensions: vec![extension],
|
||||
notification_commit: Some(identity),
|
||||
history_provenance: Some(history_provenance),
|
||||
});
|
||||
}
|
||||
@@ -233,7 +237,7 @@ mod tests {
|
||||
fn build_system_item_for_notify_carries_wrapper_body() {
|
||||
let entry = PendingNotify::Notify {
|
||||
message: "hello".into(),
|
||||
extensions: Vec::new(),
|
||||
notification_commit: None,
|
||||
history_provenance: None,
|
||||
};
|
||||
let catalog = PromptCatalog::builtins_only().unwrap();
|
||||
|
||||
@@ -122,9 +122,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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+1944
-42
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,613 @@
|
||||
//! 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>,
|
||||
argument_allowed: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl Default for Trace {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
calls: Arc::default(),
|
||||
requests: Arc::default(),
|
||||
available: Arc::new(AtomicBool::new(true)),
|
||||
argument_allowed: 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())
|
||||
|| (invocation.arguments[0].value == InvocationValue::String("first".into())
|
||||
&& !self.0.argument_allowed.load(Ordering::SeqCst))
|
||||
{
|
||||
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_argument_permission_revocation_fences_success_replay_and_resume() {
|
||||
let trace = Trace::default();
|
||||
let (_dir, mut worker) = fixture(trace.clone()).await;
|
||||
worker.run(vec![invoke("same", "first")]).await.unwrap();
|
||||
trace.argument_allowed.store(false, Ordering::SeqCst);
|
||||
// The feature remains available: only this argument's authority was revoked.
|
||||
assert_eq!(worker.feature_invocations().descriptors().len(), 1);
|
||||
assert!(worker.resume().await.is_err());
|
||||
assert!(
|
||||
worker
|
||||
.run_for_notification(protocol::InvokeKind::Notify)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(worker.run(vec![invoke("same", "first")]).await.is_err());
|
||||
let replay = results(&worker).pop().unwrap();
|
||||
assert_eq!(replay.status, FeatureInvocationStatus::Failed);
|
||||
assert!(replay.context.is_none());
|
||||
assert!(replay.message.contains("host permission denied"));
|
||||
assert_eq!(*trace.calls.lock().unwrap(), ["first", "llm"]);
|
||||
// Reauthorization allows context reuse, never another business execution.
|
||||
trace.argument_allowed.store(true, Ordering::SeqCst);
|
||||
worker.run(vec![invoke("same", "first")]).await.unwrap();
|
||||
assert_eq!(*trace.calls.lock().unwrap(), ["first", "llm", "llm"]);
|
||||
}
|
||||
|
||||
#[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());
|
||||
}
|
||||
}
|
||||
@@ -735,6 +735,10 @@ 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>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub request_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
@@ -743,6 +747,10 @@ pub struct WorkerCompletionsResult {
|
||||
pub worker: RuntimeWorkerRef,
|
||||
pub kind: protocol::CompletionKind,
|
||||
pub prefix: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub context: Option<protocol::CompletionContext>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub request_id: Option<String>,
|
||||
pub entries: Vec<protocol::CompletionEntry>,
|
||||
pub diagnostics: Vec<RuntimeDiagnostic>,
|
||||
}
|
||||
@@ -1327,6 +1335,8 @@ pub trait WorkspaceWorkerRuntime: Send + Sync {
|
||||
worker: RuntimeWorkerRef::new(self.runtime_id().to_string(), worker_id.to_string()),
|
||||
kind: request.kind,
|
||||
prefix: request.prefix,
|
||||
context: request.context,
|
||||
request_id: request.request_id,
|
||||
entries: Vec::new(),
|
||||
diagnostics: vec![diagnostic(
|
||||
"worker_completions_unsupported",
|
||||
@@ -3310,6 +3320,8 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
|
||||
worker: RuntimeWorkerRef::new(self.runtime_id.clone(), worker_id.to_string()),
|
||||
kind: request.kind,
|
||||
prefix: request.prefix,
|
||||
context: request.context,
|
||||
request_id: request.request_id,
|
||||
entries: Vec::new(),
|
||||
diagnostics: vec![diagnostic(
|
||||
"embedded_worker_execution_unavailable",
|
||||
@@ -3325,6 +3337,8 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
|
||||
worker: RuntimeWorkerRef::new(self.runtime_id.clone(), worker_id.to_string()),
|
||||
kind: request.kind,
|
||||
prefix: request.prefix,
|
||||
context: request.context,
|
||||
request_id: request.request_id,
|
||||
entries: Vec::new(),
|
||||
diagnostics: vec![diagnostic(
|
||||
"embedded_worker_id_invalid",
|
||||
@@ -3333,14 +3347,18 @@ 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,
|
||||
prefix: request.prefix,
|
||||
context: request.context,
|
||||
request_id: request.request_id,
|
||||
entries,
|
||||
diagnostics: Vec::new(),
|
||||
},
|
||||
@@ -3348,6 +3366,8 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
|
||||
worker: RuntimeWorkerRef::new(self.runtime_id.clone(), worker_id.to_string()),
|
||||
kind: request.kind,
|
||||
prefix: request.prefix,
|
||||
context: request.context,
|
||||
request_id: request.request_id,
|
||||
entries: Vec::new(),
|
||||
diagnostics: vec![embedded_runtime_diagnostic(&error)],
|
||||
},
|
||||
@@ -5415,9 +5435,13 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
|
||||
let request = runtime_api::CompletionRequest {
|
||||
kind: request.kind,
|
||||
prefix: request.prefix,
|
||||
context: request.context,
|
||||
request_id: request.request_id,
|
||||
};
|
||||
let failure_kind = request.kind;
|
||||
let failure_prefix = request.prefix.clone();
|
||||
let failure_context = request.context.clone();
|
||||
let failure_request_id = request.request_id.clone();
|
||||
let worker_id_owned = worker_id.to_string();
|
||||
match self.run_runtime_api(
|
||||
self.request_timeout,
|
||||
@@ -5432,6 +5456,8 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
|
||||
worker: RuntimeWorkerRef::new(self.runtime_id.clone(), worker_id.to_string()),
|
||||
kind: response.kind,
|
||||
prefix: response.prefix,
|
||||
context: response.context,
|
||||
request_id: response.request_id,
|
||||
entries: response.entries,
|
||||
diagnostics: Vec::new(),
|
||||
},
|
||||
@@ -5439,6 +5465,8 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
|
||||
worker: RuntimeWorkerRef::new(self.runtime_id.clone(), worker_id.to_string()),
|
||||
kind: failure_kind,
|
||||
prefix: failure_prefix,
|
||||
context: failure_context,
|
||||
request_id: failure_request_id,
|
||||
entries: Vec::new(),
|
||||
diagnostics: vec![diagnostic],
|
||||
},
|
||||
@@ -6711,6 +6739,46 @@ 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()),
|
||||
}),
|
||||
request_id: Some("completion-nonce".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();
|
||||
|
||||
@@ -6508,23 +6508,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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -6543,9 +6531,10 @@ 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,
|
||||
request_id: request.request_id,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -6641,17 +6630,11 @@ 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<_>>>()?,
|
||||
context: result.context,
|
||||
request_id: result.request_id,
|
||||
entries: result.entries,
|
||||
diagnostics: result.diagnostics.into_iter().map(Into::into).collect(),
|
||||
})
|
||||
}
|
||||
@@ -36263,6 +36246,33 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feature_completion_api_conversions_preserve_nonce_and_context_for_aba_requests() {
|
||||
for nonce in ["first", "second", "first"] {
|
||||
let json = serde_json::json!({"kind":"feature_argument", "prefix":"資料/", "request_id":nonce,
|
||||
"context":{"invocation":"builtin:test/prepare", "argument":"path"}});
|
||||
let wire: server_api::RuntimeWorkerCompletionsRequest =
|
||||
serde_json::from_value(json.clone()).unwrap();
|
||||
let request = worker_completions_request_from_api(wire).unwrap();
|
||||
assert_eq!(serde_json::to_value(&request).unwrap(), json);
|
||||
let result = worker_completions_result_to_api(WorkerCompletionsResult {
|
||||
worker: RuntimeWorkerRef::new("runtime", "worker"),
|
||||
kind: request.kind,
|
||||
prefix: request.prefix,
|
||||
context: request.context,
|
||||
request_id: request.request_id,
|
||||
entries: Vec::new(),
|
||||
diagnostics: Vec::new(),
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(result.request_id.as_deref(), Some(nonce));
|
||||
assert_eq!(
|
||||
serde_json::to_value(result.context).unwrap(),
|
||||
json["context"]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flow_or_generic_worker_state_change_is_not_ticket_completion_authority() {
|
||||
let change = || {
|
||||
@@ -57327,6 +57337,8 @@ mod tests {
|
||||
serde_json::to_string(&protocol::Method::ListCompletions {
|
||||
kind: protocol::CompletionKind::File,
|
||||
prefix: String::new(),
|
||||
request_id: None,
|
||||
context: None,
|
||||
})
|
||||
.unwrap()
|
||||
.into(),
|
||||
@@ -57807,6 +57819,8 @@ mod tests {
|
||||
method: protocol::Method::ListCompletions {
|
||||
kind: protocol::CompletionKind::File,
|
||||
prefix: String::new(),
|
||||
request_id: None,
|
||||
context: None,
|
||||
},
|
||||
},
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user