feat: add declarative Feature chat invocations and typed composers

This commit is contained in:
2026-10-05 18:44:41 +09:00
parent cc97987482
commit f7783b3d2a
69 changed files with 10065 additions and 657 deletions
Generated
+1
View File
@@ -3483,6 +3483,7 @@ dependencies = [
"serde",
"serde_json",
"sha2 0.11.0",
"thiserror 2.0.18",
"tokio",
"ts-rs",
"uuid",
+1
View File
@@ -14,6 +14,7 @@ json-schema = ["dep:schemars"]
schemars = { workspace = true, optional = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
thiserror = { workspace = true }
sha2.workspace = true
tokio = { workspace = true, features = ["io-util"], optional = true }
ts-rs = { version = "12.0.1", optional = true }
+949
View File
@@ -0,0 +1,949 @@
//! Declarative chat Feature invocation contract.
//!
//! The input spelling is deliberately bounded and unambiguous:
//! `/name(arg=value, "positional value")`. Parentheses terminate arguments,
//! so following prose is never consumed. Strings use JSON-style quoting and
//! escaping. Unquoted values are limited to booleans, integers, enum-like words,
//! and paths without comma/parenthesis/whitespace. This module parses input for
//! clients and validates the structured payload again at the host boundary.
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
/// UTF-8 byte bound shared by input validation and durable recovery keys.
pub const MAX_FEATURE_INVOCATION_ID_BYTES: usize = 128;
/// Installed descriptors must fit a public completion entry without truncation.
pub const MAX_FEATURE_INVOCATION_DESCRIPTOR_BYTES: usize = 32 * 1024;
/// Source-qualified identity of one invocation contribution, for example
/// `builtin:attachments/attach`. Display names and aliases are never authority.
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct FeatureInvocationIdentity(pub String);
impl fmt::Display for FeatureInvocationIdentity {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum FeatureInvocationSyntax {
/// `/name(value, key=value)` with JSON-style quoted strings and `)` as the
/// explicit boundary before following natural language.
Parenthesized,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum InvocationArgumentType {
String,
Integer,
Boolean,
Enum {
values: Vec<String>,
},
/// A Worker-readable path. This does not grant filesystem authority.
WorkerFile,
/// A resource selected and staged by the client. It must never be resolved
/// as a path on the Worker host.
ClientFile,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum InvocationCompletion {
None,
Static {
values: Vec<String>,
},
WorkerFile,
ClientFile,
/// Host-owned provider selected by source-qualified id. Metadata lookup is
/// side-effect free; implementations never cross this wire boundary.
Provider {
provider: String,
},
}
impl Default for InvocationCompletion {
fn default() -> Self {
Self::None
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct InvocationArgumentDescriptor {
pub name: String,
/// Zero-based positional index. `None` means named-only. A positional
/// argument may also be supplied by name; this makes chip editing stable.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub position: Option<u16>,
#[serde(default)]
pub required: bool,
pub value_type: InvocationArgumentType,
#[serde(default)]
pub completion: InvocationCompletion,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum InvocationClientAdapter {
/// The client stages a local file and replaces the draft invocation with an
/// `UploadedFile` chip. Local paths are not sent to or persisted by Worker.
Attachment,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct FeatureInvocationDescriptor {
pub identity: FeatureInvocationIdentity,
pub name: String,
#[serde(default)]
pub aliases: Vec<String>,
pub display_name: String,
pub description: String,
pub syntax: FeatureInvocationSyntax,
#[serde(default)]
pub arguments: Vec<InvocationArgumentDescriptor>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub client_adapter: Option<InvocationClientAdapter>,
}
impl FeatureInvocationDescriptor {
pub fn usage(&self) -> String {
let mut usage = format!("/{}(", self.name);
for (index, argument) in self.arguments.iter().enumerate() {
if index > 0 {
usage.push_str(", ");
}
if argument.position.is_none() {
usage.push_str(&argument.name);
usage.push('=');
}
let marker = match &argument.value_type {
InvocationArgumentType::String => "text",
InvocationArgumentType::Integer => "integer",
InvocationArgumentType::Boolean => "true|false",
InvocationArgumentType::Enum { .. } => "choice",
InvocationArgumentType::WorkerFile => "worker-path",
InvocationArgumentType::ClientFile => "local-file",
};
if argument.required {
usage.push('<');
usage.push_str(marker);
usage.push('>');
} else {
usage.push('[');
usage.push_str(marker);
usage.push(']');
}
}
usage.push(')');
usage
}
pub fn validate(&self) -> Result<(), InvocationValidationError> {
const MAX_PUBLIC_NAMES: usize = 16;
const MAX_ARGUMENTS: usize = 32;
const MAX_METADATA_BYTES: usize = 4096;
validate_invocation_name(&self.name)?;
if self.aliases.len() > MAX_PUBLIC_NAMES || self.arguments.len() > MAX_ARGUMENTS {
return Err(InvocationValidationError::InvalidDescriptor(
"invocation descriptor exceeds collection limits".into(),
));
}
if self.display_name.len() > MAX_METADATA_BYTES
|| self.description.len() > MAX_METADATA_BYTES
{
return Err(InvocationValidationError::InvalidDescriptor(
"invocation metadata exceeds byte limits".into(),
));
}
if self.identity.0.trim().is_empty()
|| !self.identity.0.contains(':')
|| self.identity.0.len() > 256
{
return Err(InvocationValidationError::InvalidDescriptor(
"invocation identity must be source-qualified".into(),
));
}
let mut names = BTreeSet::new();
let mut positions = BTreeSet::new();
for name in std::iter::once(&self.name).chain(self.aliases.iter()) {
validate_invocation_name(name)?;
if !names.insert(name) {
return Err(InvocationValidationError::InvalidDescriptor(format!(
"duplicate invocation name or alias `{name}`"
)));
}
}
let mut argument_names = BTreeSet::new();
for argument in &self.arguments {
validate_argument_name(&argument.name)?;
if !argument_names.insert(argument.name.as_str()) {
return Err(InvocationValidationError::InvalidDescriptor(format!(
"duplicate argument `{}`",
argument.name
)));
}
if let Some(position) = argument.position
&& !positions.insert(position)
{
return Err(InvocationValidationError::InvalidDescriptor(format!(
"duplicate positional index `{position}`"
)));
}
if argument
.description
.as_ref()
.is_some_and(|value| value.len() > MAX_METADATA_BYTES)
{
return Err(InvocationValidationError::InvalidDescriptor(format!(
"argument `{}` description exceeds byte limits",
argument.name
)));
}
if let InvocationCompletion::Provider { provider } = &argument.completion
&& (provider.trim().is_empty() || provider.len() > 256)
{
return Err(InvocationValidationError::InvalidDescriptor(format!(
"argument `{}` has an invalid completion provider",
argument.name
)));
}
if matches!(&argument.value_type, InvocationArgumentType::Enum { values }
if values.len() > 256 || values.iter().any(|value| value.len() > MAX_METADATA_BYTES))
|| matches!(&argument.completion, InvocationCompletion::Static { values }
if values.len() > 256 || values.iter().any(|value| value.len() > MAX_METADATA_BYTES))
{
return Err(InvocationValidationError::InvalidDescriptor(format!(
"argument `{}` completion values exceed limits",
argument.name
)));
}
if matches!(&argument.value_type, InvocationArgumentType::Enum { values } if values.is_empty())
{
return Err(InvocationValidationError::InvalidDescriptor(format!(
"enum argument `{}` has no values",
argument.name
)));
}
}
for (expected, actual) in positions.iter().copied().enumerate() {
if actual as usize != expected {
return Err(InvocationValidationError::InvalidDescriptor(
"positional argument indices must be contiguous from zero".into(),
));
}
}
if serde_json::to_vec(self).map_or(true, |encoded| {
encoded.len() > MAX_FEATURE_INVOCATION_DESCRIPTOR_BYTES
}) {
return Err(InvocationValidationError::InvalidDescriptor(
"invocation descriptor exceeds the public completion byte limit".into(),
));
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
pub enum InvocationValue {
String(String),
Integer(i32),
Boolean(bool),
}
impl InvocationValue {
pub fn display_input(&self) -> String {
match self {
Self::String(value) => quote_invocation_string(value),
Self::Integer(value) => value.to_string(),
Self::Boolean(value) => value.to_string(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct InvocationArgumentValue {
pub name: String,
pub value: InvocationValue,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct FeatureInvocation {
/// Stable client-generated id retained across queueing and transport retry.
pub invocation_id: String,
pub identity: FeatureInvocationIdentity,
/// Selected public name, retained for exact draft/history restoration. Host
/// resolution is always by `identity`.
pub name: String,
pub arguments: Vec<InvocationArgumentValue>,
}
impl FeatureInvocation {
pub fn display_input(&self) -> String {
let mut input = format!("/{}(", self.name);
for (index, argument) in self.arguments.iter().enumerate() {
if index > 0 {
input.push_str(", ");
}
input.push_str(&argument.name);
input.push('=');
input.push_str(&argument.value.display_input());
}
input.push(')');
input
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum FeatureInvocationStatus {
Succeeded,
Failed,
OutcomeUnknown,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct FeatureInvocationResult {
pub invocation_id: String,
pub identity: FeatureInvocationIdentity,
pub status: FeatureInvocationStatus,
pub message: String,
/// Bounded model-visible context produced by the handler. Credentials and
/// executable implementation details must not be included.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParsedFeatureInvocation {
pub invocation: FeatureInvocation,
/// UTF-8 byte offset immediately after the closing parenthesis.
pub end: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum InvocationValidationError {
#[error("invalid invocation descriptor: {0}")]
InvalidDescriptor(String),
#[error("incomplete invocation at byte {0}")]
Incomplete(usize),
#[error("unknown argument `{0}`")]
UnknownArgument(String),
#[error("duplicate argument `{0}`")]
DuplicateArgument(String),
#[error("missing required argument `{0}`")]
MissingRequired(String),
#[error("too many positional arguments")]
TooManyPositional,
#[error("invalid value for `{argument}`: {message}")]
InvalidValue { argument: String, message: String },
#[error("invocation identity does not match descriptor")]
IdentityMismatch,
#[error("invocation name does not match descriptor")]
NameMismatch,
#[error("invalid invocation syntax at byte {offset}: {message}")]
Syntax { offset: usize, message: String },
}
pub fn validate_feature_invocation(
descriptor: &FeatureInvocationDescriptor,
invocation: &FeatureInvocation,
) -> Result<(), InvocationValidationError> {
descriptor.validate()?;
if invocation.identity != descriptor.identity {
return Err(InvocationValidationError::IdentityMismatch);
}
if invocation.name != descriptor.name && !descriptor.aliases.contains(&invocation.name) {
return Err(InvocationValidationError::NameMismatch);
}
if invocation.invocation_id.trim().is_empty()
|| invocation.invocation_id.len() > MAX_FEATURE_INVOCATION_ID_BYTES
{
return Err(InvocationValidationError::InvalidValue {
argument: "invocation_id".into(),
message: "must be nonempty and at most 128 UTF-8 bytes".into(),
});
}
if invocation.arguments.len() > 32 {
return Err(InvocationValidationError::InvalidValue {
argument: "arguments".into(),
message: "too many arguments".into(),
});
}
let mut values = BTreeMap::new();
for argument in &invocation.arguments {
if values
.insert(argument.name.as_str(), &argument.value)
.is_some()
{
return Err(InvocationValidationError::DuplicateArgument(
argument.name.clone(),
));
}
}
for name in values.keys() {
if !descriptor
.arguments
.iter()
.any(|argument| argument.name == **name)
{
return Err(InvocationValidationError::UnknownArgument(
(*name).to_string(),
));
}
}
for argument in &descriptor.arguments {
match values.get(argument.name.as_str()) {
Some(value) => validate_value(argument, value)?,
None if argument.required => {
return Err(InvocationValidationError::MissingRequired(
argument.name.clone(),
));
}
None => {}
}
}
Ok(())
}
/// Parse one complete parenthesized invocation starting at `start`. Callers
/// decide when input becomes a typed chip; parsing alone never executes it.
pub fn parse_feature_invocation(
input: &str,
start: usize,
descriptor: &FeatureInvocationDescriptor,
invocation_id: impl Into<String>,
) -> Result<ParsedFeatureInvocation, InvocationValidationError> {
descriptor.validate()?;
if !input.is_char_boundary(start) || input.as_bytes().get(start) != Some(&b'/') {
return Err(InvocationValidationError::Syntax {
offset: start,
message: "expected `/`".into(),
});
}
let mut cursor = start + 1;
let name_start = cursor;
while let Some(byte) = input.as_bytes().get(cursor) {
if byte.is_ascii_alphanumeric() || matches!(*byte, b'-' | b'_') {
cursor += 1;
} else {
break;
}
}
let name = &input[name_start..cursor];
if name != descriptor.name && !descriptor.aliases.iter().any(|alias| alias == name) {
return Err(InvocationValidationError::NameMismatch);
}
skip_ws(input, &mut cursor);
if input.as_bytes().get(cursor) != Some(&b'(') {
return Err(InvocationValidationError::Incomplete(cursor));
}
cursor += 1;
let positional = descriptor
.arguments
.iter()
.filter_map(|argument| argument.position.map(|position| (position, argument)))
.collect::<BTreeMap<_, _>>();
let mut next_position = 0u16;
let mut parsed = Vec::<InvocationArgumentValue>::new();
loop {
skip_ws(input, &mut cursor);
match input.as_bytes().get(cursor) {
Some(b')') => {
cursor += 1;
break;
}
None => return Err(InvocationValidationError::Incomplete(cursor)),
_ => {}
}
let token_start = cursor;
let possible_name = parse_identifier(input, &mut cursor);
let after_name = cursor;
skip_ws(input, &mut cursor);
let (argument, raw_value) = if !possible_name.is_empty()
&& input.as_bytes().get(cursor) == Some(&b'=')
{
cursor += 1;
skip_ws(input, &mut cursor);
let argument = descriptor
.arguments
.iter()
.find(|argument| argument.name == possible_name)
.ok_or_else(|| InvocationValidationError::UnknownArgument(possible_name.into()))?;
(argument, parse_raw_value(input, &mut cursor)?)
} else {
cursor = token_start;
let argument = positional
.get(&next_position)
.copied()
.ok_or(InvocationValidationError::TooManyPositional)?;
next_position += 1;
(argument, parse_raw_value(input, &mut cursor)?)
};
if parsed.iter().any(|value| value.name == argument.name) {
return Err(InvocationValidationError::DuplicateArgument(
argument.name.clone(),
));
}
let value = coerce_value(argument, raw_value)?;
parsed.push(InvocationArgumentValue {
name: argument.name.clone(),
value,
});
skip_ws(input, &mut cursor);
match input.as_bytes().get(cursor) {
Some(b',') => cursor += 1,
Some(b')') => {}
None => return Err(InvocationValidationError::Incomplete(cursor)),
_ => {
return Err(InvocationValidationError::Syntax {
offset: after_name.max(cursor),
message: "expected `,` or `)`".into(),
});
}
}
}
let invocation = FeatureInvocation {
invocation_id: invocation_id.into(),
identity: descriptor.identity.clone(),
name: name.to_string(),
arguments: parsed,
};
validate_feature_invocation(descriptor, &invocation)?;
Ok(ParsedFeatureInvocation {
invocation,
end: cursor,
})
}
#[derive(Debug)]
enum RawValue {
Quoted(String),
Bare(String),
}
fn parse_raw_value(input: &str, cursor: &mut usize) -> Result<RawValue, InvocationValidationError> {
if input.as_bytes().get(*cursor) == Some(&b'"') {
let start = *cursor;
*cursor += 1;
let mut escaped = false;
loop {
let Some(ch) = input[*cursor..].chars().next() else {
return Err(InvocationValidationError::Incomplete(*cursor));
};
*cursor += ch.len_utf8();
if ch == '"' && !escaped {
let value =
serde_json::from_str::<String>(&input[start..*cursor]).map_err(|error| {
InvocationValidationError::Syntax {
offset: start,
message: error.to_string(),
}
})?;
return Ok(RawValue::Quoted(value));
}
escaped = ch == '\\' && !escaped;
}
}
let start = *cursor;
while let Some(ch) = input[*cursor..].chars().next() {
if ch == ',' || ch == ')' || ch.is_whitespace() {
break;
}
*cursor += ch.len_utf8();
}
if *cursor == start {
return Err(InvocationValidationError::Syntax {
offset: start,
message: "expected argument value".into(),
});
}
Ok(RawValue::Bare(input[start..*cursor].to_string()))
}
fn coerce_value(
descriptor: &InvocationArgumentDescriptor,
raw: RawValue,
) -> Result<InvocationValue, InvocationValidationError> {
let text = match raw {
RawValue::Quoted(value) | RawValue::Bare(value) => value,
};
let invalid = |message: &str| InvocationValidationError::InvalidValue {
argument: descriptor.name.clone(),
message: message.into(),
};
match &descriptor.value_type {
InvocationArgumentType::String
| InvocationArgumentType::WorkerFile
| InvocationArgumentType::ClientFile => Ok(InvocationValue::String(text)),
InvocationArgumentType::Integer => {
let digits = text.strip_prefix('-').unwrap_or(&text);
if digits.is_empty() || !digits.bytes().all(|byte| byte.is_ascii_digit()) {
return Err(invalid("expected a decimal integer"));
}
text.parse::<i32>()
.map(InvocationValue::Integer)
.map_err(|_| invalid("expected a signed 32-bit integer"))
}
InvocationArgumentType::Boolean => match text.as_str() {
"true" => Ok(InvocationValue::Boolean(true)),
"false" => Ok(InvocationValue::Boolean(false)),
_ => Err(invalid("expected `true` or `false`")),
},
InvocationArgumentType::Enum { values } => {
if values.contains(&text) {
Ok(InvocationValue::String(text))
} else {
Err(invalid("value is not in the declared enum"))
}
}
}
}
fn validate_value(
descriptor: &InvocationArgumentDescriptor,
value: &InvocationValue,
) -> Result<(), InvocationValidationError> {
let valid = match (&descriptor.value_type, value) {
(
InvocationArgumentType::String
| InvocationArgumentType::WorkerFile
| InvocationArgumentType::ClientFile,
InvocationValue::String(value),
) => value.len() <= 16 * 1024,
(InvocationArgumentType::Integer, InvocationValue::Integer(_)) => true,
(InvocationArgumentType::Boolean, InvocationValue::Boolean(_)) => true,
(InvocationArgumentType::Enum { values }, InvocationValue::String(value)) => {
values.contains(value)
}
_ => false,
};
if valid {
Ok(())
} else {
Err(InvocationValidationError::InvalidValue {
argument: descriptor.name.clone(),
message: "structured value does not match the declared type".into(),
})
}
}
fn validate_invocation_name(name: &str) -> Result<(), InvocationValidationError> {
if name.is_empty()
|| !name
.bytes()
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
|| !name.as_bytes()[0].is_ascii_lowercase()
{
return Err(InvocationValidationError::InvalidDescriptor(format!(
"invalid invocation name `{name}`"
)));
}
Ok(())
}
fn validate_argument_name(name: &str) -> Result<(), InvocationValidationError> {
if name.is_empty()
|| !name
.bytes()
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_')
|| !name.as_bytes()[0].is_ascii_lowercase()
{
return Err(InvocationValidationError::InvalidDescriptor(format!(
"invalid argument name `{name}`"
)));
}
Ok(())
}
fn parse_identifier<'a>(input: &'a str, cursor: &mut usize) -> &'a str {
let start = *cursor;
while let Some(byte) = input.as_bytes().get(*cursor) {
if byte.is_ascii_lowercase() || byte.is_ascii_digit() || *byte == b'_' {
*cursor += 1;
} else {
break;
}
}
&input[start..*cursor]
}
fn skip_ws(input: &str, cursor: &mut usize) {
while let Some(ch) = input[*cursor..].chars().next() {
if !ch.is_whitespace() {
break;
}
*cursor += ch.len_utf8();
}
}
pub fn quote_invocation_string(value: &str) -> String {
// Keep the spelling identical to Web's JSON.stringify, including control
// characters and JSON unicode escapes accepted by both common parsers.
serde_json::to_string(value).expect("serializing a string cannot fail")
}
#[cfg(test)]
mod tests {
use super::*;
fn descriptor() -> FeatureInvocationDescriptor {
FeatureInvocationDescriptor {
identity: FeatureInvocationIdentity("builtin:test/run".into()),
name: "run".into(),
aliases: vec!["execute".into()],
display_name: "Run".into(),
description: "test invocation".into(),
syntax: FeatureInvocationSyntax::Parenthesized,
arguments: vec![
InvocationArgumentDescriptor {
name: "path".into(),
position: Some(0),
required: true,
value_type: InvocationArgumentType::String,
completion: InvocationCompletion::WorkerFile,
description: None,
},
InvocationArgumentDescriptor {
name: "mode".into(),
position: None,
required: false,
value_type: InvocationArgumentType::Enum {
values: vec!["fast".into(), "safe".into()],
},
completion: InvocationCompletion::Static {
values: vec!["fast".into(), "safe".into()],
},
description: None,
},
],
client_adapter: None,
}
}
#[test]
fn parses_unicode_quoted_paths_and_stops_before_natural_language() {
let input = "/run(\"資料/my file / x\", mode=safe) この後は本文";
let parsed = parse_feature_invocation(input, 0, &descriptor(), "invoke-1").unwrap();
assert_eq!(&input[parsed.end..], " この後は本文");
assert_eq!(
parsed.invocation.arguments,
vec![
InvocationArgumentValue {
name: "path".into(),
value: InvocationValue::String("資料/my file / x".into()),
},
InvocationArgumentValue {
name: "mode".into(),
value: InvocationValue::String("safe".into()),
},
]
);
}
#[test]
fn reports_incomplete_unknown_duplicate_missing_and_type_errors() {
assert!(matches!(
parse_feature_invocation("/run(\"x\"", 0, &descriptor(), "1"),
Err(InvocationValidationError::Incomplete(_))
));
assert!(matches!(
parse_feature_invocation("/run(path=x, nope=y)", 0, &descriptor(), "1"),
Err(InvocationValidationError::UnknownArgument(name)) if name == "nope"
));
assert!(matches!(
parse_feature_invocation("/run(path=x, path=y)", 0, &descriptor(), "1"),
Err(InvocationValidationError::DuplicateArgument(name)) if name == "path"
));
assert!(matches!(
parse_feature_invocation("/run(mode=fast)", 0, &descriptor(), "1"),
Err(InvocationValidationError::MissingRequired(name)) if name == "path"
));
assert!(matches!(
parse_feature_invocation("/run(x, mode=unknown)", 0, &descriptor(), "1"),
Err(InvocationValidationError::InvalidValue { argument, .. }) if argument == "mode"
));
}
#[test]
fn descriptors_cannot_install_with_undiscoverable_oversized_metadata() {
let mut descriptor = descriptor();
descriptor.arguments[0].completion = InvocationCompletion::Static {
values: vec!["a".repeat(1024); 40],
};
assert!(
matches!(descriptor.validate(), Err(InvocationValidationError::InvalidDescriptor(message)) if message.contains("completion byte limit"))
);
}
#[test]
fn client_and_recovery_invocation_key_bound_is_shared() {
let descriptor = descriptor();
assert!(
parse_feature_invocation(
"/run(x)",
0,
&descriptor,
"a".repeat(MAX_FEATURE_INVOCATION_ID_BYTES)
)
.is_ok()
);
assert!(
parse_feature_invocation(
"/run(x)",
0,
&descriptor,
"a".repeat(MAX_FEATURE_INVOCATION_ID_BYTES + 1)
)
.is_err()
);
assert!(parse_feature_invocation("/run(x)", 0, &descriptor, "界".repeat(43)).is_err());
}
#[test]
fn structured_payload_validation_does_not_trust_client_identity_or_types() {
let descriptor = descriptor();
let original = parse_feature_invocation("/run(path=x)", 0, &descriptor, "stable")
.unwrap()
.invocation;
let mut changed = original.clone();
changed.identity = FeatureInvocationIdentity("builtin:other/run".into());
assert_eq!(
validate_feature_invocation(&descriptor, &changed),
Err(InvocationValidationError::IdentityMismatch)
);
changed = original.clone();
changed.name = "unregistered".into();
assert_eq!(
validate_feature_invocation(&descriptor, &changed),
Err(InvocationValidationError::NameMismatch)
);
changed = original.clone();
changed.arguments[0].value = InvocationValue::Boolean(true);
assert!(matches!(
validate_feature_invocation(&descriptor, &changed),
Err(InvocationValidationError::InvalidValue { .. })
));
changed = original.clone();
changed.arguments.push(changed.arguments[0].clone());
assert!(matches!(
validate_feature_invocation(&descriptor, &changed),
Err(InvocationValidationError::DuplicateArgument(_))
));
changed = original;
changed.invocation_id.clear();
assert!(validate_feature_invocation(&descriptor, &changed).is_err());
}
#[test]
fn integer_boolean_and_utf8_offsets_use_the_common_typed_contract() {
let mut descriptor = descriptor();
descriptor.arguments[0].value_type = InvocationArgumentType::Integer;
descriptor.arguments[1].value_type = InvocationArgumentType::Boolean;
let parsed = parse_feature_invocation(
"本文 /execute(-2147483648, mode=true) 後",
"本文 ".len(),
&descriptor,
"typed",
)
.unwrap();
assert_eq!(
parsed.invocation.arguments[0].value,
InvocationValue::Integer(i32::MIN)
);
assert_eq!(
parsed.invocation.arguments[1].value,
InvocationValue::Boolean(true)
);
for invalid in ["/run(2147483648)", "/run(1.0)", "/run(1, mode=yes)"] {
assert!(parse_feature_invocation(invalid, 0, &descriptor, "typed").is_err());
}
assert!(parse_feature_invocation("本文 /run(1)", 1, &descriptor, "typed").is_err());
assert!(parse_feature_invocation("/run(1)", usize::MAX, &descriptor, "typed").is_err());
}
#[test]
fn json_escapes_and_multiple_invocations_have_explicit_boundaries() {
let input = r#"/run("\u8cc7\u6599\/a\b\f", mode=safe) prose /execute("second") end"#;
let first = parse_feature_invocation(input, 0, &descriptor(), "first").unwrap();
assert_eq!(
first.invocation.arguments[0].value,
InvocationValue::String("資料/a\u{8}\u{c}".into())
);
let next = input[first.end..].find("/execute").unwrap() + first.end;
let second = parse_feature_invocation(input, next, &descriptor(), "second").unwrap();
assert_eq!(&input[second.end..], " end");
assert!(matches!(
parse_feature_invocation(r#"/run("\uD800")"#, 0, &descriptor(), "bad"),
Err(InvocationValidationError::Syntax { .. })
));
let value = "controls \u{0}\u{8}\u{c}";
let spelling = format!("/run({})", quote_invocation_string(value));
assert_eq!(
parse_feature_invocation(&spelling, 0, &descriptor(), "control")
.unwrap()
.invocation
.arguments[0]
.value,
InvocationValue::String(value.into())
);
}
#[test]
fn typed_segment_round_trips_without_flattening_identity_or_arguments() {
let parsed =
parse_feature_invocation("/run(\"x y\", mode=fast)", 0, &descriptor(), "1").unwrap();
let segment = crate::Segment::FeatureInvoke {
invocation: parsed.invocation,
};
let json = serde_json::to_string(&segment).unwrap();
assert!(json.contains("\"kind\":\"feature_invoke\""));
assert!(json.contains("builtin:test/run"));
assert_eq!(
serde_json::from_str::<crate::Segment>(&json).unwrap(),
segment
);
}
#[test]
fn quoting_round_trips_spaces_unicode_slashes_and_escapes() {
let value = "a b/界\\\"\n";
let input = format!("/run({})", quote_invocation_string(value));
let parsed = parse_feature_invocation(&input, 0, &descriptor(), "1").unwrap();
assert_eq!(
parsed.invocation.arguments[0].value,
InvocationValue::String(value.into())
);
}
}
+133 -10
View File
@@ -1,4 +1,5 @@
pub mod identity;
pub mod invocation;
#[cfg(feature = "stream")]
pub mod stream;
pub mod subscription;
@@ -10,6 +11,14 @@ use std::path::PathBuf;
use serde::{Deserialize, Serialize};
pub use identity::{WorkerId, WorkerIdParseError};
pub use invocation::{
FeatureInvocation, FeatureInvocationDescriptor, FeatureInvocationIdentity,
FeatureInvocationResult, FeatureInvocationStatus, FeatureInvocationSyntax,
InvocationArgumentDescriptor, InvocationArgumentType, InvocationArgumentValue,
InvocationClientAdapter, InvocationCompletion, InvocationValidationError, InvocationValue,
ParsedFeatureInvocation, parse_feature_invocation, quote_invocation_string,
validate_feature_invocation,
};
/// Allocate an opaque idempotency key for one client Submit request.
pub fn new_submission_request_id() -> String {
@@ -62,6 +71,17 @@ impl Default for AuthenticatedInputSource {
}
}
// Tracked methods may carry serialized transport provenance, but wire values
// never grant authority. Keep this field recognized under fail-closed decoding
// while replacing it with UntrustedWire, including malformed/forged values.
fn ignore_wire_input_source<'de, D>(deserializer: D) -> Result<AuthenticatedInputSource, D::Error>
where
D: serde::Deserializer<'de>,
{
let _ = serde::de::IgnoredAny::deserialize(deserializer)?;
Ok(AuthenticatedInputSource::UntrustedWire)
}
impl AuthenticatedInputSource {
pub fn namespace(&self) -> String {
match self {
@@ -224,7 +244,12 @@ impl From<WorkerStatus> for WorkerStateSnapshot {
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(tag = "method", content = "params", rename_all = "snake_case")]
#[serde(
tag = "method",
content = "params",
rename_all = "snake_case",
deny_unknown_fields
)]
pub enum Method {
/// Durably accept typed input for immediate activation or the session FIFO.
/// Running or Paused Workers queue Submit for a later turn; use Notify for
@@ -244,7 +269,7 @@ pub enum Method {
SubmitTracked {
submission_request_id: String,
input: Vec<Segment>,
#[serde(skip_deserializing, default)]
#[serde(default, deserialize_with = "ignore_wire_input_source")]
source: AuthenticatedInputSource,
},
/// Agent-controlled input: accept only at an Idle controller boundary, never
@@ -254,7 +279,7 @@ pub enum Method {
SubmitIfIdle {
submission_request_id: String,
input: Vec<Segment>,
#[serde(skip_deserializing, default)]
#[serde(default, deserialize_with = "ignore_wire_input_source")]
source: AuthenticatedInputSource,
},
/// Human-readable text injected into the target Worker's LLM context as a
@@ -272,7 +297,7 @@ pub enum Method {
NotifyTracked {
notification_request_id: String,
message: String,
#[serde(skip_deserializing, default)]
#[serde(default, deserialize_with = "ignore_wire_input_source")]
source: AuthenticatedInputSource,
},
/// Typed lifecycle report from a child Worker to its direct parent.
@@ -336,6 +361,12 @@ pub enum Method {
ListCompletions {
kind: CompletionKind,
prefix: String,
/// Client query generation. Echoed verbatim so identical-prefix ABA
/// responses cannot cross edits, target switches, or permission changes.
#[serde(default, skip_serializing_if = "Option::is_none")]
request_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
context: Option<CompletionContext>,
},
/// List Workers visible to this Worker from durable Worker state and the spawned-child
/// registry. This is not a host-wide Worker universe query.
@@ -609,6 +640,10 @@ pub enum Segment {
/// `[Dir: <path>]` listings; the flattened user text keeps the literal
/// `@<path>` placeholder either way.
FileRef { path: String },
/// Explicitly selected, source-qualified Feature invocation. The host never
/// promotes free text to this variant and validates it against the installed
/// declaration before execution.
FeatureInvoke { invocation: FeatureInvocation },
/// Source-qualified request for the host to start a Flow before committing
/// this user input. Runtime resolves it through Workspace authority and
/// replaces it with the entered state's instructions before Worker-side
@@ -677,6 +712,9 @@ impl Segment {
out.push('@');
out.push_str(path);
}
Segment::FeatureInvoke { invocation } => {
out.push_str(&invocation.display_input());
}
Segment::Flow { selector } => {
out.push_str("[Flow: ");
out.push_str(selector);
@@ -1348,6 +1386,12 @@ pub enum Event {
/// wired up.
Completions {
kind: CompletionKind,
#[serde(default)]
prefix: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
request_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
context: Option<CompletionContext>,
entries: Vec<CompletionEntry>,
},
/// Reply to `Method::ListRewindTargets`. Clients should only open a picker
@@ -1460,25 +1504,42 @@ pub enum AlertSource {
/// Kind of completion requested by `Method::ListCompletions`.
///
/// Mirrors the completion prefix sigil: `@` → `File`.
/// `File` is the existing `@` path lane. `Feature` lists installed chat
/// invocations. `FeatureArgument` resolves a declared argument using the
/// source-qualified [`CompletionContext`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum CompletionKind {
File,
Feature,
FeatureArgument,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct CompletionContext {
pub invocation: FeatureInvocationIdentity,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub argument: Option<String>,
}
/// One completion candidate for a prefix query.
///
/// `value` is a path (file kind).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct CompletionEntry {
pub value: String,
#[serde(default)]
pub is_dir: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub usage: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub invocation: Option<FeatureInvocationDescriptor>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -2114,6 +2175,27 @@ mod tests {
);
}
#[test]
fn notify_rejects_typed_intent_instead_of_silently_discarding_it() {
for method in ["notify", "notify_tracked"] {
for key in ["input", "segments", "invocation"] {
let mut params =
serde_json::json!({"notification_request_id":"request", "message":"advisory"});
params[key] = serde_json::json!([{ "kind":"feature_invoke", "invocation": { "identity":"builtin:test/run" } }]);
assert!(
serde_json::from_value::<Method>(
serde_json::json!({"method":method,"params":params})
)
.is_err()
);
}
}
// Text is still text: no decoding or Host scan promotes it to an invoke.
assert!(
matches!(serde_json::from_value::<Method>(serde_json::json!({"method":"notify", "params":{"notification_request_id":"text","message":"/run()"}})).unwrap(), Method::Notify { message, .. } if message == "/run()")
);
}
#[test]
fn segment_unknown_variant_decodes_as_unknown() {
// A future client sends a segment kind this Worker has never heard of.
@@ -2352,6 +2434,8 @@ mod tests {
let method = Method::ListCompletions {
kind: CompletionKind::File,
prefix: "src/".into(),
request_id: None,
context: None,
};
let json = serde_json::to_string(&method).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
@@ -2361,7 +2445,7 @@ mod tests {
let decoded: Method = serde_json::from_str(&json).unwrap();
match decoded {
Method::ListCompletions { kind, prefix } => {
Method::ListCompletions { kind, prefix, .. } => {
assert_eq!(kind, CompletionKind::File);
assert_eq!(prefix, "src/");
}
@@ -2369,13 +2453,52 @@ mod tests {
}
}
#[test]
fn completion_generation_round_trips_for_identical_query_correlation() {
let context = CompletionContext {
invocation: FeatureInvocationIdentity("builtin:test/run".into()),
argument: Some("mode".into()),
};
for request_id in ["query-1", "query-2"] {
let method = Method::ListCompletions {
kind: CompletionKind::FeatureArgument,
prefix: "s".into(),
request_id: Some(request_id.into()),
context: Some(context.clone()),
};
let decoded: Method =
serde_json::from_str(&serde_json::to_string(&method).unwrap()).unwrap();
assert!(
matches!(decoded, Method::ListCompletions { request_id: Some(id), context: Some(restored), .. } if id == request_id && restored == context)
);
let event = Event::Completions {
kind: CompletionKind::FeatureArgument,
prefix: "s".into(),
request_id: Some(request_id.into()),
context: Some(context.clone()),
entries: Vec::new(),
};
let decoded: Event =
serde_json::from_str(&serde_json::to_string(&event).unwrap()).unwrap();
assert!(
matches!(decoded, Event::Completions { request_id: Some(id), context: Some(restored), .. } if id == request_id && restored == context)
);
}
}
#[test]
fn event_completions_format_and_default_is_dir() {
let event = Event::Completions {
kind: CompletionKind::File,
prefix: "src/".into(),
request_id: None,
context: None,
entries: vec![CompletionEntry {
value: "src/main.rs".into(),
is_dir: false,
description: None,
usage: None,
invocation: None,
}],
};
let json = serde_json::to_string(&event).unwrap();
@@ -2389,7 +2512,7 @@ mod tests {
r#"{"event":"completions","data":{"kind":"file","entries":[{"value":"main.rs"}]}}"#;
let decoded: Event = serde_json::from_str(inbound).unwrap();
match decoded {
Event::Completions { kind, entries } => {
Event::Completions { kind, entries, .. } => {
assert_eq!(kind, CompletionKind::File);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].value, "main.rs");
+2
View File
@@ -1197,6 +1197,8 @@ mod tests {
method: crate::Method::ListCompletions {
kind: crate::CompletionKind::File,
prefix: "src/".to_string(),
request_id: None,
context: None,
},
},
));
+29 -13
View File
@@ -6,20 +6,23 @@ use ts_rs::{Config, TS};
use crate::{
Alert, AlertLevel, AlertSource, CommandEvent, CommandSnapshot, CommandStatus, CommandStream,
CommandStreamSlice, CompactionLifecycle, CompactionLifecycleState, CompactionPhase,
CompactionTrigger, CompletionEntry, CompletionKind, ContextTokenSource, ContextUsage,
ErrorCode, Event, Greeting, InFlightBlock, InFlightCompaction, InFlightSnapshot,
CompactionTrigger, CompletionContext, CompletionEntry, CompletionKind, ContextTokenSource,
ContextUsage, ErrorCode, Event, FeatureInvocation, FeatureInvocationDescriptor,
FeatureInvocationIdentity, FeatureInvocationResult, FeatureInvocationStatus,
FeatureInvocationSyntax, Greeting, InFlightBlock, InFlightCompaction, InFlightSnapshot,
InFlightToolCallState, InternalWorkerKind, InternalWorkerRef, InternalWorkerSnapshot,
InvokeKind, MemoryWorkerEvent, Method, PasteArtifactAvailability, PasteArtifactMediaType,
PasteArtifactRef, PendingSubmissionSummary, PendingSubmissionsSnapshot, Permission,
ReasoningConfig, RewindSummary, RewindTarget, RewindTargetId, RunFailureKind, RunResult,
RunResumeSource, RunYieldReason, ScopeRule, Segment, SessionContentPart,
SessionConversationTurn, SessionEntryProvenance, SessionHistoryLineageBoundary,
SessionHistoryPage, SessionMessageRole, SessionSnapshot, SessionSnapshotEntry,
SessionSnapshotEntryData, SessionToolAttachment, SubmissionDisposition, SymlinkPolicy,
ToolResultDisposition, TurnResult, UploadedFileAvailability, UploadedFileRef, WorkerBusyState,
WorkerCommandAcknowledgement, WorkerCommandDisposition, WorkerCommandEnvelope,
WorkerCommandKind, WorkerEvent, WorkerMaintenanceState, WorkerRunState, WorkerState,
WorkerStateSnapshot, WorkerStatus,
InvocationArgumentDescriptor, InvocationArgumentType, InvocationArgumentValue,
InvocationClientAdapter, InvocationCompletion, InvocationValue, InvokeKind, MemoryWorkerEvent,
Method, PasteArtifactAvailability, PasteArtifactMediaType, PasteArtifactRef,
PendingSubmissionSummary, PendingSubmissionsSnapshot, Permission, ReasoningConfig,
RewindSummary, RewindTarget, RewindTargetId, RunFailureKind, RunResult, RunResumeSource,
RunYieldReason, ScopeRule, Segment, SessionContentPart, SessionConversationTurn,
SessionEntryProvenance, SessionHistoryLineageBoundary, SessionHistoryPage, SessionMessageRole,
SessionSnapshot, SessionSnapshotEntry, SessionSnapshotEntryData, SessionToolAttachment,
SubmissionDisposition, SymlinkPolicy, ToolResultDisposition, TurnResult,
UploadedFileAvailability, UploadedFileRef, WorkerBusyState, WorkerCommandAcknowledgement,
WorkerCommandDisposition, WorkerCommandEnvelope, WorkerCommandKind, WorkerEvent,
WorkerMaintenanceState, WorkerRunState, WorkerState, WorkerStateSnapshot, WorkerStatus,
subscription::{
EventSubscriptionSelector, MAX_CORRELATION_ID_BYTES, MAX_REJECTION_MESSAGE_BYTES,
MAX_RESOURCE_ID_BYTES, MAX_SUBSCRIPTION_COLLECTION_ITEMS,
@@ -64,6 +67,19 @@ pub fn generated_protocol_types() -> String {
push_decl::<AlertLevel>(&cfg, &mut output);
push_decl::<AlertSource>(&cfg, &mut output);
push_decl::<CompletionKind>(&cfg, &mut output);
push_decl::<CompletionContext>(&cfg, &mut output);
push_decl::<FeatureInvocationIdentity>(&cfg, &mut output);
push_decl::<FeatureInvocationSyntax>(&cfg, &mut output);
push_decl::<InvocationArgumentType>(&cfg, &mut output);
push_decl::<InvocationCompletion>(&cfg, &mut output);
push_decl::<InvocationArgumentDescriptor>(&cfg, &mut output);
push_decl::<InvocationClientAdapter>(&cfg, &mut output);
push_decl::<FeatureInvocationDescriptor>(&cfg, &mut output);
push_decl::<InvocationValue>(&cfg, &mut output);
push_decl::<InvocationArgumentValue>(&cfg, &mut output);
push_decl::<FeatureInvocation>(&cfg, &mut output);
push_decl::<FeatureInvocationStatus>(&cfg, &mut output);
push_decl::<FeatureInvocationResult>(&cfg, &mut output);
push_decl::<WorkerStatus>(&cfg, &mut output);
push_decl::<WorkerCommandEnvelope>(&cfg, &mut output);
push_decl::<WorkerCommandKind>(&cfg, &mut output);
+17
View File
@@ -567,6 +567,8 @@ pub struct CompletionRequest {
pub kind: CompletionKind,
#[serde(default)]
pub prefix: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context: Option<protocol::CompletionContext>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
@@ -1202,6 +1204,21 @@ pub const REMAINING_RUNTIME_ROUTES: &[RemainingRuntimeRoute] = &[
mod tests {
use super::*;
#[test]
fn feature_argument_completion_wire_preserves_source_and_argument() {
let json = serde_json::json!({"kind":"feature_argument", "prefix":"資料/", "context": {"invocation":"builtin:test/prepare", "argument":"path"}});
let request: CompletionRequest = serde_json::from_value(json.clone()).unwrap();
assert_eq!(request.kind, CompletionKind::FeatureArgument);
assert_eq!(
request.context.as_ref().unwrap().argument.as_deref(),
Some("path")
);
assert_eq!(serde_json::to_value(request).unwrap(), json);
let legacy: CompletionRequest =
serde_json::from_value(serde_json::json!({"kind":"file"})).unwrap();
assert!(legacy.context.is_none());
}
#[derive(Clone)]
struct RoundTripService;
+45 -4
View File
@@ -8233,7 +8233,7 @@ pub struct RuntimeWorkerInputRequest {
pub kind: Option<String>,
pub content: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub segments: Option<Vec<serde_json::Value>>,
pub segments: Option<Vec<protocol::Segment>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
@@ -8268,9 +8268,11 @@ pub struct RuntimeWorkerLifecycleResult {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct RuntimeWorkerCompletionsRequest {
pub kind: serde_json::Value,
pub kind: protocol::CompletionKind,
#[serde(default)]
pub prefix: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context: Option<protocol::CompletionContext>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
@@ -8278,9 +8280,9 @@ pub struct RuntimeWorkerCompletionsRequest {
pub struct RuntimeWorkerCompletionsResult {
pub runtime_id: String,
pub worker_id: String,
pub kind: serde_json::Value,
pub kind: protocol::CompletionKind,
pub prefix: String,
pub entries: Vec<serde_json::Value>,
pub entries: Vec<protocol::CompletionEntry>,
#[serde(default)]
pub diagnostics: Vec<Diagnostic>,
}
@@ -12661,6 +12663,45 @@ mod tests {
assert!(serde_json::from_value::<CompanionTranscriptItem>(private_item).is_err());
}
#[test]
fn structured_invocation_input_round_trips_through_typed_api() {
let json = serde_json::json!({"kind":"user", "content":"", "segments":[{"kind":"feature_invoke", "invocation":{
"invocation_id":"stable-invoke", "identity":"builtin:test/prepare", "name":"prepare",
"arguments":[{"name":"path", "value":{"kind":"string","value":"資料/a b"}}]
}}]});
let request: RuntimeWorkerInputRequest = serde_json::from_value(json.clone()).unwrap();
assert!(
matches!(&request.segments.as_ref().unwrap()[0], protocol::Segment::FeatureInvoke { invocation }
if invocation.identity.0 == "builtin:test/prepare" && invocation.invocation_id == "stable-invoke")
);
assert_eq!(serde_json::to_value(request).unwrap(), json);
let mut invalid = json;
invalid["segments"][0]["invocation"]["arguments"][0]["value"] =
serde_json::json!({"kind":"integer","value":2147483648_i64});
assert!(serde_json::from_value::<RuntimeWorkerInputRequest>(invalid).is_err());
}
#[test]
fn feature_completion_contract_is_typed_and_preserves_argument_context() {
let json = serde_json::json!({"kind":"feature_argument", "prefix":"資料/", "context":{"invocation":"builtin:test/prepare","argument":"path"}});
let request: RuntimeWorkerCompletionsRequest =
serde_json::from_value(json.clone()).unwrap();
assert_eq!(request.kind, protocol::CompletionKind::FeatureArgument);
assert_eq!(serde_json::to_value(request).unwrap(), json);
assert!(
serde_json::from_value::<RuntimeWorkerCompletionsRequest>(
serde_json::json!({"kind":"unknown"})
)
.is_err()
);
assert!(
serde_json::from_value::<RuntimeWorkerCompletionsRequest>(
serde_json::json!({"kind":"feature_argument", "context":{"invocation":123}})
)
.is_err()
);
}
#[test]
fn generated_companion_api_contract_is_current() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
@@ -1227,8 +1227,16 @@ fn json_content_schema<'a>(
}
fn schema_reference<'a>(value: &'a Value, context: &str) -> Result<&'a str, GenerationError> {
const ALLOWED_REFERENCE_SIBLINGS: &[&str] =
&["$ref", "$comment", "deprecated", "description", "title"];
// `default` is a JSON Schema annotation, not a constraint or a replacement
// for the referenced type. Schemars emits it for defaulted enum fields.
const ALLOWED_REFERENCE_SIBLINGS: &[&str] = &[
"$ref",
"$comment",
"default",
"deprecated",
"description",
"title",
];
let schema = object(value, context)?;
if let Some(keyword) = schema
.keys()
@@ -2023,6 +2031,27 @@ mod tests {
assert!(error.to_string().contains("ambiguous anyOf"));
}
#[test]
fn reference_default_annotations_do_not_replace_the_referenced_type() {
let value = serde_json::json!({"$ref":"#/components/schemas/InvocationCompletion", "default":{"kind":"none"}});
assert_eq!(
schema_reference(&value, "completion").unwrap(),
"#/components/schemas/InvocationCompletion"
);
let output = generate_runtime_typescript(
&crate::canonical_openapi_document()
.unwrap()
.to_json()
.unwrap(),
)
.unwrap();
assert!(output.contains("completion?: InvocationCompletion"));
assert!(output.contains("export type FeatureInvocation ="));
assert!(output.contains(
"export type CompletionKind = \"file\" | \"feature\" | \"feature_argument\""
));
}
#[test]
fn generator_rejects_lossy_reference_siblings() {
let mut document: Value = serde_json::from_str(OPENAPI).unwrap();
+33
View File
@@ -123,6 +123,14 @@ pub enum SystemItem {
prompt_provenance: Option<PromptRenderProvenance>,
},
/// Result of an explicitly selected chat Feature invocation. The typed
/// result is retained for clients; `body` is the exact bounded context made
/// visible to the Worker model.
FeatureInvocationResult {
result: protocol::FeatureInvocationResult,
body: String,
},
/// `@<path>` file reference resolution. `body` is the rendered
/// LLM-context text (`[File: <path>]\n…` for regular files,
/// `[Dir: <path>]\n…` for directory listings, possibly with a
@@ -184,6 +192,7 @@ impl SystemItem {
match self {
SystemItem::Notification { body, .. } => body.clone(),
SystemItem::WorkerEvent { body, .. } => body.clone(),
SystemItem::FeatureInvocationResult { body, .. } => body.clone(),
SystemItem::FileAttachment { body, .. } => body.clone(),
SystemItem::SkillActivation { body, .. } => body.clone(),
SystemItem::LegacyKnowledgeIgnored { .. } => String::new(),
@@ -208,6 +217,7 @@ impl SystemItem {
match self {
SystemItem::Notification { .. } => "notification",
SystemItem::WorkerEvent { .. } => "worker_event",
SystemItem::FeatureInvocationResult { .. } => "feature_invocation_result",
SystemItem::FileAttachment { .. } => "file_attachment",
SystemItem::SkillActivation { .. } => "skill_activation",
SystemItem::LegacyKnowledgeIgnored { .. } => "legacy_knowledge_ignored",
@@ -363,6 +373,29 @@ mod tests {
}
}
#[test]
fn invocation_result_retains_typed_outcome_and_exact_context() {
let result = protocol::FeatureInvocationResult {
invocation_id: "stable-invoke".into(),
identity: protocol::FeatureInvocationIdentity("builtin:test/prepare".into()),
status: protocol::FeatureInvocationStatus::OutcomeUnknown,
message: "operation may have committed".into(),
context: None,
};
let body = "Outcome unknown; do not retry automatically";
let item = SystemItem::FeatureInvocationResult {
result: result.clone(),
body: body.into(),
};
let restored: SystemItem =
serde_json::from_str(&serde_json::to_string(&item).unwrap()).unwrap();
assert_eq!(restored.history_text(), body);
assert_eq!(restored.kind_label(), "feature_invocation_result");
assert!(
matches!(restored, SystemItem::FeatureInvocationResult { result: saved, .. } if saved == result)
);
}
#[test]
fn round_trip_worker_event() {
let item = SystemItem::WorkerEvent {
@@ -606,6 +606,70 @@ fn rewound_fork_preserves_uploaded_file_segments_in_snapshot() {
)));
}
#[test]
fn feature_invocations_survive_replay_snapshot_and_rewind_without_reparsing() {
let (_dir, store) = make_store();
let config = RequestConfig::default();
let (sid, segid) = session_store::create_segment(
&store,
SegmentStartState {
system_prompt: Some("System prompt"),
config: &config,
history: Vec::new(),
user_segments: Vec::new(),
},
)
.unwrap();
let segments = vec![
Segment::Text {
content: "before ".into(),
},
Segment::FeatureInvoke {
invocation: protocol::FeatureInvocation {
invocation_id: "stable-request-1".into(),
identity: protocol::FeatureInvocationIdentity("builtin:test/prepare".into()),
name: "prepare".into(),
arguments: vec![protocol::InvocationArgumentValue {
name: "path".into(),
value: protocol::InvocationValue::String("資料/a b".into()),
}],
},
},
Segment::Text {
content: " after /prepare(unselected)".into(),
},
];
session_store::save_user_input(
&store,
sid,
segid,
segments.clone(),
annotated(&[Item::user_message(Segment::flatten_to_text(&segments))]),
)
.unwrap();
session_store::save_turn_end(&store, sid, segid, 1).unwrap();
let entries = store.read_all(sid, segid).unwrap();
assert_eq!(
collect_state(&entries).user_segments,
vec![segments.clone()]
);
let fork_id = session_store::fork_at(&store, sid, segid, 1).unwrap();
let fork_entries = store.read_all(sid, fork_id).unwrap();
assert_eq!(
collect_state(&fork_entries).user_segments,
vec![segments.clone()]
);
let snapshot = session_store::public_snapshot::project_session_snapshot(sid, &fork_entries);
assert!(snapshot.entries.iter().any(|entry| matches!(
&entry.data, SessionSnapshotEntryData::UserInput { segments: restored } if restored == &segments
)));
// Original history remains append-only and retains the same exact typed intent.
assert_eq!(
serde_json::to_value(store.read_all(sid, segid).unwrap()).unwrap(),
serde_json::to_value(entries).unwrap()
);
}
#[tokio::test]
async fn session_config_changed_logged() {
let (_dir, store) = make_store();
+30
View File
@@ -28,3 +28,33 @@ The TUI should display committed events and Worker snapshots rather than inventi
- [`../../docs/design/context-history.md`](../../docs/design/context-history.md)
- [`../../docs/design/worker-session-state.md`](../../docs/design/worker-session-state.md)
## Feature invocation editing
`/` discovers enabled Feature declarations with descriptions and usage. Choose a
name or alias with Tab/Enter, complete its parenthesized arguments, and press
Enter to confirm that selected occurrence as a typed chip. A later Enter sends
the message. Raw slash text and paste content are never dispatched as commands.
Use Alt+Enter beside a Feature chip to rediscover its declaration and reopen its
arguments; Backspace/Delete removes a chip atomically. Typed chips survive local
history, draft restoration, transport retry, and rewind.
Every completion and chip-re-edit query carries a fresh `request_id`. Replies
must echo that nonce and match the active query context, composer edit/cursor
revision, authority snapshot generation, and Worker target. Identical-prefix ABA
edits, cancelled/reopened queries, permission snapshots, and Worker/view switches
cannot reuse older replies or candidates. Uncorrelated replies are ignored, even
for File queries where the wire field remains optional for legacy callers.
The declared attachment adapter stages a client-local file in place, replacing
its staging chip with an `UploadedFile` reference when ready. Local file argument
completion runs on the client, not Worker. Alt+Enter beside a failed staging chip
retries with the same upload ID; deleting an unsent chip cancels only its owned
staging resource. Accepted, restored, or history attachments are never deleted by
composer cleanup. Staging blocks Send/Notify and history navigation until it is
ready, retried, or removed. There is no raw `/attach` or `/clear-attachments`
command lane.
Attachment staging remains Backend-only: the existing Standalone transport has
no client-local file-upload staging API. The TUI reports that limitation and
preserves the draft invocation; it never falls back to raw path dispatch.
+815 -45
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+654 -27
View File
@@ -115,12 +115,31 @@ impl FlowRefAtom {
}
}
#[derive(Debug, Clone)]
pub struct FeatureInvokeAtom {
pub invocation: protocol::FeatureInvocation,
}
impl FeatureInvokeAtom {
pub fn label(&self) -> String {
self.invocation.display_input()
}
}
#[derive(Debug, Clone)]
pub enum Atom {
Char(char),
Unknown,
AttachmentStage {
id: String,
file_name: String,
failed: bool,
},
Paste(PasteRef),
PasteArtifact(protocol::PasteArtifactRef),
UploadedFile(protocol::UploadedFileRef),
FileRef(FileRefAtom),
FeatureInvoke(FeatureInvokeAtom),
FlowRef(FlowRefAtom),
}
@@ -130,6 +149,23 @@ impl Atom {
fn chip(&self) -> Option<(Style, String)> {
match self {
Atom::Char(_) => None,
Atom::Unknown => Some((
Style::default().fg(Color::Red),
"[unknown input segment]".into(),
)),
Atom::AttachmentStage {
file_name, failed, ..
} => Some((
Style::default().fg(Color::Cyan),
format!(
"[Attached {file_name} | {}]",
if *failed {
"failed; Alt+Enter retries"
} else {
"uploading"
}
),
)),
Atom::Paste(p) => Some((Style::default().fg(Color::Magenta), p.label())),
Atom::PasteArtifact(artifact) => Some((
Style::default().fg(Color::Magenta),
@@ -143,7 +179,17 @@ impl Atom {
artifact.created_at_ms
),
)),
Atom::UploadedFile(file) => Some((
Style::default().fg(Color::Cyan),
format!(
"[Attached {} | {} bytes, ready]",
file.file_name, file.byte_len
),
)),
Atom::FileRef(r) => Some((Style::default().fg(Color::Cyan), r.label())),
Atom::FeatureInvoke(invocation) => {
Some((Style::default().fg(Color::Yellow), invocation.label()))
}
Atom::FlowRef(r) => Some((Style::default().fg(Color::Yellow), r.label())),
}
}
@@ -173,9 +219,14 @@ enum WordKind {
fn atom_class(atom: &Atom) -> AtomClass {
match atom {
Atom::Char(c) => char_class(*c),
Atom::Paste(_) | Atom::PasteArtifact(_) | Atom::FileRef(_) | Atom::FlowRef(_) => {
AtomClass::Chip
}
Atom::Unknown
| Atom::AttachmentStage { .. }
| Atom::Paste(_)
| Atom::PasteArtifact(_)
| Atom::UploadedFile(_)
| Atom::FileRef(_)
| Atom::FeatureInvoke(_)
| Atom::FlowRef(_) => AtomClass::Chip,
}
}
@@ -195,12 +246,53 @@ fn char_class(c: char) -> AtomClass {
}
}
fn first_unquoted_char(value: &str, target: char) -> Option<usize> {
unquoted_char_indices(value).find_map(|(index, ch)| (ch == target).then_some(index))
}
fn last_unquoted_char(value: &str, target: char) -> Option<usize> {
unquoted_char_indices(value)
.filter_map(|(index, ch)| (ch == target).then_some(index))
.last()
}
fn unquoted_char_indices(value: &str) -> impl Iterator<Item = (usize, char)> + '_ {
let mut quoted = false;
let mut escaped = false;
value.char_indices().filter(move |(_, ch)| {
if !escaped && *ch == '"' {
quoted = !quoted;
escaped = false;
return false;
}
let visible = !quoted;
escaped = quoted && !escaped && *ch == '\\';
if *ch != '\\' {
escaped = false;
}
visible
})
}
#[derive(Clone)]
struct SelectedInvocation {
start: usize,
descriptor: protocol::FeatureInvocationDescriptor,
invocation_id: String,
}
pub struct InputBuffer {
/// Monotonic semantic edit/cursor revision, including ABA changes.
revision: u64,
atoms: Vec<Atom>,
selected_invocations: Vec<SelectedInvocation>,
removed_attachment_stages: Vec<String>,
/// Insertion point in `0..=atoms.len()`.
cursor: usize,
/// Top wrapped row of the visible composer viewport.
scroll_offset: usize,
/// Uploaded draft chips removed by explicit editing and awaiting client-side cleanup.
removed_uploaded_files: Vec<protocol::UploadedFileRef>,
/// Monotonic counter reused across the TUI process lifetime.
next_paste_id: u32,
}
@@ -208,20 +300,34 @@ pub struct InputBuffer {
impl Default for InputBuffer {
fn default() -> Self {
Self {
revision: 0,
atoms: Vec::new(),
selected_invocations: Vec::new(),
removed_attachment_stages: Vec::new(),
cursor: 0,
scroll_offset: 0,
removed_uploaded_files: Vec::new(),
next_paste_id: 1,
}
}
}
impl InputBuffer {
pub fn revision(&self) -> u64 {
self.revision
}
fn bump_revision(&mut self) {
self.revision = self.revision.wrapping_add(1);
}
pub fn new() -> Self {
Self::default()
}
pub fn clear(&mut self) {
self.bump_revision();
self.selected_invocations.clear();
self.atoms.clear();
self.cursor = 0;
self.scroll_offset = 0;
@@ -243,6 +349,8 @@ impl InputBuffer {
/// by [`submit_segments`](Self::submit_segments), preserving typed chips
/// and placing the cursor at the end of the restored input.
pub fn replace_with_segments(&mut self, segments: &[protocol::Segment]) {
self.bump_revision();
self.selected_invocations.clear();
self.atoms.clear();
for segment in segments {
match segment {
@@ -267,24 +375,24 @@ impl InputBuffer {
self.atoms.push(Atom::PasteArtifact(artifact.clone()));
}
protocol::Segment::UploadedFile { file } => {
self.atoms.extend(
format!("[Attached file: {}]", file.file_name)
.chars()
.map(Atom::Char),
);
self.atoms.push(Atom::UploadedFile(file.clone()));
}
protocol::Segment::FileRef { path } => {
self.atoms
.push(Atom::FileRef(FileRefAtom { path: path.clone() }));
}
protocol::Segment::FeatureInvoke { invocation } => {
self.atoms.push(Atom::FeatureInvoke(FeatureInvokeAtom {
invocation: invocation.clone(),
}));
}
protocol::Segment::Flow { selector } => {
self.atoms.push(Atom::FlowRef(FlowRefAtom {
selector: selector.clone(),
}));
}
protocol::Segment::Unknown => {
self.atoms
.extend("[unknown input segment]".chars().map(Atom::Char));
self.atoms.push(Atom::Unknown);
}
}
}
@@ -292,6 +400,8 @@ impl InputBuffer {
}
pub fn insert_char(&mut self, c: char) {
self.bump_revision();
self.adjust_selection(self.cursor, self.cursor, 1);
self.atoms.insert(self.cursor, Atom::Char(c));
self.cursor += 1;
}
@@ -307,6 +417,10 @@ impl InputBuffer {
for atom in &self.atoms {
match atom {
Atom::Char(c) => text.push(*c),
Atom::Unknown => text.push_str("[unknown input segment]"),
Atom::AttachmentStage { file_name, .. } => {
text.push_str(&format!("[Staging {file_name}]"))
}
Atom::Paste(paste) => text.push_str(&paste.content),
Atom::PasteArtifact(artifact) => {
text.push_str(&protocol::Segment::flatten_to_text(&[
@@ -315,7 +429,11 @@ impl InputBuffer {
},
]))
}
Atom::UploadedFile(file) => {
text.push_str(&format!("[Attached file: {}]", file.file_name));
}
Atom::FileRef(file) => text.push_str(&file.path),
Atom::FeatureInvoke(invocation) => text.push_str(&invocation.label()),
Atom::FlowRef(flow) => text.push_str(&flow.selector),
}
}
@@ -327,6 +445,7 @@ impl InputBuffer {
}
pub fn insert_paste(&mut self, content: String) {
self.bump_revision();
let measurement = measure_paste(&content);
if measurement.presentation() == PastePresentation::Text {
self.insert_str(&content);
@@ -335,6 +454,7 @@ impl InputBuffer {
let id = self.next_paste_id;
self.next_paste_id = self.next_paste_id.wrapping_add(1);
self.adjust_selection(self.cursor, self.cursor, 1);
self.atoms.insert(
self.cursor,
Atom::Paste(PasteRef {
@@ -347,14 +467,236 @@ impl InputBuffer {
self.cursor += 1;
}
#[cfg(test)]
pub fn insert_uploaded_file(&mut self, file: protocol::UploadedFileRef) {
self.bump_revision();
self.adjust_selection(self.cursor, self.cursor, 1);
self.atoms.insert(self.cursor, Atom::UploadedFile(file));
self.cursor += 1;
}
// Selections belong to an exact occurrence, never to all text matching a name.
fn adjust_selection(&mut self, start: usize, end: usize, inserted: usize) {
self.selected_invocations.retain_mut(|selection| {
if start < end && (start..end).contains(&selection.start) {
return false;
}
if selection.start >= end {
selection.start = selection.start - (end - start) + inserted;
}
true
});
}
pub fn select_feature_invocation(
&mut self,
start: usize,
descriptor: protocol::FeatureInvocationDescriptor,
) {
self.bump_revision();
self.selected_invocations
.retain(|selection| selection.start != start);
self.selected_invocations.push(SelectedInvocation {
start,
descriptor,
invocation_id: protocol::new_submission_request_id(),
});
}
pub fn has_selected_feature_invocations(&self) -> bool {
!self.selected_invocations.is_empty()
}
pub fn retry_attachment_stage(&mut self, id: &str) {
self.bump_revision();
for atom in &mut self.atoms {
if let Atom::AttachmentStage {
id: stage_id,
failed,
..
} = atom
{
if stage_id == id {
*failed = false;
}
}
}
}
pub fn finalize_feature_invocations(&mut self) -> Result<(), String> {
// Validate first so invalid arguments cannot partially chip a draft.
let mut parsed = Vec::new();
for selection in &self.selected_invocations {
let text = self.atoms[selection.start..]
.iter()
.take_while(|atom| matches!(atom, Atom::Char(_)))
.filter_map(|atom| {
if let Atom::Char(ch) = atom {
Some(*ch)
} else {
None
}
})
.collect::<String>();
let invocation = protocol::parse_feature_invocation(
&text,
0,
&selection.descriptor,
selection.invocation_id.clone(),
)
.map_err(|error| error.to_string())?;
parsed.push((
selection.start,
text[..invocation.end].chars().count(),
invocation.invocation,
));
}
parsed.sort_by_key(|(start, _, _)| std::cmp::Reverse(*start));
for (start, count, invocation) in parsed {
self.replace_atoms(
start,
start + count,
vec![Atom::FeatureInvoke(FeatureInvokeAtom { invocation })],
);
}
Ok(())
}
fn replace_atoms(&mut self, start: usize, end: usize, atoms: Vec<Atom>) {
self.bump_revision();
let inserted = atoms.len();
self.adjust_selection(start, end, inserted);
let removed = self.atoms.splice(start..end, atoms).collect::<Vec<_>>();
for atom in removed {
self.record_removed_atom(atom);
}
if self.cursor >= end {
self.cursor = self.cursor - (end - start) + inserted;
} else if self.cursor > start {
self.cursor = start + inserted;
}
}
pub fn adjacent_feature_invocation(&self) -> Option<protocol::FeatureInvocation> {
[self.cursor.checked_sub(1), Some(self.cursor)]
.into_iter()
.flatten()
.find_map(|index| match self.atoms.get(index) {
Some(Atom::FeatureInvoke(atom)) => Some(atom.invocation.clone()),
_ => None,
})
}
pub fn edit_feature_invocation(
&mut self,
id: &str,
descriptor: protocol::FeatureInvocationDescriptor,
) -> bool {
let Some((index, invocation)) =
self.atoms
.iter()
.enumerate()
.find_map(|(index, atom)| match atom {
Atom::FeatureInvoke(atom) if atom.invocation.invocation_id == id => {
Some((index, atom.invocation.clone()))
}
_ => None,
})
else {
return false;
};
if invocation.identity != descriptor.identity {
return false;
}
let text = invocation.display_input();
self.replace_atoms(index, index + 1, text.chars().map(Atom::Char).collect());
self.cursor = index + text.chars().count() - 1;
self.selected_invocations.push(SelectedInvocation {
start: index,
descriptor,
invocation_id: invocation.invocation_id,
});
true
}
pub fn stage_attachment(&mut self, invocation_id: &str, id: String, file_name: String) -> bool {
let Some(index) = self.atoms.iter().position(|atom| matches!(atom, Atom::FeatureInvoke(atom) if atom.invocation.invocation_id == invocation_id)) else { return false; };
self.replace_atoms(
index,
index + 1,
vec![Atom::AttachmentStage {
id,
file_name,
failed: false,
}],
);
true
}
pub fn has_attachment_stages(&self) -> bool {
self.atoms
.iter()
.any(|atom| matches!(atom, Atom::AttachmentStage { .. }))
}
pub fn attachment_stage_ids(&self) -> Vec<String> {
self.atoms
.iter()
.filter_map(|atom| {
if let Atom::AttachmentStage { id, .. } = atom {
Some(id.clone())
} else {
None
}
})
.collect()
}
pub fn adjacent_failed_attachment(&self) -> Option<String> {
[self.cursor.checked_sub(1), Some(self.cursor)]
.into_iter()
.flatten()
.find_map(|index| match self.atoms.get(index) {
Some(Atom::AttachmentStage {
id, failed: true, ..
}) => Some(id.clone()),
_ => None,
})
}
pub fn finish_attachment_stage(
&mut self,
id: &str,
file: Option<protocol::UploadedFileRef>,
) -> bool {
self.bump_revision();
let Some(index) = self.atoms.iter().position(
|atom| matches!(atom, Atom::AttachmentStage { id: stage_id, .. } if stage_id == id),
) else {
return false;
};
if let Some(file) = file {
self.atoms[index] = Atom::UploadedFile(file);
} else if let Atom::AttachmentStage { failed, .. } = &mut self.atoms[index] {
*failed = true;
}
true
}
pub fn take_removed_attachment_stages(&mut self) -> Vec<String> {
std::mem::take(&mut self.removed_attachment_stages)
}
/// Replace `atoms[start..self.cursor]` (the in-flight `@<typed>` /
/// active `@<typed>` file token) with the corresponding chip atom
/// and place the cursor right after the chip. Used by the completion
/// confirm path.
pub fn replace_with_file_ref(&mut self, start: usize, path: String) {
self.atoms.drain(start..self.cursor);
self.atoms
.insert(start, Atom::FileRef(FileRefAtom { path }));
self.replace_atoms(
start,
self.cursor,
vec![Atom::FileRef(FileRefAtom { path })],
);
self.cursor = start + 1;
}
@@ -363,14 +705,177 @@ impl InputBuffer {
/// completion path: the popup-selected entry is inserted as raw
/// text (not a chip) so the user can keep typing — e.g. drill into
/// a directory whose value ends with `/`.
pub fn replace_with_text_at(&mut self, start: usize, text: &str) {
self.atoms.drain(start..self.cursor);
let mut idx = start;
for c in text.chars() {
self.atoms.insert(idx, Atom::Char(c));
idx += 1;
pub fn char_at(&self, index: usize) -> Option<char> {
match self.atoms.get(index) {
Some(Atom::Char(ch)) => Some(*ch),
_ => None,
}
self.cursor = idx;
}
pub fn replace_with_text_at(&mut self, start: usize, text: &str) {
self.replace_atoms(start, self.cursor, text.chars().map(Atom::Char).collect());
self.cursor = start + text.chars().count();
}
pub fn can_complete_argument_name(&self, prefix_start: usize) -> bool {
let mut before = prefix_start;
while self.char_at(before).is_some_and(char::is_whitespace) {
let Some(previous) = before.checked_sub(1) else {
return false;
};
before = previous;
}
matches!(self.char_at(before), Some('(' | ','))
}
pub fn replace_feature_name_completion(&mut self, start: usize, text: &str) {
let mut end = self.cursor;
while self
.char_at(end)
.is_some_and(|ch| ch.is_ascii_alphanumeric() || ch == '-')
{
end += 1;
}
if self.char_at(end) == Some('(') {
end += 1;
}
self.replace_atoms(start, end, text.chars().map(Atom::Char).collect());
self.cursor = start + text.chars().count();
}
pub fn replace_argument_completion(&mut self, start: usize, text: &str) {
// Replace the whole value including an existing closing quote when editing its middle.
let mut end = self.cursor;
let quoted = self.char_at(start) == Some('"');
let mut escaped = false;
while let Some(ch) = self.char_at(end) {
if !quoted && (ch == ',' || ch == ')' || ch.is_whitespace()) {
break;
}
end += 1;
if quoted && !escaped && ch == '"' {
break;
}
escaped = ch == '\\' && !escaped;
if ch != '\\' {
escaped = false;
}
}
if quoted && self.char_at(end.saturating_sub(1)) != Some('"') {
end = self.cursor;
}
self.replace_atoms(start, end, text.chars().map(Atom::Char).collect());
self.cursor = start + text.chars().count();
}
pub fn pending_feature_argument_completion(
&self,
descriptors: &[protocol::FeatureInvocationDescriptor],
) -> Option<(
protocol::CompletionKind,
usize,
String,
protocol::CompletionContext,
)> {
if self.cursor == 0 {
return None;
}
let selection = self
.selected_invocations
.iter()
.filter(|selected| selected.start < self.cursor)
.max_by_key(|selected| selected.start)?;
let run_start = selection.start;
let descriptor = &selection.descriptor;
if !descriptors
.iter()
.any(|candidate| candidate.identity == descriptor.identity)
{
return None;
}
let invocation = self.atoms[run_start..self.cursor]
.iter()
.map(|atom| match atom {
Atom::Char(ch) => Some(*ch),
_ => None,
})
.collect::<Option<String>>()?;
let open = invocation.find('(')?;
let arguments = &invocation[open + 1..];
if first_unquoted_char(arguments, ')').is_some() {
return None;
}
let current_start = last_unquoted_char(arguments, ',').map_or(0, |index| index + 1);
let current = &arguments[current_start..];
let leading = current.len() - current.trim_start().len();
let current = &current[leading..];
let (argument, prefix_offset, mut prefix) =
if let Some(equal) = first_unquoted_char(current, '=') {
let name = current[..equal].trim();
let mut value = &current[equal + 1..];
let mut offset = equal + 1;
let value_leading = value.len() - value.trim_start().len();
value = value.trim_start();
offset += value_leading;
if let Some(unquoted) = value.strip_prefix('"') {
value = unquoted;
offset += 1;
}
(Some(name.to_string()), offset, value.to_string())
} else {
let mut previous_start = 0;
let mut position = 0;
for comma in unquoted_char_indices(&arguments[..current_start])
.filter_map(|(index, ch)| (ch == ',').then_some(index))
{
if first_unquoted_char(&arguments[previous_start..comma], '=').is_none() {
position += 1;
}
previous_start = comma + 1;
}
let positional = descriptor
.arguments
.iter()
.find(|argument| argument.position == Some(position));
if (current.is_empty() && positional.is_none())
|| (current.bytes().all(|byte| {
byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_'
}) && positional.is_none())
{
(None, 0, current.to_string())
} else {
let mut value = current;
let mut offset = 0;
if let Some(unquoted) = value.strip_prefix('"') {
value = unquoted;
offset = 1;
}
(
positional.map(|argument| argument.name.clone()),
offset,
value.to_string(),
)
}
};
if prefix_offset > 0 && current[..prefix_offset].ends_with('"') {
if serde_json::from_str::<String>(&format!("\"{}", prefix)).is_ok() {
return None;
}
prefix = serde_json::from_str::<String>(&format!("\"{}\"", prefix)).ok()?;
}
let prefix_start = run_start
+ invocation[..open + 1].chars().count()
+ arguments[..current_start + leading].chars().count()
+ current[..prefix_offset].chars().count();
Some((
protocol::CompletionKind::FeatureArgument,
prefix_start.saturating_sub(1),
prefix,
protocol::CompletionContext {
invocation: descriptor.identity.clone(),
argument,
},
))
}
/// If the cursor is currently inside a `@<typed>` /
@@ -398,7 +903,9 @@ impl InputBuffer {
}
let kind = match c {
'@' => Some(protocol::CompletionKind::File),
'/' if !typed.contains(['(', ')', '=', ',']) => {
Some(protocol::CompletionKind::Feature)
}
_ => None,
};
if let Some(k) = kind {
@@ -422,17 +929,29 @@ impl InputBuffer {
None
}
pub fn take_removed_uploaded_files(&mut self) -> Vec<protocol::UploadedFileRef> {
std::mem::take(&mut self.removed_uploaded_files)
}
fn record_removed_atom(&mut self, atom: Atom) {
match atom {
Atom::UploadedFile(file) => self.removed_uploaded_files.push(file),
Atom::AttachmentStage { id, .. } => self.removed_attachment_stages.push(id),
_ => {}
}
}
pub fn delete_before(&mut self) {
if self.cursor == 0 {
return;
}
self.cursor -= 1;
self.atoms.remove(self.cursor);
self.replace_atoms(self.cursor, self.cursor + 1, Vec::new());
}
pub fn delete_after(&mut self) {
if self.cursor < self.atoms.len() {
self.atoms.remove(self.cursor);
self.replace_atoms(self.cursor, self.cursor + 1, Vec::new());
}
}
@@ -442,14 +961,16 @@ impl InputBuffer {
let end = self.cursor;
self.move_word_left();
let start = self.cursor;
self.atoms.drain(start..end);
self.replace_atoms(start, end, Vec::new());
}
pub fn move_left(&mut self) {
self.bump_revision();
self.cursor = self.cursor.saturating_sub(1);
}
pub fn move_right(&mut self) {
self.bump_revision();
self.cursor = (self.cursor + 1).min(self.atoms.len());
}
@@ -457,6 +978,7 @@ impl InputBuffer {
/// atoms sharing the same [`AtomClass`] — so `Word(Hiragana)` next to
/// `Word(Han)` are separate blocks, and a `Paste` atom is its own block.
pub fn move_word_left(&mut self) {
self.bump_revision();
while self.cursor > 0 && atom_class(&self.atoms[self.cursor - 1]) == AtomClass::Sep {
self.cursor -= 1;
}
@@ -471,6 +993,7 @@ impl InputBuffer {
/// Move forward by one word. Mirror of [`move_word_left`].
pub fn move_word_right(&mut self) {
self.bump_revision();
while self.cursor < self.atoms.len()
&& atom_class(&self.atoms[self.cursor]) == AtomClass::Sep
{
@@ -486,6 +1009,7 @@ impl InputBuffer {
}
pub fn move_start(&mut self) {
self.bump_revision();
self.cursor = 0;
}
@@ -534,11 +1058,13 @@ impl InputBuffer {
}
pub fn move_home(&mut self) {
self.bump_revision();
let (ranges, line, _) = self.logical_line_and_col();
self.cursor = ranges[line].0;
}
pub fn move_end(&mut self) {
self.bump_revision();
let (ranges, line, _) = self.logical_line_and_col();
self.cursor = ranges[line].1;
}
@@ -546,6 +1072,7 @@ impl InputBuffer {
/// Move one logical line up, preserving column (atom count from
/// current line start). No-op if already on the first line.
pub fn move_up(&mut self) {
self.bump_revision();
let (ranges, line, col) = self.logical_line_and_col();
if line == 0 {
return;
@@ -556,6 +1083,7 @@ impl InputBuffer {
/// Move one logical line down, preserving column.
pub fn move_down(&mut self) {
self.bump_revision();
let (ranges, line, col) = self.logical_line_and_col();
let Some(&(start, end)) = ranges.get(line + 1) else {
return;
@@ -580,6 +1108,12 @@ impl InputBuffer {
for a in &self.atoms {
match a {
Atom::Char(c) => buf.push(*c),
Atom::Unknown => {
flush_text(&mut buf, &mut out);
out.push(protocol::Segment::Unknown);
}
// Staging is client-owned and never a submit payload.
Atom::AttachmentStage { .. } => {}
Atom::Paste(p) => {
flush_text(&mut buf, &mut out);
out.push(protocol::Segment::Paste {
@@ -595,12 +1129,22 @@ impl InputBuffer {
artifact: artifact.clone(),
});
}
Atom::UploadedFile(file) => {
flush_text(&mut buf, &mut out);
out.push(protocol::Segment::UploadedFile { file: file.clone() });
}
Atom::FileRef(r) => {
flush_text(&mut buf, &mut out);
out.push(protocol::Segment::FileRef {
path: r.path.clone(),
});
}
Atom::FeatureInvoke(invocation) => {
flush_text(&mut buf, &mut out);
out.push(protocol::Segment::FeatureInvoke {
invocation: invocation.invocation.clone(),
});
}
Atom::FlowRef(r) => {
flush_text(&mut buf, &mut out);
out.push(protocol::Segment::Flow {
@@ -1531,9 +2075,14 @@ mod word_motion_tests {
for a in &buf.atoms {
match a {
Atom::Char(c) => out.push(*c),
Atom::Paste(_) | Atom::PasteArtifact(_) | Atom::FileRef(_) | Atom::FlowRef(_) => {
out.push_str("<P>")
}
Atom::Unknown
| Atom::AttachmentStage { .. }
| Atom::Paste(_)
| Atom::PasteArtifact(_)
| Atom::UploadedFile(_)
| Atom::FileRef(_)
| Atom::FeatureInvoke(_)
| Atom::FlowRef(_) => out.push_str("<P>"),
}
}
out
@@ -1621,3 +2170,81 @@ mod word_motion_tests {
assert_eq!(cursor(&buf), 7); // after "った"
}
}
#[cfg(test)]
mod feature_invocation_input_tests {
use super::*;
use protocol::{
FeatureInvocationDescriptor, FeatureInvocationIdentity, FeatureInvocationSyntax,
InvocationArgumentDescriptor, InvocationArgumentType, InvocationCompletion, Segment,
UploadedFileAvailability, UploadedFileRef,
};
fn descriptor() -> FeatureInvocationDescriptor {
FeatureInvocationDescriptor {
identity: FeatureInvocationIdentity("builtin:test/run".into()),
name: "run".into(),
aliases: Vec::new(),
display_name: "Run".into(),
description: "test".into(),
syntax: FeatureInvocationSyntax::Parenthesized,
arguments: vec![InvocationArgumentDescriptor {
name: "path".into(),
position: Some(0),
required: true,
value_type: InvocationArgumentType::String,
completion: InvocationCompletion::None,
description: None,
}],
client_adapter: None,
}
}
#[test]
fn selected_invocations_become_typed_atoms_and_preserve_following_prose() {
let mut input = InputBuffer::new();
input.insert_str("before /run(\"資料/a b\") after /run(\"x/y\") done");
input.select_feature_invocation(7, descriptor());
input.select_feature_invocation(
"before /run(\"資料/a b\") after ".chars().count(),
descriptor(),
);
input.finalize_feature_invocations().unwrap();
let segments = input.submit_segments();
assert_eq!(
segments
.iter()
.filter(|segment| matches!(segment, Segment::FeatureInvoke { .. }))
.count(),
2
);
assert!(matches!(segments.last(), Some(Segment::Text { content }) if content == " done"));
let mut unselected = InputBuffer::new();
unselected.insert_str("/run(\"x\")");
unselected.finalize_feature_invocations().unwrap();
assert!(matches!(
unselected.submit_segments().as_slice(),
[Segment::Text { content }] if content == "/run(\"x\")"
));
}
#[test]
fn deleting_an_uploaded_file_chip_reports_exact_staged_resource() {
let file = UploadedFileRef {
artifact_id: "artifact-1".into(),
file_name: "report.md".into(),
media_type: "text/markdown".into(),
created_at_ms: 1,
availability: UploadedFileAvailability::Available,
byte_len: 4,
sha256: "a".repeat(64),
source_entry_id: None,
};
let mut input = InputBuffer::new();
input.insert_uploaded_file(file.clone());
input.delete_before();
assert_eq!(input.take_removed_uploaded_files(), vec![file]);
assert!(input.take_removed_uploaded_files().is_empty());
}
}
+653
View File
@@ -0,0 +1,653 @@
//! T-696 client contract regressions, exercised through real composer selection.
use crate::{app::App, input::InputBuffer};
use protocol::*;
pub(crate) fn descriptor() -> FeatureInvocationDescriptor {
FeatureInvocationDescriptor {
identity: FeatureInvocationIdentity("plugin:test/run".into()),
name: "run".into(),
aliases: vec!["execute".into()],
display_name: "Run".into(),
description: "Run a declared action".into(),
syntax: FeatureInvocationSyntax::Parenthesized,
arguments: vec![
InvocationArgumentDescriptor {
name: "path".into(),
position: Some(0),
required: true,
value_type: InvocationArgumentType::WorkerFile,
completion: InvocationCompletion::WorkerFile,
description: None,
},
InvocationArgumentDescriptor {
name: "mode".into(),
position: None,
required: false,
value_type: InvocationArgumentType::Enum {
values: vec!["safe".into(), "fast".into()],
},
completion: InvocationCompletion::Static {
values: vec!["safe".into(), "fast".into()],
},
description: None,
},
InvocationArgumentDescriptor {
name: "count".into(),
position: None,
required: false,
value_type: InvocationArgumentType::Integer,
completion: InvocationCompletion::None,
description: None,
},
InvocationArgumentDescriptor {
name: "force".into(),
position: None,
required: false,
value_type: InvocationArgumentType::Boolean,
completion: InvocationCompletion::None,
description: None,
},
],
client_adapter: None,
}
}
pub(crate) fn attachment_descriptor() -> FeatureInvocationDescriptor {
let mut descriptor = descriptor();
// No hardcoded builtin identity, public name, or argument name in adapter dispatch.
descriptor.identity = FeatureInvocationIdentity("plugin:files/add".into());
descriptor.name = "add-file".into();
descriptor.aliases.clear();
descriptor.arguments.truncate(1);
descriptor.arguments[0].name = "resource".into();
descriptor.arguments[0].value_type = InvocationArgumentType::ClientFile;
descriptor.arguments[0].completion = InvocationCompletion::ClientFile;
descriptor.client_adapter = Some(InvocationClientAdapter::Attachment);
descriptor
}
pub(crate) fn uploaded_file() -> UploadedFileRef {
UploadedFileRef {
artifact_id: "artifact-owned".into(),
file_name: "資料 a.txt".into(),
media_type: "text/plain".into(),
created_at_ms: 1,
availability: UploadedFileAvailability::Available,
byte_len: 4,
sha256: "a".repeat(64),
source_entry_id: None,
}
}
pub(crate) fn select(
app: &mut App,
descriptor: FeatureInvocationDescriptor,
name: &str,
) -> Option<Method> {
app.insert_char('/');
assert!(matches!(
app.refresh_completion(),
Some(Method::ListCompletions {
kind: CompletionKind::Feature,
..
})
));
app.handle_worker_event(Event::Completions {
request_id: app.completion_request_id(),
kind: CompletionKind::Feature,
prefix: String::new(),
context: None,
entries: vec![CompletionEntry {
value: name.into(),
invocation: Some(descriptor),
..Default::default()
}],
});
app.apply_completion_text()
}
fn complete(app: &mut App, value: &str, is_dir: bool) -> Option<Method> {
let state = app.completion.as_ref().expect("argument completion");
app.handle_worker_event(Event::Completions {
request_id: app.completion_request_id(),
kind: state.kind,
prefix: state.prefix.clone(),
context: state.context.clone(),
entries: vec![CompletionEntry {
value: value.into(),
is_dir,
..Default::default()
}],
});
app.apply_completion_text()
}
#[test]
fn feature_invocation_discovery_alias_selection_and_confirmation_are_occurrence_scoped() {
let mut app = App::new("test".into());
app.input.insert_str("説明 ");
assert!(
matches!(select(&mut app, descriptor(), "execute"), Some(Method::ListCompletions {
kind: CompletionKind::FeatureArgument, context: Some(CompletionContext { argument: Some(argument), .. }), ..
}) if argument == "path")
);
app.input
.insert_str("\"資料/a b\") after /execute(\"not selected\")");
assert!(
app.submit_input().is_none(),
"first Enter confirms typed chips, not Send"
);
let segments = app.input.submit_segments();
assert!(
matches!(&segments[1], Segment::FeatureInvoke { invocation } if invocation.identity == descriptor().identity && invocation.name == "execute")
);
assert!(
matches!(segments.last(), Some(Segment::Text { content }) if content == " after /execute(\"not selected\")")
);
assert!(matches!(app.submit_input(), Some(Method::Submit { input, .. }) if input == segments));
}
#[test]
fn feature_invocation_raw_text_paste_and_unknown_payloads_do_not_dispatch() {
let mut app = App::new("test".into());
app.input
.insert_str("/run(x) /attach(/tmp/a.txt) /clear-attachments");
assert!(
matches!(app.submit_input(), Some(Method::Submit { input, .. }) if matches!(input.as_slice(), [Segment::Text { .. }]))
);
let unknown: Segment =
serde_json::from_str(r#"{"kind":"future_invocation","identity":"plugin:test/run"}"#)
.unwrap();
app.input.replace_with_segments(&[unknown]);
assert_eq!(app.input.submit_segments(), vec![Segment::Unknown]);
let mut input = InputBuffer::new();
input.insert_paste("/run(x) ".repeat(20));
input.finalize_feature_invocations().unwrap();
assert!(matches!(
input.submit_segments().as_slice(),
[Segment::Paste { .. }]
));
}
#[test]
fn feature_invocation_argument_insertion_quotes_unicode_spaces_escapes_and_keeps_prose() {
let mut app = App::new("test".into());
app.input.insert_str("界 ");
select(&mut app, descriptor(), "run");
let value = "資料/a b/quote\"slash\\end";
complete(&mut app, value, false);
app.input.insert_str(", mode=sa");
app.refresh_completion();
complete(&mut app, "safe", false);
app.input.insert_str(", count=1");
app.refresh_completion();
complete(&mut app, "12", false);
app.input.insert_str(", force=f");
app.refresh_completion();
complete(&mut app, "false", false);
app.input.insert_str(") trailing prose");
assert!(app.submit_input().is_none());
let segments = app.input.submit_segments();
let Segment::FeatureInvoke { invocation } = &segments[1] else {
panic!("typed chip expected")
};
assert_eq!(
invocation.arguments[0].value,
InvocationValue::String(value.into())
);
assert_eq!(invocation.arguments[2].value, InvocationValue::Integer(12));
assert_eq!(
invocation.arguments[3].value,
InvocationValue::Boolean(false)
);
assert!(
matches!(segments.last(), Some(Segment::Text { content }) if content == " trailing prose")
);
}
#[test]
fn feature_invocation_argument_names_and_directory_drill_in_are_safe() {
let mut app = App::new("test".into());
select(&mut app, descriptor(), "run");
assert!(
matches!(complete(&mut app, "資料 dir", true), Some(Method::ListCompletions { prefix, .. }) if prefix == "資料 dir/")
);
complete(&mut app, "資料 dir/file name.txt", false);
app.input.insert_str(", mo");
app.refresh_completion();
assert!(
app.completion
.as_ref()
.unwrap()
.context
.as_ref()
.unwrap()
.argument
.is_none()
);
assert!(
matches!(complete(&mut app, "mode=", false), Some(Method::ListCompletions { context: Some(CompletionContext { argument: Some(argument), .. }), .. }) if argument == "mode")
);
complete(&mut app, "fast", false);
app.input.insert_char(')');
assert!(app.submit_input().is_none());
assert!(matches!(
app.input.submit_segments().as_slice(),
[Segment::FeatureInvoke { .. }]
));
}
#[test]
fn feature_invocation_completion_replaces_middle_value_without_suffix_corruption() {
let mut input = InputBuffer::new();
input.insert_str("界 /run(path= \"資料/old name\", mode=safe) prose");
input.select_feature_invocation(2, descriptor());
input.move_home();
for _ in 0.."界 /run(path= \"資料/ol".chars().count() {
input.move_right();
}
let (_, before, prefix, context) = input
.pending_feature_argument_completion(&[descriptor()])
.unwrap();
assert_eq!(prefix, "資料/ol");
assert_eq!(context.argument.as_deref(), Some("path"));
let start = before; // preceding atom is the opening quote
input.replace_argument_completion(start, &quote_invocation_string("資料/new name"));
assert_eq!(
input.plain_text(),
"界 /run(path= \"資料/new name\", mode=safe) prose"
);
input.finalize_feature_invocations().unwrap();
assert!(
matches!(input.submit_segments().last(), Some(Segment::Text { content }) if content == " prose")
);
}
#[test]
fn feature_invocation_validation_is_atomic_for_multiple_selected_calls() {
for invalid in [
"/run()",
"/run(x, nope=y)",
"/run(x, mode=unknown)",
"/run(x, count=oops)",
"/run(x, force=yes)",
"/run(x, path=y)",
"/run(\"unfinished",
] {
let mut input = InputBuffer::new();
input.insert_str("/run(ok) then ");
let second = input.plain_text().chars().count();
input.insert_str(invalid);
input.select_feature_invocation(0, descriptor());
input.select_feature_invocation(second, descriptor());
let before = input.submit_segments();
assert!(input.finalize_feature_invocations().is_err(), "{invalid}");
assert_eq!(input.submit_segments(), before);
}
}
#[test]
fn feature_invocation_selection_tracks_cursor_edits_and_does_not_survive_deleting_its_sigil() {
let mut input = InputBuffer::new();
input.insert_str("/run(x)");
input.select_feature_invocation(0, descriptor());
input.move_home();
input.insert_str("prefix ");
input.finalize_feature_invocations().unwrap();
assert!(matches!(
&input.submit_segments()[1],
Segment::FeatureInvoke { .. }
));
input.replace_with_segments(&[Segment::text("/run(y)")]);
input.select_feature_invocation(0, descriptor());
input.move_home();
input.delete_after();
input.insert_char('/');
input.finalize_feature_invocations().unwrap();
assert_eq!(input.submit_segments(), vec![Segment::text("/run(y)")]);
}
#[test]
fn feature_invocation_chip_reedit_preserves_identity_id_and_typed_arguments() {
let parsed = parse_feature_invocation(
"/execute(\"a b\", mode=safe, count=2)",
0,
&descriptor(),
"stable-id",
)
.unwrap()
.invocation;
let segments = vec![
Segment::FeatureInvoke {
invocation: parsed.clone(),
},
Segment::UploadedFile {
file: uploaded_file(),
},
Segment::Flow {
selector: "review".into(),
},
Segment::FileRef {
path: "src/".into(),
},
];
let mut input = InputBuffer::new();
input.replace_with_segments(&segments);
assert_eq!(input.submit_segments(), segments);
assert!(input.edit_feature_invocation("stable-id", descriptor()));
input.finalize_feature_invocations().unwrap();
assert_eq!(input.submit_segments(), segments);
assert!(!input.edit_feature_invocation("stable-id", attachment_descriptor()));
}
#[test]
fn feature_invocation_reedit_unknown_disabled_or_stale_keeps_original_chip() {
let invocation = parse_feature_invocation("/run(x)", 0, &descriptor(), "id")
.unwrap()
.invocation;
let mut app = App::new("test".into());
let segments = vec![Segment::FeatureInvoke { invocation }];
app.input.replace_with_segments(&segments);
assert!(matches!(
app.edit_adjacent_feature_invocation(),
Some(Method::ListCompletions { .. })
));
app.handle_worker_event(Event::Completions {
request_id: app.completion_request_id(),
kind: CompletionKind::Feature,
prefix: "run".into(),
context: None,
entries: Vec::new(),
});
assert_eq!(app.input.submit_segments(), segments);
app.edit_adjacent_feature_invocation();
app.input.move_home();
app.refresh_completion();
app.handle_worker_event(Event::Completions {
request_id: app.completion_request_id(),
kind: CompletionKind::Feature,
prefix: "run".into(),
context: None,
entries: vec![CompletionEntry {
value: "run".into(),
invocation: Some(descriptor()),
..Default::default()
}],
});
assert_eq!(app.input.submit_segments(), segments);
}
#[test]
fn feature_invocation_generic_attachment_adapter_never_records_or_sends_local_path() {
let mut app = App::new("test".into());
select(&mut app, attachment_descriptor(), "add-file");
app.input.insert_str("\"/tmp/資料 a.txt\") keep this prose");
assert!(app.submit_input().is_none());
assert_eq!(app.input_history_len(), 0);
let adapters = app.attachment_invocations();
assert_eq!(adapters.len(), 1);
assert_eq!(adapters[0].1.to_str().unwrap(), "/tmp/資料 a.txt");
app.input
.stage_attachment(&adapters[0].0, "staging-id".into(), "資料 a.txt".into());
assert!(app.submit_input().is_none());
assert!(app.submit_notify_input().is_none());
app.input
.finish_attachment_stage("staging-id", Some(uploaded_file()));
let Method::Submit { input, .. } = app.submit_input().unwrap() else {
panic!("submit expected")
};
assert!(matches!(&input[0], Segment::UploadedFile { .. }));
assert!(!serde_json::to_string(&input).unwrap().contains("/tmp/"));
assert!(
matches!(input.last(), Some(Segment::Text { content }) if content == " keep this prose")
);
}
#[test]
fn feature_invocation_staging_deletion_and_late_completion_do_not_resurrect_chips() {
for delete in [0, 1, 2] {
let invocation = parse_feature_invocation("/run(x)", 0, &descriptor(), "invoke")
.unwrap()
.invocation;
let mut input = InputBuffer::new();
input.replace_with_segments(&[Segment::FeatureInvoke { invocation }]);
input.stage_attachment("invoke", "stage".into(), "a.txt".into());
match delete {
0 => input.delete_before(),
1 => {
input.move_left();
input.delete_after();
}
_ => input.delete_word_before(),
}
assert_eq!(input.take_removed_attachment_stages(), vec!["stage"]);
assert!(!input.finish_attachment_stage("stage", Some(uploaded_file())));
assert!(input.is_empty());
}
}
#[test]
fn feature_invocation_typed_history_draft_transport_and_rewind_restore_without_flattening() {
let invocation = parse_feature_invocation(
"/run(\"資料/a b\", mode=safe)",
0,
&descriptor(),
"stable-id",
)
.unwrap()
.invocation;
let segments = vec![
Segment::text("before "),
Segment::FeatureInvoke { invocation },
Segment::UploadedFile {
file: uploaded_file(),
},
Segment::Paste {
id: 2,
chars: 4,
lines: 1,
content: "data".into(),
},
Segment::FileRef {
path: "src/".into(),
},
Segment::Flow {
selector: "review".into(),
},
];
let mut app = App::new("test".into());
app.input.replace_with_segments(&segments);
let method = app.submit_input().unwrap();
app.restore_unsent_run(&method);
assert_eq!(app.input.submit_segments(), segments);
app.input.clear();
let draft = vec![
Segment::text("draft "),
Segment::UploadedFile {
file: uploaded_file(),
},
];
app.input.replace_with_segments(&draft);
app.input.move_home();
assert!(app.browse_input_history_older());
assert_eq!(app.input.submit_segments(), segments);
assert!(app.browse_input_history_newer());
assert_eq!(app.input.submit_segments(), draft);
app.input.clear();
app.handle_worker_event(Event::RewindApplied {
session: SessionSnapshot {
entries: vec![],
pending_submissions: PendingSubmissionsSnapshot::default(),
},
input: segments.clone(),
summary: RewindSummary {
truncated_to_entries: 1,
discarded_entries: 2,
tool_side_effect_warning: false,
},
});
assert_eq!(app.input.submit_segments(), segments);
}
#[test]
fn feature_invocation_stale_argument_context_and_unvalidated_discovery_entries_are_ignored() {
let mut app = App::new("test".into());
select(&mut app, descriptor(), "run");
let state = app.completion.as_ref().unwrap();
let prefix = state.prefix.clone();
let mut context = state.context.clone().unwrap();
context.invocation = FeatureInvocationIdentity("plugin:other/run".into());
app.handle_worker_event(Event::Completions {
request_id: app.completion_request_id(),
kind: CompletionKind::FeatureArgument,
prefix,
context: Some(context),
entries: vec![CompletionEntry {
value: "unsafe".into(),
..Default::default()
}],
});
assert!(app.completion.as_ref().unwrap().entries.is_empty());
let mut app = App::new("test".into());
app.insert_char('/');
app.refresh_completion();
let mut invalid = descriptor();
invalid.identity = FeatureInvocationIdentity("unqualified".into());
app.handle_worker_event(Event::Completions {
request_id: app.completion_request_id(),
kind: CompletionKind::Feature,
prefix: String::new(),
context: None,
entries: vec![CompletionEntry {
value: "run".into(),
invocation: Some(invalid),
..Default::default()
}],
});
assert!(app.apply_completion_text().is_none());
assert_eq!(app.input.plain_text(), "/");
}
#[test]
fn feature_invocation_multiple_selected_calls_preserve_prose_and_source_qualified_identity() {
let mut app = App::new("test".into());
select(&mut app, descriptor(), "run");
app.input.insert_str("x) prose ");
let mut other = descriptor();
other.identity = FeatureInvocationIdentity("plugin:other/second".into());
other.name = "second".into();
other.aliases.clear();
select(&mut app, other.clone(), "second");
app.input.insert_str("\"y z\") done");
assert!(app.submit_input().is_none());
let segments = app.input.submit_segments();
assert!(
matches!(&segments[0], Segment::FeatureInvoke { invocation } if invocation.identity == descriptor().identity)
);
assert!(matches!(&segments[1], Segment::Text { content } if content == " prose "));
assert!(
matches!(&segments[2], Segment::FeatureInvoke { invocation } if invocation.identity == other.identity)
);
assert!(matches!(&segments[3], Segment::Text { content } if content == " done"));
}
#[test]
fn feature_invocation_range_replacement_reports_each_removed_upload_and_stage() {
let invocation = parse_feature_invocation("/run(x)", 0, &descriptor(), "id")
.unwrap()
.invocation;
let mut input = InputBuffer::new();
input.replace_with_segments(&[
Segment::UploadedFile {
file: uploaded_file(),
},
Segment::text(" "),
Segment::FeatureInvoke { invocation },
]);
input.stage_attachment("id", "stage".into(), "a.txt".into());
input.replace_with_text_at(0, "replacement");
assert_eq!(input.take_removed_uploaded_files(), vec![uploaded_file()]);
assert_eq!(input.take_removed_attachment_stages(), vec!["stage"]);
assert_eq!(input.plain_text(), "replacement");
}
#[test]
fn feature_invocation_argument_positions_ignore_previous_named_arguments() {
let mut input = InputBuffer::new();
input.insert_str("/run(mode=safe, \"資料/a");
input.select_feature_invocation(0, descriptor());
let (_, _, prefix, context) = input
.pending_feature_argument_completion(&[descriptor()])
.unwrap();
assert_eq!(context.argument.as_deref(), Some("path"));
assert_eq!(prefix, "資料/a");
}
#[test]
fn feature_invocation_name_completion_replaces_suffix_at_middle_cursor() {
let mut app = App::new("test".into());
app.input.insert_str("/run() prose");
app.input.move_home();
app.input.move_right();
app.input.move_right();
app.refresh_completion();
app.handle_worker_event(Event::Completions {
request_id: app.completion_request_id(),
kind: CompletionKind::Feature,
prefix: "r".into(),
context: None,
entries: vec![CompletionEntry {
value: "run".into(),
invocation: Some(descriptor()),
..Default::default()
}],
});
app.apply_completion_text();
assert_eq!(app.input.plain_text(), "/run() prose");
}
#[test]
fn feature_invocation_discovery_expands_aliases_from_host_metadata() {
let mut app = App::new("test".into());
app.input.insert_str("/exe");
app.refresh_completion();
app.handle_worker_event(Event::Completions {
request_id: app.completion_request_id(),
kind: CompletionKind::Feature,
prefix: "exe".into(),
context: None,
entries: vec![CompletionEntry {
value: "run".into(),
invocation: Some(descriptor()),
..Default::default()
}],
});
assert_eq!(app.completion.as_ref().unwrap().entries[0].value, "execute");
app.apply_completion_text();
assert_eq!(app.input.plain_text(), "/execute(");
}
#[test]
fn feature_invocation_named_arguments_are_suggested_alongside_positional_values() {
let mut app = App::new("test".into());
select(&mut app, descriptor(), "run");
app.input.insert_str("pa");
app.refresh_completion();
let state = app.completion.as_ref().unwrap();
app.handle_worker_event(Event::Completions {
request_id: app.completion_request_id(),
kind: state.kind,
prefix: state.prefix.clone(),
context: state.context.clone(),
entries: Vec::new(),
});
assert_eq!(app.completion.as_ref().unwrap().entries[0].value, "path=");
app.apply_completion_text();
assert_eq!(app.input.plain_text(), "/run(path=");
app.input.insert_str("\"資料/path\")");
assert!(app.submit_input().is_none());
assert!(matches!(
app.input.submit_segments().as_slice(),
[Segment::FeatureInvoke { .. }]
));
}
+2
View File
@@ -13,6 +13,8 @@ mod console;
mod e2e_observer;
mod inline_terminal;
mod input;
#[cfg(test)]
mod invocation_tests;
pub mod keys;
mod markdown;
mod scroll;
+46 -8
View File
@@ -366,6 +366,23 @@ fn task_status_mark(status: TaskStatus) -> (&'static str, Style) {
/// taller than that single row); `Clear` blanks the cells first so
/// underlying text doesn't bleed through. The popup width matches the
/// widest visible label, capped at the input-area width.
fn completion_entry_label(entry: &CompletionEntry) -> String {
if let Some(invocation) = &entry.invocation {
format!(
"/{} — {} — {}",
entry.value,
invocation.description,
invocation.usage()
)
} else {
let suffix = if entry.is_dir { "/" } else { "" };
match &entry.description {
Some(description) => format!("{}{suffix} — {description}", entry.value),
None => format!("{}{suffix}", entry.value),
}
}
}
fn draw_completion_popup(frame: &mut Frame, state: &CompletionState, input_area: Rect) {
let entries = &state.entries;
if entries.is_empty() || input_area.y == 0 {
@@ -380,16 +397,10 @@ fn draw_completion_popup(frame: &mut Frame, state: &CompletionState, input_area:
};
let view_end = (view_start + visible).min(entries.len());
let label_for = |entry: &CompletionEntry| {
let mut s = entry.value.clone();
if entry.is_dir {
s.push('/');
}
s
};
let label_for = |entry: &CompletionEntry| completion_entry_label(entry);
let max_label = entries[view_start..view_end]
.iter()
.map(|e| label_for(e).chars().count() as u16)
.map(|e| label_for(e).width().min(u16::MAX as usize) as u16)
.max()
.unwrap_or(0);
let popup_w = max_label.saturating_add(2).min(input_area.width).max(1);
@@ -1341,6 +1352,10 @@ fn chip_span_for(seg: &Segment, fallback: Style) -> (Style, String) {
),
),
Segment::FileRef { path } => (Style::default().fg(Color::Cyan), format!("@{path}")),
Segment::FeatureInvoke { invocation } => (
Style::default().fg(Color::Yellow),
invocation.display_input(),
),
Segment::Flow { selector } => (
Style::default().fg(Color::Yellow),
format!("[Flow: {selector}]"),
@@ -1375,6 +1390,7 @@ fn segment_display_text(seg: &Segment) -> String {
file.availability.as_str()
),
Segment::FileRef { path } => format!("@{path}"),
Segment::FeatureInvoke { invocation } => invocation.display_input(),
Segment::Flow { selector } => format!("[Flow: {selector}]"),
Segment::Unknown => "[unknown segment]".to_owned(),
}
@@ -2186,6 +2202,28 @@ mod tests {
});
}
#[test]
fn feature_invocation_completion_labels_include_alias_description_usage_and_argument_help() {
let descriptor = crate::invocation_tests::descriptor();
let entry = CompletionEntry {
value: "execute".into(),
invocation: Some(descriptor.clone()),
..Default::default()
};
let label = completion_entry_label(&entry);
assert!(label.starts_with("/execute"));
assert!(label.contains(&descriptor.description));
assert!(label.contains(&descriptor.usage()));
assert_eq!(
completion_entry_label(&CompletionEntry {
value: "mode=".into(),
description: Some("execution mode".into()),
..Default::default()
}),
"mode= — execution mode"
);
}
#[test]
fn run_status_line_matches_console_metrics_and_spinner_frame() {
let now = Instant::now();
+4 -1
View File
@@ -720,6 +720,7 @@ pub trait WorkerExecutionBackend: Send + Sync + 'static {
_handle: &WorkerExecutionHandle,
_kind: protocol::CompletionKind,
_prefix: &str,
_context: Option<&protocol::CompletionContext>,
) -> Vec<protocol::CompletionEntry> {
Vec::new()
}
@@ -942,8 +943,10 @@ impl WorkerExecutionBackendRef {
handle: &WorkerExecutionHandle,
kind: protocol::CompletionKind,
prefix: &str,
context: Option<&protocol::CompletionContext>,
) -> Vec<protocol::CompletionEntry> {
self.backend.worker_completions(handle, kind, prefix)
self.backend
.worker_completions(handle, kind, prefix, context)
}
pub(crate) fn stop_worker_operation(
+9 -3
View File
@@ -526,6 +526,8 @@ pub struct RuntimeHttpWorkerCompletionsRequest {
pub kind: protocol::CompletionKind,
#[serde(default)]
pub prefix: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context: Option<protocol::CompletionContext>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
@@ -1865,10 +1867,14 @@ async fn worker_completions(
&worker_ref,
request.kind,
&request.prefix,
request.context.as_ref(),
),
None => state.runtime.worker_completions(
&worker_ref,
request.kind,
&request.prefix,
request.context.as_ref(),
),
None => state
.runtime
.worker_completions(&worker_ref, request.kind, &request.prefix),
}
.map_err(RuntimeHttpRestError::runtime)?;
Ok(Json(RuntimeHttpWorkerCompletionsResponse {
@@ -562,15 +562,19 @@ impl runtime_api::RuntimeApi for RuntimeManagementApi {
let entries = match auth_workspace_scope(&self.state, auth_extension().as_ref())
.map_err(api_error)?
{
Some(scope) => {
self.state
.runtime
.worker_completions_scoped(&scope, &worker_ref, kind, &prefix)
}
None => self
.state
.runtime
.worker_completions(&worker_ref, kind, &prefix),
Some(scope) => self.state.runtime.worker_completions_scoped(
&scope,
&worker_ref,
kind,
&prefix,
value.context.as_ref(),
),
None => self.state.runtime.worker_completions(
&worker_ref,
kind,
&prefix,
value.context.as_ref(),
),
}
.map_err(RuntimeHttpRestError::runtime)
.map_err(api_error)?;
+167 -6
View File
@@ -2430,9 +2430,10 @@ impl Runtime {
worker_ref: &WorkerRef,
kind: protocol::CompletionKind,
prefix: &str,
context: Option<&protocol::CompletionContext>,
) -> Result<Vec<protocol::CompletionEntry>, RuntimeError> {
self.ensure_worker_in_workspace(scope, worker_ref)?;
self.worker_completions(worker_ref, kind, prefix)
self.worker_completions(worker_ref, kind, prefix, context)
}
/// Return live completion entries for the Worker composer.
@@ -2441,6 +2442,7 @@ impl Runtime {
worker_ref: &WorkerRef,
kind: protocol::CompletionKind,
prefix: &str,
context: Option<&protocol::CompletionContext>,
) -> Result<Vec<protocol::CompletionEntry>, RuntimeError> {
let (backend, handle) = {
let state = self.lock()?;
@@ -2457,7 +2459,7 @@ impl Runtime {
let Some((backend, handle)) = backend.zip(handle) else {
return Ok(Vec::new());
};
Ok(backend.worker_completions(&handle, kind, prefix))
Ok(backend.worker_completions(&handle, kind, prefix, context))
}
/// Accept a protocol method through a workspace-scoped Runtime authorization context.
@@ -2482,9 +2484,21 @@ impl Runtime {
worker_ref: &WorkerRef,
method: Method,
) -> Result<Vec<Event>, RuntimeError> {
if let Method::ListCompletions { kind, prefix } = method {
let entries = self.worker_completions(worker_ref, kind, &prefix)?;
return Ok(vec![Event::Completions { kind, entries }]);
if let Method::ListCompletions {
kind,
prefix,
request_id,
context,
} = method
{
let entries = self.worker_completions(worker_ref, kind, &prefix, context.as_ref())?;
return Ok(vec![Event::Completions {
kind,
prefix,
request_id,
context,
entries,
}]);
}
if matches!(&method, Method::Shutdown { .. }) {
self.stop_worker(worker_ref, Some("worker protocol shutdown".to_string()))?;
@@ -5490,7 +5504,29 @@ fn validate_create_workspace_scope(
Ok(())
}
fn validate_worker_input(input: &WorkerInput) -> Result<(), RuntimeError> {
pub(crate) fn validate_worker_input(input: &WorkerInput) -> Result<(), RuntimeError> {
if input.segments.as_ref().is_some_and(|segments| {
segments
.iter()
.any(|segment| matches!(segment, protocol::Segment::Unknown))
}) {
return Err(RuntimeError::InvalidRequest(
"unsupported typed input must not be discarded; use a compatible client and host"
.into(),
));
}
if !matches!(
input.kind,
WorkerInputKind::User | WorkerInputKind::UserIfIdle
) && input.segments.as_ref().is_some_and(|segments| {
segments
.iter()
.any(|segment| !matches!(segment, protocol::Segment::Text { .. }))
}) {
return Err(RuntimeError::InvalidRequest(
"Notify and control inputs cannot carry typed submission intent; use Submit".into(),
));
}
let has_segments = input
.segments
.as_ref()
@@ -6500,6 +6536,13 @@ mod tests {
workspace_config_results: Mutex<Vec<WorkspaceConfigFetchResult>>,
contexts: Mutex<BTreeMap<WorkerId, WorkerExecutionContext>>,
dispatched_inputs: Mutex<Vec<WorkerInput>>,
completion_queries: Mutex<
Vec<(
protocol::CompletionKind,
String,
Option<protocol::CompletionContext>,
)>,
>,
repository_accesses: Mutex<Vec<WorkingDirectoryRepositoryAccessRequest>>,
repository_access_available: AtomicBool,
working_directory_requests: Mutex<Vec<WorkingDirectoryRequest>>,
@@ -6677,6 +6720,23 @@ mod tests {
}
}
fn worker_completions(
&self,
_handle: &WorkerExecutionHandle,
kind: protocol::CompletionKind,
prefix: &str,
context: Option<&protocol::CompletionContext>,
) -> Vec<protocol::CompletionEntry> {
self.completion_queries
.lock()
.unwrap()
.push((kind, prefix.into(), context.cloned()));
vec![protocol::CompletionEntry {
value: "資料/a b".into(),
..Default::default()
}]
}
fn dispatch_input(
&self,
_handle: &WorkerExecutionHandle,
@@ -6816,6 +6876,107 @@ mod tests {
.block_on(subscription.recv())
}
#[test]
fn unknown_typed_intent_is_rejected_before_backend_dispatch() {
let (runtime, backend) = runtime_and_backend();
let worker = runtime
.create_worker(task_request("unsupported input"))
.unwrap();
let mut input = WorkerInput::user("normal prose");
input.segments = Some(vec![
protocol::Segment::text("normal prose"),
protocol::Segment::Unknown,
]);
assert!(matches!(
runtime.send_input(&worker.worker_ref, input),
Err(RuntimeError::InvalidRequest(_))
));
assert!(backend.dispatched_inputs.lock().unwrap().is_empty());
}
#[test]
fn notify_rejects_typed_feature_intent_before_backend_dispatch() {
let (runtime, backend) = runtime_and_backend();
let worker = runtime.create_worker(task_request("notification")).unwrap();
let invoke = protocol::Segment::FeatureInvoke {
invocation: protocol::FeatureInvocation {
invocation_id: "not-executed".into(),
identity: protocol::FeatureInvocationIdentity("builtin:test/run".into()),
name: "run".into(),
arguments: Vec::new(),
},
};
let mut input = WorkerInput::user("/run()");
input.kind = WorkerInputKind::Notify;
input.segments = Some(vec![invoke]);
assert!(matches!(
runtime.send_input(&worker.worker_ref, input),
Err(RuntimeError::InvalidRequest(_))
));
assert!(backend.dispatched_inputs.lock().unwrap().is_empty());
let mut text = WorkerInput::user("/run() advisory text");
text.kind = WorkerInputKind::Notify;
assert!(
runtime
.send_input(&worker.worker_ref, text)
.unwrap()
.notification
.is_some()
);
assert_eq!(backend.dispatched_inputs.lock().unwrap().len(), 1);
}
#[test]
fn feature_argument_completions_preserve_context_and_workspace_authority() {
let (runtime, backend) = runtime_and_backend();
let owner = scope("workspace-a", "server-a");
let worker = runtime
.create_worker_scoped(&owner, scoped_task_request("completion", "workspace-a"))
.unwrap();
let context = protocol::CompletionContext {
invocation: protocol::FeatureInvocationIdentity("builtin:test/prepare".into()),
argument: Some("path".into()),
};
let events = runtime
.send_protocol_method_scoped(
&owner,
&worker.worker_ref,
Method::ListCompletions {
kind: protocol::CompletionKind::FeatureArgument,
prefix: "資料/".into(),
request_id: Some("query-current".into()),
context: Some(context.clone()),
},
)
.unwrap();
assert!(
matches!(&events[0], Event::Completions { kind: protocol::CompletionKind::FeatureArgument, prefix, request_id: Some(request_id), context: Some(restored), entries }
if request_id == "query-current" && prefix == "資料/" && restored == &context && entries[0].value == "資料/a b")
);
assert_eq!(
*backend.completion_queries.lock().unwrap(),
vec![(
protocol::CompletionKind::FeatureArgument,
"資料/".into(),
Some(context.clone())
)]
);
let outsider = scope("workspace-b", "server-b");
assert!(
runtime
.worker_completions_scoped(
&outsider,
&worker.worker_ref,
protocol::CompletionKind::FeatureArgument,
"",
Some(&context)
)
.is_err()
);
assert_eq!(backend.completion_queries.lock().unwrap().len(), 1);
assert!(backend.dispatched_inputs.lock().unwrap().is_empty());
}
#[test]
fn runtime_worker_subscription_has_gap_free_snapshot_and_live_updates() {
let runtime = runtime_with_backend();
+10 -1
View File
@@ -3067,6 +3067,12 @@ where
handle: &WorkerExecutionHandle,
input: WorkerInput,
) -> WorkerExecutionResult {
if let Err(error) = crate::runtime::validate_worker_input(&input) {
return WorkerExecutionResult::rejected(
WorkerExecutionOperation::Input,
error.to_string(),
);
}
let (worker, worker_state, _workspace_client) = match self.get_execution(handle) {
Ok(execution) => execution,
Err(mut result) => {
@@ -3367,6 +3373,7 @@ where
handle: &WorkerExecutionHandle,
kind: protocol::CompletionKind,
prefix: &str,
context: Option<&protocol::CompletionContext>,
) -> Vec<protocol::CompletionEntry> {
if handle.backend_id() != self.backend_id() {
return Vec::new();
@@ -3377,7 +3384,9 @@ where
workers
.get(handle.worker_ref())
.map(|execution| {
futures::executor::block_on(execution.handle.completion_entries(kind, prefix))
futures::executor::block_on(
execution.handle.completion_entries(kind, prefix, context),
)
})
.unwrap_or_default()
}
+59
View File
@@ -184,6 +184,7 @@ impl WorkerHandle {
&self,
kind: protocol::CompletionKind,
prefix: &str,
context: Option<&protocol::CompletionContext>,
) -> Vec<protocol::CompletionEntry> {
match kind {
protocol::CompletionKind::File => {
@@ -196,9 +197,62 @@ impl WorkerHandle {
.map(|candidate| protocol::CompletionEntry {
value: candidate.path,
is_dir: candidate.is_dir,
description: None,
usage: None,
invocation: None,
})
.collect()
}
protocol::CompletionKind::Feature => self
.shared_state
.feature_invocations()
.feature_completions(prefix),
protocol::CompletionKind::FeatureArgument => {
let Some(context) = context else {
return Vec::new();
};
let registry = self.shared_state.feature_invocations();
let Some(argument) = context.argument.as_deref() else {
return registry.argument_name_completions(&context.invocation, prefix);
};
let worker_file = registry
.descriptor(&context.invocation)
.and_then(|descriptor| {
descriptor
.arguments
.iter()
.find(|candidate| candidate.name == argument)
})
.is_some_and(|descriptor| {
matches!(
descriptor.completion,
protocol::InvocationCompletion::WorkerFile
)
});
if worker_file {
let Some(view) = self.shared_state.fs_view() else {
return Vec::new();
};
let candidates = view.list_file_completions(prefix).await;
if registry.descriptor(&context.invocation).is_none() {
return Vec::new();
}
return crate::feature::invocation::bounded_completion_entries(
candidates
.into_iter()
.map(|candidate| protocol::CompletionEntry {
value: candidate.path,
is_dir: candidate.is_dir,
description: None,
usage: None,
invocation: None,
}),
);
}
registry
.argument_completions(&context.invocation, argument, prefix)
.await
}
}
}
@@ -846,6 +900,7 @@ impl WorkerController {
manifest_toml.clone(),
greeting,
));
shared_state.set_feature_invocations(worker.feature_invocations());
let usage_state = shared_state.clone();
let usage_events = working_event_tx.clone();
worker.engine_mut().on_usage(move |event| {
@@ -1327,6 +1382,10 @@ where
let worker_enabled = feature_config.worker.enabled;
let sub_worker_enabled = feature_config.sub_worker.enabled;
let mut feature_registry = FeatureRegistryBuilder::new();
if !backend_job_profile {
feature_registry
.add_module(crate::feature::builtin::chat_invocation::AttachmentInvocationFeature);
}
if backend_job_profile {
let workspace_client = worker.workspace_client_handle();
if !workspace_client.is_available() || workspace_client.workspace_id().is_none() {
+56
View File
@@ -20,6 +20,7 @@ use agen::Engine;
use agen::llm_client::client::LlmClient;
use agen::state::Mutable;
use agen::tool::ToolDefinition;
use protocol::FeatureInvocationDescriptor;
use serde::{Deserialize, Serialize};
use thiserror::Error;
@@ -623,6 +624,9 @@ pub struct FeatureDescriptor {
pub provides_services: Vec<ServiceDeclaration>,
pub requires_services: Vec<ServiceRequirement>,
pub protocol_providers: Vec<ProtocolProviderDeclaration>,
/// Declarative, user-selected chat invocations contributed by this Feature.
/// Metadata is public; handlers are registered separately during install.
pub chat_invocations: Vec<FeatureInvocationDescriptor>,
}
impl FeatureDescriptor {
@@ -640,6 +644,7 @@ impl FeatureDescriptor {
provides_services: Vec::new(),
requires_services: Vec::new(),
protocol_providers: Vec::new(),
chat_invocations: Vec::new(),
}
}
@@ -682,6 +687,11 @@ impl FeatureDescriptor {
self.protocol_providers.push(provider);
self
}
pub fn with_chat_invocation(mut self, invocation: FeatureInvocationDescriptor) -> Self {
self.chat_invocations.push(invocation);
self
}
}
/// Feature module contribution boundary.
@@ -739,6 +749,7 @@ pub enum FeatureContributionKind {
BackgroundTask,
Service,
ProtocolProvider,
ChatInvocation,
Notification,
Alert,
Diagnostic,
@@ -765,6 +776,7 @@ pub struct FeatureInstallReport {
pub provided_services: Vec<ServiceDeclaration>,
pub resolved_service_requirements: Vec<ServiceRequirement>,
pub protocol_providers: Vec<ProtocolProviderLifecycleDiagnostic>,
pub installed_chat_invocations: Vec<FeatureInvocationDescriptor>,
pub skipped: Vec<SkippedContribution>,
pub diagnostics: Vec<FeatureDiagnostic>,
}
@@ -782,6 +794,7 @@ impl FeatureInstallReport {
provided_services: Vec::new(),
resolved_service_requirements: Vec::new(),
protocol_providers: Vec::new(),
installed_chat_invocations: Vec::new(),
skipped: Vec::new(),
diagnostics: Vec::new(),
}
@@ -794,6 +807,7 @@ impl FeatureInstallReport {
self.installed_instructions.clear();
self.declared_background_tasks.clear();
self.provided_services.clear();
self.installed_chat_invocations.clear();
}
fn mark_skipped(
@@ -819,6 +833,7 @@ struct FeatureContributionDeclarations {
provided_services: HashSet<(ServiceId, String)>,
required_services: HashSet<ServiceId>,
protocol_providers: HashSet<ProviderId>,
chat_invocations: Vec<FeatureInvocationDescriptor>,
}
impl FeatureContributionDeclarations {
@@ -859,6 +874,7 @@ impl FeatureContributionDeclarations {
.iter()
.map(|provider| provider.id.clone())
.collect(),
chat_invocations: descriptor.chat_invocations.clone(),
}
}
@@ -1527,6 +1543,7 @@ pub struct FeatureInstallContext<'a> {
hook_builder: &'a mut HookRegistryBuilder,
background_task_builder: &'a mut FeatureBackgroundTaskRegistryBuilder,
service_registry: &'a mut FeatureServiceRegistry,
invocation_registry: &'a mut FeatureInvocationRegistry,
report: &'a mut FeatureInstallReport,
}
@@ -1591,6 +1608,15 @@ impl FeatureInstallContext<'_> {
}
}
pub fn chat_invocations(&mut self) -> InvocationContributionRegistrar<'_> {
InvocationContributionRegistrar {
feature_id: self.feature_id,
declarations: &self.declarations.chat_invocations,
registry: self.invocation_registry,
installed: &mut self.report.installed_chat_invocations,
}
}
pub fn diagnostics(&mut self) -> FeatureDiagnosticSink<'_> {
FeatureDiagnosticSink {
report: self.report,
@@ -1604,6 +1630,7 @@ pub struct FeatureRegistryInstallReport {
pub reports: Vec<FeatureInstallReport>,
pub services: FeatureServiceRegistry,
pub background_tasks: FeatureBackgroundTaskRegistry,
pub chat_invocations: FeatureInvocationRegistry,
pub plan_error: Option<FeaturePlanError>,
}
@@ -2026,11 +2053,13 @@ impl FeatureRegistryBuilder {
reports,
services: FeatureServiceRegistry::default(),
background_tasks: FeatureBackgroundTaskRegistry::default(),
chat_invocations: FeatureInvocationRegistry::default(),
plan_error: Some(error),
};
}
};
let mut service_registry = FeatureServiceRegistry::default();
let mut invocation_registry = FeatureInvocationRegistry::default();
let mut background_task_builder = FeatureBackgroundTaskRegistryBuilder::default();
let mut reports = Vec::with_capacity(plan.ordered_indices.len());
let install_hook_checkpoint = hook_builder.checkpoint();
@@ -2055,6 +2084,7 @@ impl FeatureRegistryBuilder {
let hook_checkpoint = hook_builder.checkpoint();
let background_checkpoint = background_task_builder.checkpoint();
let service_checkpoint = service_registry.clone();
let invocation_checkpoint = invocation_registry.clone();
let tool_checkpoint = pending_tools.len();
let installed_tool_checkpoint = installed_tool_names.clone();
@@ -2102,6 +2132,7 @@ impl FeatureRegistryBuilder {
hook_builder,
background_task_builder: &mut background_task_builder,
service_registry: &mut service_registry,
invocation_registry: &mut invocation_registry,
report: &mut report,
};
module.install(&mut context)
@@ -2113,6 +2144,7 @@ impl FeatureRegistryBuilder {
hook_builder.rollback_to(hook_checkpoint);
background_task_builder.rollback_to(&background_checkpoint);
service_registry = service_checkpoint.clone();
invocation_registry = invocation_checkpoint.clone();
pending_tools.truncate(tool_checkpoint);
installed_tool_names = installed_tool_checkpoint.clone();
report.clear_installed_contributions();
@@ -2138,6 +2170,14 @@ impl FeatureRegistryBuilder {
)));
}
}
for invocation in &descriptor.chat_invocations {
if !report.installed_chat_invocations.contains(invocation) {
report.diagnostics.push(FeatureDiagnostic::error(format!(
"feature `{}` declared chat invocation `{}` but did not register an executable handler",
descriptor.id, invocation.identity
)));
}
}
if report
.diagnostics
.iter()
@@ -2146,6 +2186,7 @@ impl FeatureRegistryBuilder {
hook_builder.rollback_to(hook_checkpoint);
background_task_builder.rollback_to(&background_checkpoint);
service_registry = service_checkpoint.clone();
invocation_registry = invocation_checkpoint.clone();
pending_tools.truncate(tool_checkpoint);
installed_tool_names = installed_tool_checkpoint.clone();
report.clear_installed_contributions();
@@ -2176,6 +2217,7 @@ impl FeatureRegistryBuilder {
reports,
services: FeatureServiceRegistry::default(),
background_tasks: FeatureBackgroundTaskRegistry::default(),
chat_invocations: FeatureInvocationRegistry::default(),
plan_error: None,
}
} else {
@@ -2183,6 +2225,7 @@ impl FeatureRegistryBuilder {
reports,
services: service_registry,
background_tasks: background_task_builder.build(),
chat_invocations: invocation_registry,
plan_error: None,
}
}
@@ -2225,11 +2268,24 @@ pub enum FeatureInstallError {
first_feature: String,
duplicate_feature: String,
},
#[error("duplicate invocation identity `{identity}`")]
DuplicateInvocationIdentity { identity: String },
#[error("duplicate invocation name `{name}` from `{duplicate}`; first registered by `{first}`")]
DuplicateInvocationName {
name: String,
first: String,
duplicate: String,
},
#[error("feature install failed: {0}")]
Install(String),
}
pub mod background;
pub mod invocation;
pub use invocation::{
FeatureInvocationCompletionProvider, FeatureInvocationContext, FeatureInvocationHandler,
FeatureInvocationHandlerError, FeatureInvocationRegistry, InvocationContributionRegistrar,
};
pub mod builtin;
pub mod mcp;
pub(crate) mod session;
+1
View File
@@ -5,6 +5,7 @@
//! an external plugin-loading surface.
pub mod backend_job_result;
pub mod chat_invocation;
pub mod flow_transition;
pub mod manage_workdir;
pub mod manage_worker;
@@ -0,0 +1,72 @@
//! Builtin metadata for client-local attachment staging.
//!
//! `/attach` uses the common invocation declaration and parser, but its path is
//! intentionally consumed by the client adapter. The Worker accepts only the
//! resulting immutable `UploadedFile` segment and never resolves a client path.
use async_trait::async_trait;
use protocol::{
FeatureInvocation, FeatureInvocationDescriptor, FeatureInvocationIdentity,
FeatureInvocationResult, FeatureInvocationSyntax, InvocationArgumentDescriptor,
InvocationArgumentType, InvocationClientAdapter, InvocationCompletion,
};
use crate::feature::{
FeatureDescriptor, FeatureInstallContext, FeatureInstallError, FeatureInvocationContext,
FeatureInvocationHandler, FeatureInvocationHandlerError, FeatureModule,
};
pub const ATTACH_FEATURE_ID: &str = "attachments";
pub const ATTACH_INVOCATION_ID: &str = "builtin:attachments/attach";
pub fn attach_invocation_descriptor() -> FeatureInvocationDescriptor {
FeatureInvocationDescriptor {
identity: FeatureInvocationIdentity(ATTACH_INVOCATION_ID.into()),
name: "attach".into(),
aliases: Vec::new(),
display_name: "Attach file".into(),
description: "Stage a client-local file as an editable attachment chip.".into(),
syntax: FeatureInvocationSyntax::Parenthesized,
arguments: vec![InvocationArgumentDescriptor {
name: "file".into(),
position: Some(0),
required: true,
value_type: InvocationArgumentType::ClientFile,
completion: InvocationCompletion::ClientFile,
description: Some("File available to this client".into()),
}],
client_adapter: Some(InvocationClientAdapter::Attachment),
}
}
pub struct AttachmentInvocationFeature;
impl FeatureModule for AttachmentInvocationFeature {
fn descriptor(&self) -> FeatureDescriptor {
FeatureDescriptor::builtin(ATTACH_FEATURE_ID, "Attachments")
.with_description("Client-local attachment staging and typed uploaded-file input")
.with_chat_invocation(attach_invocation_descriptor())
}
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
context.chat_invocations().register(
attach_invocation_descriptor(),
AttachmentMustBeStagedByClient,
)
}
}
struct AttachmentMustBeStagedByClient;
#[async_trait]
impl FeatureInvocationHandler for AttachmentMustBeStagedByClient {
async fn invoke(
&self,
_context: FeatureInvocationContext,
_invocation: &FeatureInvocation,
) -> Result<FeatureInvocationResult, FeatureInvocationHandlerError> {
Err(FeatureInvocationHandlerError::failed(
"attachment invocation reached the Worker without client-local upload staging",
))
}
}
+966
View File
@@ -0,0 +1,966 @@
use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use std::sync::Arc;
use async_trait::async_trait;
use protocol::{
CompletionEntry, FeatureInvocation, FeatureInvocationDescriptor, FeatureInvocationIdentity,
FeatureInvocationResult, InvocationCompletion, InvocationValue, validate_feature_invocation,
};
use super::{FeatureId, FeatureInstallError};
pub(crate) const MAX_INVOCATION_MESSAGE_BYTES: usize = 4 * 1024;
pub(crate) const MAX_INVOCATION_CONTEXT_BYTES: usize = 16 * 1024;
const MAX_COMPLETION_ENTRIES: usize = 64;
const MAX_COMPLETION_VALUE_BYTES: usize = 1024;
const MAX_COMPLETION_DESCRIPTION_BYTES: usize = 2 * 1024;
const MAX_COMPLETION_USAGE_BYTES: usize = 4 * 1024;
const MAX_INVOCATION_DESCRIPTOR_BYTES: usize =
protocol::invocation::MAX_FEATURE_INVOCATION_DESCRIPTOR_BYTES;
const MAX_COMPLETION_RESPONSE_BYTES: usize = 64 * 1024;
pub(crate) fn invocation_result_is_bounded(result: &FeatureInvocationResult) -> bool {
result.message.len() <= MAX_INVOCATION_MESSAGE_BYTES
&& result
.context
.as_ref()
.is_none_or(|context| context.len() <= MAX_INVOCATION_CONTEXT_BYTES)
}
fn truncate_display(text: &mut String, max_bytes: usize) {
let mut end = text.len().min(max_bytes);
while !text.is_char_boundary(end) {
end -= 1;
}
text.truncate(end);
}
/// Values are never truncated: doing so could silently select a different
/// operation. Display-only text may be shortened; oversized metadata is dropped.
pub(crate) fn bounded_completion_entries(
entries: impl IntoIterator<Item = CompletionEntry>,
) -> Vec<CompletionEntry> {
let mut result = Vec::new();
let mut bytes = 2; // JSON array delimiters.
for mut entry in entries.into_iter().take(MAX_COMPLETION_ENTRIES * 4) {
if entry.value.len() > MAX_COMPLETION_VALUE_BYTES {
continue;
}
if let Some(description) = &mut entry.description {
truncate_display(description, MAX_COMPLETION_DESCRIPTION_BYTES);
}
if let Some(usage) = &mut entry.usage {
truncate_display(usage, MAX_COMPLETION_USAGE_BYTES);
}
if entry.invocation.as_ref().is_some_and(|descriptor| {
serde_json::to_vec(descriptor).map_or(true, |encoded| {
encoded.len() > MAX_INVOCATION_DESCRIPTOR_BYTES
})
}) {
continue;
}
let Ok(encoded) = serde_json::to_vec(&entry) else {
continue;
};
let additional = encoded.len() + usize::from(!result.is_empty());
if bytes + additional > MAX_COMPLETION_RESPONSE_BYTES {
continue;
}
bytes += additional;
result.push(entry);
if result.len() == MAX_COMPLETION_ENTRIES {
break;
}
}
result
}
/// Host-owned execution context. `invocation_id` is the idempotency key a
/// handler must use when its operation has external side effects. Workspace,
/// credential, and permission authority remain captured by the handler itself.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FeatureInvocationContext {
pub invocation_id: String,
}
#[async_trait]
pub trait FeatureInvocationHandler: Send + Sync {
/// Side-effect-free runtime availability/permission check. Installation is
/// necessary but not sufficient: discovery and execution both consult this.
fn is_available(&self) -> bool {
true
}
/// Side-effect-free semantic and authority validation. Installed handlers
/// retain Workspace/credential/permission authority; request arguments never
/// supply it. The Worker calls this before persisting an execution start.
fn validate(
&self,
_invocation: &FeatureInvocation,
) -> Result<(), FeatureInvocationHandlerError> {
Ok(())
}
async fn invoke(
&self,
context: FeatureInvocationContext,
invocation: &FeatureInvocation,
) -> Result<FeatureInvocationResult, FeatureInvocationHandlerError>;
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("{message}")]
pub struct FeatureInvocationHandlerError {
pub message: String,
/// True only when the handler cannot determine whether its business-side
/// effect committed. The Worker never automatically retries this outcome.
pub outcome_unknown: bool,
}
impl FeatureInvocationHandlerError {
pub fn failed(message: impl Into<String>) -> Self {
Self {
message: message.into(),
outcome_unknown: false,
}
}
pub fn outcome_unknown(message: impl Into<String>) -> Self {
Self {
message: message.into(),
outcome_unknown: true,
}
}
}
#[async_trait]
pub trait FeatureInvocationCompletionProvider: Send + Sync {
/// The provider selector comes only from installed host metadata, never
/// from completion-request input. Providers retain their own scoped authority.
async fn complete(
&self,
completion: &InvocationCompletion,
argument: &str,
prefix: &str,
) -> Vec<CompletionEntry>;
}
#[derive(Clone)]
struct RegisteredInvocation {
descriptor: FeatureInvocationDescriptor,
handler: Arc<dyn FeatureInvocationHandler>,
completion_provider: Option<Arc<dyn FeatureInvocationCompletionProvider>>,
}
/// Installed invocation metadata and executable handlers. Equality/debug expose
/// only public descriptors; implementation objects and credentials never leak.
#[derive(Clone, Default)]
pub struct FeatureInvocationRegistry {
entries: BTreeMap<FeatureInvocationIdentity, RegisteredInvocation>,
names: BTreeMap<String, FeatureInvocationIdentity>,
}
impl fmt::Debug for FeatureInvocationRegistry {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("FeatureInvocationRegistry")
.field("descriptors", &self.descriptors())
.finish()
}
}
impl PartialEq for FeatureInvocationRegistry {
fn eq(&self, other: &Self) -> bool {
self.descriptors() == other.descriptors()
}
}
impl Eq for FeatureInvocationRegistry {}
impl FeatureInvocationRegistry {
pub fn descriptors(&self) -> Vec<FeatureInvocationDescriptor> {
self.entries
.values()
.filter(|entry| entry.handler.is_available())
.map(|entry| entry.descriptor.clone())
.collect()
}
pub fn descriptor(
&self,
identity: &FeatureInvocationIdentity,
) -> Option<&FeatureInvocationDescriptor> {
self.entries
.get(identity)
.filter(|entry| entry.handler.is_available())
.map(|entry| &entry.descriptor)
}
pub fn feature_completions(&self, prefix: &str) -> Vec<CompletionEntry> {
bounded_completion_entries(
self.entries
.values()
.filter(|entry| entry.handler.is_available())
.filter(|entry| {
entry.descriptor.name.starts_with(prefix)
|| entry
.descriptor
.aliases
.iter()
.any(|alias| alias.starts_with(prefix))
})
.map(|entry| CompletionEntry {
value: entry.descriptor.name.clone(),
is_dir: false,
description: Some(entry.descriptor.description.clone()),
usage: Some(entry.descriptor.usage()),
invocation: Some(entry.descriptor.clone()),
}),
)
}
pub fn argument_name_completions(
&self,
identity: &FeatureInvocationIdentity,
prefix: &str,
) -> Vec<CompletionEntry> {
bounded_completion_entries(
self.entries
.get(identity)
.filter(|entry| entry.handler.is_available())
.into_iter()
.flat_map(|entry| entry.descriptor.arguments.iter())
.filter(|argument| argument.name.starts_with(prefix))
.map(|argument| CompletionEntry {
value: format!("{}=", argument.name),
is_dir: false,
description: argument.description.clone(),
usage: None,
invocation: None,
}),
)
}
pub async fn argument_completions(
&self,
identity: &FeatureInvocationIdentity,
argument: &str,
prefix: &str,
) -> Vec<CompletionEntry> {
let Some(entry) = self
.entries
.get(identity)
.filter(|entry| entry.handler.is_available())
else {
return Vec::new();
};
let Some(descriptor) = entry
.descriptor
.arguments
.iter()
.find(|candidate| candidate.name == argument)
else {
return Vec::new();
};
let strings = match &descriptor.completion {
InvocationCompletion::None => match &descriptor.value_type {
protocol::InvocationArgumentType::Boolean => ["true", "false"]
.into_iter()
.filter(|value| value.starts_with(prefix))
.map(str::to_owned)
.collect(),
protocol::InvocationArgumentType::Enum { values } => values
.iter()
.filter(|value| value.starts_with(prefix))
.cloned()
.collect(),
_ => Vec::new(),
},
InvocationCompletion::WorkerFile | InvocationCompletion::ClientFile => Vec::new(),
InvocationCompletion::Static { values } => values
.iter()
.filter(|value| value.starts_with(prefix))
.cloned()
.collect(),
InvocationCompletion::Provider { .. } => {
return match &entry.completion_provider {
Some(provider) => {
let entries = provider
.complete(&descriptor.completion, argument, prefix)
.await;
if !entry.handler.is_available() {
return Vec::new();
}
bounded_completion_entries(entries.into_iter().map(|mut candidate| {
// Argument providers contribute values/display text,
// never new executable invocation metadata.
candidate.invocation = None;
candidate
}))
}
None => Vec::new(),
};
}
};
bounded_completion_entries(strings.into_iter().map(|value| CompletionEntry {
value,
is_dir: false,
description: descriptor.description.clone(),
usage: None,
invocation: None,
}))
}
pub(crate) fn validate_metadata(
&self,
invocation: &FeatureInvocation,
) -> Result<(), FeatureInvocationHandlerError> {
let entry = self
.entries
.get(&invocation.identity)
.filter(|entry| entry.handler.is_available())
.ok_or_else(|| {
FeatureInvocationHandlerError::failed(
"Feature invocation is not enabled or currently available",
)
})?;
if entry.descriptor.client_adapter.is_some() {
return Err(FeatureInvocationHandlerError::failed(
"client-local invocation must be staged by the client, not executed by the Worker",
));
}
validate_feature_invocation(&entry.descriptor, invocation).map_err(|error| {
let mut message = error.to_string();
truncate_display(&mut message, MAX_INVOCATION_MESSAGE_BYTES);
FeatureInvocationHandlerError::failed(message)
})
}
pub fn validate(
&self,
invocation: &FeatureInvocation,
) -> Result<(), FeatureInvocationHandlerError> {
self.validate_metadata(invocation)?;
self.entries[&invocation.identity]
.handler
.validate(invocation)
.map_err(|mut error| {
// Validation is side-effect-free; bound its diagnostic before
// any start, without claiming an uncertain business outcome.
truncate_display(&mut error.message, MAX_INVOCATION_MESSAGE_BYTES);
error
})
}
pub async fn invoke(
&self,
invocation: &FeatureInvocation,
) -> Result<FeatureInvocationResult, FeatureInvocationHandlerError> {
self.validate(invocation)?;
let entry = &self.entries[&invocation.identity];
let result = entry
.handler
.invoke(
FeatureInvocationContext {
invocation_id: invocation.invocation_id.clone(),
},
invocation,
)
.await
.map_err(|error| {
if error.message.len() > MAX_INVOCATION_MESSAGE_BYTES {
FeatureInvocationHandlerError::outcome_unknown(
"handler error exceeded the result byte limit; do not retry automatically",
)
} else {
error
}
})?;
if !invocation_result_is_bounded(&result) {
return Err(FeatureInvocationHandlerError::outcome_unknown(
"handler result exceeded the message/context byte limit; do not retry automatically",
));
}
if result.invocation_id != invocation.invocation_id
|| result.identity != invocation.identity
{
return Err(FeatureInvocationHandlerError::outcome_unknown(
"handler returned a result for a different invocation; do not retry automatically",
));
}
if result.status != protocol::FeatureInvocationStatus::Succeeded && result.context.is_some()
{
return Err(FeatureInvocationHandlerError::outcome_unknown(
"handler returned preparation context without a successful result",
));
}
if result.status == protocol::FeatureInvocationStatus::Succeeded
&& !entry.handler.is_available()
{
return Err(FeatureInvocationHandlerError::failed(
"handler reported success but the invocation is no longer available; preparation context withheld; do not retry automatically",
));
}
Ok(result)
}
pub(crate) fn register(
&mut self,
feature_id: FeatureId,
descriptor: FeatureInvocationDescriptor,
handler: Arc<dyn FeatureInvocationHandler>,
completion_provider: Option<Arc<dyn FeatureInvocationCompletionProvider>>,
) -> Result<(), FeatureInstallError> {
if descriptor.identity.0.len() > 256
|| serde_json::to_vec(&descriptor).map_or(true, |encoded| {
encoded.len() > MAX_INVOCATION_DESCRIPTOR_BYTES
})
{
return Err(FeatureInstallError::InvalidDescriptor(
"invocation metadata exceeds the host byte limit".into(),
));
}
descriptor
.validate()
.map_err(|error| FeatureInstallError::InvalidDescriptor(error.to_string()))?;
let expected_prefix = format!("{feature_id}/");
if !descriptor.identity.0.starts_with(&expected_prefix) {
return Err(FeatureInstallError::InvalidDescriptor(format!(
"invocation identity `{}` must be qualified by feature `{feature_id}`",
descriptor.identity
)));
}
if self.entries.contains_key(&descriptor.identity) {
return Err(FeatureInstallError::DuplicateInvocationIdentity {
identity: descriptor.identity.to_string(),
});
}
let public_names = std::iter::once(descriptor.name.as_str())
.chain(descriptor.aliases.iter().map(String::as_str))
.collect::<BTreeSet<_>>();
for name in &public_names {
if let Some(existing) = self.names.get(*name) {
return Err(FeatureInstallError::DuplicateInvocationName {
name: (*name).to_string(),
first: existing.to_string(),
duplicate: descriptor.identity.to_string(),
});
}
}
for name in public_names {
self.names
.insert(name.to_string(), descriptor.identity.clone());
}
self.entries.insert(
descriptor.identity.clone(),
RegisteredInvocation {
descriptor,
handler,
completion_provider,
},
);
Ok(())
}
pub fn argument_value<'a>(
invocation: &'a FeatureInvocation,
name: &str,
) -> Option<&'a InvocationValue> {
invocation
.arguments
.iter()
.find(|argument| argument.name == name)
.map(|argument| &argument.value)
}
}
pub struct InvocationContributionRegistrar<'a> {
pub(super) feature_id: &'a FeatureId,
pub(super) declarations: &'a [FeatureInvocationDescriptor],
pub(super) registry: &'a mut FeatureInvocationRegistry,
pub(super) installed: &'a mut Vec<FeatureInvocationDescriptor>,
}
impl InvocationContributionRegistrar<'_> {
pub fn register<H>(
&mut self,
descriptor: FeatureInvocationDescriptor,
handler: H,
) -> Result<(), FeatureInstallError>
where
H: FeatureInvocationHandler + 'static,
{
self.register_with_provider(descriptor, Arc::new(handler), None)
}
pub fn register_with_completion<H, P>(
&mut self,
descriptor: FeatureInvocationDescriptor,
handler: H,
provider: P,
) -> Result<(), FeatureInstallError>
where
H: FeatureInvocationHandler + 'static,
P: FeatureInvocationCompletionProvider + 'static,
{
self.register_with_provider(descriptor, Arc::new(handler), Some(Arc::new(provider)))
}
fn register_with_provider(
&mut self,
descriptor: FeatureInvocationDescriptor,
handler: Arc<dyn FeatureInvocationHandler>,
provider: Option<Arc<dyn FeatureInvocationCompletionProvider>>,
) -> Result<(), FeatureInstallError> {
if provider.is_none()
&& descriptor.arguments.iter().any(|argument| {
matches!(argument.completion, InvocationCompletion::Provider { .. })
})
{
return Err(FeatureInstallError::InvalidDescriptor(format!(
"invocation `{}` declares a completion provider but did not register one",
descriptor.identity
)));
}
if !self.declarations.contains(&descriptor) {
return Err(FeatureInstallError::UndeclaredContribution {
kind: super::FeatureContributionKind::ChatInvocation,
name: descriptor.identity.to_string(),
feature: self.feature_id.to_string(),
});
}
self.registry.register(
self.feature_id.clone(),
descriptor.clone(),
handler,
provider,
)?;
self.installed.push(descriptor);
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use protocol::{
FeatureInvocationStatus, FeatureInvocationSyntax, InvocationArgumentDescriptor,
InvocationArgumentType, InvocationArgumentValue,
};
use std::sync::atomic::{AtomicUsize, Ordering};
fn descriptor(feature: &str, name: &str) -> FeatureInvocationDescriptor {
FeatureInvocationDescriptor {
identity: FeatureInvocationIdentity(format!("{feature}/{name}")),
name: name.into(),
aliases: Vec::new(),
display_name: name.into(),
description: format!("invoke {name}"),
syntax: FeatureInvocationSyntax::Parenthesized,
arguments: vec![InvocationArgumentDescriptor {
name: "value".into(),
position: Some(0),
required: true,
value_type: InvocationArgumentType::String,
completion: InvocationCompletion::Static {
values: vec!["alpha".into(), "beta".into()],
},
description: Some("selected value".into()),
}],
client_adapter: None,
}
}
struct CountingHandler(Arc<AtomicUsize>);
#[async_trait]
impl FeatureInvocationHandler for CountingHandler {
async fn invoke(
&self,
_context: FeatureInvocationContext,
invocation: &FeatureInvocation,
) -> Result<FeatureInvocationResult, FeatureInvocationHandlerError> {
self.0.fetch_add(1, Ordering::SeqCst);
Ok(FeatureInvocationResult {
invocation_id: invocation.invocation_id.clone(),
identity: invocation.identity.clone(),
status: FeatureInvocationStatus::Succeeded,
message: "done".into(),
context: Some("prepared".into()),
})
}
}
fn invocation(descriptor: &FeatureInvocationDescriptor) -> FeatureInvocation {
FeatureInvocation {
invocation_id: "invoke-1".into(),
identity: descriptor.identity.clone(),
name: descriptor.name.clone(),
arguments: vec![InvocationArgumentValue {
name: "value".into(),
value: InvocationValue::String("alpha".into()),
}],
}
}
#[tokio::test]
async fn installed_registry_validates_before_calling_handler_and_returns_context() {
let calls = Arc::new(AtomicUsize::new(0));
let descriptor = descriptor("builtin:test", "run");
let mut registry = FeatureInvocationRegistry::default();
registry
.register(
FeatureId::builtin("test"),
descriptor.clone(),
Arc::new(CountingHandler(calls.clone())),
None,
)
.unwrap();
let result = registry.invoke(&invocation(&descriptor)).await.unwrap();
assert_eq!(result.status, FeatureInvocationStatus::Succeeded);
assert_eq!(result.context.as_deref(), Some("prepared"));
assert_eq!(calls.load(Ordering::SeqCst), 1);
let mut malformed = invocation(&descriptor);
malformed.arguments.clear();
assert!(registry.invoke(&malformed).await.is_err());
assert_eq!(calls.load(Ordering::SeqCst), 1);
}
#[test]
fn registration_rejects_public_name_collisions_across_source_qualified_features() {
let calls = Arc::new(AtomicUsize::new(0));
let mut registry = FeatureInvocationRegistry::default();
registry
.register(
FeatureId::builtin("first"),
descriptor("builtin:first", "run"),
Arc::new(CountingHandler(calls.clone())),
None,
)
.unwrap();
let error = registry
.register(
FeatureId::builtin("second"),
descriptor("builtin:second", "run"),
Arc::new(CountingHandler(calls)),
None,
)
.unwrap_err();
assert!(matches!(
error,
FeatureInstallError::DuplicateInvocationName { .. }
));
}
#[tokio::test]
async fn client_local_adapter_metadata_cannot_authorize_worker_execution() {
let calls = Arc::new(AtomicUsize::new(0));
let descriptor = crate::feature::builtin::chat_invocation::attach_invocation_descriptor();
let mut registry = FeatureInvocationRegistry::default();
registry
.register(
FeatureId::builtin("attachments"),
descriptor.clone(),
Arc::new(CountingHandler(calls.clone())),
None,
)
.unwrap();
let invocation = FeatureInvocation {
invocation_id: "client-path".into(),
identity: descriptor.identity,
name: descriptor.name,
arguments: vec![InvocationArgumentValue {
name: "file".into(),
value: InvocationValue::String("/client/private/file".into()),
}],
};
assert!(registry.invoke(&invocation).await.is_err());
assert_eq!(calls.load(Ordering::SeqCst), 0);
}
struct CountingCompletion(Arc<AtomicUsize>);
#[async_trait]
impl FeatureInvocationCompletionProvider for CountingCompletion {
async fn complete(
&self,
_completion: &InvocationCompletion,
_argument: &str,
_prefix: &str,
) -> Vec<CompletionEntry> {
self.0.fetch_add(1, Ordering::SeqCst);
Vec::new()
}
}
#[tokio::test]
async fn completion_dispatch_uses_installed_argument_metadata_not_provider_presence() {
let calls = Arc::new(AtomicUsize::new(0));
let provider_calls = Arc::new(AtomicUsize::new(0));
let descriptor = descriptor("builtin:test", "run");
let mut registry = FeatureInvocationRegistry::default();
registry
.register(
FeatureId::builtin("test"),
descriptor.clone(),
Arc::new(CountingHandler(calls.clone())),
Some(Arc::new(CountingCompletion(provider_calls.clone()))),
)
.unwrap();
assert_eq!(
registry
.argument_completions(&descriptor.identity, "value", "a")
.await[0]
.value,
"alpha"
);
assert!(
registry
.argument_completions(
&FeatureInvocationIdentity("foreign:feature/run".into()),
"value",
""
)
.await
.is_empty()
);
assert!(
registry
.argument_completions(&descriptor.identity, "credential", "")
.await
.is_empty()
);
assert_eq!(provider_calls.load(Ordering::SeqCst), 0);
assert_eq!(calls.load(Ordering::SeqCst), 0);
}
struct AvailabilityHandler {
available: Arc<std::sync::atomic::AtomicBool>,
calls: Arc<AtomicUsize>,
}
#[async_trait]
impl FeatureInvocationHandler for AvailabilityHandler {
fn is_available(&self) -> bool {
self.available.load(Ordering::SeqCst)
}
async fn invoke(
&self,
context: FeatureInvocationContext,
invocation: &FeatureInvocation,
) -> Result<FeatureInvocationResult, FeatureInvocationHandlerError> {
CountingHandler(self.calls.clone())
.invoke(context, invocation)
.await
}
}
struct TestProvider {
entries: Vec<CompletionEntry>,
revoke: Option<Arc<std::sync::atomic::AtomicBool>>,
}
#[async_trait]
impl FeatureInvocationCompletionProvider for TestProvider {
async fn complete(
&self,
_completion: &InvocationCompletion,
_argument: &str,
_prefix: &str,
) -> Vec<CompletionEntry> {
if let Some(available) = &self.revoke {
available.store(false, Ordering::SeqCst);
}
self.entries.clone()
}
}
#[tokio::test]
async fn provider_output_is_bounded_and_cannot_inject_invocation_metadata() {
let available = Arc::new(std::sync::atomic::AtomicBool::new(true));
let mut descriptor = descriptor("builtin:test", "run");
descriptor.arguments[0].completion = InvocationCompletion::Provider {
provider: "choices".into(),
};
let entries = (0..128)
.map(|index| CompletionEntry {
value: format!("value-{index}"),
is_dir: false,
description: Some("é".repeat(MAX_COMPLETION_DESCRIPTION_BYTES)),
usage: Some("usage".repeat(MAX_COMPLETION_USAGE_BYTES)),
invocation: Some(descriptor.clone()),
})
.chain([CompletionEntry {
value: "x".repeat(MAX_COMPLETION_VALUE_BYTES + 1),
is_dir: false,
description: None,
usage: None,
invocation: None,
}])
.collect();
let mut registry = FeatureInvocationRegistry::default();
registry
.register(
FeatureId::builtin("test"),
descriptor.clone(),
Arc::new(AvailabilityHandler {
available,
calls: Arc::new(AtomicUsize::new(0)),
}),
Some(Arc::new(TestProvider {
entries,
revoke: None,
})),
)
.unwrap();
let candidates = registry
.argument_completions(&descriptor.identity, "value", "")
.await;
assert!(!candidates.is_empty());
assert!(candidates.len() <= MAX_COMPLETION_ENTRIES);
assert!(serde_json::to_vec(&candidates).unwrap().len() <= MAX_COMPLETION_RESPONSE_BYTES);
assert!(candidates.iter().all(|candidate| {
candidate.value.len() <= MAX_COMPLETION_VALUE_BYTES
&& candidate.invocation.is_none()
&& candidate.description.as_ref().unwrap().len() <= MAX_COMPLETION_DESCRIPTION_BYTES
&& candidate.usage.as_ref().unwrap().len() <= MAX_COMPLETION_USAGE_BYTES
}));
let simple = (0..128).map(|index| CompletionEntry {
value: format!("value-{index}"),
is_dir: false,
description: None,
usage: None,
invocation: None,
});
assert_eq!(
bounded_completion_entries(simple).len(),
MAX_COMPLETION_ENTRIES
);
assert!(
bounded_completion_entries([CompletionEntry {
value: "x".repeat(MAX_COMPLETION_VALUE_BYTES + 1),
is_dir: false,
description: None,
usage: None,
invocation: None,
}])
.is_empty()
);
}
#[tokio::test]
async fn provider_revocation_during_completion_discards_its_output() {
let available = Arc::new(std::sync::atomic::AtomicBool::new(true));
let calls = Arc::new(AtomicUsize::new(0));
let mut descriptor = descriptor("builtin:test", "run");
descriptor.arguments[0].completion = InvocationCompletion::Provider {
provider: "choices".into(),
};
let mut registry = FeatureInvocationRegistry::default();
registry
.register(
FeatureId::builtin("test"),
descriptor.clone(),
Arc::new(AvailabilityHandler {
available: available.clone(),
calls: calls.clone(),
}),
Some(Arc::new(TestProvider {
entries: vec![CompletionEntry {
value: "private".into(),
is_dir: false,
description: None,
usage: None,
invocation: None,
}],
revoke: Some(available),
})),
)
.unwrap();
assert!(
registry
.argument_completions(&descriptor.identity, "value", "")
.await
.is_empty()
);
assert!(registry.descriptors().is_empty());
assert!(registry.invoke(&invocation(&descriptor)).await.is_err());
assert_eq!(calls.load(Ordering::SeqCst), 0);
}
#[tokio::test]
async fn enum_and_boolean_completions_derive_from_type_schema_when_no_provider_declared() {
let calls = Arc::new(AtomicUsize::new(0));
for (value_type, prefix, expected) in [
(InvocationArgumentType::Boolean, "f", "false"),
(
InvocationArgumentType::Enum {
values: vec!["read".into(), "write".into()],
},
"w",
"write",
),
] {
let mut descriptor = descriptor("builtin:test", "run");
descriptor.arguments[0].value_type = value_type;
descriptor.arguments[0].completion = InvocationCompletion::None;
let mut registry = FeatureInvocationRegistry::default();
registry
.register(
FeatureId::builtin("test"),
descriptor.clone(),
Arc::new(CountingHandler(calls.clone())),
None,
)
.unwrap();
let candidates = registry
.argument_completions(&descriptor.identity, "value", prefix)
.await;
assert_eq!(candidates.len(), 1);
assert_eq!(candidates[0].value, expected);
}
assert_eq!(calls.load(Ordering::SeqCst), 0);
}
#[test]
fn oversized_installed_invocation_metadata_is_rejected() {
let mut descriptor = descriptor("builtin:test", "run");
descriptor.description = "x".repeat(MAX_INVOCATION_DESCRIPTOR_BYTES);
let mut registry = FeatureInvocationRegistry::default();
assert!(
registry
.register(
FeatureId::builtin("test"),
descriptor,
Arc::new(CountingHandler(Arc::new(AtomicUsize::new(0)))),
None,
)
.is_err()
);
assert!(registry.descriptors().is_empty());
}
#[tokio::test]
async fn metadata_and_argument_completion_are_side_effect_free() {
let calls = Arc::new(AtomicUsize::new(0));
let descriptor = descriptor("builtin:test", "run");
let mut registry = FeatureInvocationRegistry::default();
registry
.register(
FeatureId::builtin("test"),
descriptor.clone(),
Arc::new(CountingHandler(calls.clone())),
None,
)
.unwrap();
assert_eq!(registry.feature_completions("ru")[0].value, "run");
assert_eq!(
registry
.argument_completions(&descriptor.identity, "value", "a")
.await[0]
.value,
"alpha"
);
assert_eq!(calls.load(Ordering::SeqCst), 0);
}
}
+20 -2
View File
@@ -399,8 +399,23 @@ impl Interceptor<SessionHistoryMetadata> for WorkerInterceptor {
.lock()
.expect("pending_attachments poisoned"),
);
// Preparations can commit before Engine receives the user prompt. The
// annotation queue alone does not materialize their model-visible items:
// return them through the normal prompt lifecycle, without recommitting.
// The annotator consumes their original IDs/provenance as Engine appends.
let mut prepared_items = self
.pending_committed_history
.lock()
.expect("pending committed history poisoned")
.iter()
.map(|entry| entry.item.clone())
.collect::<Vec<_>>();
Ok(if extras.is_empty() {
PromptAction::Continue
if prepared_items.is_empty() {
PromptAction::Continue
} else {
PromptAction::ContinueWith(prepared_items)
}
} else {
// Commit the typed system items first, then hand the
// matching `Item::system_message`s to the worker. Sync
@@ -410,7 +425,10 @@ impl Interceptor<SessionHistoryMetadata> for WorkerInterceptor {
self.attach_prompt_provenance(&mut extras);
let items: Vec<Item> = extras.iter().map(SystemItem::to_history_item).collect();
match self.commit_system_items(&extras) {
Ok(()) => PromptAction::ContinueWith(items),
Ok(()) => {
prepared_items.extend(items);
PromptAction::ContinueWith(prepared_items)
}
Err(error) => PromptAction::Cancel(format!("session persistence failed: {error}")),
}
})
+16 -3
View File
@@ -121,9 +121,22 @@ pub async fn dispatch_worker_protocol_method(
method: Method,
) -> Option<Event> {
match method {
Method::ListCompletions { kind, prefix } => {
let entries = handle.completion_entries(kind, &prefix).await;
Some(Event::Completions { kind, entries })
Method::ListCompletions {
request_id,
kind,
prefix,
context,
} => {
let entries = handle
.completion_entries(kind, &prefix, context.as_ref())
.await;
Some(Event::Completions {
request_id,
kind,
prefix,
context,
entries,
})
}
method => {
let _ = handle.send(method).await;
+16
View File
@@ -48,6 +48,7 @@ pub struct WorkerSharedState {
/// `ListCompletions` queries without going through the controller. It is
/// unset only in unit tests that construct `WorkerSharedState` directly.
fs_view: OnceLock<WorkerFsView>,
feature_invocations: RwLock<crate::feature::FeatureInvocationRegistry>,
flow_transition_enabled: AtomicBool,
}
@@ -66,6 +67,7 @@ impl WorkerSharedState {
state: RwLock::new(WorkerStateSnapshot::initial()),
accepted_commands: RwLock::new(VecDeque::new()),
fs_view: OnceLock::new(),
feature_invocations: RwLock::new(crate::feature::FeatureInvocationRegistry::default()),
flow_transition_enabled: AtomicBool::new(false),
}
}
@@ -82,6 +84,20 @@ impl WorkerSharedState {
self.fs_view.get()
}
pub fn feature_invocations(&self) -> crate::feature::FeatureInvocationRegistry {
self.feature_invocations
.read()
.expect("feature invocation registry lock poisoned")
.clone()
}
pub fn set_feature_invocations(&self, registry: crate::feature::FeatureInvocationRegistry) {
*self
.feature_invocations
.write()
.expect("feature invocation registry lock poisoned") = registry;
}
pub fn enable_flow_transition(&self) {
self.flow_transition_enabled.store(true, Ordering::Release);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,581 @@
//! Installed host-feature execution, not lexical slash-command dispatch.
use std::pin::Pin;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use agen::Engine;
use agen::llm_client::event::{Event as LlmEvent, ResponseStatus, StatusEvent};
use agen::llm_client::{ClientError, LlmClient, Request};
use async_trait::async_trait;
use futures::Stream;
use protocol::{
FeatureInvocation, FeatureInvocationDescriptor, FeatureInvocationIdentity,
FeatureInvocationResult, FeatureInvocationStatus, FeatureInvocationSyntax,
InvocationArgumentDescriptor, InvocationArgumentType, InvocationArgumentValue,
InvocationCompletion, InvocationValue, Segment,
};
use session_store::{CombinedStore, FsStore, FsWorkerStore, LogEntry, SystemItem};
use worker::feature::{
FeatureDescriptor, FeatureInstallContext, FeatureInstallError, FeatureInvocationContext,
FeatureInvocationHandler, FeatureInvocationHandlerError, FeatureModule, FeatureRegistryBuilder,
};
use worker::{Worker, WorkerFilesystemAuthority, WorkerManifest, WorkerWorkspaceContext};
#[derive(Clone)]
struct Trace {
calls: Arc<Mutex<Vec<String>>>,
requests: Arc<Mutex<Vec<Request>>>,
available: Arc<AtomicBool>,
}
impl Default for Trace {
fn default() -> Self {
Self {
calls: Arc::default(),
requests: Arc::default(),
available: Arc::new(AtomicBool::new(true)),
}
}
}
#[async_trait]
impl LlmClient for Trace {
fn clone_boxed(&self) -> Box<dyn LlmClient> {
Box::new(self.clone())
}
async fn stream(
&self,
request: Request,
) -> Result<Pin<Box<dyn Stream<Item = Result<LlmEvent, ClientError>> + Send>>, ClientError>
{
self.calls.lock().unwrap().push("llm".into());
self.requests.lock().unwrap().push(request);
Ok(Box::pin(futures::stream::iter(vec![
Ok(LlmEvent::text_block_start(0)),
Ok(LlmEvent::text_delta(0, "done")),
Ok(LlmEvent::text_block_stop(0, None)),
Ok(LlmEvent::Status(StatusEvent {
status: ResponseStatus::Completed,
})),
])))
}
}
fn descriptor() -> FeatureInvocationDescriptor {
FeatureInvocationDescriptor {
identity: FeatureInvocationIdentity("builtin:fake/prepare".into()),
name: "prepare".into(),
aliases: vec!["prep".into()],
display_name: "Prepare".into(),
description: "Prepare test context".into(),
syntax: FeatureInvocationSyntax::Parenthesized,
arguments: vec![InvocationArgumentDescriptor {
name: "value".into(),
position: Some(0),
required: true,
value_type: InvocationArgumentType::String,
completion: InvocationCompletion::None,
description: None,
}],
client_adapter: None,
}
}
#[derive(Clone)]
struct FakeFeature(Trace);
impl FeatureModule for FakeFeature {
fn descriptor(&self) -> FeatureDescriptor {
FeatureDescriptor::builtin("fake", "Fake").with_chat_invocation(descriptor())
}
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
context
.chat_invocations()
.register(descriptor(), self.clone())
}
}
#[async_trait]
impl FeatureInvocationHandler for FakeFeature {
fn is_available(&self) -> bool {
self.0.available.load(Ordering::SeqCst)
}
fn validate(
&self,
invocation: &FeatureInvocation,
) -> Result<(), FeatureInvocationHandlerError> {
if invocation.arguments[0].value == InvocationValue::String("denied".into()) {
return Err(FeatureInvocationHandlerError::failed(
"host permission denied",
));
}
Ok(())
}
async fn invoke(
&self,
context: FeatureInvocationContext,
invocation: &FeatureInvocation,
) -> Result<FeatureInvocationResult, FeatureInvocationHandlerError> {
assert_eq!(context.invocation_id, invocation.invocation_id);
let InvocationValue::String(value) = &invocation.arguments[0].value else {
panic!("validated string")
};
self.0.calls.lock().unwrap().push(value.clone());
match value.as_str() {
"fail" => {
return Err(FeatureInvocationHandlerError::failed(
"test preparation failed",
));
}
"unknown" => {
return Err(FeatureInvocationHandlerError::outcome_unknown(
"test effect uncertain",
));
}
"revoke-during-effect" => self.0.available.store(false, Ordering::SeqCst),
"large-error" => return Err(FeatureInvocationHandlerError::failed("x".repeat(4097))),
_ => {}
}
Ok(FeatureInvocationResult {
invocation_id: if value == "wrong" {
"wrong-id".into()
} else {
invocation.invocation_id.clone()
},
identity: invocation.identity.clone(),
status: FeatureInvocationStatus::Succeeded,
message: match value.as_str() {
"large-message" => "é".repeat(2049),
"limit-message" => "x".repeat(4096),
_ => "prepared".into(),
},
context: Some(match value.as_str() {
"large-context" => "é".repeat(8193),
"limit-context" => "x".repeat(16384),
_ => format!("trusted-preparation-{value}"),
}),
})
}
}
fn invoke(id: &str, value: &str) -> Segment {
Segment::FeatureInvoke {
invocation: FeatureInvocation {
invocation_id: id.into(),
identity: descriptor().identity,
name: "prepare".into(),
arguments: vec![InvocationArgumentValue {
name: "value".into(),
value: InvocationValue::String(value.into()),
}],
},
}
}
type TestStore = CombinedStore<FsStore, FsWorkerStore>;
async fn fixture(trace: Trace) -> (tempfile::TempDir, Worker<Trace, TestStore>) {
let dir = tempfile::tempdir().unwrap();
let manifest = WorkerManifest::from_toml(
r#"
[worker]
name = "invocation-test"
[model]
scheme = "anthropic"
model_id = "test-model"
[engine]
max_tokens = 100
[scope]
allow = []
"#,
)
.unwrap();
let store = CombinedStore::new(
FsStore::new(dir.path().join("sessions")).unwrap(),
FsWorkerStore::new(dir.path().join("workers")).unwrap(),
);
let mut worker = Worker::new(
manifest,
Engine::<_, agen::state::Mutable, worker::SessionHistoryMetadata>::new_annotated(
trace.clone(),
),
store,
WorkerWorkspaceContext::no_workspace(),
WorkerFilesystemAuthority::None,
manifest::Scope::empty(),
)
.await
.unwrap();
let report =
worker.install_features(FeatureRegistryBuilder::new().with_module(FakeFeature(trace)));
assert!(!report.has_errors(), "{report:?}");
(dir, worker)
}
fn results(worker: &Worker<Trace, TestStore>) -> Vec<FeatureInvocationResult> {
worker
.sink()
.subscribe_with_snapshot()
.0
.into_iter()
.filter_map(|entry| match entry {
LogEntry::AnnotatedSystemItem { entry, .. } => match entry.item {
SystemItem::FeatureInvocationResult { result, .. } => Some(result),
_ => None,
},
_ => None,
})
.collect()
}
#[derive(Clone)]
struct TracePromptHook {
trace: Trace,
cancel: bool,
}
#[async_trait]
impl worker::hook::Hook<worker::hook::OnPromptSubmit> for TracePromptHook {
async fn call(
&self,
_input: &worker::hook::PromptSubmitInfo,
) -> Result<worker::hook::HookPromptAction, worker::hook::HookError> {
self.trace.calls.lock().unwrap().push("prompt-hook".into());
Ok(if self.cancel {
worker::hook::HookPromptAction::Cancel("test cancellation".into())
} else {
worker::hook::HookPromptAction::Continue
})
}
}
#[tokio::test]
async fn installed_feature_invocations_execute_in_order_before_model_and_context_is_visible() {
let trace = Trace::default();
let (_dir, mut worker) = fixture(trace.clone()).await;
worker.add_on_prompt_submit_hook(TracePromptHook {
trace: trace.clone(),
cancel: false,
});
worker
.run(vec![
invoke("one", "first"),
Segment::text("explain"),
invoke("two", "second"),
])
.await
.unwrap();
assert_eq!(
*trace.calls.lock().unwrap(),
["first", "second", "prompt-hook", "llm"]
);
let requests = trace.requests.lock().unwrap();
let request = format!("{:?}", requests[0]);
assert!(request.contains("trusted-preparation-first"));
assert!(request.contains("trusted-preparation-second"));
assert!(request.find("trusted-preparation-first") < request.find("trusted-preparation-second"));
assert_eq!(results(&worker).len(), 2);
// Preparation results must be model-visible in the same order as replay.
let history = worker.history();
let first = history
.iter()
.position(|item| {
item.as_text()
.is_some_and(|text| text.contains("trusted-preparation-first"))
})
.unwrap();
let second = history
.iter()
.position(|item| {
item.as_text()
.is_some_and(|text| text.contains("trusted-preparation-second"))
})
.unwrap();
assert!(first < second);
}
#[tokio::test]
async fn feature_failure_fences_later_invocations_model_resume_and_notifications() {
for value in ["fail", "unknown", "wrong"] {
let trace = Trace::default();
let (_dir, mut worker) = fixture(trace.clone()).await;
assert!(
worker
.run(vec![
invoke("one", "first"),
invoke("two", value),
invoke("three", "never")
])
.await
.is_err()
);
assert_eq!(*trace.calls.lock().unwrap(), ["first", value]);
let results = results(&worker);
assert_eq!(results.len(), 3);
assert_eq!(results[0].status, FeatureInvocationStatus::Succeeded);
assert_eq!(
results[1].status,
if value == "fail" {
FeatureInvocationStatus::Failed
} else {
FeatureInvocationStatus::OutcomeUnknown
}
);
assert_eq!(results[1].invocation_id, "two");
assert!(results[1].context.is_none());
assert_eq!(results[2].status, FeatureInvocationStatus::Failed);
assert!(worker.resume().await.is_err());
assert!(
worker
.run_for_notification(protocol::InvokeKind::Notify)
.await
.is_err()
);
assert!(trace.requests.lock().unwrap().is_empty());
}
}
#[tokio::test]
async fn feature_replay_reuses_result_and_rejects_id_payload_changes() {
let trace = Trace::default();
let (_dir, mut worker) = fixture(trace.clone()).await;
worker.run(vec![invoke("same", "first")]).await.unwrap();
worker.run(vec![invoke("same", "first")]).await.unwrap();
assert_eq!(*trace.calls.lock().unwrap(), ["first", "llm", "llm"]);
assert!(worker.run(vec![invoke("same", "different")]).await.is_err());
assert_eq!(*trace.calls.lock().unwrap(), ["first", "llm", "llm"]);
assert_eq!(
results(&worker).last().unwrap().status,
FeatureInvocationStatus::Failed
);
}
#[tokio::test]
async fn feature_success_replay_does_not_resurrect_disabled_installed_capability() {
let trace = Trace::default();
let (_dir, mut worker) = fixture(trace.clone()).await;
worker.run(vec![invoke("same", "first")]).await.unwrap();
assert!(
!worker
.install_features(FeatureRegistryBuilder::new())
.has_errors()
);
assert!(
worker
.feature_invocations()
.feature_completions("")
.is_empty()
);
assert!(worker.run(vec![invoke("same", "first")]).await.is_err());
assert_eq!(*trace.calls.lock().unwrap(), ["first", "llm"]);
assert_eq!(
results(&worker).last().unwrap().status,
FeatureInvocationStatus::Failed
);
}
#[tokio::test]
async fn feature_invocation_text_is_not_executable_and_duplicate_ids_are_rejected_before_effects() {
let trace = Trace::default();
let (_dir, mut worker) = fixture(trace.clone()).await;
worker
.run(vec![Segment::text("/prepare(first) quoted /prep(fail)")])
.await
.unwrap();
assert_eq!(*trace.calls.lock().unwrap(), ["llm"]);
assert!(results(&worker).is_empty());
assert!(
worker
.run(vec![invoke("same", "first"), invoke("same", "second")])
.await
.is_err()
);
assert_eq!(*trace.calls.lock().unwrap(), ["llm"]);
}
#[tokio::test]
async fn feature_handler_validation_rejects_semantics_and_forged_authority_before_effects() {
let trace = Trace::default();
let (_dir, mut worker) = fixture(trace.clone()).await;
assert!(worker.run(vec![invoke("denied", "denied")]).await.is_err());
let mut forged = invoke("forged", "first");
if let Segment::FeatureInvoke { invocation } = &mut forged {
invocation.arguments.push(InvocationArgumentValue {
name: "workspace_id".into(),
value: InvocationValue::String("foreign".into()),
});
}
assert!(worker.run(vec![forged]).await.is_err());
assert!(trace.calls.lock().unwrap().is_empty());
assert_eq!(results(&worker).len(), 2);
assert!(
results(&worker)
.iter()
.all(|result| result.status == FeatureInvocationStatus::Failed)
);
}
#[tokio::test]
async fn feature_prompt_hook_cancellation_does_not_rollback_durable_business_preparation() {
let trace = Trace::default();
let (_dir, mut worker) = fixture(trace.clone()).await;
worker.add_on_prompt_submit_hook(TracePromptHook {
trace: trace.clone(),
cancel: true,
});
let _ = worker.run(vec![invoke("one", "first")]).await;
assert_eq!(*trace.calls.lock().unwrap(), ["first", "prompt-hook"]);
assert_eq!(results(&worker).len(), 1);
assert!(worker.history().iter().any(|item| {
item.as_text()
.is_some_and(|text| text.contains("trusted-preparation-first"))
}));
let _ = worker.run(vec![invoke("one", "first")]).await;
assert_eq!(
*trace.calls.lock().unwrap(),
["first", "prompt-hook", "prompt-hook"]
);
}
#[tokio::test]
async fn feature_unknown_input_rejected_before_effects_commit_or_model() {
let trace = Trace::default();
let (_dir, mut worker) = fixture(trace.clone()).await;
let unknown: Segment = serde_json::from_value(serde_json::json!({
"kind": "future_side_effect", "args": { "target": "important" }
}))
.unwrap();
assert!(matches!(unknown, Segment::Unknown));
let before = worker.sink().subscribe_with_snapshot().0.len();
assert!(
worker
.run(vec![
invoke("one", "first"),
unknown,
Segment::text("explain")
])
.await
.is_err()
);
assert_eq!(worker.sink().subscribe_with_snapshot().0.len(), before);
assert!(trace.calls.lock().unwrap().is_empty());
}
#[tokio::test]
async fn feature_oversized_results_become_bounded_unknown_and_do_not_repeat_effects() {
for value in ["large-message", "large-context", "large-error"] {
let trace = Trace::default();
let (_dir, mut worker) = fixture(trace.clone()).await;
assert!(
worker
.run(vec![invoke("large", value), invoke("later", "never")])
.await
.is_err()
);
assert_eq!(*trace.calls.lock().unwrap(), [value]);
for result in results(&worker) {
assert!(result.message.len() <= 4096);
assert!(result.context.is_none());
}
assert_eq!(
results(&worker)[0].status,
FeatureInvocationStatus::OutcomeUnknown
);
assert!(worker.run(vec![invoke("large", value)]).await.is_err());
assert!(worker.resume().await.is_err());
assert_eq!(*trace.calls.lock().unwrap(), [value]);
}
for value in ["limit-message", "limit-context"] {
let trace = Trace::default();
let (_dir, mut worker) = fixture(trace.clone()).await;
worker.run(vec![invoke("limit", value)]).await.unwrap();
worker.run(vec![invoke("limit", value)]).await.unwrap();
assert_eq!(*trace.calls.lock().unwrap(), [value, "llm", "llm"]);
}
}
#[tokio::test]
async fn feature_dynamic_permission_revocation_hides_discovery_and_fences_execution_and_replay() {
let trace = Trace::default();
let (_dir, mut worker) = fixture(trace.clone()).await;
let registry = worker.feature_invocations();
assert_eq!(registry.descriptors().len(), 1);
worker.run(vec![invoke("same", "first")]).await.unwrap();
trace.available.store(false, Ordering::SeqCst);
assert!(registry.descriptors().is_empty());
assert!(registry.descriptor(&descriptor().identity).is_none());
assert!(registry.feature_completions("").is_empty());
assert!(
registry
.argument_name_completions(&descriptor().identity, "")
.is_empty()
);
assert!(
registry
.argument_completions(&descriptor().identity, "value", "")
.await
.is_empty()
);
assert!(worker.run(vec![invoke("same", "first")]).await.is_err());
assert!(worker.run(vec![invoke("new", "second")]).await.is_err());
assert!(worker.resume().await.is_err());
assert_eq!(*trace.calls.lock().unwrap(), ["first", "llm"]);
trace.available.store(true, Ordering::SeqCst);
assert_eq!(registry.descriptors().len(), 1);
worker.run(vec![invoke("same", "first")]).await.unwrap();
assert_eq!(*trace.calls.lock().unwrap(), ["first", "llm", "llm"]);
}
#[tokio::test]
async fn feature_revocation_during_effect_withholds_preparation_without_retrying_committed_effect()
{
let trace = Trace::default();
let (_dir, mut worker) = fixture(trace.clone()).await;
assert!(
worker
.run(vec![invoke("same", "revoke-during-effect")])
.await
.is_err()
);
assert_eq!(*trace.calls.lock().unwrap(), ["revoke-during-effect"]);
let result = &results(&worker)[0];
assert_eq!(result.status, FeatureInvocationStatus::Failed);
assert!(result.context.is_none());
assert!(result.message.contains("reported success"));
trace.available.store(true, Ordering::SeqCst);
assert!(
worker
.run(vec![invoke("same", "revoke-during-effect")])
.await
.is_err()
);
assert_eq!(*trace.calls.lock().unwrap(), ["revoke-during-effect"]);
}
#[test]
fn notify_rejects_typed_side_effect_message_payloads() {
let methods = [
protocol::Method::Notify {
notification_request_id: "n-1".into(),
message: "/prepare(first)".into(),
},
protocol::Method::NotifyTracked {
notification_request_id: "n-1".into(),
message: "/prepare(first)".into(),
source: protocol::AuthenticatedInputSource::UntrustedWire,
},
];
for method in methods {
// Notify accepts an advisory string, not typed input or invocation
// objects. Ordinary text spelling an invocation remains advisory text.
let mut request = serde_json::to_value(method).unwrap();
assert!(serde_json::from_value::<protocol::Method>(request.clone()).is_ok());
let mut with_input = request.clone();
with_input["params"]["input"] = serde_json::json!([invoke("one", "first")]);
assert!(
serde_json::from_value::<protocol::Method>(with_input).is_err(),
"Notify must reject typed input rather than silently discard it"
);
request["params"]["message"] = serde_json::json!([invoke("one", "first")]);
assert!(serde_json::from_value::<protocol::Method>(request).is_err());
}
}
+48 -4
View File
@@ -735,6 +735,8 @@ pub struct WorkerCompletionsRequest {
pub kind: protocol::CompletionKind,
#[serde(default)]
pub prefix: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context: Option<protocol::CompletionContext>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@@ -3333,10 +3335,12 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
)],
};
};
match self
.runtime
.worker_completions(&worker_ref, request.kind, &request.prefix)
{
match self.runtime.worker_completions(
&worker_ref,
request.kind,
&request.prefix,
request.context.as_ref(),
) {
Ok(entries) => WorkerCompletionsResult {
worker: RuntimeWorkerRef::new(self.runtime_id.clone(), worker_id.to_string()),
kind: request.kind,
@@ -5415,6 +5419,7 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
let request = runtime_api::CompletionRequest {
kind: request.kind,
prefix: request.prefix,
context: request.context,
};
let failure_kind = request.kind;
let failure_prefix = request.prefix.clone();
@@ -6711,6 +6716,45 @@ mod tests {
}
}
#[test]
fn feature_invocation_and_completion_context_survive_runtime_contract() {
let segments = vec![
protocol::Segment::FeatureInvoke {
invocation: protocol::FeatureInvocation {
invocation_id: "stable-request".into(),
identity: protocol::FeatureInvocationIdentity("builtin:test/prepare".into()),
name: "prepare".into(),
arguments: vec![protocol::InvocationArgumentValue {
name: "path".into(),
value: protocol::InvocationValue::String("資料/a b".into()),
}],
},
},
protocol::Segment::text(" subsequent prose"),
];
let input = EmbeddedWorkerInput {
kind: EmbeddedWorkerInputKind::User,
content: protocol::Segment::flatten_to_text(&segments),
submission_request_id: Some("submit-request".into()),
segments: Some(segments),
};
let wire: runtime_api::WorkerInput = runtime_contract_convert(input.clone()).unwrap();
let restored: EmbeddedWorkerInput = runtime_contract_convert(wire).unwrap();
assert_eq!(restored, input);
let request = WorkerCompletionsRequest {
kind: protocol::CompletionKind::FeatureArgument,
prefix: "資料/".into(),
context: Some(protocol::CompletionContext {
invocation: protocol::FeatureInvocationIdentity("builtin:test/prepare".into()),
argument: Some("path".into()),
}),
};
let wire: runtime_api::CompletionRequest =
runtime_contract_convert(request.clone()).unwrap();
let restored: WorkerCompletionsRequest = runtime_contract_convert(wire).unwrap();
assert_eq!(restored, request);
}
#[test]
fn remote_profile_source_uses_workspace_config_archive_reference() {
let request = embedded_spawn_request();
+9 -25
View File
@@ -6304,23 +6304,11 @@ fn worker_input_request_from_api(
)));
}
};
let segments = request
.segments
.map(|segments| {
segments
.into_iter()
.map(|segment| {
serde_json::from_value::<Segment>(segment)
.map_err(|error| invalid_contract_value("Worker input segment", error))
})
.collect::<Result<Vec<_>>>()
})
.transpose()?;
Ok(WorkerInputRequest {
kind,
content: request.content,
submission_request_id: None,
segments,
segments: request.segments,
})
}
@@ -6339,9 +6327,9 @@ fn worker_completions_request_from_api(
request: server_api::RuntimeWorkerCompletionsRequest,
) -> Result<WorkerCompletionsRequest> {
Ok(WorkerCompletionsRequest {
kind: serde_json::from_value(request.kind)
.map_err(|error| invalid_contract_value("Worker completion kind", error))?,
kind: request.kind,
prefix: request.prefix,
context: request.context,
})
}
@@ -6437,17 +6425,9 @@ fn worker_completions_result_to_api(
Ok(server_api::RuntimeWorkerCompletionsResult {
runtime_id: result.worker.runtime_id,
worker_id: result.worker.worker_id,
kind: serde_json::to_value(result.kind)
.map_err(|error| invalid_contract_value("Worker completion kind", error))?,
kind: result.kind,
prefix: result.prefix,
entries: result
.entries
.into_iter()
.map(|entry| {
serde_json::to_value(entry)
.map_err(|error| invalid_contract_value("Worker completion entry", error))
})
.collect::<Result<Vec<_>>>()?,
entries: result.entries,
diagnostics: result.diagnostics.into_iter().map(Into::into).collect(),
})
}
@@ -54822,6 +54802,8 @@ mod tests {
serde_json::to_string(&protocol::Method::ListCompletions {
kind: protocol::CompletionKind::File,
prefix: String::new(),
request_id: None,
context: None,
})
.unwrap()
.into(),
@@ -55301,6 +55283,8 @@ mod tests {
method: protocol::Method::ListCompletions {
kind: protocol::CompletionKind::File,
prefix: String::new(),
request_id: None,
context: None,
},
},
),
+1
View File
@@ -35,6 +35,7 @@ It is not a dumping ground for external research, old plans, API inventories, or
27. [`development/subjektiv-product-cutover.md`](development/subjektiv-product-cutover.md) — legacy Workspace Memoryからsubjektivへのoperator cutover、explicit reset、backup/rollback runbook。
28. [`design/worker-wip-mode.md`](design/worker-wip-mode.md) — explicit-opt-in Worker WIP mode, Worldspace projection, authority, cache, and retry contracts.
29. [`design/objective-wip-projection.md`](design/objective-wip-projection.md) — Host-assigned Objective collection/item routes, operation mapping, Backend authority, bounds, and compatibility suppression.
30. [`design/feature-chat-invocations.md`](design/feature-chat-invocations.md) — declarative `/` invocation identity, grammar, completion, typed draft, attachment staging, execution, and authority boundaries.
## What belongs here
+90
View File
@@ -0,0 +1,90 @@
# Declarative Feature chat invocations
Feature chat invocations are explicit structured user input. They let an enabled Feature contribute discoverable chat actions without teaching each client Feature-specific syntax or asking the LLM to infer an operation from text.
## Namespaces and identity
Composer sigils have separate meanings:
- `:` is a client control command for the whole draft.
- `@` is a Worker-readable file reference.
- `/` selects a Feature invocation and may occur among ordinary prose.
- `#` has no invocation meaning.
A public name such as `attach` is presentation, not authority. The payload carries the installed source-qualified `FeatureInvocationIdentity` (for example `builtin:attachments/attach`). Installation rejects duplicate public names and aliases, so clients never choose an arbitrary winner. Disabled, failed-to-install, or unavailable Features are absent from completion and cannot be resolved at execution.
## Declarative grammar
The common spelling is:
```text
/name(positional, named=value) following natural language
```
The closing `)` is the explicit argument/prose boundary. String values use JSON double quoting and escaping (including Unicode escapes and escaped control characters). Bare values end at whitespace, comma, or `)`; comma separates arguments and a trailing comma is accepted. Integers are decimal signed 32-bit values (`-?[0-9]+`); booleans are `true` or `false`. Positional indices are contiguous; every argument can also be represented by its stable name in restored chip text. Mixed named/positional input advances the positional cursor only for positional values, and assigning the same argument twice is an error.
A descriptor is declarative data, for example:
```json
{
"identity": "builtin:prepare/prepare",
"name": "prepare",
"aliases": [],
"display_name": "Prepare context",
"description": "Prepare scoped context before processing the request",
"syntax": "parenthesized",
"arguments": [
{"name": "path", "position": 0, "required": true, "value_type": {"kind": "worker_file"}, "completion": {"kind": "worker_file"}},
{"name": "mode", "required": false, "value_type": {"kind": "enum", "values": ["safe", "fast"]}, "completion": {"kind": "static", "values": ["safe", "fast"]}}
]
}
```
`/prepare("資料/a b", mode=safe) explain the result` produces a typed invocation and a separate following Text segment only when explicitly confirmed. Missing required arguments, unknown or duplicate arguments, invalid enum/primitive values, and incomplete quotations remain validation errors, not partial business execution.
Descriptors declare requiredness, value type, positional index, completion strategy, and optional description. Completion strategies are static values, Worker files, client files, or a host-owned provider. Metadata and completion are side-effect free. Arbitrary parser or provider code never crosses into a client.
Clients may parse this spelling only for input assistance. A call becomes executable only after the user selects a descriptor and the client emits `Segment::FeatureInvoke`. Free text, URLs, paths, pasted content, attachments, assistant output, and tool output are not scanned or promoted by the Host.
## Composer and completion lifecycle
TUI and Web composers retain selected invocations as atomic typed chips containing identity and structured arguments. Backspace/Delete and range deletion remove an unsent chip; removal is not an undo for an already executed operation. Draft history, transport retry, queueing, Session history, snapshot/live projections, and rewind restoration preserve the typed segment rather than flattening and reparsing it.
Completion requests carry a kind, fresh `request_id`, and source-qualified argument context. Host and Runtime echo the request ID unchanged. Clients correlate replies with that nonce plus their current draft revision, cursor, target Worker, authority snapshot, and connection generation; even identical-prefix/context ABA replies are discarded. Old uncorrelated replies cannot establish a selected invocation. Client-file arguments use a declared `InvocationClientAdapter`; they do not ask the Worker to resolve a client-local path.
## Attachment adapter
`/attach` is the built-in real use of the generic contract. Its descriptor declares a required client-file argument and the attachment client adapter.
- TUI may select a path visible to the TUI process, stage an upload, and replace the invocation intent with an `UploadedFile` chip.
- Web opens the existing browser picker; it does not enumerate the browser host filesystem.
- The local path is never sent to or resolved by the Worker.
- Uploading is cancellable draft staging, not invocation execution. Removing an unsent uploaded-file chip cancels or deletes staged state, and a late upload completion must not recreate a removed chip.
- Authoritative `SubmissionAccepted` (not socket-send success) transfers uploaded references to the durable input lifecycle. Draft removal cannot delete an in-flight reference whose admission may already have succeeded, or an accepted attachment. Rejection restores the exact typed intent for retry; retry retains request/invocation IDs. Upload completion alone is not submission acceptance.
- TUI standalone currently has no client-local upload staging transport. It reports the unsupported adapter and preserves the draft; it does not reinterpret a local path as a Worker host path.
There is no `/clear-attachments` command. Normal composer editing is the removal mechanism.
## Installation and execution
A Feature declares invocation descriptors using `FeatureDescriptor::with_chat_invocation` and registers matching handlers with `context.chat_invocations().register` (or `register_with_completion`) during `FeatureModule::install`. Registration must match the declared descriptor and the installing Feature's source-qualified identity. The invocation registry is checkpointed and rolled back with other Feature contributions. Only successfully installed, currently available descriptors are exposed through completion.
Authority-sensitive handlers override the side-effect-free `is_available()` discovery hook and `validate()` semantic hook. They must still revalidate authorization and use scoped idempotency at their business commit boundary inside `invoke()`; defaults grant no extra domain authority. Availability is checked again after asynchronous provider/handler work and before reusing a successful preparation result. Completion strategies come from installed metadata, never from a client-selected provider identifier. Enum and boolean candidates can derive directly from value types when no explicit completion strategy is supplied.
Completion output is bounded to 64 entries / 64 KiB encoded data, with per-field byte limits. Oversized candidate values are discarded instead of altered. Handler results allow 4 KiB messages and 16 KiB context; oversized results/errors after execution become `OutcomeUnknown`, withholding dependent model context rather than assuming the operation was unexecuted.
The Worker validates every structured payload again against the installed descriptor. It executes invocations in segment order only when the durable submission is activated, after committing the user input and before the dependent LLM request. A completion choice, draft edit, queued-but-cancelled submission, reconnect, history view, or restored snapshot never executes a handler.
A submission may contain at most eight invocations with distinct invocation IDs. The first failed or outcome-unknown result fences later invocations **and prevents the dependent LLM run**; no cross-invocation rollback is promised. Every result is committed as typed Session history and rendered as bounded model-visible context. `OutcomeUnknown` is distinct from failure and is never automatically retried.
Before calling a handler, the Worker appends a start receipt through the existing `worker.pending_activations.v1` extension checkpoint. The result and updated receipt are appended together with the annotated system item. An unresolved start (including handler completion followed by a failed result append) remains a fence across same-process retry, restart, Pause/Resume, compaction, and rewind. Reusing an ID with changed arguments is rejected; exact replay can reuse its recorded result but does not invoke again. Receipt capacity is bounded and refuses untracked execution rather than evicting recovery evidence. Restoring or displaying history never reexecutes a handler.
The recovery receipt retains the invocation ID, qualified identity, SHA256 typed-payload digest, and bounded result—not another copy of every argument. Invocation IDs are nonempty and at most 128 UTF-8 bytes; clients generate fresh UUIDs on explicit selection and preserve them through transport retry. The session retains at most 1,024 recovery receipts without eviction. Corrupt, duplicate, inconsistent, or oversized recovery evidence fails closed.
`submission_request_id` protects submission admission and retry, while `invocation_id` is the stable idempotency key passed to a side-effecting handler. A handler must scope that identity at its domain boundary. This design does not claim a new generic exactly-once substrate.
## Authority boundaries
Possession of syntax, metadata, identity, or a typed payload grants no permission. Runtime target, enabled Feature, argument schema, and captured domain authority are checked at execution. Completion providers cannot perform business mutations or expose credentials. Handlers may share an underlying domain operation with Worker tools, but chat discovery does not expose every tool automatically.
Text-only clients submit text. The Backend and Host never reinterpret text as an invocation. Unknown typed input is visibly unresolved in restoration and **rejected on submission**, not silently flattened or processed by the LLM. Notify remains text-only: extra typed wire fields or typed Runtime input cannot be discarded into an advisory message or executed as a side-effecting call.
@@ -0,0 +1,10 @@
# Delegated coding context and artifact access
During T-696 recovery, the parent Coder could read the authoritative Ticket through `ShowTicket`, but default coding SubWorkers did not receive that tool. A delegated task that simply asks a child to reread the Ticket therefore cannot be fulfilled. One child attempted a CLI fallback; this is not an authorized substitute for typed Backend tools. The parent supplied the authoritative revision and relevant requirements instead and retained lifecycle/publish authority.
Improvement: make the child tool/capability projection explicit in the spawn result, and support passing a bounded authoritative Ticket snapshot with revision into a child without implying mutation authority. Delegation prompts should include the necessary contract rather than assuming the child has the parent's Backend tools.
The Bash provider also advertised a full-output spill under the parent's declared readable `bash-output` directory, but `Read` rejected the returned absolute path as outside the attachment scope. The parent did not bypass that rejection; it used smaller bounded queries. Improvement: return a typed read capability/artifact reference for spill output, or ensure the advertised spill path is actually readable through the selected attachment.
A child spawned with `cwd: tools/web-ux` observed its filesystem attachment rooted there, and sibling `docs`, `web/workspace`, and `target/web-ux` reads were rejected despite explicit sibling scope rules. The tool description says `cwd` changes only the default command directory. The parent stopped the unchanged child and created a root-oriented child with an explicit nonrecursive readable root plus the original bounded sibling rules. Improvement: preserve attachment-relative path identity independently of command cwd, or document the actual child path binding in the spawn result. Capability rejections should remain authoritative, not trigger filesystem/CLI fallbacks.
+1 -1
View File
@@ -6,7 +6,7 @@
"dev": "deno run -A npm:vite@8.3.2 dev",
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
"check": "deno run -A npm:@sveltejs/kit@3.0.0 sync && deno run -A npm:svelte-check@4.7.6 --tsconfig ./tsconfig.json",
"test": "deno test --allow-read=src,test,tests,../../docs --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts",
"test": "deno test --allow-read=src,test,tests,../../docs --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts",
"test:component": "deno run -A npm:vitest@5.0.3 run --config vitest.config.ts",
"build": "deno run -A npm:vite@8.3.2 build",
"preview": "deno run -A npm:vite@8.3.2 preview",
@@ -1,6 +1,6 @@
// @generated by yoi-companion-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:f9f0f8918dbcce386e94d5368fa938f02565ca98f9f67f07537bd88ed8c49870)
// Input contract: 0.1.0 (sha256:0dc47aa72f9cace6e0026fbf5d9385f51b6d79c8f6a70a04954067a35e01c887)
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/companion-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
File diff suppressed because one or more lines are too long
+55 -5
View File
@@ -6,7 +6,52 @@ export type AlertLevel = "warn" | "error";
export type AlertSource = "worker" | "engine" | "compactor" | "agents_md";
export type CompletionKind = "file";
export type CompletionKind = "file" | "feature" | "feature_argument";
export type CompletionContext = { invocation: FeatureInvocationIdentity, argument?: string | null, };
export type FeatureInvocationIdentity = string;
export type FeatureInvocationSyntax = "parenthesized";
export type InvocationArgumentType = { "kind": "string" } | { "kind": "integer" } | { "kind": "boolean" } | { "kind": "enum", values: Array<string>, } | { "kind": "worker_file" } | { "kind": "client_file" };
export type InvocationCompletion = { "kind": "none" } | { "kind": "static", values: Array<string>, } | { "kind": "worker_file" } | { "kind": "client_file" } | { "kind": "provider", provider: string, };
export type InvocationArgumentDescriptor = { name: string,
/**
* Zero-based positional index. `None` means named-only. A positional
* argument may also be supplied by name; this makes chip editing stable.
*/
position?: number | null, required: boolean, value_type: InvocationArgumentType, completion: InvocationCompletion, description?: string | null, };
export type InvocationClientAdapter = "attachment";
export type FeatureInvocationDescriptor = { identity: FeatureInvocationIdentity, name: string, aliases: Array<string>, display_name: string, description: string, syntax: FeatureInvocationSyntax, arguments: Array<InvocationArgumentDescriptor>, client_adapter?: InvocationClientAdapter | null, };
export type InvocationValue = { "kind": "string", "value": string } | { "kind": "integer", "value": number } | { "kind": "boolean", "value": boolean };
export type InvocationArgumentValue = { name: string, value: InvocationValue, };
export type FeatureInvocation = {
/**
* Stable client-generated id retained across queueing and transport retry.
*/
invocation_id: string, identity: FeatureInvocationIdentity,
/**
* Selected public name, retained for exact draft/history restoration. Host
* resolution is always by `identity`.
*/
name: string, arguments: Array<InvocationArgumentValue>, };
export type FeatureInvocationStatus = "succeeded" | "failed" | "outcome_unknown";
export type FeatureInvocationResult = { invocation_id: string, identity: FeatureInvocationIdentity, status: FeatureInvocationStatus, message: string,
/**
* Bounded model-visible context produced by the handler. Credentials and
* executable implementation details must not be included.
*/
context?: string | null, };
export type WorkerStatus = "idle" | "running" | "paused" | "stopped";
@@ -108,7 +153,7 @@ recursive: boolean,
*/
symlink_policy: SymlinkPolicy, };
export type CompletionEntry = { value: string, is_dir: boolean, };
export type CompletionEntry = { value: string, is_dir: boolean, description?: string | null, usage?: string | null, invocation?: FeatureInvocationDescriptor | null, };
export type RewindTargetId = { segment_id: string, user_input_entry_index: number, };
@@ -270,7 +315,7 @@ export type PasteArtifactRef = { artifact_id: string, created_at_ms: number, med
*/
availability: PasteArtifactAvailability, byte_len: number, char_count: number, line_count: number, sha256: string, source_entry_id: string, };
export type Segment = { "kind": "text", content: string, } | { "kind": "paste", id: number, chars: number, lines: number, content: string, } | { "kind": "paste_artifact", artifact: PasteArtifactRef, } | { "kind": "uploaded_file", file: UploadedFileRef, } | { "kind": "file_ref", path: string, } | { "kind": "flow", selector: string, } | { "kind": "unknown" };
export type Segment = { "kind": "text", content: string, } | { "kind": "paste", id: number, chars: number, lines: number, content: string, } | { "kind": "paste_artifact", artifact: PasteArtifactRef, } | { "kind": "uploaded_file", file: UploadedFileRef, } | { "kind": "file_ref", path: string, } | { "kind": "feature_invoke", invocation: FeatureInvocation, } | { "kind": "flow", selector: string, } | { "kind": "unknown" };
export type WorkerEvent = { "kind": "turn_ended", worker_name: string, } | { "kind": "errored", worker_name: string, message: string, } | { "kind": "shut_down", worker_name: string, } | { "kind": "scope_sub_delegated",
/**
@@ -367,7 +412,12 @@ export type SubscriptionFramePayload = { "frame": "request", "message": Subscrip
export type SubscriptionFrame = { protocol_version: number, } & ({ "frame": "request", "message": SubscriptionRequest } | { "frame": "response", "message": SubscriptionResponse } | { "frame": "event", "message": SubscriptionEvent } | { "frame": "worker_protocol", "message": SubscriptionWorkerProtocolMethod });
export type Method = { "method": "submit", "params": { submission_request_id: string, input: Array<Segment>, } } | { "method": "notify", "params": { notification_request_id: string, message: string, } } | { "method": "worker_event", "params": WorkerEvent } | { "method": "list_pending_submissions" } | { "method": "cancel_pending_submission", "params": { submission_id: string, expected_revision: number, } } | { "method": "clear_pending_submissions", "params": { expected_revision: number, } } | { "method": "continue_pending", "params": { expected_revision: number, expected_head_id: string, } } | { "method": "resume", "params": { command: WorkerCommandEnvelope, } } | { "method": "cancel", "params": { command: WorkerCommandEnvelope, } } | { "method": "pause", "params": { command: WorkerCommandEnvelope, } } | { "method": "compact", "params": { command: WorkerCommandEnvelope, } } | { "method": "list_rewind_targets" } | { "method": "rewind_to", "params": { target: RewindTargetId, expected_head_entries: number, } } | { "method": "shutdown", "params": { command: WorkerCommandEnvelope, } } | { "method": "list_completions", "params": { kind: CompletionKind, prefix: string, } } | { "method": "list_workers" } | { "method": "restore_worker", "params": { name: string, } } | { "method": "register_peer", "params": { name: string, } };
export type Method = { "method": "submit", "params": { submission_request_id: string, input: Array<Segment>, } } | { "method": "notify", "params": { notification_request_id: string, message: string, } } | { "method": "worker_event", "params": WorkerEvent } | { "method": "list_pending_submissions" } | { "method": "cancel_pending_submission", "params": { submission_id: string, expected_revision: number, } } | { "method": "clear_pending_submissions", "params": { expected_revision: number, } } | { "method": "continue_pending", "params": { expected_revision: number, expected_head_id: string, } } | { "method": "resume", "params": { command: WorkerCommandEnvelope, } } | { "method": "cancel", "params": { command: WorkerCommandEnvelope, } } | { "method": "pause", "params": { command: WorkerCommandEnvelope, } } | { "method": "compact", "params": { command: WorkerCommandEnvelope, } } | { "method": "list_rewind_targets" } | { "method": "rewind_to", "params": { target: RewindTargetId, expected_head_entries: number, } } | { "method": "shutdown", "params": { command: WorkerCommandEnvelope, } } | { "method": "list_completions", "params": { kind: CompletionKind, prefix: string,
/**
* Client query generation. Echoed verbatim so identical-prefix ABA
* responses cannot cross edits, target switches, or permission changes.
*/
request_id?: string | null, context?: CompletionContext | null, } } | { "method": "list_workers" } | { "method": "restore_worker", "params": { name: string, } } | { "method": "register_peer", "params": { name: string, } };
export type Event = { "event": "submission_accepted", "data": { submission_request_id: string, submission_id: string, disposition: SubmissionDisposition, } } | { "event": "submission_rejected", "data": { submission_request_id: string, message: string, } } | { "event": "notification_accepted", "data": { notification_request_id: string, } } | { "event": "notification_rejected", "data": { notification_request_id: string, message: string, } } | { "event": "pending_submissions_changed", "data": { pending: PendingSubmissionsSnapshot, } } | { "event": "user_message", "data": {
/**
@@ -406,4 +456,4 @@ in_flight?: InFlightSnapshot,
* Parent-owned Internal Worker sessions visible to this client.
* Service-private Internal Workers are deliberately excluded.
*/
internal_workers?: Array<InternalWorkerSnapshot>, } } | { "event": "internal_worker", "data": { worker: InternalWorkerRef, revision: number, event: Event, } } | { "event": "internal_worker_removed", "data": { worker: InternalWorkerRef, revision: number, } } | { "event": "segment_rotated", "data": { session: SessionSnapshot, } } | { "event": "worker_state", "data": { snapshot: WorkerStateSnapshot, } } | { "event": "command_acknowledged", "data": { acknowledgement: WorkerCommandAcknowledgement, } } | { "event": "command", "data": { event: CommandEvent, } } | { "event": "completions", "data": { kind: CompletionKind, entries: Array<CompletionEntry>, } } | { "event": "rewind_targets", "data": { head_entries: number, targets: Array<RewindTarget>, } } | { "event": "rewind_applied", "data": { session: SessionSnapshot, input: Array<Segment>, summary: RewindSummary, } } | { "event": "workers_listed", "data": { workers: unknown, } } | { "event": "worker_restored", "data": { result: unknown, } } | { "event": "peer_registered", "data": { result: unknown, } } | { "event": "alert", "data": Alert } | { "event": "memory_worker", "data": MemoryWorkerEvent } | { "event": "compaction_progress", "data": { compaction: InFlightCompaction | null, } } | { "event": "compact_start", "data": { lifecycle: CompactionLifecycle, } } | { "event": "compact_done", "data": { lifecycle: CompactionLifecycle, } } | { "event": "compact_failed", "data": { lifecycle: CompactionLifecycle, } } | { "event": "shutdown" };
internal_workers?: Array<InternalWorkerSnapshot>, } } | { "event": "internal_worker", "data": { worker: InternalWorkerRef, revision: number, event: Event, } } | { "event": "internal_worker_removed", "data": { worker: InternalWorkerRef, revision: number, } } | { "event": "segment_rotated", "data": { session: SessionSnapshot, } } | { "event": "worker_state", "data": { snapshot: WorkerStateSnapshot, } } | { "event": "command_acknowledged", "data": { acknowledgement: WorkerCommandAcknowledgement, } } | { "event": "command", "data": { event: CommandEvent, } } | { "event": "completions", "data": { kind: CompletionKind, prefix: string, request_id?: string | null, context?: CompletionContext | null, entries: Array<CompletionEntry>, } } | { "event": "rewind_targets", "data": { head_entries: number, targets: Array<RewindTarget>, } } | { "event": "rewind_applied", "data": { session: SessionSnapshot, input: Array<Segment>, summary: RewindSummary, } } | { "event": "workers_listed", "data": { workers: unknown, } } | { "event": "worker_restored", "data": { result: unknown, } } | { "event": "peer_registered", "data": { result: unknown, } } | { "event": "alert", "data": Alert } | { "event": "memory_worker", "data": MemoryWorkerEvent } | { "event": "compaction_progress", "data": { compaction: InFlightCompaction | null, } } | { "event": "compact_start", "data": { lifecycle: CompactionLifecycle, } } | { "event": "compact_done", "data": { lifecycle: CompactionLifecycle, } } | { "event": "compact_failed", "data": { lifecycle: CompactionLifecycle, } } | { "event": "shutdown" };
@@ -1,6 +1,6 @@
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:f9f0f8918dbcce386e94d5368fa938f02565ca98f9f67f07537bd88ed8c49870)
// Input contract: 0.1.0 (sha256:0dc47aa72f9cace6e0026fbf5d9385f51b6d79c8f6a70a04954067a35e01c887)
// Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
// Output: web/workspace/src/lib/generated/repository-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
+38 -4
View File
@@ -1,6 +1,6 @@
// @generated by yoi-runtime-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:f9f0f8918dbcce386e94d5368fa938f02565ca98f9f67f07537bd88ed8c49870)
// Input contract: 0.1.0 (sha256:0dc47aa72f9cace6e0026fbf5d9385f51b6d79c8f6a70a04954067a35e01c887)
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/runtime-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
@@ -19,6 +19,12 @@ export type CleanupWorkdirFileStatus = "pending" | "present" | "active" | "clean
export type CleanupWorkerCandidate = { action: CleanupTargetKind; blocking_reason?: string | null; estimated_reclaim_bytes?: number | null; linked_workdir_ids: Array<string>; pinned: boolean; reason: string; retention_state: string; running_linked: boolean; runtime_id: string; runtime_worker_id: string; target_id: string; worker_id: string; };
export type CompletionContext = { argument?: string | null; invocation: FeatureInvocationIdentity; };
export type CompletionEntry = { description?: string | null; invocation?: FeatureInvocationDescriptor | null; is_dir?: boolean; usage?: string | null; value: string; };
export type CompletionKind = "file" | "feature" | "feature_argument";
export type CreateRemoteRuntimeRequest = { display_name?: string | null; endpoint: string; expected_revision?: number | null; public_bundle: RuntimePublicIdentityBundle; };
export type Diagnostic = { code: string; message: string; severity: DiagnosticSeverity; };
@@ -29,6 +35,32 @@ export type ExecuteRuntimeCleanupRequest = { confirm_dirty_discard_target_ids?:
export type ExternalWorkdirPermissions = { command: boolean; read: boolean; write: boolean; };
export type FeatureInvocation = { arguments: Array<InvocationArgumentValue>; identity: FeatureInvocationIdentity; invocation_id: string; name: string; };
export type FeatureInvocationDescriptor = { aliases?: Array<string>; arguments?: Array<InvocationArgumentDescriptor>; client_adapter?: InvocationClientAdapter | null; description: string; display_name: string; identity: FeatureInvocationIdentity; name: string; syntax: FeatureInvocationSyntax; };
export type FeatureInvocationIdentity = string;
export type FeatureInvocationSyntax = "parenthesized";
export type InvocationArgumentDescriptor = { completion?: InvocationCompletion; description?: string | null; name: string; position?: number | null; required?: boolean; value_type: InvocationArgumentType; };
export type InvocationArgumentType = { kind: "string"; } | { kind: "integer"; } | { kind: "boolean"; } | { kind: "enum"; values: Array<string>; } | { kind: "worker_file"; } | { kind: "client_file"; };
export type InvocationArgumentValue = { name: string; value: InvocationValue; };
export type InvocationClientAdapter = "attachment";
export type InvocationCompletion = { kind: "none"; } | { kind: "static"; values: Array<string>; } | { kind: "worker_file"; } | { kind: "client_file"; } | { kind: "provider"; provider: string; };
export type InvocationValue = { kind: "string"; value: string; } | { kind: "integer"; value: number; } | { kind: "boolean"; value: boolean; };
export type PasteArtifactAvailability = "available" | "unavailable" | "integrity_failed";
export type PasteArtifactMediaType = "text_plain_utf8";
export type PasteArtifactRef = { artifact_id: string; availability: PasteArtifactAvailability; byte_len: number; char_count: number; created_at_ms: number; line_count: number; media_type: PasteArtifactMediaType; sha256: string; source_entry_id: string; };
export type RemoveRuntimeRequest = { expected_binding_revision: number; operation_id: string; };
export type RepositoryApiError = { diagnostics?: Array<Diagnostic>; error: string; message: string; };
@@ -93,11 +125,11 @@ export type RuntimeVerificationEvidenceSummary = { binding_revision: number; las
export type RuntimeVerificationOutcome = "verified" | "challenge_issued" | "verification_failed" | "connectivity_failed";
export type RuntimeWorkerCompletionsRequest = { kind: unknown; prefix?: string; };
export type RuntimeWorkerCompletionsRequest = { context?: CompletionContext | null; kind: CompletionKind; prefix?: string; };
export type RuntimeWorkerCompletionsResult = { diagnostics?: Array<Diagnostic>; entries: Array<unknown>; kind: unknown; prefix: string; runtime_id: string; worker_id: string; };
export type RuntimeWorkerCompletionsResult = { diagnostics?: Array<Diagnostic>; entries: Array<CompletionEntry>; kind: CompletionKind; prefix: string; runtime_id: string; worker_id: string; };
export type RuntimeWorkerInputRequest = { content: string; kind?: string | null; segments?: Array<unknown> | null; };
export type RuntimeWorkerInputRequest = { content: string; kind?: string | null; segments?: Array<Segment> | null; };
export type RuntimeWorkerInputResult = { diagnostics?: Array<Diagnostic>; runtime_id: string; state: WorkerOperationState; worker_id: string; };
@@ -127,6 +159,8 @@ export type RuntimeWorkerSummary = { diagnostics?: Array<Diagnostic>; display_na
export type RuntimeWorkerTicketAssignmentRequest = { operation_id: string; ticket_id: string; };
export type Segment = { content: string; kind: "text"; } | { chars: number; content: string; id: number; kind: "paste"; lines: number; } | { artifact: PasteArtifactRef; kind: "paste_artifact"; } | { file: UploadedFileRef; kind: "uploaded_file"; } | { kind: "file_ref"; path: string; } | { invocation: FeatureInvocation; kind: "feature_invoke"; } | { kind: "flow"; selector: string; } | { kind: "unknown"; };
export type SubscriptionWorkerAvailability = "observed" | "unavailable";
export type UpdateRemoteRuntimeRequest = { display_name?: string | null; endpoint: string; };
@@ -1,6 +1,6 @@
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:f9f0f8918dbcce386e94d5368fa938f02565ca98f9f67f07537bd88ed8c49870)
// Input contract: 0.1.0 (sha256:0dc47aa72f9cace6e0026fbf5d9385f51b6d79c8f6a70a04954067a35e01c887)
// Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/ticket-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-worker-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:f9f0f8918dbcce386e94d5368fa938f02565ca98f9f67f07537bd88ed8c49870)
// Input contract: 0.1.0 (sha256:0dc47aa72f9cace6e0026fbf5d9385f51b6d79c8f6a70a04954067a35e01c887)
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/worker-launch-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check
@@ -21,6 +21,14 @@ export type DiagnosticSeverity = "info" | "warning" | "error";
export type ExternalWorkdirPermissions = { command: boolean; read: boolean; write: boolean; };
export type FeatureInvocation = { arguments: Array<InvocationArgumentValue>; identity: FeatureInvocationIdentity; invocation_id: string; name: string; };
export type FeatureInvocationIdentity = string;
export type InvocationArgumentValue = { name: string; value: InvocationValue; };
export type InvocationValue = { kind: "string"; value: string; } | { kind: "integer"; value: number; } | { kind: "boolean"; value: boolean; };
export type PasteArtifactAvailability = "available" | "unavailable" | "integrity_failed";
export type PasteArtifactMediaType = "text_plain_utf8";
@@ -35,7 +43,7 @@ export type RuntimeWorkingDirectoryCleanupTarget = { kind: string; repository_id
export type RuntimeWorkingDirectorySummary = { cleanliness?: string | null; cleanup_target?: RuntimeWorkingDirectoryCleanupTarget | null; creation_ref?: string | null; creation_selector?: string | null; creation_tree?: string | null; current_ref?: string | null; current_selector?: string | null; current_tree?: string | null; display_name?: string | null; materializer_kind: WorkingDirectoryMaterializerKind; observed_at_epoch_seconds?: number | null; occupied_by?: WorkingDirectoryOccupancy | null; repository_id: string; status: WorkingDirectoryStatusKind; working_directory_id: string; };
export type Segment = { content: string; kind: "text"; } | { chars: number; content: string; id: number; kind: "paste"; lines: number; } | { artifact: PasteArtifactRef; kind: "paste_artifact"; } | { file: UploadedFileRef; kind: "uploaded_file"; } | { kind: "file_ref"; path: string; } | { kind: "flow"; selector: string; } | { kind: "unknown"; };
export type Segment = { content: string; kind: "text"; } | { chars: number; content: string; id: number; kind: "paste"; lines: number; } | { artifact: PasteArtifactRef; kind: "paste_artifact"; } | { file: UploadedFileRef; kind: "uploaded_file"; } | { kind: "file_ref"; path: string; } | { invocation: FeatureInvocation; kind: "feature_invoke"; } | { kind: "flow"; selector: string; } | { kind: "unknown"; };
export type SubjektivWorkerConnectionRequest = { subject_id: string; };
@@ -295,3 +295,20 @@ Deno.test("Worker create request parser requires the complete shared request", (
"kind is invalid",
);
});
Deno.test("Worker launch request preserves typed Feature invocations and rejects unsafe values", () => {
const invocation = { invocation_id: "invoke-1", identity: "test/run", name: "run", arguments: [
{ name: "path", value: { kind: "string", value: "資料/a b" } },
{ name: "count", value: { kind: "integer", value: 1 } },
{ name: "enabled", value: { kind: "boolean", value: true } },
] };
const request = { runtime_id: "runtime-a", display_name: "Worker A", profile: "builtin:coder", ticket_assignment: null,
initial_submit: [{ kind: "feature_invoke", invocation }], workdir_attachments: [], feature_connections: {}, control_operation_id: null };
assertEquals(parseCreateWorkspaceWorkerRequest(request).initial_submit, request.initial_submit);
assertThrows(() => parseCreateWorkspaceWorkerRequest({ ...request, initial_submit: [{ kind: "feature_invoke", invocation: {
...invocation, arguments: [{ name: "count", value: { kind: "integer", value: 9007199254740992 } }],
} }] }), Error, "safe integer");
assertThrows(() => parseCreateWorkspaceWorkerRequest({ ...request, initial_submit: [{ kind: "feature_invoke", invocation: {
...invocation, extra: true,
} }] }), Error, "unknown field extra");
});
@@ -827,6 +827,45 @@ function uploadedFile(
};
}
function invocationValue(
value: unknown,
label: string,
): Extract<Extract<Segment, { kind: "feature_invoke" }>["invocation"]["arguments"][number], { name: string }>["value"] {
const item = record(value, label);
const kind = string(item.kind, `${label}.kind`);
exact(item, ["kind", "value"], label);
if (kind === "string") return { kind, value: string(item.value, `${label}.value`) };
if (kind === "integer") {
const parsed = number(item.value, `${label}.value`);
if (!Number.isSafeInteger(parsed)) throw new Error(`${label}.value must be a safe integer`);
if (parsed < -2147483648 || parsed > 2147483647) throw new Error(`${label}.value must be a signed 32-bit integer`);
return { kind, value: parsed };
}
if (kind === "boolean") return { kind, value: boolean(item.value, `${label}.value`) };
throw new Error(`${label}.kind is invalid`);
}
function featureInvocation(
value: unknown,
label: string,
): Extract<Segment, { kind: "feature_invoke" }>["invocation"] {
const item = record(value, label);
exact(item, ["invocation_id", "identity", "name", "arguments"], label);
return {
invocation_id: string(item.invocation_id, `${label}.invocation_id`),
identity: string(item.identity, `${label}.identity`),
name: string(item.name, `${label}.name`),
arguments: array(item.arguments, `${label}.arguments`, (argument, argumentLabel) => {
const entry = record(argument, argumentLabel);
exact(entry, ["name", "value"], argumentLabel);
return {
name: string(entry.name, `${argumentLabel}.name`),
value: invocationValue(entry.value, `${argumentLabel}.value`),
};
}),
};
}
function segment(value: unknown, label: string): Segment {
const item = record(value, label);
const kind = string(item.kind, `${label}.kind`) as Segment["kind"];
@@ -855,6 +894,12 @@ function segment(value: unknown, label: string): Segment {
case "file_ref":
exact(item, ["kind", "path"], label);
return { kind, path: string(item.path, `${label}.path`) };
case "feature_invoke":
exact(item, ["kind", "invocation"], label);
return {
kind,
invocation: featureInvocation(item.invocation, `${label}.invocation`),
};
case "flow":
exact(item, ["kind", "selector"], label);
return { kind, selector: string(item.selector, `${label}.selector`) };
@@ -2,6 +2,8 @@
import { cleanup, fireEvent, render, waitFor } from "@testing-library/svelte";
import { afterEach, expect, test, vi } from "vitest";
import { EditorView } from "@codemirror/view";
import type { FeatureInvocationDescriptor, Segment } from "#lib/generated/protocol.ts";
import { undo } from "@codemirror/commands";
import ComposerInput from "./ComposerInput.svelte";
afterEach(cleanup);
@@ -148,3 +150,250 @@ test("cursor changes, Escape, scope changes and disable discard late file result
await ui.rerender({ disabled: true });
expect(ui.queryByRole("listbox")).toBeNull();
});
test("synchronous draft and cursor ABA invalidate old Feature completion generations", async () => {
const requests: Array<ReturnType<typeof deferred<Array<{ value: string; invocation: FeatureInvocationDescriptor }>>>> = [];
const resolver = vi.fn(() => { const result = deferred<Array<{ value: string; invocation: FeatureInvocationDescriptor }>>(); requests.push(result); return result.promise; });
const ui = render(ComposerInput, { historyScope: "feature-aba", resolveFeatureCompletions: resolver });
const cm = editor(ui.container);
type(cm, "/ru");
await waitFor(() => expect(requests).toHaveLength(1));
cm.dispatch({ changes: { from: 2, to: 3, insert: "x" } });
cm.dispatch({ changes: { from: 2, to: 3, insert: "u" } });
requests[0].resolve([{ value: "stale", invocation: invocationDescriptor }]);
await waitFor(() => expect(requests).toHaveLength(2));
expect(ui.queryByRole("option")).toBeNull();
cm.dispatch({ selection: { anchor: 0 } });
cm.dispatch({ selection: { anchor: 3 } });
requests[1].resolve([{ value: "stale-cursor", invocation: invocationDescriptor }]);
await waitFor(() => expect(requests).toHaveLength(3));
expect(ui.queryByRole("option")).toBeNull();
requests[2].resolve([{ value: "run", invocation: invocationDescriptor }]);
expect((await ui.findByRole("option")).textContent).toContain("/run");
});
test("restored chip descriptor edits reject same-draft cursor ABA", async () => {
const request = deferred<Array<{ value: string; invocation: FeatureInvocationDescriptor }>>();
const resolver = vi.fn(() => request.promise);
const ui = render(ComposerInput, { historyScope: "edit-cursor-aba", resolveFeatureCompletions: resolver });
ui.component.restoreSegments([invocationSegment]);
const cm = editor(ui.container);
await fireEvent.mouseDown(ui.container.querySelector(".composer-typed-chip")!);
await waitFor(() => expect(resolver).toHaveBeenCalledOnce());
cm.dispatch({ selection: { anchor: 0 } });
cm.dispatch({ selection: { anchor: cm.state.doc.length } });
request.resolve([{ value: "run", invocation: invocationDescriptor }]);
await Promise.resolve();
await Promise.resolve();
expect(ui.component.snapshot().segments).toEqual([invocationSegment]);
});
test("cancelled upload cannot become sendable via late completion or Undo", async () => {
const onremoveatom = vi.fn();
const ui = render(ComposerInput, { historyScope: "upload-cancel-late", onremoveatom });
const cm = editor(ui.container);
type(cm, "before after", 7);
const reservation = ui.component.reserveUpload("report.md")!;
ui.component.cancelUpload(reservation);
expect(ui.component.completeUpload(reservation, uploadedSegment)).toBe(false);
undo(cm);
expect(ui.component.snapshot().segments.some((segment) => segment.kind === "uploaded_file" || segment.kind === "unknown")).toBe(false);
ui.component.clear();
expect(onremoveatom).not.toHaveBeenCalled();
});
const invocationDescriptor: FeatureInvocationDescriptor = {
identity: "feature:test/run", name: "run", aliases: [], display_name: "Run",
description: "Declared invocation", syntax: "parenthesized",
arguments: [{ name: "path", position: 0, required: true,
value_type: { kind: "worker_file" }, completion: { kind: "worker_file" } }],
};
const invocationSegment: Segment = {
kind: "feature_invoke", invocation: { invocation_id: "invoke-1",
identity: invocationDescriptor.identity, name: "run",
arguments: [{ name: "path", value: { kind: "string", value: "a b/資料" } }] },
};
const uploadedSegment: Segment = { kind: "uploaded_file", file: {
artifact_id: "artifact-1", file_name: "report.md", media_type: "text/markdown",
created_at_ms: 1, availability: "available", byte_len: 4, sha256: "abcd",
} };
test("argument popup stays anchored to the edited outer call when its data contains a call-looking slash", async () => {
const descriptor: FeatureInvocationDescriptor = { ...invocationDescriptor, arguments: [...invocationDescriptor.arguments,
{ name: "mode", required: false, value_type: { kind: "enum", values: ["safe"] }, completion: { kind: "static", values: ["safe"] } }] };
const resolveFeatureArgumentCompletions = vi.fn(async () => [{ value: "completed" }]);
const ui = render(ComposerInput, { historyScope: "quoted-data-argument", resolveFeatureArgumentCompletions,
resolveFeatureCompletions: async () => [{ value: "run", invocation: descriptor }] });
const segment: Segment = { kind: "feature_invoke", invocation: { ...invocationSegment.invocation, arguments: [
{ name: "path", value: { kind: "string", value: "a /run(pa" } },
{ name: "mode", value: { kind: "string", value: "safe" } },
] } };
ui.component.restoreSegments([segment]);
await fireEvent.mouseDown(ui.container.querySelector(".composer-typed-chip")!);
const cm = editor(ui.container);
await waitFor(() => expect(cm.state.doc.toString()).toBe('/run(path="a /run(pa", mode="safe")'));
cm.dispatch({ selection: { anchor: cm.state.doc.toString().lastIndexOf("(pa") + 3 } });
await waitFor(() => expect(resolveFeatureArgumentCompletions).toHaveBeenLastCalledWith(
{ invocation: descriptor.identity, argument: "path" }, "a /run(pa", expect.any(AbortSignal),
));
await ui.findByRole("option");
await fireEvent.keyDown(cm.contentDOM, { key: "Enter" });
expect(cm.state.doc.toString()).toBe('/run(path="completed", mode="safe")');
});
test("mid-cursor completion of an incomplete quote preserves following arguments and prose", async () => {
const descriptor: FeatureInvocationDescriptor = { ...invocationDescriptor, arguments: [...invocationDescriptor.arguments,
{ name: "mode", required: false, value_type: { kind: "enum", values: ["safe"] }, completion: { kind: "static", values: ["safe"] } }] };
const ui = render(ComposerInput, { historyScope: "incomplete-quote-suffix",
resolveFeatureCompletions: async () => [{ value: "run", invocation: descriptor }],
resolveFeatureArgumentCompletions: async () => [{ value: "completed" }] });
const cm = editor(ui.container);
type(cm, "/ru");
await ui.findByRole("option");
await fireEvent.keyDown(cm.contentDOM, { key: "Enter" });
const value = '/run(path="ab, mode=safe) following prose';
cm.dispatch({ changes: { from: cm.state.doc.length, insert: value.slice(5) }, selection: { anchor: value.indexOf("ab") + 2 } });
await ui.findByRole("option");
await fireEvent.keyDown(cm.contentDOM, { key: "Enter" });
expect(cm.state.doc.toString()).toBe('/run(path="completed", mode=safe) following prose');
const snapshot = ui.component.snapshot();
expect(snapshot.segments[0]).toMatchObject({ kind: "feature_invoke", invocation: { arguments: [
{ name: "path", value: { kind: "string", value: "completed" } },
{ name: "mode", value: { kind: "string", value: "safe" } },
] } });
expect(snapshot.segments[1]).toEqual({ kind: "text", content: " following prose" });
});
test("selected malformed surrogate strings stay editable and reject Submit snapshot", async () => {
const ui = render(ComposerInput, { historyScope: "invalid-surrogate-submit",
resolveFeatureCompletions: async () => [{ value: "run", invocation: invocationDescriptor }] });
const cm = editor(ui.container);
type(cm, "/ru");
await ui.findByRole("option");
await fireEvent.keyDown(cm.contentDOM, { key: "Enter" });
const invalid = String.raw`"\uD800")`;
cm.dispatch({ changes: { from: cm.state.doc.length, insert: invalid }, selection: { anchor: 5 + invalid.length } });
await Promise.resolve();
expect(ui.container.querySelector(".composer-typed-chip")).toBeNull();
expect(() => ui.component.snapshot()).toThrow("unpaired Unicode surrogates");
const valid = String.raw`"\uD83D\uDE00")`;
cm.dispatch({ changes: { from: 5, to: cm.state.doc.length, insert: valid }, selection: { anchor: 5 + valid.length } });
const snapshot = ui.component.snapshot();
expect(snapshot.segments[0]).toMatchObject({ kind: "feature_invoke", invocation: { arguments: [
{ name: "path", value: { kind: "string", value: "😀" } },
] } });
});
test("selected calls chipify without promoting matching typed or pasted text", async () => {
const ui = render(ComposerInput, { historyScope: "invocation-select",
resolveFeatureCompletions: async () => [{ value: "run", invocation: invocationDescriptor }] });
const cm = editor(ui.container);
type(cm, "/ru");
await ui.findByRole("option");
await fireEvent.keyDown(cm.contentDOM, { key: "Enter" });
cm.dispatch({ changes: { from: cm.state.doc.length, insert: '"a b/資料")' }, selection: { anchor: '/run("a b/資料")'.length } });
await waitFor(() => expect(ui.container.querySelectorAll(".composer-typed-chip")).toHaveLength(1));
cm.dispatch({ changes: { from: cm.state.doc.length, insert: ' /run("literal")' } });
await Promise.resolve();
const snapshot = ui.component.snapshot();
expect(snapshot.segments.map((segment) => segment.kind)).toEqual(["feature_invoke", "text"]);
expect(snapshot.segments[1]).toEqual({ kind: "text", content: ' /run("literal")' });
expect(snapshot.segments[0].kind === "feature_invoke" && snapshot.segments[0].invocation.arguments[0].value)
.toEqual({ kind: "string", value: "a b/資料" });
});
test("restored invocation chips fetch declarative descriptors and stay editable inside arguments", async () => {
const resolveFeatureCompletions = vi.fn(async () => [{ value: "run", invocation: invocationDescriptor }]);
const ui = render(ComposerInput, { historyScope: "invocation-edit", resolveFeatureCompletions });
ui.component.restoreSegments([invocationSegment]);
const cm = editor(ui.container);
await fireEvent.mouseDown(ui.container.querySelector(".composer-typed-chip")!);
await waitFor(() => expect(cm.state.doc.toString()).toBe('/run(path="a b/資料")'));
expect(resolveFeatureCompletions).toHaveBeenCalledWith("run", expect.any(AbortSignal));
cm.dispatch({ changes: { from: 11, to: 12, insert: "c" }, selection: { anchor: 12 } });
await Promise.resolve();
expect(ui.container.querySelector(".composer-typed-chip")).toBeNull();
const snapshot = ui.component.snapshot();
expect(snapshot.segments[0].kind).toBe("feature_invoke");
});
test("attachment adapter uses descriptor capability rather than a hardcoded slash name", async () => {
const onclientadapter = vi.fn();
const adapter = { ...invocationDescriptor, name: "send-file", arguments: [], client_adapter: "attachment" as const };
const ui = render(ComposerInput, { historyScope: "invocation-adapter", onclientadapter,
resolveFeatureCompletions: async () => [{ value: adapter.name, invocation: adapter }] });
const cm = editor(ui.container);
type(cm, "before /send");
await ui.findByRole("option");
await fireEvent.keyDown(cm.contentDOM, { key: "Enter" });
expect(onclientadapter).toHaveBeenCalledWith(adapter);
expect(cm.state.doc.toString()).toBe("before ");
});
test("uploads preserve reserved order, deletion undo, cleanup and accepted ownership", async () => {
const onremoveatom = vi.fn();
const ui = render(ComposerInput, { historyScope: "invocation-upload", onremoveatom });
const cm = editor(ui.container);
type(cm, "before ");
const first = ui.component.reserveUpload("report.md")!;
const second = ui.component.reserveUpload("second.md")!;
cm.dispatch({ changes: { from: cm.state.doc.length, insert: " after" } });
const secondSegment = { ...uploadedSegment, file: { ...uploadedSegment.file, artifact_id: "artifact-2", file_name: "second.md" } };
expect(ui.component.completeUpload(second, secondSegment)).toBe(true);
expect(ui.component.completeUpload(first, uploadedSegment)).toBe(true);
expect(ui.component.snapshot().segments).toEqual([
{ kind: "text", content: "before " }, uploadedSegment, secondSegment, { kind: "text", content: " after" },
]);
const tokenEnd = "before ".length + 3;
cm.dispatch({ selection: { anchor: tokenEnd } });
await fireEvent.keyDown(cm.contentDOM, { key: "Backspace" });
expect(onremoveatom).not.toHaveBeenCalled();
undo(cm);
expect(ui.component.snapshot().segments).toContainEqual(uploadedSegment);
ui.component.clear(true);
expect(onremoveatom).not.toHaveBeenCalled();
expect(undo(cm)).toBe(false);
ui.component.insertSegment(uploadedSegment, true);
ui.component.restoreSegments([invocationSegment]);
expect(onremoveatom).toHaveBeenCalledWith(uploadedSegment);
});
test("restored opaque segments and typed clipboard labels are not lost", async () => {
const ui = render(ComposerInput, { historyScope: "invocation-restore" });
const segments: Segment[] = [invocationSegment, uploadedSegment, { kind: "file_ref", path: "a b/c" }, { kind: "flow", selector: "legacy" }];
ui.component.restoreSegments(segments);
expect(ui.component.snapshot().segments).toEqual(segments);
const cm = editor(ui.container);
cm.dispatch({ selection: { anchor: 0, head: cm.state.doc.length } });
const setData = vi.fn();
await fireEvent.copy(cm.contentDOM, { clipboardData: { setData } });
expect(setData).toHaveBeenCalledWith("text/plain", expect.stringContaining('/run(path="a b/資料")'));
expect(setData.mock.calls[0][1]).not.toContain("\uFFF8");
});
test("restored descriptor requests cannot edit a newer draft after a scope change", async () => {
const request = deferred<Array<{ value: string; invocation: FeatureInvocationDescriptor }>>();
const resolver = vi.fn(() => request.promise);
const ui = render(ComposerInput, { historyScope: "invocation-stale-edit", completionScope: "worker-a", resolveFeatureCompletions: resolver });
ui.component.restoreSegments([invocationSegment]);
await fireEvent.mouseDown(ui.container.querySelector(".composer-typed-chip")!);
await waitFor(() => expect(resolver).toHaveBeenCalledOnce());
await ui.rerender({ completionScope: "worker-b" });
ui.component.restoreSegments([{ kind: "text", content: "new draft" }]);
request.resolve([{ value: "run", invocation: invocationDescriptor }]);
await Promise.resolve();
await Promise.resolve();
expect(ui.component.snapshot().segments).toEqual([{ kind: "text", content: "new draft" }]);
});
test("pasted invocation text remains literal after descriptor selection", async () => {
const ui = render(ComposerInput, { historyScope: "invocation-paste-literal", resolveFeatureCompletions: async () => [{ value: "run", invocation: invocationDescriptor }] });
const cm = editor(ui.container);
type(cm, "/ru");
await ui.findByRole("option");
await fireEvent.keyDown(cm.contentDOM, { key: "Enter" });
cm.dispatch({ changes: { from: cm.state.doc.length, insert: '"x")' }, selection: { anchor: '/run("x")'.length } });
await waitFor(() => expect(ui.container.querySelectorAll(".composer-typed-chip")).toHaveLength(1));
await fireEvent.paste(cm.contentDOM, { clipboardData: { getData: () => ' /run("pasted")', items: [] } });
expect(ui.component.snapshot().segments.map((segment) => segment.kind)).toEqual(["feature_invoke", "text"]);
});
@@ -6,6 +6,7 @@
Prec,
StateEffect,
StateField,
type Extension,
} from "@codemirror/state";
import {
Decoration,
@@ -22,7 +23,11 @@
isolateHistory,
} from "@codemirror/commands";
import { onMount } from "svelte";
import type { Segment } from "#lib/generated/protocol.ts";
import type {
CompletionContext,
FeatureInvocationDescriptor,
Segment,
} from "#lib/generated/protocol.ts";
import {
handleComposerImagePaste,
measureComposerPaste,
@@ -32,11 +37,14 @@
composerDeletionRange,
composerPasteAtoms,
composerPasteToken,
composerTypedAtoms,
composerTypedToken,
pasteChipLabel,
snapshotComposerDraft,
type ComposerDraftSnapshot,
type ComposerPaste,
type ComposerTextPaste,
type ComposerTypedSegment,
} from "#lib/workspace/console/composer-draft.ts";
import {
ComposerHistory,
@@ -48,6 +56,10 @@
} from "#lib/workspace/console/composer-history.ts";
import { applyCompletion, completionSelection, completionTokenAt, localCommandCompletions, type ComposerCompletionEntry, type ComposerCompletionToken } from "./composer-completion";
import { shouldSubmitChatKey } from "#lib/workspace/console/chat-submit.ts";
import {
invocationInput,
selectedFeatureInvocationAt,
} from "#lib/workspace/console/composer-invocation.ts";
interface Props {
disabled?: boolean;
@@ -61,6 +73,14 @@
oncommand?: () => void;
completionScope?: string;
resolveFileCompletions?: (prefix: string, signal: AbortSignal) => Promise<ComposerCompletionEntry[]>;
resolveFeatureCompletions?: (prefix: string, signal: AbortSignal) => Promise<ComposerCompletionEntry[]>;
resolveFeatureArgumentCompletions?: (
context: CompletionContext,
prefix: string,
signal: AbortSignal,
) => Promise<ComposerCompletionEntry[]>;
onremoveatom?: (segment: Segment) => void;
onclientadapter?: (descriptor: FeatureInvocationDescriptor) => void;
}
let {
@@ -75,14 +95,45 @@
oncommand,
completionScope = "",
resolveFileCompletions,
resolveFeatureCompletions,
resolveFeatureArgumentCompletions,
onremoveatom,
onclientadapter,
}: Props = $props();
let mountElement: HTMLDivElement;
let view: EditorView | null = null;
let editorExtensions: Extension[] = [];
const stagedTypedAtoms = new Map<number, Segment>();
let composerHistory = new ComposerHistory();
let restoringHistory = false;
let nextPasteId = 1;
let nextPasteKey = 1;
let nextTypedKey = 1;
const selectedFeatureInvocations = new Map<string, FeatureInvocationDescriptor>();
type SelectedInvocation = { from: number; descriptor: FeatureInvocationDescriptor };
const selectInvocation = StateEffect.define<SelectedInvocation>();
const restoreSelectedInvocations = StateEffect.define<readonly SelectedInvocation[]>();
const selectedInvocationState = StateField.define<readonly SelectedInvocation[]>({
create: () => [],
update(selected, transaction) {
const restored = transaction.effects.find((effect) => effect.is(restoreSelectedInvocations));
if (restored) return restored.value;
const document = transaction.newDoc.toString();
const retained = selected.map((item) => ({
...item,
from: transaction.changes.mapPos(item.from, 1),
})).filter((item) =>
[item.descriptor.name, ...item.descriptor.aliases].some((name) =>
document.startsWith(`/${name}(`, item.from)
)
);
for (const effect of transaction.effects) {
if (effect.is(selectInvocation)) retained.push(effect.value);
}
return retained;
},
});
const editable = new Compartment();
const completionId = $props.id();
@@ -117,7 +168,12 @@
completionContext = context;
closeCompletion();
if (disabled || !view.hasFocus || view.composing || !selection.empty) return;
const token = completionTokenAt(document, selection.head);
const token = completionTokenAt(
document,
selection.head,
[...selectedFeatureInvocations.values()],
view.state.field(selectedInvocationState),
);
if (!token) return;
completionToken = token;
if (token.kind === "command") {
@@ -125,16 +181,26 @@
if (!completionEntries.length) completionMessage = "No matching commands";
return;
}
if (!resolveFileCompletions) { closeCompletion(); return; }
const resolver = token.kind === "file"
? resolveFileCompletions && ((signal: AbortSignal) =>
resolveFileCompletions(token.prefix, signal))
: token.kind === "feature"
? resolveFeatureCompletions && ((signal: AbortSignal) =>
resolveFeatureCompletions(token.prefix, signal))
: token.context && resolveFeatureArgumentCompletions
? ((signal: AbortSignal) =>
resolveFeatureArgumentCompletions(token.context!, token.prefix, signal))
: null;
if (!resolver) { closeCompletion(); return; }
completionBusy = true;
const version = completionVersion;
const abort = new AbortController();
completionAbort = abort;
void resolveFileCompletions(token.prefix, abort.signal).then((entries) => {
void resolver(abort.signal).then((entries) => {
if (version !== completionVersion || abort.signal.aborted) return;
completionEntries = entries;
completionSelected = entries.length ? 0 : null;
if (!entries.length) completionMessage = "No matching files";
if (!entries.length) completionMessage = `No matching ${token.kind.replace("_", " ")}s`;
}).catch((error) => {
if (version !== completionVersion || abort.signal.aborted) return;
completionMessage = error instanceof Error ? error.message : String(error);
@@ -158,11 +224,27 @@
}
const entry = index === null ? undefined : completionEntries[index];
if (!entry) return;
if (token.kind === "feature" && entry.invocation) {
selectedFeatureInvocations.set(entry.invocation.identity, entry.invocation);
if (entry.invocation.client_adapter === "attachment") {
closeCompletion();
view.dispatch({
changes: { from: token.start, to: token.end, insert: "" },
selection: EditorSelection.cursor(token.start),
userEvent: "input.complete",
});
onclientadapter?.(entry.invocation);
return;
}
}
const result = applyCompletion(document, token, entry, action);
const to = document.length - (result.value.length - result.cursor);
closeCompletion();
view.dispatch({
changes: { from: token.start, to, insert: result.value.slice(token.start, result.cursor) },
effects: token.kind === "feature" && entry.invocation
? selectInvocation.of({ from: token.start, descriptor: entry.invocation })
: [],
selection: EditorSelection.cursor(result.cursor), userEvent: "input.complete",
});
view.focus();
@@ -209,6 +291,12 @@
$effect(() => {
void completionScope;
selectedFeatureInvocations.clear();
closeCompletion();
completionContext = "";
});
$effect(() => {
void disabled;
closeCompletion();
completionContext = "";
@@ -315,6 +403,171 @@
EditorView.atomicRanges.of((currentView) => pasteDecorations(currentView.state)),
];
const registerTypedAtom = StateEffect.define<{
key: number;
atom: ComposerTypedSegment;
}>();
const typedRegistry = StateField.define<ReadonlyMap<number, ComposerTypedSegment>>({
create: () => new Map(),
update(registry, transaction) {
const additions = transaction.effects.filter((effect) => effect.is(registerTypedAtom));
if (!additions.length) return registry;
const next = new Map(registry);
for (const addition of additions) next.set(addition.value.key, addition.value.atom);
return next;
},
});
class TypedChipWidget extends WidgetType {
readonly atom: ComposerTypedSegment;
constructor(atom: ComposerTypedSegment) {
super();
this.atom = atom;
}
override eq(other: TypedChipWidget): boolean {
return JSON.stringify(other.atom.segment) === JSON.stringify(this.atom.segment) &&
other.atom.label === this.atom.label;
}
override toDOM(): HTMLElement {
const chip = document.createElement("span");
chip.className = "composer-typed-chip";
chip.textContent = this.atom.label;
chip.title = this.atom.label;
chip.setAttribute("role", "note");
chip.setAttribute("aria-label", this.atom.label);
return chip;
}
override ignoreEvent(): boolean { return false; }
}
function typedDecorations(state: EditorState): DecorationSet {
return Decoration.set(
composerTypedAtoms(state.doc.toString(), state.field(typedRegistry)).map((atom) =>
Decoration.replace({
widget: new TypedChipWidget(atom),
inclusive: false,
}).range(atom.from, atom.to)
),
true,
);
}
const typedChips = [
typedRegistry,
EditorView.decorations.of((currentView) => typedDecorations(currentView.state)),
EditorView.atomicRanges.of((currentView) => typedDecorations(currentView.state)),
];
// Deleted uploads remain staged while Undo can still restore their atoms.
// Release abandoned resources at a draft boundary; accepted live atoms belong to the host.
function releaseStagedAtoms(accepted = false): void {
const live = accepted && view ? new Set(composerTypedAtoms(
view.state.doc.toString(), view.state.field(typedRegistry),
).map((atom) => JSON.stringify(atom.segment))) : new Set<string>();
for (const segment of stagedTypedAtoms.values()) {
if (!live.has(JSON.stringify(segment))) onremoveatom?.(segment);
}
stagedTypedAtoms.clear();
}
let suppressInvocationChipify = false;
function typedAtomLabel(segment: Segment): string {
if (segment.kind === "feature_invoke") return invocationInput(segment.invocation);
if (segment.kind === "uploaded_file") return `Attached ${segment.file.file_name}`;
if (segment.kind === "file_ref") return `@${segment.path}`;
if (segment.kind === "paste_artifact") return "Restored paste";
if (segment.kind === "flow") return `Flow ${segment.selector}`;
return "Structured input";
}
function insertTypedSegment(
segment: Segment,
options: { from?: number; to?: number; cleanup?: boolean; label?: string } = {},
): number | null {
if (!view || view.state.readOnly) return null;
const selection = view.state.selection.main;
const from = options.from ?? selection.from;
const to = options.to ?? selection.to;
const key = nextTypedKey++;
if (options.cleanup) stagedTypedAtoms.set(key, segment);
const token = composerTypedToken(key);
view.dispatch({
changes: { from, to, insert: token },
selection: EditorSelection.cursor(from + token.length),
effects: registerTypedAtom.of({
key,
atom: { segment, label: options.label ?? typedAtomLabel(segment), cleanup: options.cleanup },
}),
annotations: isolateHistory.of("full"),
userEvent: "input.complete",
});
return key;
}
function chipifySelectedInvocation(strict = false): void {
if (!view || view.composing || view.state.readOnly) return;
// Only completion-selected or explicitly edited calls become structured input.
// Descriptor discovery must never promote unrelated typed or pasted prose.
for (const selected of [...view.state.field(selectedInvocationState)].reverse()) {
try {
const range = selectedFeatureInvocationAt(
view.state.doc.toString(), selected.from, selected.descriptor,
);
if (!range) {
if (strict) throw new Error("Finish or remove the selected Feature invocation before sending.");
continue;
}
const head = view.state.selection.main.head;
if (!strict && head > range.start && head < range.end) continue;
insertTypedSegment(
{ kind: "feature_invoke", invocation: range.invocation },
{ from: range.start, to: range.end },
);
} catch (error) {
// Invalid calls stay editable until Submit reports their validation error.
if (strict) throw error;
}
}
}
let editVersion = 0;
let editorGeneration = 0;
async function editInvocation(key: number): Promise<void> {
if (!view || disabled) return;
const scope = completionScope;
const version = ++editVersion;
const document = view.state.doc.toString();
const selection = view.state.selection.main;
const generation = editorGeneration;
let atom = composerTypedAtoms(view.state.doc.toString(), view.state.field(typedRegistry))
.find((candidate) => candidate.key === key);
if (atom?.segment.kind !== "feature_invoke") return;
const invocation = atom.segment.invocation;
let descriptor = selectedFeatureInvocations.get(invocation.identity);
if (!descriptor && resolveFeatureCompletions) {
try {
const entries = await resolveFeatureCompletions(invocation.name, new AbortController().signal);
descriptor = entries.find((entry) => entry.invocation?.identity === invocation.identity)?.invocation ?? undefined;
} catch { return; }
}
if (!descriptor || !view || disabled || version !== editVersion || scope !== completionScope ||
generation !== editorGeneration || document !== view.state.doc.toString() ||
selection.from !== view.state.selection.main.from || selection.to !== view.state.selection.main.to) return;
atom = composerTypedAtoms(view.state.doc.toString(), view.state.field(typedRegistry))
.find((candidate) => candidate.key === key);
if (atom?.segment.kind !== "feature_invoke") return;
selectedFeatureInvocations.set(descriptor.identity, descriptor);
const input = invocationInput(invocation);
suppressInvocationChipify = true;
view.dispatch({
changes: { from: atom.from, to: atom.to, insert: input },
selection: EditorSelection.cursor(atom.from + input.length - 1),
effects: selectInvocation.of({ from: atom.from, descriptor }),
annotations: isolateHistory.of("full"),
userEvent: "input",
});
view.focus();
}
function currentSnapshot(state = view?.state): ComposerDraftSnapshot {
if (!state) {
return { document: "", content: "", segments: [], pastes: [], textPastes: [] };
@@ -323,6 +576,7 @@
state.doc.toString(),
state.field(pasteRegistry),
state.field(textPasteState),
state.field(typedRegistry),
);
}
@@ -339,6 +593,7 @@
}
function browseHistory(currentView: EditorView, direction: ComposerHistoryDirection): boolean {
if ([...currentView.state.field(typedRegistry).values()].some((atom) => atom.segment.kind === "unknown")) return false;
const selection = currentView.state.selection.main;
const cursorLine = currentView.state.doc.lineAt(selection.head).number;
if (!shouldBrowseComposerHistory({
@@ -435,20 +690,26 @@
from: paste.from - selection.from,
to: paste.to - selection.from,
}));
return snapshotComposerDraft(document, selectedRegistry, selectedTextPastes).content;
return snapshotComposerDraft(document, selectedRegistry, selectedTextPastes, state.field(typedRegistry)).content;
}
function deleteAdjacentPasteFromView(
function deleteAdjacentAtomFromView(
currentView: EditorView,
direction: "backward" | "forward",
): boolean {
if (currentView.state.readOnly) return false;
const selection = currentView.state.selection.main;
const pastes = composerPasteAtoms(
currentView.state.doc.toString(),
currentView.state.field(pasteRegistry),
);
const deletion = composerDeletionRange(selection, pastes, direction);
const atoms = [
...composerPasteAtoms(
currentView.state.doc.toString(),
currentView.state.field(pasteRegistry),
),
...composerTypedAtoms(
currentView.state.doc.toString(),
currentView.state.field(typedRegistry),
),
];
const deletion = composerDeletionRange(selection, atoms, direction);
if (!deletion) return false;
currentView.dispatch({
changes: deletion,
@@ -467,7 +728,7 @@
view = new EditorView({
parent: mountElement,
state: EditorState.create({
extensions: [
extensions: editorExtensions = [
history(),
Prec.highest(EditorView.domEventHandlers({ keydown: completionKeydown })),
Prec.highest(keymap.of([
@@ -494,16 +755,21 @@
{
key: "Backspace",
run: (currentView) =>
deleteAdjacentPasteFromView(currentView, "backward"),
deleteAdjacentAtomFromView(currentView, "backward"),
},
{
key: "Delete",
run: (currentView) =>
deleteAdjacentPasteFromView(currentView, "forward"),
deleteAdjacentAtomFromView(currentView, "forward"),
},
])),
keymap.of([...defaultKeymap, ...historyKeymap]),
selectedInvocationState,
invertedEffects.of((transaction) => transaction.docChanged
? [restoreSelectedInvocations.of(transaction.startState.field(selectedInvocationState))]
: []),
pasteChips,
typedChips,
editable.of([
EditorView.editable.of(!disabled),
EditorState.readOnly.of(disabled),
@@ -518,7 +784,20 @@
spellcheck: "true",
}),
EditorView.updateListener.of((update) => {
if (update.docChanged || update.selectionSet) queueMicrotask(() => refreshCompletion());
if (update.docChanged || update.selectionSet) {
editorGeneration++;
// Invalidate synchronously: an A→B→A edit/cursor cycle in one
// microtask must not revive an earlier completion generation.
closeCompletion();
completionContext = "";
queueMicrotask(() => refreshCompletion());
}
if (update.docChanged) {
if (suppressInvocationChipify) suppressInvocationChipify = false;
else if (!update.transactions.some((transaction) => transaction.isUserEvent("undo") || transaction.isUserEvent("redo"))) {
queueMicrotask(chipifySelectedInvocation);
}
}
if (update.docChanged && !restoringHistory) composerHistory.cancelNavigation();
if (update.docChanged || update.transactions.some((tx) => tx.effects.length > 0)) {
emitChange();
@@ -528,6 +807,26 @@
focus() { queueMicrotask(() => refreshCompletion(true)); return false; },
blur() { closeCompletion(); return false; },
compositionend() { queueMicrotask(() => refreshCompletion(true)); return false; },
mousedown(event, currentView) {
const target = event.target instanceof Element
? event.target.closest(".composer-typed-chip")
: null;
if (!target || currentView.state.readOnly) return false;
let position: number;
try {
position = currentView.posAtDOM(target);
} catch {
return false;
}
const atom = composerTypedAtoms(
currentView.state.doc.toString(),
currentView.state.field(typedRegistry),
).find((candidate) => candidate.from <= position && position <= candidate.to);
if (atom?.segment.kind !== "feature_invoke") return false;
event.preventDefault();
void editInvocation(atom.key);
return true;
},
paste(event) {
return handlePasteEvent(event);
},
@@ -582,6 +881,8 @@
return () => {
closeCompletion();
releaseStagedAtoms();
editVersion++;
view?.destroy();
view = null;
};
@@ -598,12 +899,14 @@
});
export function snapshot(): ComposerDraftSnapshot {
chipifySelectedInvocation(true);
return currentSnapshot();
}
export function recordHistory(value: ComposerDraftSnapshot): void {
const entry: ComposerHistoryEntry = {
segments: value.segments,
// Uploaded references are Worker-owned and must not leak through workspace-wide history.
segments: value.segments.filter((segment) => segment.kind !== "uploaded_file"),
preserveExactText: value.textPastes.length > 0,
};
if (composerHistory.record(entry)) {
@@ -632,23 +935,61 @@
});
}
export function clear(): void {
export function insertSegment(segment: Segment, cleanup = false): boolean {
return insertTypedSegment(segment, { cleanup }) !== null;
}
export function reserveUpload(fileName: string): number | null {
return insertTypedSegment({ kind: "unknown" }, { label: `Uploading ${fileName}` });
}
export function completeUpload(key: number, segment: Segment): boolean {
if (!view || view.state.field(typedRegistry).get(key)?.segment.kind !== "unknown") return false;
stagedTypedAtoms.set(key, segment);
view.dispatch({ effects: registerTypedAtom.of({
key, atom: { segment, label: typedAtomLabel(segment), cleanup: true },
}) });
return true;
}
export function cancelUpload(key: number): void {
if (!view) return;
const atom = composerTypedAtoms(view.state.doc.toString(), view.state.field(typedRegistry))
.find((candidate) => candidate.key === key);
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: "" },
selection: EditorSelection.cursor(0),
userEvent: "input",
changes: atom ? { from: atom.from, to: atom.to, insert: "" } : [],
// Undo cannot resurrect a cancelled upload as a sendable placeholder.
effects: registerTypedAtom.of({ key, atom: {
segment: { kind: "text", content: "" }, label: "Removed upload",
} }),
userEvent: "delete",
});
}
export function clear(acceptedTypedResources = false, retainStagedResources = false): void {
if (!view) return;
if (!retainStagedResources) releaseStagedAtoms(acceptedTypedResources);
editVersion++;
closeCompletion();
completionContext = "";
view.setState(EditorState.create({ extensions: editorExtensions }));
view.dispatch({ effects: editable.reconfigure([
EditorView.editable.of(!disabled), EditorState.readOnly.of(disabled),
]) });
emitChange();
}
export function restoreSegments(
segments: readonly Segment[],
preserveExactText = false,
ownStagedUploads = false,
): void {
if (!view) return;
clear(false, restoringHistory);
let document = "";
const pasteEffects: StateEffect<{ key: number; paste: ComposerPaste }>[] = [];
const textEffects: StateEffect<ComposerTextPaste>[] = [];
const typedEffects: StateEffect<{ key: number; atom: ComposerTypedSegment }>[] = [];
let highestPasteId = nextPasteId - 1;
for (const segment of segments) {
if (segment.kind === "text") {
@@ -674,15 +1015,22 @@
highestPasteId = Math.max(highestPasteId, paste.id);
document += composerPasteToken(key);
pasteEffects.push(registerPaste.of({ key, paste }));
} else if (segment.kind === "file_ref") {
document += `@${segment.path}`;
} else {
const key = nextTypedKey++;
document += composerTypedToken(key);
const cleanup = ownStagedUploads && segment.kind === "uploaded_file";
if (cleanup) stagedTypedAtoms.set(key, segment);
typedEffects.push(registerTypedAtom.of({
key,
atom: { segment, label: typedAtomLabel(segment), cleanup },
}));
}
}
nextPasteId = highestPasteId + 1;
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: document },
selection: EditorSelection.cursor(document.length),
effects: [...pasteEffects, ...textEffects],
effects: [...pasteEffects, ...textEffects, ...typedEffects],
userEvent: "input.restore",
});
}
@@ -788,7 +1136,8 @@
padding: 0;
}
.composer-input :global(.composer-paste-chip) {
.composer-input :global(.composer-paste-chip),
.composer-input :global(.composer-typed-chip) {
display: inline-flex;
align-items: center;
max-width: min(26rem, 70vw);
@@ -1,3 +1,4 @@
import { invocationInput } from "./composer-invocation.ts";
import type { Segment } from "#lib/generated/protocol.ts";
export type WorkerConsoleInputKind =
@@ -88,9 +89,10 @@ export function buildComposerSegmentsRequest(
sourceSegments: readonly Segment[],
options: ComposerSegmentsRequestOptions = {},
): ComposerCommandResult {
const hasRichSegment = sourceSegments.some((segment) =>
segment.kind === "paste" || segment.kind === "uploaded_file"
);
if (sourceSegments.some((segment) => segment.kind === "unknown")) {
return { ok: false, message: "Finish or remove incomplete structured input before sending." };
}
const hasRichSegment = sourceSegments.some((segment) => segment.kind !== "text");
if (!hasRichSegment) {
const content = sourceSegments.map(segmentContent).join("");
if (!options.preserveExactText || content.trimStart().startsWith(":")) {
@@ -154,8 +156,10 @@ function segmentContent(segment: Segment): string {
return `@${segment.path}`;
case "flow":
return segment.selector;
case "feature_invoke":
return invocationInput(segment.invocation);
case "paste_artifact":
return "";
return "[Restored paste]";
case "uploaded_file":
return `[Attached file: ${segment.file.file_name}]`;
default:
@@ -23,7 +23,7 @@ function assertEquals<T>(actual: T, expected: T): void {
}
}
Deno.test("completionTokenAt detects command and file sigils before the cursor", () => {
Deno.test("completionTokenAt detects command, file, and Feature sigils before the cursor", () => {
assertEquals(completionTokenAt("open @src/ma", "open @src/ma".length), {
sigil: "@",
kind: "file",
@@ -32,7 +32,13 @@ Deno.test("completionTokenAt detects command and file sigils before the cursor",
prefix: "src/ma",
});
assertEquals(completionTokenAt(":comp", 5)?.kind, "command");
assertEquals(completionTokenAt("run /work", 9), null);
assertEquals(completionTokenAt("run /work", 9), {
sigil: "/",
kind: "feature",
start: 4,
end: 9,
prefix: "work",
});
assertEquals(completionTokenAt("ask #plain", "ask #plain".length), null);
});
@@ -92,3 +98,59 @@ Deno.test("localCommandCompletions filters colon commands", () => {
"compact",
]);
});
Deno.test("argument completions replace whole quoted values and preserve following arguments", () => {
const descriptor = { identity: "test/run", name: "run", aliases: [], display_name: "Run", description: "Test",
syntax: "parenthesized" as const, arguments: [{ name: "path", position: 0, required: true,
value_type: { kind: "worker_file" as const }, completion: { kind: "worker_file" as const } }] };
for (const value of ['/run("src/ma', '/run("src/ma", next=true)', '/run(path="src/ma", next=true)']) {
const cursor = value.indexOf("ma") + 1;
const token = completionTokenAt(value, cursor, [descriptor]);
assert(token, "argument token should exist");
assertEquals(token.prefix, "src/m");
assertEquals(applyCompletion(value, token, { value: '資料/a "b"/c' }).value,
value.startsWith('/run(path') ? '/run(path="資料/a \\"b\\"/c", next=true)' :
value.endsWith('true)') ? '/run("資料/a \\"b\\"/c", next=true)' : '/run("資料/a \\"b\\"/c"a');
}
});
Deno.test("incomplete or invalid quoted completion preserves mode and body after the cursor", () => {
const descriptor = { identity: "test/run", name: "run", aliases: [], display_name: "Run", description: "Test",
syntax: "parenthesized" as const, arguments: [{ name: "path", position: 0, required: true,
value_type: { kind: "worker_file" as const }, completion: { kind: "worker_file" as const } }] };
for (const value of ['/run(path="ab, mode=safe) following prose', '/run("ab, mode=safe) following prose', String.raw`/run(path="ab\q", mode=safe) following prose`]) {
const cursor = value.indexOf("ab") + 2;
const token = completionTokenAt(value, cursor, [descriptor], [{ from: 0, descriptor }]);
assert(token, "selected argument completion should exist");
assertEquals(token.end, cursor);
const result = applyCompletion(value, token, { value: "completed/資料" });
assertEquals(result.value.slice(result.cursor), value.slice(cursor));
assert(result.value.includes("mode=safe) following prose"), "other arguments and body must remain intact");
}
});
Deno.test("quoted call-looking data completes the selected outer value without erasing other arguments", () => {
const descriptor = { identity: "test/run", name: "run", aliases: [], display_name: "Run", description: "Test",
syntax: "parenthesized" as const, arguments: [{ name: "path", position: 0, required: true,
value_type: { kind: "worker_file" as const }, completion: { kind: "worker_file" as const } }] };
const value = '/run("a /run(pa", mode=safe) prose';
const token = completionTokenAt(value, value.lastIndexOf("pa") + 2, [descriptor], [{ from: 0, descriptor }]);
assert(token, "outer argument completion should exist");
assertEquals(token.context?.argument, "path");
assertEquals(token.prefix, "a /run(pa");
assertEquals(applyCompletion(value, token, { value: "replacement" }).value, '/run("replacement", mode=safe) prose');
});
Deno.test("argument name replacement preserves its value and directories drill inside quotes", () => {
const descriptor = { identity: "test/run", name: "run", aliases: [], display_name: "Run", description: "Test",
syntax: "parenthesized" as const, arguments: [{ name: "path", position: 0, required: true,
value_type: { kind: "worker_file" as const }, completion: { kind: "worker_file" as const } }] };
const named = '/run(path="original")';
const token = completionTokenAt(named, 7, [descriptor]);
assert(token, "named completion should exist");
assertEquals(applyCompletion(named, token, { value: "path=" }).value, named);
const directory = '/run("sr")';
const result = applyCompletion(directory, completionTokenAt(directory, 8, [descriptor])!, { value: "src", is_dir: true });
assertEquals(result.value, '/run("src/")');
assertEquals(completionTokenAt(result.value, result.cursor, [descriptor])?.prefix, "src/");
});
@@ -1,18 +1,25 @@
import { COMMANDS } from "./composer-command.ts";
import type {
CompletionContext,
FeatureInvocationDescriptor,
} from "#lib/generated/protocol.ts";
import { activeInvocationArgument, type SelectedInvocationOccurrence } from "./composer-invocation.ts";
export type ComposerCompletionKind = "command" | "file";
export type ComposerCompletionKind = "command" | "file" | "feature" | "feature_argument";
export type ComposerCompletionToken = {
sigil: ":" | "@";
sigil: ":" | "@" | "/" | "";
kind: ComposerCompletionKind;
start: number;
end: number;
prefix: string;
context?: CompletionContext;
};
export type ComposerCompletionEntry = {
value: string;
is_dir?: boolean;
description?: string;
usage?: string;
description?: string | null;
usage?: string | null;
invocation?: FeatureInvocationDescriptor | null;
};
export type CompletionApplyResult = { value: string; cursor: number };
const ALIASES: Record<string, string> = { "?": "help", rollback: "rewind" };
@@ -28,9 +35,40 @@ export const COLON_COMMAND_COMPLETIONS: ComposerCompletionEntry[] = Object
export function completionTokenAt(
value: string,
cursor: number,
descriptors: readonly FeatureInvocationDescriptor[] = [],
selectedOccurrences?: readonly SelectedInvocationOccurrence[],
): ComposerCompletionToken | null {
const boundedCursor = Math.max(0, Math.min(cursor, value.length));
const activeArgument = activeInvocationArgument(value, boundedCursor, descriptors, selectedOccurrences);
if (activeArgument) {
return {
sigil: "",
kind: "feature_argument",
start: activeArgument.start,
end: activeArgument.end,
prefix: activeArgument.prefix,
context: {
invocation: activeArgument.descriptor.identity,
argument: activeArgument.argument?.name,
},
};
}
const before = value.slice(0, boundedCursor);
const feature = /(^|\s)\/([a-z0-9_-]*)$/.exec(before);
if (feature) {
const prefix = feature[2] ?? "";
const start = before.length - prefix.length - 1;
const tail = /^[a-z0-9_-]*/.exec(value.slice(boundedCursor))?.[0] ?? "";
return {
sigil: "/",
kind: "feature",
start,
end: boundedCursor + tail.length,
prefix,
};
}
const match = /(^|\s)([:@])([^\s\uFFF9\uFFFB]*)$/.exec(before);
if (!match) return null;
const sigil = match[2] as ":" | "@";
@@ -78,19 +116,46 @@ export function applyCompletion(
entry: ComposerCompletionEntry,
action: "tab" | "accept" = "accept",
): CompletionApplyResult {
if (token.kind === "feature") {
const replacement = `/${entry.value}(`;
return replaceCompletionRange(value, token, replacement);
}
if (token.kind === "feature_argument") {
const argumentName = !token.context?.argument && entry.value.endsWith("=");
const completed = entry.is_dir && !entry.value.endsWith("/") ? `${entry.value}/` : entry.value;
const replacement = argumentName ? entry.value : JSON.stringify(completed);
const result = replaceCompletionRange(value,
argumentName && value[token.end] === "=" ? { ...token, end: token.end + 1 } : token,
replacement,
);
// Keep directory drilling inside the quoted value instead of after its closing quote.
if (entry.is_dir && !argumentName) result.cursor--;
return result;
}
const suffix = entry.is_dir
? (entry.value.endsWith("/") ? "" : "/")
: token.kind === "file" && action === "tab"
? ""
: " ";
const replacement = `${token.sigil}${entry.value}${suffix}`;
const restStart = suffix === " " && value[token.end] === " "
return replaceCompletionRange(
value,
token,
`${token.sigil}${entry.value}${suffix}`,
suffix === " ",
);
}
function replaceCompletionRange(
value: string,
token: ComposerCompletionToken,
replacement: string,
consumeSpace = false,
): CompletionApplyResult {
const restStart = consumeSpace && value[token.end] === " "
? token.end + 1
: token.end;
return {
value: `${value.slice(0, token.start)}${replacement}${
value.slice(restStart)
}`,
value: `${value.slice(0, token.start)}${replacement}${value.slice(restStart)}`,
cursor: token.start + replacement.length,
};
}
@@ -2,8 +2,12 @@ declare const Deno: {
test(name: string, fn: () => void): void;
};
import { deepStrictEqual, throws } from "node:assert/strict";
import type { Segment } from "#lib/generated/protocol.ts";
import type { ComposerDraftSnapshot } from "./composer-draft.ts";
import {
canDeliverComposerDraft,
ComposerAdmissions,
sendComposerDelivery,
} from "./composer-delivery.ts";
@@ -13,6 +17,187 @@ function assertEquals(actual: unknown, expected: unknown): void {
}
}
const upload: Segment = {
kind: "uploaded_file",
file: {
artifact_id: "staged-upload",
file_name: "資料.md",
media_type: "text/markdown",
created_at_ms: 1,
availability: "available",
byte_len: 4,
sha256: "abcd",
},
};
const typedSnapshot: ComposerDraftSnapshot = {
document: "exact typed draft",
content: 'before /run(path="a b/資料") after',
pastes: [],
textPastes: [],
segments: [
{ kind: "text", content: "before " },
{
kind: "feature_invoke",
invocation: {
invocation_id: "stable-invocation",
identity: "feature:test/run",
name: "run",
arguments: [{
name: "path",
value: { kind: "string", value: "a b/資料" },
}],
},
},
upload,
{ kind: "text", content: " after" },
],
};
Deno.test("Submit admission keeps exact typed draft and stable IDs across rejection and retry", () => {
const admissions = new ComposerAdmissions();
const method = {
method: "submit" as const,
params: {
submission_request_id: "stable-request",
input: typedSnapshot.segments,
},
};
const first = admissions.begin("target-a", method, typedSnapshot, "submit");
deepStrictEqual(first.snapshot, typedSnapshot);
assertEquals(admissions.protects(upload), true);
assertEquals(
admissions.acknowledge("target-b", {
event: "submission_accepted",
data: {
submission_request_id: "stable-request",
submission_id: "s",
disposition: "started",
},
}),
null,
);
assertEquals(
admissions.acknowledge("target-a", {
event: "submission_rejected",
data: {
submission_request_id: "other-client",
message: "unrelated",
},
}),
null,
);
const rejection = admissions.acknowledge("target-a", {
event: "submission_rejected",
data: {
submission_request_id: "stable-request",
message: "validation failed",
},
});
assertEquals(rejection?.accepted, false);
deepStrictEqual(rejection?.record.snapshot, typedSnapshot);
assertEquals(admissions.protects(upload), false);
const retry = admissions.begin(
"target-a",
{
...method,
params: { ...method.params, submission_request_id: "do-not-use-new-id" },
},
typedSnapshot,
"submit",
);
deepStrictEqual(retry.method, first.method);
const accepted = admissions.acknowledge("target-a", {
event: "submission_accepted",
data: {
submission_request_id: "stable-request",
submission_id: "s",
disposition: "queued",
},
});
assertEquals(accepted?.accepted, true);
deepStrictEqual(accepted?.record.snapshot.segments, typedSnapshot.segments);
assertEquals(admissions.get("target-a"), null);
});
Deno.test("unknown admission protects uploads and in-flight retries from draft deletion and target ABA", () => {
const admissions = new ComposerAdmissions();
const method = {
method: "submit" as const,
params: {
submission_request_id: "unknown-request",
input: typedSnapshot.segments,
},
};
admissions.begin("target-a", method, typedSnapshot, "queue");
admissions.disconnected("target-a");
assertEquals(admissions.get("target-a")?.status, "unknown");
assertEquals(admissions.protects(upload), true);
throws(
() =>
admissions.begin(
"target-a",
method,
{ ...typedSnapshot, segments: [] },
"queue",
),
/in-flight/,
);
admissions.disconnected("target-b");
deepStrictEqual(admissions.retry("target-a")?.method, method);
deepStrictEqual(admissions.get("target-a")?.snapshot, typedSnapshot);
assertEquals(admissions.protects(upload), true);
});
Deno.test("Notify admission correlates authoritative rejection/success and retries unchanged IDs", () => {
const admissions = new ComposerAdmissions();
const snapshot = {
...typedSnapshot,
segments: [{ kind: "text" as const, content: "通知" }],
};
const method = {
method: "notify" as const,
params: { notification_request_id: "notification-1", message: "通知" },
};
admissions.begin("target", method, snapshot, "notify");
assertEquals(
admissions.acknowledge("target", {
event: "notification_rejected",
data: { notification_request_id: "other", message: "old" },
}),
null,
);
assertEquals(
admissions.acknowledge("target", {
event: "notification_rejected",
data: {
notification_request_id: "notification-1",
message: "not running",
},
})?.accepted,
false,
);
deepStrictEqual(
admissions.begin(
"target",
{
...method,
params: { ...method.params, notification_request_id: "new" },
},
snapshot,
"notify",
).method,
method,
);
assertEquals(
admissions.acknowledge("target", {
event: "notification_accepted",
data: { notification_request_id: "notification-1" },
})?.accepted,
true,
);
assertEquals(admissions.get("target"), null);
});
const base = {
protocolOpen: true,
sending: false,
@@ -1,3 +1,123 @@
import type { Event, Method, Segment } from "#lib/generated/protocol.ts";
import type { ComposerDraftSnapshot } from "./composer-draft.ts";
export type ComposerAdmissionMethod = Extract<
Method,
{ method: "submit" | "notify" }
>;
export type ComposerAdmission = {
method: ComposerAdmissionMethod;
snapshot: ComposerDraftSnapshot;
delivery: ComposerDelivery;
status: "waiting" | "unknown" | "rejected";
};
function admissionId(method: ComposerAdmissionMethod): string {
return method.method === "submit"
? method.params.submission_request_id
: method.params.notification_request_id;
}
/** Transport send is not admission. Keep exact typed input and idempotency IDs
* until an authoritative acknowledgement; uncertain sends retain upload leases.
*/
export class ComposerAdmissions {
private records = new Map<string, ComposerAdmission>();
get(target: string): ComposerAdmission | null {
return this.records.get(target) ?? null;
}
begin(
target: string,
method: ComposerAdmissionMethod,
snapshot: ComposerDraftSnapshot,
delivery: ComposerDelivery,
): ComposerAdmission {
const prior = this.get(target);
if (prior && prior.status !== "rejected") {
throw new Error(
"Resolve the in-flight admission before editing or sending a new draft.",
);
}
const unchanged = prior && prior.method.method === method.method &&
JSON.stringify(prior.snapshot.segments) ===
JSON.stringify(snapshot.segments) &&
(prior.snapshot.textPastes.length > 0) ===
(snapshot.textPastes.length > 0);
const record: ComposerAdmission = unchanged
? { ...prior, status: "waiting" }
: {
// Route-cached drafts may contain Svelte proxies. Snapshot the actual
// JSON wire values rather than trying to structured-clone those proxies.
method: JSON.parse(JSON.stringify(method)) as ComposerAdmissionMethod,
snapshot: JSON.parse(JSON.stringify(snapshot)) as ComposerDraftSnapshot,
delivery,
status: "waiting",
};
this.records.set(target, record);
return record;
}
retry(target: string): ComposerAdmission | null {
const prior = this.get(target);
if (!prior || prior.status === "rejected") return null;
const record: ComposerAdmission = { ...prior, status: "waiting" };
this.records.set(target, record);
return record;
}
disconnected(target: string): void {
const prior = this.get(target);
if (prior && prior.status !== "rejected") {
this.records.set(target, { ...prior, status: "unknown" });
}
}
protects(segment: Segment): boolean {
if (segment.kind !== "uploaded_file") return false;
return [...this.records.values()].some((record) =>
record.status !== "rejected" &&
record.snapshot.segments.some((candidate) =>
candidate.kind === "uploaded_file" &&
candidate.file.artifact_id === segment.file.artifact_id
)
);
}
acknowledge(
target: string,
event: Event,
): { record: ComposerAdmission; accepted: boolean; message?: string } | null {
const record = this.get(target);
if (!record) return null;
let id: string;
let accepted: boolean;
let message: string | undefined;
if (
(event.event === "submission_accepted" ||
event.event === "submission_rejected") &&
record.method.method === "submit"
) {
id = event.data.submission_request_id;
accepted = event.event === "submission_accepted";
if (event.event === "submission_rejected") message = event.data.message;
} else if (
(event.event === "notification_accepted" ||
event.event === "notification_rejected") &&
record.method.method === "notify"
) {
id = event.data.notification_request_id;
accepted = event.event === "notification_accepted";
if (event.event === "notification_rejected") message = event.data.message;
} else return null;
if (id !== admissionId(record.method)) return null;
if (accepted) this.records.delete(target);
else this.records.set(target, { ...record, status: "rejected" });
return { record, accepted, message };
}
}
export type ComposerDelivery = "submit" | "queue" | "notify";
export type ComposerDeliveryState = {
@@ -22,7 +142,8 @@ export function canDeliverComposerDraft(state: ComposerDeliveryState): boolean {
case "submit":
return state.workerState === "idle" && hasInput;
case "queue":
return (state.workerState === "running" || state.workerState === "paused") &&
return (state.workerState === "running" ||
state.workerState === "paused") &&
hasInput;
case "notify":
return state.workerState === "running" && state.hasText &&
@@ -3,6 +3,8 @@ import {
composerDeletionRange,
type ComposerPaste,
composerPasteToken,
composerTypedAtoms,
composerTypedToken,
pasteChipLabel,
snapshotComposerDraft,
} from "#lib/workspace/console/composer-draft.ts";
@@ -223,6 +225,50 @@ Deno.test("Paste content beginning with a colon remains opaque user input", () =
assertEquals(afterWhitespace.request.content, " :not-a-command\r\n");
});
Deno.test("typed invocation and attachment atoms round-trip in source order and delete atomically", () => {
const invocation: Segment = {
kind: "feature_invoke",
invocation: {
invocation_id: "invoke-1",
identity: "builtin:test/run",
name: "run",
arguments: [{ name: "value", value: { kind: "string", value: "資料 x" } }],
},
};
const attachment: Segment = {
kind: "uploaded_file",
file: {
artifact_id: "artifact-1",
file_name: "report.md",
media_type: "text/markdown",
created_at_ms: 1,
availability: "available",
byte_len: 4,
sha256: "abcd",
},
};
const first = composerTypedToken(10);
const second = composerTypedToken(11);
const document = `before ${first} between ${second} after`;
const registry = new Map([
[10, { segment: invocation, label: '/run(value="資料 x")' }],
[11, { segment: attachment, label: "Attached report.md", cleanup: true }],
]);
const snapshot = snapshotComposerDraft(document, new Map(), [], registry);
assertEquals(snapshot.segments.map((segment) => segment.kind), [
"text",
"feature_invoke",
"text",
"uploaded_file",
"text",
]);
const atoms = composerTypedAtoms(document, registry);
assertEquals(
composerDeletionRange({ from: atoms[1].to, to: atoms[1].to, head: atoms[1].to }, atoms, "backward"),
{ from: atoms[1].from, to: atoms[1].to },
);
});
Deno.test("plain short-paste Text retains the existing composer request path", () => {
const result = buildComposerSegmentsRequest([
{ kind: "text", content: " short\r\npaste\r\n " },
@@ -3,6 +3,9 @@ import type { Segment } from "#lib/generated/protocol.ts";
const PASTE_TOKEN_PREFIX = "\uFFF9";
const PASTE_TOKEN_SUFFIX = "\uFFFB";
const PASTE_TOKEN_PATTERN = /\uFFF9(\d+)\uFFFB/g;
const TYPED_TOKEN_PREFIX = "\uFFF8";
const TYPED_TOKEN_SUFFIX = "\uFFFA";
const TYPED_TOKEN_PATTERN = /\uFFF8(\d+)\uFFFA/g;
export interface ComposerPaste {
id: number;
@@ -24,6 +27,19 @@ export interface ComposerTextPaste {
content: string;
}
export interface ComposerTypedSegment {
segment: Segment;
label: string;
/** Whether deleting this unsent atom must release client-staged state. */
cleanup?: boolean;
}
export interface ComposerTypedAtom extends ComposerTypedSegment {
key: number;
from: number;
to: number;
}
export interface ComposerSelection {
from: number;
to: number;
@@ -32,7 +48,7 @@ export interface ComposerSelection {
export function composerDeletionRange(
selection: ComposerSelection,
pastes: readonly ComposerPasteAtom[],
pastes: readonly { from: number; to: number }[],
direction: "backward" | "forward",
): { from: number; to: number } | null {
if (selection.from !== selection.to) {
@@ -75,6 +91,29 @@ export function composerPasteAtoms(
return atoms;
}
export function composerTypedToken(key: number): string {
return `${TYPED_TOKEN_PREFIX}${key}${TYPED_TOKEN_SUFFIX}`;
}
export function composerTypedAtoms(
document: string,
registry: ReadonlyMap<number, ComposerTypedSegment>,
): ComposerTypedAtom[] {
const atoms: ComposerTypedAtom[] = [];
for (const match of document.matchAll(TYPED_TOKEN_PATTERN)) {
const key = Number(match[1]);
const atom = registry.get(key);
if (!atom || match.index === undefined) continue;
atoms.push({
...atom,
key,
from: match.index,
to: match.index + match[0].length,
});
}
return atoms;
}
function appendTextSegment(segments: Segment[], content: string): void {
if (content.length === 0) return;
const previous = segments.at(-1);
@@ -89,8 +128,10 @@ export function snapshotComposerDraft(
document: string,
registry: ReadonlyMap<number, ComposerPaste>,
candidateTextPastes: readonly ComposerTextPaste[] = [],
typedRegistry: ReadonlyMap<number, ComposerTypedSegment> = new Map(),
): ComposerDraftSnapshot {
const pastes = composerPasteAtoms(document, registry);
const typedAtoms = composerTypedAtoms(document, typedRegistry);
const textPastes = candidateTextPastes
.filter((paste) =>
paste.from >= 0 &&
@@ -105,6 +146,12 @@ export function snapshotComposerDraft(
to: paste.to,
paste,
})),
...typedAtoms.map((atom) => ({
kind: "typed" as const,
from: atom.from,
to: atom.to,
atom,
})),
...textPastes.map((paste) => ({
kind: "text_paste" as const,
from: paste.from,
@@ -130,10 +177,14 @@ export function snapshotComposerDraft(
chars: event.paste.chars,
lines: event.paste.lines,
});
content += event.paste.content;
} else if (event.kind === "typed") {
segments.push(event.atom.segment);
content += event.atom.label;
} else {
appendTextSegment(segments, event.paste.content);
content += event.paste.content;
}
content += event.paste.content;
cursor = event.to;
}
@@ -29,7 +29,7 @@ type ComposerHistoryStorage = Pick<Storage, "getItem" | "setItem">;
function cloneEntry(entry: ComposerHistoryEntry): ComposerHistoryEntry {
return {
segments: entry.segments.map((segment) => ({ ...segment })) as Segment[],
segments: JSON.parse(JSON.stringify(entry.segments)) as Segment[],
preserveExactText: entry.preserveExactText,
};
}
@@ -45,6 +45,18 @@ function isSegment(value: unknown): value is Segment {
typeof segment.lines === "number";
}
if (segment.kind === "file_ref") return typeof segment.path === "string";
if (segment.kind === "feature_invoke") {
const invocation = segment.invocation as Record<string, unknown> | undefined;
return Boolean(invocation && typeof invocation.invocation_id === "string" &&
typeof invocation.identity === "string" && typeof invocation.name === "string" &&
Array.isArray(invocation.arguments) && invocation.arguments.every((argument) => {
if (!argument || typeof argument !== "object" || typeof argument.name !== "string") return false;
const value = argument.value;
return value && ((value.kind === "string" && typeof value.value === "string") ||
(value.kind === "integer" && Number.isSafeInteger(value.value) && value.value >= -2147483648 && value.value <= 2147483647) ||
(value.kind === "boolean" && typeof value.value === "boolean"));
}));
}
return false;
}
@@ -0,0 +1,239 @@
import { throws } from "node:assert/strict";
import type { FeatureInvocationDescriptor } from "#lib/generated/protocol.ts";
import {
activeInvocationArgument,
finalizeSelectedFeatureInvocations,
invocationInput,
selectedFeatureInvocationRanges,
} from "./composer-invocation.ts";
declare const Deno: { test(name: string, fn: () => void): void };
function assert(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message);
}
function assertEquals(actual: unknown, expected: unknown): void {
const left = JSON.stringify(actual);
const right = JSON.stringify(expected);
if (left !== right) throw new Error(`Expected ${right}, got ${left}`);
}
const descriptor: FeatureInvocationDescriptor = {
identity: "builtin:test/run",
name: "run",
aliases: ["execute"],
display_name: "Run",
description: "Run a declared operation",
syntax: "parenthesized",
arguments: [
{
name: "path",
position: 0,
required: true,
value_type: { kind: "worker_file" },
completion: { kind: "worker_file" },
},
{
name: "mode",
required: false,
value_type: { kind: "enum", values: ["safe", "fast"] },
completion: { kind: "static", values: ["safe", "fast"] },
},
],
};
Deno.test("selected invocation parsing preserves Unicode, spaces, slashes, prose, and multiple calls", () => {
const text = '前 /run("資料/a b/c", mode=safe) 後 /execute("x/y") done';
const ranges = selectedFeatureInvocationRanges(text, [descriptor]);
assertEquals(ranges.length, 2);
assertEquals(text.slice(ranges[0].end), ' 後 /execute("x/y") done');
assertEquals(ranges[0].invocation.arguments.map((argument) => argument.value), [
{ kind: "string", value: "資料/a b/c" },
{ kind: "string", value: "safe" },
]);
const segments = finalizeSelectedFeatureInvocations(
[{ kind: "text", content: text }],
[descriptor],
);
assertEquals(segments.map((segment) => segment.kind), [
"text",
"feature_invoke",
"text",
"feature_invoke",
"text",
]);
});
Deno.test("unselected text, URLs, paths, and paste content are never promoted", () => {
assertEquals(
finalizeSelectedFeatureInvocations(
[
{ kind: "text", content: '/run("x") https://host/run("y") /tmp/run("z")' },
{ kind: "paste", id: 1, chars: 9, lines: 1, content: '/run("p")' },
],
[],
).map((segment) => segment.kind),
["text", "paste"],
);
});
Deno.test("argument completion ignores quoted invocation-looking data and escaped quotes", () => {
for (const [input, prefix] of [
['/run("a /run(pa", mode=safe) prose', 'a /run(pa'],
[String.raw`/run(path="a \" /execute(pa", mode=safe) prose`, 'a " /execute(pa'],
[String.raw`/run(path="a \" /run(pa, mode=safe) prose`, 'a " /run(pa'],
]) {
const dataCursor = input.lastIndexOf("(pa") + 3;
for (const occurrences of [undefined, [{ from: 0, descriptor }]]) {
const active = activeInvocationArgument(input, dataCursor, [descriptor], occurrences);
assertEquals(active?.descriptor.identity, descriptor.identity);
assertEquals(active?.argument?.name, "path");
assertEquals(active?.start, input.indexOf('"'));
assertEquals(active?.prefix, prefix);
}
}
const input = '/run("a /run(pa", mode=safe) prose /execute("new")';
const active = activeInvocationArgument(input, input.indexOf("new") + 2, [descriptor]);
assertEquals(active?.prefix, "ne");
assertEquals(active?.start, input.lastIndexOf('"new'));
assertEquals(activeInvocationArgument(input, input.indexOf("prose") + 3, [descriptor]), null);
const unknown = '/unknown("a /run(pa", mode=safe) prose';
assertEquals(activeInvocationArgument(unknown, unknown.lastIndexOf("pa") + 2, [descriptor]), null);
const quotedProse = 'quoted "a /run(pa" prose';
assertEquals(activeInvocationArgument(quotedProse, quotedProse.lastIndexOf("pa") + 2, [descriptor]), null);
});
Deno.test("editor occurrence anchors exclude unselected matching call contexts", () => {
const input = '/run("literal /run(unfinished data before /execute("selected")';
const from = input.indexOf("/execute(");
const active = activeInvocationArgument(input, input.indexOf("selected") + 3, [descriptor], [{ from, descriptor }]);
assertEquals(active?.prefix, "sel");
assertEquals(active?.start, input.indexOf('"selected'));
assertEquals(activeInvocationArgument(input, input.indexOf("literal") + 2, [descriptor], []), null);
});
Deno.test("incomplete quoted values bound completion replacement to the cursor", () => {
for (const input of ['/run(path="ab, mode=safe) following prose', '/run("ab, mode=safe) following prose', String.raw`/run(path="ab\q", mode=safe) following prose`]) {
const cursor = input.indexOf("ab") + 2;
const active = activeInvocationArgument(input, cursor, [descriptor]);
assertEquals(active?.end, cursor);
assertEquals(active?.prefix, "ab");
}
});
Deno.test("Feature strings reject lone surrogate escapes and code units like Rust JSON", () => {
for (const literal of [String.raw`"\uD800"`, String.raw`"\uDC00"`, String.raw`"\uD800x"`, String.raw`"\uD800\uD800"`, String.raw`"\uDC00\uD800"`, JSON.stringify("\uD800"), JSON.stringify("\uDC00")]) {
throws(() => selectedFeatureInvocationRanges(`/run(${literal})`, [descriptor]), /unpaired Unicode surrogates/);
const input = `/run(${literal})`;
assertEquals(activeInvocationArgument(input, input.length - 1, [descriptor]), null);
}
throws(() => selectedFeatureInvocationRanges('/run("\uD800")', [descriptor]), /unpaired Unicode surrogates/);
throws(() => selectedFeatureInvocationRanges('/run(\uDC00)', [descriptor]), /unpaired Unicode surrogates/);
for (const literal of [String.raw`"\uD83D\uDE00"`, '"😀"', String.raw`"\uDBFF\uDFFF"`, String.raw`"\u0000\b\f\n\r\t\"\\\/資料"`]) {
const parsed = selectedFeatureInvocationRanges(`/run(${literal})`, [descriptor])[0].invocation;
assertEquals(parsed.arguments[0].value, { kind: "string", value: JSON.parse(literal) });
}
for (const literal of [String.raw`"\q"`, String.raw`"\u12xz"`, '"line\nfeed"']) {
throws(() => selectedFeatureInvocationRanges(`/run(${literal})`, [descriptor]), SyntaxError);
}
});
Deno.test("argument completion is cursor-aware and typed invocation round-trips to input", () => {
const named = activeInvocationArgument('/run("x", mo', 12, [descriptor]);
assert(named, "named argument completion should be active");
assertEquals(named.argument, null);
assertEquals(named.prefix, "mo");
const value = activeInvocationArgument('/run("x", mode=sa', 18, [descriptor]);
assert(value?.argument, "enum value completion should resolve its descriptor");
assertEquals(value.argument.name, "mode");
assertEquals(value.prefix, "sa");
const invocation = selectedFeatureInvocationRanges('/run("a b", mode=fast)', [descriptor])[0]
.invocation;
assertEquals(invocationInput(invocation), '/run(path="a b", mode="fast")');
});
Deno.test("complete invalid calls reject unknown, duplicate, missing, and mistyped arguments", () => {
for (const input of [
'/run(other="x")',
'/run("x", path="y")',
"/run()",
'/run("x", mode=invalid)',
]) {
let rejected = false;
try {
selectedFeatureInvocationRanges(input, [descriptor]);
} catch {
rejected = true;
}
assert(rejected, `${input} should be rejected`);
}
});
Deno.test("a leading slash without a selected descriptor terminates without promotion", () => {
assertEquals(activeInvocationArgument("/ru", 3, []), null);
assertEquals(activeInvocationArgument("/unknown(", 9, [descriptor]), null);
});
Deno.test("integer invocation arguments reject unsafe JavaScript wire values", () => {
const integerDescriptor: FeatureInvocationDescriptor = { ...descriptor,
arguments: [{ name: "count", position: 0, required: true, value_type: { kind: "integer" }, completion: { kind: "none" } }] };
let rejected = false;
try { selectedFeatureInvocationRanges("/run(9007199254740992)", [integerDescriptor]); }
catch { rejected = true; }
assert(rejected, "unsafe integers must reject before sending to Rust");
});
Deno.test("structured invocation-only Submit and mixed source order retain exact typed payloads", async () => {
const { buildComposerSegmentsRequest } = await import("./composer-command.ts");
const invocation = finalizeSelectedFeatureInvocations([{ kind: "text", content: '/run("a b/資料")' }], [descriptor])[0];
const segments = [{ kind: "text" as const, content: "before " }, invocation, { kind: "text" as const, content: " after" }];
const result = buildComposerSegmentsRequest(segments);
assert(result.ok && result.request, "mixed invocation should build a Submit");
assertEquals(result.request.segments, segments);
const only = buildComposerSegmentsRequest([invocation]);
assert(only.ok && only.request?.segments?.[0].kind === "feature_invoke", "invocation-only Submit must not become empty text");
});
Deno.test("positional completion after a named-only argument uses positional rather than comma index", () => {
const input = '/run(mode=safe, "';
assertEquals(activeInvocationArgument(input, input.length, [descriptor])?.argument?.name, "path");
});
Deno.test("Feature string parsing decodes full JSON escapes and preserves round-trip Unicode", () => {
const raw = String.raw`/run("a\b\f\n\r\t\"\\\/\u0000\uD83D\uDE00")`;
const parsed = selectedFeatureInvocationRanges(raw, [descriptor])[0].invocation;
assertEquals(parsed.arguments[0].value, { kind: "string", value: 'a\b\f\n\r\t"\\/\u0000😀' });
const restored = selectedFeatureInvocationRanges(invocationInput(parsed), [descriptor])[0].invocation;
assertEquals(restored.arguments, parsed.arguments);
});
Deno.test("Feature integer lexical syntax matches negative-or-positive digits only", () => {
const integerDescriptor: FeatureInvocationDescriptor = { ...descriptor,
arguments: [{ name: "count", position: 0, required: true, value_type: { kind: "integer" }, completion: { kind: "none" } }] };
for (const [raw, value] of [["-0", 0], ["0012", 12], ["-0012", -12]] as const) {
assertEquals(selectedFeatureInvocationRanges(`/run(${raw})`, [integerDescriptor])[0].invocation.arguments[0].value,
{ kind: "integer", value });
}
for (const raw of ["+1", "1.0", "1e0", "NaN", "Infinity"]) {
let rejected = false;
try { selectedFeatureInvocationRanges(`/run(${raw})`, [integerDescriptor]); } catch { rejected = true; }
assert(rejected, `${raw} must reject`);
}
});
Deno.test("invocation integer parsing matches the generated Rust int32 schema", () => {
const integerDescriptor: FeatureInvocationDescriptor = { ...descriptor,
arguments: [{ name: "count", position: 0, required: true, value_type: { kind: "integer" }, completion: { kind: "none" } }] };
for (const value of [-2147483648, 2147483647]) {
assertEquals(selectedFeatureInvocationRanges(`/run(${value})`, [integerDescriptor])[0].invocation.arguments[0].value,
{ kind: "integer", value });
}
for (const value of [-2147483649, 2147483648]) {
let rejected = false;
try { selectedFeatureInvocationRanges(`/run(${value})`, [integerDescriptor]); } catch { rejected = true; }
assert(rejected, "values outside Rust int32 range must reject");
}
});
@@ -0,0 +1,433 @@
import type {
FeatureInvocation,
FeatureInvocationDescriptor,
InvocationArgumentDescriptor,
InvocationArgumentValue,
InvocationValue,
Segment,
} from "#lib/generated/protocol.ts";
export type ActiveInvocationArgument = {
descriptor: FeatureInvocationDescriptor;
argument: InvocationArgumentDescriptor | null;
start: number;
end: number;
prefix: string;
};
export function quoteInvocationString(value: string): string {
return JSON.stringify(value);
}
export function invocationInput(invocation: FeatureInvocation): string {
const argumentsText = invocation.arguments.map((argument) =>
`${argument.name}=${invocationValueInput(argument.value)}`
).join(", ");
return `/${invocation.name}(${argumentsText})`;
}
function invocationValueInput(value: InvocationValue): string {
switch (value.kind) {
case "string":
return quoteInvocationString(value.value);
case "integer":
case "boolean":
return String(value.value);
}
}
export type SelectedInvocationOccurrence = {
from: number;
descriptor: FeatureInvocationDescriptor;
};
export function activeInvocationArgument(
value: string,
cursor: number,
descriptors: readonly FeatureInvocationDescriptor[],
selectedOccurrences?: readonly SelectedInvocationOccurrence[],
): ActiveInvocationArgument | null {
const bounded = Math.max(0, Math.min(cursor, value.length));
const occurrences = selectedOccurrences ?? activeInvocationOccurrences(value, bounded, descriptors);
for (const { from: slash, descriptor } of occurrences) {
if (slash < 0 || slash >= bounded) continue;
const tail = value.slice(slash, bounded);
if (![descriptor.name, ...descriptor.aliases].some((name) => tail.startsWith(`/${name}(`))) continue;
const open = tail.indexOf("(");
const argumentsText = tail.slice(open + 1);
if (hasUnquotedClose(argumentsText)) continue;
const currentOffset = lastUnquotedComma(argumentsText) + 1;
const rawCurrent = argumentsText.slice(currentOffset);
const leading = rawCurrent.length - rawCurrent.trimStart().length;
const current = rawCurrent.trimStart();
const equal = unquotedEqual(current);
if (equal >= 0) {
const name = current.slice(0, equal).trim();
const argument = descriptor.arguments.find((candidate) => candidate.name === name) ?? null;
let valueOffset = equal + 1;
while (/\s/.test(current[valueOffset] ?? "")) valueOffset++;
const prefix = invocationCompletionPrefix(current.slice(valueOffset));
if (prefix === null) return null;
return {
descriptor,
argument,
start: slash + open + 1 + currentOffset + leading + valueOffset,
end: invocationValueEnd(value, bounded, slash + open + 1 + currentOffset + leading + valueOffset),
prefix,
};
}
const position = countPositionalArguments(argumentsText.slice(0, currentOffset));
const positional = descriptor.arguments.find((candidate) => candidate.position === position) ?? null;
const looksLikeArgumentName = /^[a-z][a-z0-9_]*$/.test(current) &&
descriptor.arguments.some((candidate) => candidate.name.startsWith(current));
const prefix = invocationCompletionPrefix(current);
if (prefix === null) return null;
return {
descriptor,
argument: looksLikeArgumentName ? null : positional,
start: slash + open + 1 + currentOffset + leading,
end: looksLikeArgumentName
? bounded + (/^[a-z0-9_]*/.exec(value.slice(bounded))?.[0].length ?? 0)
: invocationValueEnd(value, bounded, slash + open + 1 + currentOffset + leading),
prefix,
};
}
return null;
}
/** Scan forward and skip entire quoted argument regions, never treating a slash
* inside an unfinished outer value as a new call. The editor supplies selected
* occurrence anchors instead of relying on descriptor-only discovery.
*/
function activeInvocationOccurrences(
value: string,
cursor: number,
descriptors: readonly FeatureInvocationDescriptor[],
): SelectedInvocationOccurrence[] {
let active: SelectedInvocationOccurrence | null = null;
scanEachUnquoted(value.slice(0, cursor), (char, slash) => {
if (active) {
if (char === ")") active = null;
return;
}
if (char !== "/" || (slash > 0 && !/\s/.test(value[slash - 1]))) return;
const descriptor = descriptors.find((candidate) =>
[candidate.name, ...candidate.aliases].some((name) => value.startsWith(`/${name}(`, slash))
);
if (descriptor) active = { from: slash, descriptor };
});
return active ? [active] : [];
}
function invocationCompletionPrefix(value: string): string | null {
let prefix = value;
if (value.startsWith('"')) {
try {
prefix = JSON.parse(value.endsWith('"') ? value : `${value}"`);
} catch {
prefix = value.slice(1);
}
}
try {
return validInvocationString(prefix);
} catch {
// Never send a completion prefix that Rust's JSON reader cannot decode.
return null;
}
}
function invocationValueEnd(text: string, cursor: number, start: number): number {
try {
const parsed = parseRawValue(text, start);
// Without a complete, valid token the suffix is untouched user input.
return parsed && parsed.end >= cursor ? parsed.end : cursor;
} catch {
return cursor;
}
}
export function selectedFeatureInvocationAt(
text: string,
start: number,
descriptor: FeatureInvocationDescriptor,
): ParsedInvocationRange | null {
const parsed = parseFeatureInvocation(text, start, descriptor);
return parsed ? { start, end: parsed.end, invocation: parsed.invocation } : null;
}
export type ParsedInvocationRange = {
start: number;
end: number;
invocation: FeatureInvocation;
};
/** Parse complete invocations selected from completion without promoting arbitrary text. */
export function selectedFeatureInvocationRanges(
text: string,
descriptors: readonly FeatureInvocationDescriptor[],
): ParsedInvocationRange[] {
const ranges: ParsedInvocationRange[] = [];
let search = 0;
while (search < text.length) {
const slash = text.indexOf("/", search);
if (slash < 0) break;
const descriptor = (slash === 0 || /\s/.test(text[slash - 1]))
? descriptors.find((candidate) =>
[candidate.name, ...candidate.aliases].some((name) =>
text.startsWith(`/${name}(`, slash)
)
)
: undefined;
if (!descriptor) {
search = slash + 1;
continue;
}
const parsed = parseFeatureInvocation(text, slash, descriptor);
if (!parsed) {
search = slash + 1;
continue;
}
ranges.push({ start: slash, end: parsed.end, invocation: parsed.invocation });
search = parsed.end;
}
return ranges;
}
export function finalizeSelectedFeatureInvocations(
segments: readonly Segment[],
descriptors: readonly FeatureInvocationDescriptor[],
): Segment[] {
const result: Segment[] = [];
for (const segment of segments) {
if (segment.kind !== "text") {
result.push(segment);
continue;
}
result.push(...parseTextInvocations(segment.content, descriptors));
}
return coalesceText(result);
}
function parseTextInvocations(
text: string,
descriptors: readonly FeatureInvocationDescriptor[],
): Segment[] {
const result: Segment[] = [];
let emitted = 0;
let search = 0;
while (search < text.length) {
const slash = text.indexOf("/", search);
if (slash < 0) break;
const leadingOk = slash === 0 || /\s/.test(text[slash - 1]);
const descriptor = leadingOk
? descriptors.find((candidate) =>
[candidate.name, ...candidate.aliases].some((name) =>
text.startsWith(`/${name}(`, slash)
)
)
: undefined;
if (!descriptor) {
search = slash + 1;
continue;
}
const parsed = parseFeatureInvocation(text, slash, descriptor);
if (!parsed) {
search = slash + 1;
continue;
}
appendText(result, text.slice(emitted, slash));
result.push({ kind: "feature_invoke", invocation: parsed.invocation });
emitted = parsed.end;
search = parsed.end;
}
if (!result.length) return [{ kind: "text", content: text }];
appendText(result, text.slice(emitted));
return result;
}
function parseFeatureInvocation(
text: string,
start: number,
descriptor: FeatureInvocationDescriptor,
): { invocation: FeatureInvocation; end: number } | null {
let cursor = start + 1;
while (/[a-z0-9_-]/.test(text[cursor] ?? "")) cursor++;
const name = text.slice(start + 1, cursor);
if (name !== descriptor.name && !descriptor.aliases.includes(name)) return null;
while (/\s/.test(text[cursor] ?? "")) cursor++;
if (text[cursor] !== "(") return null;
cursor++;
const values: InvocationArgumentValue[] = [];
let position = 0;
while (true) {
while (/\s/.test(text[cursor] ?? "")) cursor++;
if (text[cursor] === ")") {
cursor++;
break;
}
if (cursor >= text.length) return null;
const itemStart = cursor;
while (/[a-z0-9_]/.test(text[cursor] ?? "")) cursor++;
const possibleName = text.slice(itemStart, cursor);
while (/\s/.test(text[cursor] ?? "")) cursor++;
let argument: InvocationArgumentDescriptor | undefined;
if (possibleName && text[cursor] === "=") {
argument = descriptor.arguments.find((candidate) => candidate.name === possibleName);
if (!argument) throw new Error(`Unknown Feature argument: ${possibleName}`);
cursor++;
while (/\s/.test(text[cursor] ?? "")) cursor++;
} else {
cursor = itemStart;
argument = descriptor.arguments.find((candidate) => candidate.position === position++);
if (!argument) throw new Error("Too many positional Feature arguments.");
}
const parsed = parseRawValue(text, cursor);
if (!parsed) return null;
cursor = parsed.end;
if (values.some((candidate) => candidate.name === argument.name)) {
throw new Error(`Duplicate Feature argument: ${argument.name}`);
}
values.push({ name: argument.name, value: coerceValue(argument, parsed.value) });
while (/\s/.test(text[cursor] ?? "")) cursor++;
if (text[cursor] === ",") cursor++;
else if (text[cursor] !== ")") return null;
}
for (const argument of descriptor.arguments) {
if (argument.required && !values.some((value) => value.name === argument.name)) {
throw new Error(`Missing required Feature argument: ${argument.name}`);
}
}
return {
invocation: {
invocation_id: crypto.randomUUID(),
identity: descriptor.identity,
name,
arguments: values,
},
end: cursor,
};
}
function parseRawValue(text: string, start: number): { value: string; end: number } | null {
if (text[start] === '"') {
let cursor = start + 1;
let escaped = false;
while (cursor < text.length) {
const char = text[cursor];
if (!escaped && char === '"') {
const literal = text.slice(start, cursor + 1);
return { value: validInvocationString(JSON.parse(literal)), end: cursor + 1 };
}
escaped = !escaped && char === "\\";
if (char !== "\\") escaped = false;
cursor++;
}
return null;
}
let cursor = start;
while (cursor < text.length && !/[\s,)]/.test(text[cursor])) cursor++;
return cursor === start ? null : { value: validInvocationString(text.slice(start, cursor)), end: cursor };
}
/** Rust JSON strings contain Unicode scalar values, not lone UTF-16 code units.
* JSON.parse alone permits unpaired surrogate escapes that serde_json rejects.
*/
function validInvocationString(value: string): string {
for (let index = 0; index < value.length; index++) {
const unit = value.charCodeAt(index);
if (unit >= 0xD800 && unit <= 0xDBFF) {
const next = value.charCodeAt(++index);
if (next >= 0xDC00 && next <= 0xDFFF) continue;
} else if (unit < 0xDC00 || unit > 0xDFFF) continue;
throw new Error("Feature string arguments must not contain unpaired Unicode surrogates.");
}
return value;
}
function coerceValue(argument: InvocationArgumentDescriptor, value: string): InvocationValue {
switch (argument.value_type.kind) {
case "integer": {
if (!/^-?\d+$/.test(value)) throw new Error(`Feature argument ${argument.name} must be an integer.`);
const integer = Number(value);
if (!Number.isSafeInteger(integer)) {
throw new Error(`Feature argument ${argument.name} must be a safe integer.`);
}
if (integer < -2147483648 || integer > 2147483647) {
throw new Error(`Feature argument ${argument.name} must be a signed 32-bit integer.`);
}
return { kind: "integer", value: integer };
}
case "boolean":
if (value !== "true" && value !== "false") {
throw new Error(`Feature argument ${argument.name} must be true or false.`);
}
return { kind: "boolean", value: value === "true" };
case "enum":
if (!argument.value_type.values.includes(value)) {
throw new Error(`Feature argument ${argument.name} is not an allowed value.`);
}
return { kind: "string", value };
case "string":
case "worker_file":
case "client_file":
return { kind: "string", value };
}
}
function appendText(segments: Segment[], content: string): void {
if (!content) return;
const prior = segments.at(-1);
if (prior?.kind === "text") prior.content += content;
else segments.push({ kind: "text", content });
}
function coalesceText(segments: Segment[]): Segment[] {
const result: Segment[] = [];
for (const segment of segments) {
if (segment.kind === "text") appendText(result, segment.content);
else result.push(segment);
}
return result;
}
// These helpers only inspect syntax before the cursor. They deliberately do not
// execute or finalize anything.
function hasUnquotedClose(value: string): boolean {
return scanUnquoted(value, (char) => char === ")") >= 0;
}
function lastUnquotedComma(value: string): number {
let last = -1;
scanEachUnquoted(value, (char, index) => { if (char === ",") last = index; });
return last;
}
function unquotedEqual(value: string): number {
return scanUnquoted(value, (char) => char === "=");
}
function countPositionalArguments(value: string): number {
let count = 0;
let start = 0;
scanEachUnquoted(value, (char, index) => {
if (char !== ",") return;
if (value.slice(start, index).trim() && unquotedEqual(value.slice(start, index)) < 0) count++;
start = index + 1;
});
return count;
}
function scanUnquoted(value: string, predicate: (char: string) => boolean): number {
let found = -1;
scanEachUnquoted(value, (char, index) => {
if (found < 0 && predicate(char)) found = index;
});
return found;
}
function scanEachUnquoted(value: string, visit: (char: string, index: number) => void): void {
let quoted = false;
let escaped = false;
for (let index = 0; index < value.length; index++) {
const char = value[index];
if (!escaped && char === '"') quoted = !quoted;
if (!quoted) visit(char, index);
escaped = quoted && !escaped && char === "\\";
if (char !== "\\") escaped = false;
}
}
@@ -1,70 +1,79 @@
import { FileCompletions } from "./file-completions.ts";
import { deepStrictEqual as assertEquals, rejects } from "node:assert/strict";
async function assertRejects(
fn: () => Promise<unknown>,
_type: typeof Error,
message: string,
) {
await rejects(
fn,
(error: unknown) =>
error instanceof Error && error.message.includes(message),
);
}
import {
deepStrictEqual as assertEquals,
notEqual,
rejects,
} from "node:assert/strict";
declare const Deno: { test(name: string, fn: () => Promise<void>): void };
Deno.test("file completion serializes the latest prefix behind cancelled wire replies", async () => {
const sent: string[] = [];
const lane = new FileCompletions((prefix) => sent.push(prefix));
Deno.test("completion nonces fence same prefix/context ABA without draining cancelled requests", async () => {
const sent: Array<[string, string]> = [];
const lane = new FileCompletions((prefix, id) => sent.push([prefix, id]));
const first = new AbortController();
const a = lane.request("a", first.signal);
const a = lane.request("same", first.signal);
first.abort();
assertEquals(await a, []);
const second = new AbortController();
const b = lane.request("b", second.signal);
second.abort();
const c = lane.request("c", new AbortController().signal);
const b = lane.request("other", new AbortController().signal);
const c = lane.request("same", new AbortController().signal);
assertEquals(await b, []);
assertEquals(sent, ["a"]);
lane.receive([{ value: "old-a" }]);
assertEquals(sent, ["a", "c"]);
lane.receive([{ value: "current-c" }]);
assertEquals(sent.map(([prefix]) => prefix), ["same", "other", "same"]);
notEqual(sent[0][1], sent[2][1]);
lane.receive([{ value: "old-a" }], "same", sent[0][1]);
lane.receive([{ value: "legacy" }], "same");
lane.receive([{ value: "wrong-prefix" }], "other", sent[2][1]);
assertEquals(lane.pending, true);
lane.receive([{ value: "current-c" }], "same", sent[2][1]);
assertEquals(await c, [{ value: "current-c" }]);
lane.close();
});
Deno.test("file completion timeout blocks late responses until a new connection", async () => {
const sent: string[] = [];
const lane = new FileCompletions((prefix) => sent.push(prefix), 5);
await assertRejects(
() => lane.request("a", new AbortController().signal),
Error,
"timed out",
Deno.test("multiple clients cannot consume each other's identical completion broadcasts", async () => {
const ids: string[] = [];
const lanes = [
new FileCompletions((_prefix, id) => ids.push(id)),
new FileCompletions((_prefix, id) => ids.push(id)),
];
const promises = lanes.map((lane) =>
lane.request("same", new AbortController().signal)
);
lane.receive([{ value: "late-a" }]);
await assertRejects(
() => lane.request("b", new AbortController().signal),
Error,
"timed out",
notEqual(ids[0], ids[1]);
for (const lane of lanes) {
lane.receive([{ value: "client-a" }], "same", ids[0]);
}
assertEquals(await promises[0], [{ value: "client-a" }]);
assertEquals(lanes[1].pending, true);
for (const lane of lanes) {
lane.receive([{ value: "client-b" }], "same", ids[1]);
}
assertEquals(await promises[1], [{ value: "client-b" }]);
lanes.forEach((lane) => lane.close());
});
Deno.test("completion timeout and reconnect invalidate old nonces but allow a fresh request", async () => {
const ids: string[] = [];
const lane = new FileCompletions((_prefix, id) => ids.push(id), 5);
await rejects(
lane.request("same", new AbortController().signal),
/timed out/,
);
assertEquals(sent, ["a"]);
lane.reset();
const c = lane.request("c", new AbortController().signal);
lane.receive([{ value: "c" }]);
assertEquals(await c, [{ value: "c" }]);
const current = lane.request("same", new AbortController().signal);
lane.receive([{ value: "old" }], "same", ids[0]);
assertEquals(lane.pending, true);
lane.receive([{ value: "new" }], "same", ids[1]);
assertEquals(await current, [{ value: "new" }]);
lane.close();
});
Deno.test("file completion close rejects both active and queued requests", async () => {
Deno.test("completion close rejects active request and fences requests until reset", async () => {
const lane = new FileCompletions(() => {});
const a = lane.request("a", new AbortController().signal);
const b = lane.request("b", new AbortController().signal);
const outcomes = Promise.allSettled([a, b]);
const rejected = rejects(a, /disconnected/);
lane.close(new Error("disconnected"));
assertEquals((await outcomes).map((value) => value.status), [
"rejected",
"rejected",
]);
await rejected;
await rejects(
lane.request("b", new AbortController().signal),
/disconnected/,
);
});
@@ -1,6 +1,7 @@
import type { ComposerCompletionEntry } from "./composer-completion.ts";
type Request = {
id: string;
prefix: string;
signal: AbortSignal;
resolve: (entries: ComposerCompletionEntry[]) => void;
@@ -8,18 +9,18 @@ type Request = {
abort: () => void;
};
/** The wire reply has no request id/prefix. Never overlap requests on a lane,
* including cancelled requests: their reply must be consumed before the next.
* A timeout poisons the lane until reconnect, rather than misrouting a late reply.
/** A lane is scoped to a transport target and completion kind/context. Each
* request also has a unique wire nonce: cancelled requests never need draining,
* and legacy replies or another client's same-prefix broadcasts cannot match.
* The editor's abort signal fences the draft, cursor and completion generation.
*/
export class FileCompletions {
private active: Request | null = null;
private queued: Request | null = null;
private timer: ReturnType<typeof setTimeout> | undefined;
private failure: Error | null = null;
constructor(
private send: (prefix: string) => void,
private send: (prefix: string, requestId: string) => void,
private timeoutMs = 30_000,
) {}
@@ -33,42 +34,48 @@ export class FileCompletions {
): Promise<ComposerCompletionEntry[]> {
if (signal.aborted) return Promise.resolve([]);
if (this.failure) return Promise.reject(this.failure);
this.finish([]);
return new Promise((resolve, reject) => {
const request: Request = {
id: crypto.randomUUID(),
prefix,
signal,
resolve,
reject,
abort: () => {
resolve([]);
if (this.queued === request) this.queued = null;
if (this.active === request) this.finish([]);
},
};
this.active = request;
signal.addEventListener("abort", request.abort, { once: true });
if (this.queued) this.finish(this.queued, []);
this.queued = request;
this.pump();
this.timer = setTimeout(() => {
if (this.active === request) {
this.reject(new Error("Worker completion request timed out; retry."));
}
}, this.timeoutMs);
try {
this.send(prefix, request.id);
} catch (error) {
this.reject(error instanceof Error ? error : new Error(String(error)));
}
});
}
receive(entries: ComposerCompletionEntry[]): void {
if (!this.active) return;
clearTimeout(this.timer);
const request = this.active;
this.active = null;
this.finish(request, entries);
this.pump();
receive(
entries: ComposerCompletionEntry[],
prefix: string,
requestId?: string | null,
): void {
if (
!this.active || this.active.id !== requestId ||
this.active.prefix !== prefix
) return;
this.finish(entries);
}
close(error = new Error("Worker completion connection closed.")): void {
this.failure = error;
clearTimeout(this.timer);
for (const request of [this.active, this.queued]) {
if (!request) continue;
request.signal.removeEventListener("abort", request.abort);
request.reject(error);
}
this.active = this.queued = null;
this.reject(error);
}
reset(): void {
@@ -76,31 +83,20 @@ export class FileCompletions {
this.failure = null;
}
private finish(request: Request, entries: ComposerCompletionEntry[]): void {
request.signal.removeEventListener("abort", request.abort);
request.resolve(request.signal.aborted ? [] : entries);
private take(): Request | null {
clearTimeout(this.timer);
const request = this.active;
this.active = null;
request?.signal.removeEventListener("abort", request.abort);
return request;
}
private pump(): void {
if (this.active || !this.queued || this.failure) return;
const request = this.queued;
this.queued = null;
if (request.signal.aborted) {
this.finish(request, []);
return;
}
this.active = request;
this.timer = setTimeout(
() =>
this.close(
new Error("Worker completion request timed out; reconnect to retry."),
),
this.timeoutMs,
);
try {
this.send(request.prefix);
} catch (error) {
this.close(error instanceof Error ? error : new Error(String(error)));
}
private finish(entries: ComposerCompletionEntry[]): void {
const request = this.take();
request?.resolve(request.signal.aborted ? [] : entries);
}
private reject(error: Error): void {
this.take()?.reject(error);
}
}
@@ -352,7 +352,7 @@ Deno.test("console routing projects live errors but not completion replies", ()
} satisfies Event;
const completionEvent = {
event: "completions",
data: { kind: "file", entries: [] },
data: { kind: "file", prefix: "", entries: [] },
} satisfies Event;
assert(
@@ -3961,3 +3961,11 @@ Deno.test("committed history inserts current live rows after their last stable a
["current-user", "live-assistant"],
);
});
Deno.test("typed Feature invocations remain visible in live and restored user messages", () => {
const segment = { kind: "feature_invoke" as const, invocation: {
invocation_id: "invoke-1", identity: "test/run", name: "run",
arguments: [{ name: "path", value: { kind: "string" as const, value: "資料/a b" } }],
} };
assertEquals(segmentsToText([segment]), '/run(path="資料/a b")');
});
@@ -19,6 +19,7 @@ import type {
WorkerStateSnapshot,
WorkerStatus,
} from "#lib/generated/protocol.ts";
import { invocationInput } from "./composer-invocation.ts";
import { stringify as stringifyYaml } from "yaml";
import {
applyRunActivityEvent,
@@ -1344,6 +1345,8 @@ export function segmentsToText(segments: Segment[]): string {
return `[Large paste artifact ${segment.artifact.artifact_id}: ${segment.artifact.byte_len} bytes, ${segment.artifact.media_type}, ${segment.artifact.availability}, created ${segment.artifact.created_at_ms} ms, sha256 ${segment.artifact.sha256}]`;
case "uploaded_file":
return `[Attachment: ${segment.file.file_name} · ${segment.file.media_type} · ${segment.file.byte_len} bytes · ${segment.file.availability}]`;
case "feature_invoke":
return invocationInput(segment.invocation);
case "file_ref":
return `@file ${segment.path}`;
case "unknown":
@@ -1017,7 +1017,7 @@ Deno.test("Worker Console paste chips preserve typed draft and target authority"
composerInput.includes('key: "Backspace"') &&
composerInput.includes('key: "Delete"') &&
composerInput.includes(
"composerDeletionRange(selection, pastes, direction)",
"composerDeletionRange(selection, atoms, direction)",
) &&
composerInput.includes("EditorState.readOnly.of(isDisabled)") &&
composerInput.includes('key: "Mod-z"') &&
@@ -1572,8 +1572,8 @@ Deno.test("Composer owns TUI-style completion and replaces Compact/Rewind header
"Completion navigation must precede input history",
);
assert(
page.includes("fileCompletions.receive(event.data.entries)") &&
page.includes("fileCompletions.close()"),
page.includes("fileCompletions.receive(event.data.entries, event.data.prefix, event.data.request_id)") &&
page.includes("rejectPendingCompletion(new Error(\"Worker completion connection closed.\"), `${target.workspaceId}:${target.runtimeId}:${target.workerId}`)"),
"File completion lifecycle must be scoped to the transport",
);
});
@@ -1701,7 +1701,7 @@ Deno.test("Web Console uses Notify while running and exposes queued input cancel
consolePage.includes("{#if workerRunning || workerPaused}") &&
consolePage.includes('delivery === "queue" && request.kind !== "user"') &&
consolePage.includes(
'method.method === "submit" && delivery === "submit"',
'event.event === "submission_accepted" && event.data.disposition === "started"',
),
"busy Console must offer explicit Queue without changing paused state or queueing commands",
);
@@ -323,3 +323,28 @@ Deno.test('stateful selector validation fences a routed event before projection'
'mismatched Worker event crossed its subscription selector',
);
});
Deno.test('generated subscription schema accepts invocation descriptors, context and typed user segments', () => {
const descriptor = { identity: 'test/run', name: 'run', aliases: ['execute'], display_name: 'Run', description: 'Test',
syntax: 'parenthesized', client_adapter: null, arguments: [{ name: 'count', position: 0, required: true,
value_type: { kind: 'integer' }, completion: { kind: 'static', values: ['1', '2'] } }] };
const frame = (events: unknown[]) => subscribedFrame({
selector: { topic: 'worker_protocol', worker_id: 'worker-1' },
snapshot: { topic: 'worker_protocol', data: { worker_id: 'worker-1', events } },
});
const completion = { event: 'completions', data: { kind: 'feature_argument', prefix: '1',
context: { invocation: 'test/run', argument: 'count' },
entries: [{ value: '1', is_dir: false, invocation: descriptor }] } };
const segment = { kind: 'feature_invoke', invocation: { invocation_id: 'invoke-1', identity: 'test/run', name: 'run',
arguments: [{ name: 'count', value: { kind: 'integer', value: 1 } }] } };
const valid = decode(frame([completion, { event: 'user_message', data: { segments: [segment] } }]));
assert(valid.ok, `invocation schema mismatch: ${JSON.stringify(valid)}`);
const invalid = decode(frame([{ event: 'user_message', data: { segments: [{ ...segment, invocation: {
...segment.invocation, arguments: [{ name: 'count', value: { kind: 'integer', value: 9007199254740992 } }],
} }] } }]));
assert(!invalid.ok, 'generated invocation schema must reject unsafe wire integers');
const newer = decode(frame([{ ...completion, data: { ...completion.data, entries: [
{ value: '1', is_dir: false, invocation: { ...descriptor, client_adapter: 'newer_adapter' } },
] } }]));
assert(!newer.ok, 'unknown adapter capabilities must fail closed');
});
@@ -11,6 +11,8 @@
import ComposerInput from "#lib/workspace/console/ComposerInput.svelte";
import type { ComposerDraftSnapshot } from "#lib/workspace/console/composer-draft.ts";
import {
ComposerAdmissions,
type ComposerAdmission,
canDeliverComposerDraft,
sendComposerDelivery,
type ComposerDelivery,
@@ -38,6 +40,7 @@
type ConsoleViewScroll,
} from "#lib/workspace/console/model.ts";
import type {
CompletionContext,
Event as ProtocolEvent,
Method as ProtocolMethod,
PendingSubmissionsSnapshot,
@@ -115,10 +118,15 @@
containsTarget(target: EventTarget | null): boolean;
cursor(): number;
replaceRange(from: number, to: number, content: string): void;
clear(): void;
insertSegment(segment: Segment, cleanup?: boolean): boolean;
reserveUpload(fileName: string): number | null;
completeUpload(key: number, segment: Segment): boolean;
cancelUpload(key: number): void;
clear(acceptedTypedResources?: boolean): void;
restoreSegments(
segments: readonly Segment[],
preserveExactText?: boolean,
ownStagedUploads?: boolean,
): void;
};
@@ -138,9 +146,35 @@
let draft = $state<ComposerDraftSnapshot>(EMPTY_DRAFT);
let attachments = $state<ComposerAttachment[]>([]);
let nextAttachmentId = 1;
const stagedAttachmentPaths = new Map<string, string>();
const uploadReservations = new Map<number, number>();
let fileInput: HTMLInputElement | null = null;
let isDraggingFiles = $state(false);
const admissions = new ComposerAdmissions();
let admission = $state.raw<ComposerAdmission | null>(null);
let sending = $state(false);
function syncAdmission() {
admission = admissions.get(activeComposerTargetKey);
sending = admission !== null && admission.status !== "rejected";
}
function admissionDisconnected(targetKey = activeComposerTargetKey) {
admissions.disconnected(targetKey);
syncAdmission();
}
function retryAdmission() {
const retry = admissions.retry(activeComposerTargetKey);
if (!retry || protocolState !== "open") return;
syncAdmission();
try {
sendProtocolMethod(retry.method);
} catch (error) {
admissionDisconnected();
reportComposerError(error instanceof Error ? error.message : String(error));
}
}
let rewindTargets = $state<RewindTarget[]>([]);
let rewindHeadEntries = $state(0);
let protocolState = $state<"connecting" | "open" | "closed" | "error">(
@@ -158,9 +192,30 @@
submissions: [],
});
let pendingSubmissionItems = $derived(pendingSubmissions.submissions ?? []);
const fileCompletions = new FileCompletions((prefix) => sendProtocolMethod({
method: "list_completions", params: { kind: "file", prefix },
const fileCompletions = new FileCompletions((prefix, request_id) => sendProtocolMethod({
method: "list_completions", params: { kind: "file", prefix, request_id },
}));
const featureCompletions = new FileCompletions((prefix, request_id) => sendProtocolMethod({
method: "list_completions", params: { kind: "feature", prefix, request_id },
}));
const featureArgumentCompletions = new Map<string, FileCompletions>();
function completionContextKey(context: CompletionContext): string {
return JSON.stringify([context.invocation, context.argument ?? null]);
}
function featureArgumentCompletionLane(context: CompletionContext): FileCompletions {
const key = completionContextKey(context);
let lane = featureArgumentCompletions.get(key);
if (!lane) {
lane = new FileCompletions((prefix, request_id) => sendProtocolMethod({
method: "list_completions",
params: { kind: "feature_argument", prefix, context, request_id },
}));
featureArgumentCompletions.set(key, lane);
}
return lane;
}
let streamDiagnostics = $state<Diagnostic[]>([]);
let workerDetailsOpen = $state(false);
let taskPaneOpen = $state(false);
@@ -260,7 +315,9 @@
const workerPaused = $derived(workerState === "paused");
const composerEditable = $derived(protocolState === "open" && !sending);
const draftHasText = $derived(draft.content.trim().length > 0);
const draftHasAttachments = $derived(attachments.length > 0);
const draftHasAttachments = $derived(
attachments.length > 0 || draft.segments.some((segment) => segment.kind === "uploaded_file"),
);
const canSubmitDraft = $derived(
canDeliverComposerDraft({
delivery: "submit",
@@ -712,6 +769,17 @@
initialSnapshot?: { token: number; source: ConsoleDisplaySource },
) {
handleProtocolCommandEvent(payload);
if (payload.event === "rewind_applied") {
if (draft.segments.length === 0 && attachments.length === 0) {
composerInputElement?.restoreSegments(payload.data.input);
} else {
pushWorkspaceAlert(
"info",
"Rewind restored Session history but kept the non-empty Composer unchanged.",
{ id: controlAlertId, title: "Rewind" },
);
}
}
if (payload.event === "snapshot") {
pendingSubmissions = payload.data.session.pending_submissions;
} else if (payload.event === "segment_rotated") {
@@ -928,6 +996,7 @@
function discardAllAttachments(): void {
const discarded = attachments;
attachments = [];
uploadReservations.clear();
for (const attachment of discarded) {
attachment.request?.abort();
if (attachment.reference) {
@@ -944,31 +1013,50 @@
if (nextKey === activeComposerTargetKey) return;
if (activeComposerTargetKey) discardAllAttachments();
if (composerInputElement) {
composerDrafts.set(
activeComposerTargetKey,
cachedComposerDraft(composerInputElement.snapshot()),
);
const cached = cachedComposerDraft(draft);
const pending = admissions.get(activeComposerTargetKey);
if (!pending || pending.status === "rejected") {
cached.segments = cached.segments.filter((segment) => segment.kind !== "uploaded_file" && segment.kind !== "unknown");
}
composerDrafts.set(activeComposerTargetKey, cached);
}
activeComposerTargetKey = nextKey;
const restored = composerDrafts.get(nextKey) ?? {
segments: [],
preserveExactText: false,
};
syncAdmission();
const pending = admissions.get(nextKey);
const restored = pending && pending.status !== "rejected"
? cachedComposerDraft(pending.snapshot)
: composerDrafts.get(nextKey) ?? {
segments: [],
preserveExactText: false,
};
void tick().then(() => {
if (activeComposerTargetKey !== nextKey) return;
composerInputElement?.restoreSegments(
restored.segments,
restored.preserveExactText,
true,
);
});
}
function composerSnapshotOrReport(): ComposerDraftSnapshot | null {
try {
return composerInputElement?.snapshot() ?? draft;
} catch (error) {
reportComposerError(error instanceof Error ? error.message : String(error));
return null;
}
}
function handleComposerCommand() {
void submitDraft(composerInputElement?.snapshot() ?? draft);
const value = composerSnapshotOrReport();
if (value) void submitDraft(value);
}
function handleComposerSubmit() {
if ((composerInputElement?.snapshot() ?? draft).document.trimStart().startsWith(":")) {
const value = composerSnapshotOrReport();
if (!value) return;
if (value.document.trimStart().startsWith(":")) {
handleComposerCommand();
return;
}
@@ -976,15 +1064,17 @@
sendWorkerControl("cancel");
return;
}
void submitDraft(composerInputElement?.snapshot() ?? draft);
void submitDraft(value);
}
function handleQueueSubmit() {
void submitDraft(composerInputElement?.snapshot() ?? draft, "queue");
const value = composerSnapshotOrReport();
if (value) void submitDraft(value, "queue");
}
function handleNotifySubmit() {
void submitDraft(composerInputElement?.snapshot() ?? draft, "notify");
const value = composerSnapshotOrReport();
if (value) void submitDraft(value, "notify");
}
function attachmentPath(): string {
@@ -1001,6 +1091,32 @@
);
}
function completeAttachmentUpload(
attachment: ComposerAttachment,
reference: NonNullable<ComposerAttachment["reference"]>,
): void {
if (!attachments.some((candidate) => candidate.id === attachment.id) ||
!composerInputElement || !composerEditable || attachment.uploadPath !== attachmentPath()) {
const reservation = uploadReservations.get(attachment.id);
uploadReservations.delete(attachment.id);
if (reservation !== undefined) composerInputElement?.cancelUpload(reservation);
attachments = attachments.filter((candidate) => candidate.id !== attachment.id);
void fetch(
`${attachment.uploadPath}/attachments/${encodeURIComponent(reference.artifact_id)}`,
{ method: "DELETE" },
).catch(() => undefined);
return;
}
attachments = attachments.filter((candidate) => candidate.id !== attachment.id);
stagedAttachmentPaths.set(reference.artifact_id, attachment.uploadPath);
const segment: Segment = { kind: "uploaded_file", file: reference };
const reservation = uploadReservations.get(attachment.id);
uploadReservations.delete(attachment.id);
if (reservation === undefined || !composerInputElement.completeUpload(reservation, segment)) {
void releaseComposerAtom(segment);
}
}
function startAttachmentUpload(attachment: ComposerAttachment): void {
const error = validateAttachmentFile(attachment.file);
if (error) {
@@ -1020,14 +1136,7 @@
attachment.uploadId,
{
progress: (progress) => updateAttachment(attachment.id, { progress }),
complete: (reference) =>
updateAttachment(attachment.id, {
state: "uploaded",
progress: 1,
reference,
error: null,
request: null,
}),
complete: (reference) => completeAttachmentUpload(attachment, reference),
failed: (message) =>
updateAttachment(attachment.id, {
state: "failed",
@@ -1044,7 +1153,8 @@
function addAttachmentFiles(files: Iterable<File>): void {
if (!composerEditable) return;
const available = Math.max(0, MAX_FILES_PER_SUBMISSION - attachments.length);
const stagedFiles = draft.segments.filter((segment) => segment.kind === "uploaded_file").length;
const available = Math.max(0, MAX_FILES_PER_SUBMISSION - attachments.length - stagedFiles);
for (const file of Array.from(files).slice(0, available)) {
const attachment: ComposerAttachment = {
id: nextAttachmentId++,
@@ -1057,13 +1167,30 @@
error: null,
request: null,
};
const reservation = composerInputElement?.reserveUpload(file.name);
if (reservation === null || reservation === undefined) continue;
uploadReservations.set(attachment.id, reservation);
attachments = [...attachments, attachment];
startAttachmentUpload(attachment);
}
}
async function releaseComposerAtom(segment: Segment): Promise<void> {
if (segment.kind !== "uploaded_file" || admissions.protects(segment)) return;
const path = stagedAttachmentPaths.get(segment.file.artifact_id);
if (!path) return; // Restored references are Session-owned, not client staging.
stagedAttachmentPaths.delete(segment.file.artifact_id);
await fetch(
`${path}/attachments/${encodeURIComponent(segment.file.artifact_id)}`,
{ method: "DELETE" },
).catch(() => undefined);
}
async function removeAttachment(attachment: ComposerAttachment): Promise<void> {
attachment.request?.abort();
const reservation = uploadReservations.get(attachment.id);
uploadReservations.delete(attachment.id);
if (reservation !== undefined) composerInputElement?.cancelUpload(reservation);
attachments = attachments.filter((candidate) => candidate.id !== attachment.id);
if (attachment.reference) {
await fetch(`${attachment.uploadPath}/attachments/${encodeURIComponent(attachment.reference.artifact_id)}`, {
@@ -1106,8 +1233,13 @@
value: ComposerDraftSnapshot,
delivery: ComposerDelivery = "submit",
) {
if (delivery === "notify" && attachments.length > 0) {
reportComposerError("Notify accepts text only; remove attachments or queue a Submit.");
if (
delivery === "notify" &&
(attachments.length > 0 || value.segments.some((segment) =>
segment.kind !== "text" && segment.kind !== "paste"
))
) {
reportComposerError("Notify accepts text only; remove attachments or Feature invocations or queue a Submit.");
return;
}
const incompleteAttachment = attachments.find((attachment) =>
@@ -1119,12 +1251,8 @@
: incompleteAttachment.error ?? "Retry or remove the failed attachment.");
return;
}
const attachmentSegments: Segment[] = attachments.map((attachment) => ({
kind: "uploaded_file",
file: attachment.reference!,
}));
const command = buildComposerSegmentsRequest(
[...value.segments, ...attachmentSegments],
value.segments,
{
preserveExactText: value.textPastes.length > 0,
});
@@ -1143,7 +1271,8 @@
protocolOpen: protocolState === "open",
sending,
hasText: value.content.trim().length > 0,
hasAttachments: attachments.length > 0,
hasAttachments: attachments.length > 0 ||
value.segments.some((segment) => segment.kind === "uploaded_file"),
};
// Commands are controls, not idle-only chat submissions. Keep the
// removed header controls available while the Worker is busy too.
@@ -1166,22 +1295,28 @@
}
sending = true;
try {
const method = composerRequestToProtocolMethod(request);
let method = composerRequestToProtocolMethod(request);
if (method.method === "submit" || method.method === "notify") {
// Store before send: even a synchronous acknowledgement must correlate.
const pending = admissions.begin(activeComposerTargetKey, method, value, delivery);
method = pending.method;
syncAdmission();
}
if (isCommand) {
sendProtocolMethod(method);
} else if (!sendComposerDelivery(deliveryState, method, sendProtocolMethod)) {
return;
}
composerInputElement?.recordHistory(value);
composerInputElement?.clear();
attachments = [];
if (method.method === "submit" && delivery === "submit") {
liveWorkerState = "running";
if (method.method !== "submit" && method.method !== "notify") {
composerInputElement?.recordHistory(value);
composerInputElement?.clear();
}
// Submit/Notify retain the editor, history and upload leases until ack.
} catch (error) {
admissionDisconnected();
reportComposerError(error instanceof Error ? error.message : String(error));
} finally {
sending = false;
syncAdmission();
}
}
@@ -1229,6 +1364,7 @@
return (await response.json()) as WorkerSessionObservation;
})
.then((observation) => {
if (controller.signal.aborted || token !== reloadToken) return;
const currentTarget = consoleTarget;
if (!currentTarget) return;
const currentIdentity: WorkerSessionRequestIdentity = {
@@ -1362,6 +1498,9 @@
);
}
fileCompletions.reset();
featureCompletions.reset();
for (const lane of featureArgumentCompletions.values()) lane.reset();
featureArgumentCompletions.clear();
protocolState = "open";
} else if (
frame.frame === "event" &&
@@ -1416,7 +1555,7 @@
return () => {
if (protocolSubscription === subscription) {
protocolSubscription = null;
fileCompletions.close();
rejectPendingCompletion(new Error("Worker completion connection closed."), `${target.workspaceId}:${target.runtimeId}:${target.workerId}`);
}
subscription.close();
};
@@ -1430,8 +1569,42 @@
}
function handleProtocolCommandEvent(event: ProtocolEvent) {
const acknowledgement = admissions.acknowledge(activeComposerTargetKey, event);
if (acknowledgement) {
const { record, accepted, message } = acknowledgement;
if (accepted) {
// Only an authoritative acceptance transfers staging ownership.
for (const segment of record.snapshot.segments) {
if (segment.kind === "uploaded_file") stagedAttachmentPaths.delete(segment.file.artifact_id);
}
composerInputElement?.recordHistory(record.snapshot);
// A late acceptance of a previously rejected attempt must not erase
// edits made while the recovered draft was unlocked.
if (JSON.stringify(draft.segments) === JSON.stringify(record.snapshot.segments)) {
composerInputElement?.clear(true);
attachments = [];
}
if (event.event === "submission_accepted" && event.data.disposition === "started") {
liveWorkerState = "running";
}
} else {
// The editor has stayed frozen with the exact typed input, Undo state
// and staged resources. Unlock it; unchanged retries reuse the IDs.
reportComposerError(message ?? "Worker rejected the input.");
}
syncAdmission();
return;
}
if (event.event === "completions") {
if (event.data.kind === "file") fileCompletions.receive(event.data.entries);
if (event.data.kind === "file") {
fileCompletions.receive(event.data.entries, event.data.prefix, event.data.request_id);
} else if (event.data.kind === "feature") {
featureCompletions.receive(event.data.entries, event.data.prefix, event.data.request_id);
} else if (event.data.context) {
featureArgumentCompletions
.get(completionContextKey(event.data.context))
?.receive(event.data.entries, event.data.prefix, event.data.request_id);
}
return;
}
if (event.event === "rewind_targets") {
@@ -1448,7 +1621,10 @@
}
if (event.event === "error") {
const error = new Error(event.data.message);
if (fileCompletions.pending) {
if (
fileCompletions.pending || featureCompletions.pending ||
[...featureArgumentCompletions.values()].some((lane) => lane.pending)
) {
rejectPendingCompletion(error);
}
streamDiagnostics = [
@@ -1462,8 +1638,11 @@
}
}
function rejectPendingCompletion(error: Error) {
function rejectPendingCompletion(error: Error, targetKey = activeComposerTargetKey) {
admissionDisconnected(targetKey);
fileCompletions.close(error);
featureCompletions.close(error);
for (const lane of featureArgumentCompletions.values()) lane.close(error);
}
function mergeDiagnostics(...groups: Diagnostic[][]): Diagnostic[] {
@@ -1633,7 +1812,13 @@
});
$effect(() => {
return () => discardAllAttachments();
return () => {
// Observation frames must not project or start history reads after
// this Console is gone. In-flight history must not enqueue a refresh.
cancelObservationFlush();
pendingHistoryRefreshes.clear();
discardAllAttachments();
};
});
$effect(() => {
@@ -1988,6 +2173,13 @@
onkeydown={handleComposerKeydown}
completionScope={activeComposerTargetKey}
resolveFileCompletions={(prefix, signal) => fileCompletions.request(prefix, signal)}
resolveFeatureCompletions={(prefix, signal) => featureCompletions.request(prefix, signal)}
resolveFeatureArgumentCompletions={(context, prefix, signal) =>
featureArgumentCompletionLane(context).request(prefix, signal)}
onremoveatom={(segment) => void releaseComposerAtom(segment)}
onclientadapter={(descriptor) => {
if (descriptor.client_adapter === "attachment") fileInput?.click();
}}
oncommand={handleComposerCommand}
onsubmit={handleComposerSubmit}
onpasteimages={addPastedImages}
@@ -2017,6 +2209,20 @@
{/if}
<div class="composer-input-footer">
<div class="composer-footer-slot">
{#if admission && admission.status !== "rejected"}
<button
class="composer-retry-button"
type="button"
aria-label="Retry admission"
title="Resend the same input and request ID; admission is not yet confirmed"
disabled={protocolState !== "open"}
onclick={retryAdmission}
>
<svg class="composer-retry-icon" aria-hidden="true" viewBox="0 0 24 24">
<path d="M20 7V12H15M20 12A8 8 0 1 0 18 17" />
</svg>
</button>
{/if}
<button
class="composer-attach-button"
type="button"
@@ -2584,6 +2790,7 @@
.composer-attachment button,
.composer-attach-button,
.composer-retry-button,
.composer-queue-button,
.composer-notify-button {
border: 0;
@@ -2595,6 +2802,7 @@
}
.composer-attach-button,
.composer-retry-button,
.composer-queue-button,
.composer-notify-button {
display: inline-grid;
@@ -2607,6 +2815,7 @@
}
.composer-attach-button:hover:not(:disabled),
.composer-retry-button:hover:not(:disabled),
.composer-queue-button:hover:not(:disabled),
.composer-notify-button:hover:not(:disabled) {
background: var(--bg-subtle);
@@ -2614,6 +2823,7 @@
}
.composer-attach-button:disabled,
.composer-retry-button:disabled,
.composer-queue-button:disabled,
.composer-notify-button:disabled {
cursor: not-allowed;
@@ -2661,6 +2871,7 @@
}
.composer-attach-icon,
.composer-retry-icon,
.composer-queue-icon,
.composer-notify-icon,
.composer-send-icon {
@@ -11,11 +11,15 @@ import {
import { afterEach, beforeEach, expect, test, vi } from "vitest";
import type {
Event as ProtocolEvent,
FeatureInvocationDescriptor,
Method,
SessionConversationTurn,
SessionSnapshot,
} from "#lib/generated/protocol.ts";
import type { Worker } from "#lib/workspace/sidebar/types.ts";
import { EditorView } from "@codemirror/view";
import * as attachmentsApi from "#lib/workspace/console/composer-attachments.ts";
import { undo } from "@codemirror/commands";
import * as alerts from "#lib/workspace/alerts/store.ts";
import ConsolePage from "./+page.svelte";
@@ -212,11 +216,276 @@ function latestListener() {
return listener;
}
function protocolEvent(event: ProtocolEvent) {
latestListener().onFrame({ frame: "event", message: { event: "event", data: { payload: {
event: "worker_protocol", data: { worker_id: "worker-a", event },
} } } });
}
async function settleMicrotasks() {
await Promise.resolve();
await Promise.resolve();
}
const runDescriptor: FeatureInvocationDescriptor = {
identity: "feature:test/run", name: "run", aliases: [], display_name: "Run", description: "Run",
syntax: "parenthesized", arguments: [{ name: "path", position: 0, required: true,
value_type: { kind: "worker_file" }, completion: { kind: "worker_file" } }],
};
async function liveComposer() {
const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => Response.json({ availability: "live_protocol" }));
vi.stubGlobal("fetch", fetchMock);
const view = render(ConsolePage, { data: pageData() });
await waitFor(() => expect(multiplexer.subscribe).toHaveBeenCalledOnce());
latestListener().onFrame(subscribedFrame());
await screen.findByText("No conversation to display");
const cm = EditorView.findFromDOM(view.container.querySelector(".cm-editor") as HTMLElement)!;
const form = view.container.querySelector(".console-composer") as HTMLFormElement;
return { view, cm, form, fetchMock };
}
function completionRequest(): Extract<Method, { method: "list_completions" }> {
const method = multiplexer.sendWorkerMethod.mock.calls.at(-1)![0] as Method;
if (method.method !== "list_completions") throw new Error("Expected completion request");
return method;
}
function completionReply(method: Extract<Method, { method: "list_completions" }>, value: string, invocation?: FeatureInvocationDescriptor) {
protocolEvent({ event: "completions", data: { ...method.params, entries: [{ value, invocation, is_dir: false }] } });
}
async function selectRun(cm: EditorView) {
cm.focus();
cm.dispatch({ changes: { from: 0, to: cm.state.doc.length, insert: "/ru" }, selection: { anchor: 3 } });
await waitFor(() => expect(completionRequest().params.prefix).toBe("ru"));
completionReply(completionRequest(), "run", runDescriptor);
await screen.findByRole("option");
await fireEvent.keyDown(cm.contentDOM, { key: "Enter" });
cm.dispatch({ changes: { from: cm.state.doc.length, insert: '"a b/資料")' }, selection: { anchor: '/run("a b/資料")'.length } });
await waitFor(() => expect(cm.dom.querySelector(".composer-typed-chip")).not.toBeNull());
}
test("Console teardown cancels pending observation frames before they can read history", async () => {
const { view, fetchMock } = await liveComposer();
const schedule = vi.spyOn(window, "requestAnimationFrame");
const cancel = vi.spyOn(window, "cancelAnimationFrame");
const warn = vi.spyOn(console, "warn");
protocolEvent({ event: "user_message", data: { segments: [{ kind: "text", content: "pending at teardown" }] } });
const handle = schedule.mock.results.at(-1)!.value as number;
const requestsBeforeUnmount = fetchMock.mock.calls.length;
await view.unmount();
expect(cancel).toHaveBeenCalledWith(handle);
// Let the real happy-dom frame queue run; do not mock away the lifecycle bug.
await new Promise<void>((resolve) => window.requestAnimationFrame(() => resolve()));
expect(fetchMock).toHaveBeenCalledTimes(requestsBeforeUnmount);
expect(warn).not.toHaveBeenCalled();
});
test("Feature and argument completion correlate same-prefix ABA, context and other clients", async () => {
const { cm } = await liveComposer();
cm.focus();
cm.dispatch({ changes: { from: 0, insert: "/ru" }, selection: { anchor: 3 } });
await waitFor(() => expect(multiplexer.sendWorkerMethod).toHaveBeenCalled());
const old = completionRequest();
cm.dispatch({ changes: { from: 2, to: 3, insert: "x" } });
await waitFor(() => expect(completionRequest().params.prefix).toBe("rx"));
cm.dispatch({ changes: { from: 2, to: 3, insert: "u" } });
await waitFor(() => expect(completionRequest().params.prefix).toBe("ru"));
const current = completionRequest();
expect(current.params.request_id).not.toBe(old.params.request_id);
completionReply(old, "old", runDescriptor);
completionReply({ ...current, params: { ...current.params, request_id: "another-client" } }, "foreign", runDescriptor);
completionReply({ ...current, params: { ...current.params, request_id: undefined } }, "legacy", runDescriptor);
await settleMicrotasks();
expect(screen.queryByRole("option")).toBeNull();
completionReply(current, "run", runDescriptor);
await screen.findByRole("option");
await fireEvent.keyDown(cm.contentDOM, { key: "Enter" });
cm.dispatch({ changes: { from: cm.state.doc.length, insert: '"a' }, selection: { anchor: 7 } });
await waitFor(() => expect(completionRequest().params.context?.argument).toBe("path"));
const argOld = completionRequest();
cm.dispatch({ changes: { from: 6, to: 7, insert: "b" } });
await waitFor(() => expect(completionRequest().params.prefix).toBe("b"));
cm.dispatch({ changes: { from: 6, to: 7, insert: "a" } });
await waitFor(() => expect(completionRequest().params.prefix).toBe("a"));
const argCurrent = completionRequest();
completionReply(argOld, "stale-path");
completionReply({ ...argCurrent, params: { ...argCurrent.params, context: { invocation: "other-feature", argument: "path" } } }, "wrong-context");
await settleMicrotasks();
expect(screen.queryByRole("option")).toBeNull();
completionReply(argCurrent, "a b/資料");
expect((await screen.findByRole("option")).textContent).toContain("a b/資料");
});
test("Submit rejection keeps exact typed draft, request/invocation IDs, and only acceptance clears", async () => {
const alert = vi.spyOn(alerts, "pushWorkspaceAlert");
const { cm, form } = await liveComposer();
await selectRun(cm);
await fireEvent.submit(form);
const first = multiplexer.sendWorkerMethod.mock.calls.at(-1)![0] as Extract<Method, { method: "submit" }>;
expect(first.method).toBe("submit");
expect(first.params.input[0]).toMatchObject({ kind: "feature_invoke", invocation: { identity: runDescriptor.identity, arguments: [{ name: "path", value: { kind: "string", value: "a b/資料" } }] } });
const doc = cm.state.doc.toString();
await waitFor(() => expect(cm.state.readOnly).toBe(true));
await fireEvent.keyDown(cm.contentDOM, { key: "Backspace" });
await fireEvent.keyDown(cm.contentDOM, { key: "z", ctrlKey: true });
expect(cm.state.doc.toString()).toBe(doc);
protocolEvent({ event: "submission_rejected", data: { submission_request_id: "other-client", message: "foreign" } });
await settleMicrotasks();
expect(cm.state.readOnly).toBe(true);
protocolEvent({ event: "submission_rejected", data: { submission_request_id: first.params.submission_request_id, message: "Feature validation failed" } });
await waitFor(() => expect(cm.state.readOnly).toBe(false));
expect(alert).toHaveBeenCalledWith("error", "Feature validation failed", expect.any(Object));
expect(cm.state.doc.toString()).toBe(doc);
await fireEvent.submit(form);
expect(multiplexer.sendWorkerMethod).toHaveBeenLastCalledWith(first);
protocolEvent({ event: "submission_accepted", data: { submission_request_id: first.params.submission_request_id, submission_id: "s", disposition: "started" } });
await waitFor(() => expect(cm.state.doc.toString()).toBe(""));
expect(undo(cm)).toBe(false);
});
test("failed upload retry preserves middle insertion, Undo, unknown admission and accepted ownership", async () => {
const uploads: attachmentsApi.AttachmentUploadCallbacks[] = [];
vi.spyOn(attachmentsApi, "uploadAttachment").mockImplementation((_path, _file, _id, callbacks) => {
uploads.push(callbacks);
return { abort: vi.fn() };
});
const { cm, form, view, fetchMock } = await liveComposer();
cm.dispatch({ changes: { from: 0, insert: "before after" }, selection: { anchor: 7 } });
await fireEvent.change(view.container.querySelector('input[type="file"]')!, { target: { files: [new File(["data"], "report.md", { type: "text/markdown" })] } });
uploads[0].failed("upload failed");
await fireEvent.click(await screen.findByRole("button", { name: "Retry" }));
const reference = { artifact_id: "staged-artifact", file_name: "report.md", media_type: "text/markdown", created_at_ms: 1, availability: "available" as const, byte_len: 4, sha256: "abcd" };
uploads[1].complete(reference);
await waitFor(() => expect(view.container.textContent).toContain("Attached report.md"));
cm.dispatch({ selection: { anchor: 10 } });
await fireEvent.keyDown(cm.contentDOM, { key: "Backspace" });
expect(view.container.textContent).not.toContain("Attached report.md");
undo(cm);
await waitFor(() => expect(view.container.textContent).toContain("Attached report.md"));
await fireEvent.submit(form);
const first = multiplexer.sendWorkerMethod.mock.calls.at(-1)![0] as Extract<Method, { method: "submit" }>;
expect(first.params.input).toEqual([{ kind: "text", content: "before " }, { kind: "uploaded_file", file: reference }, { kind: "text", content: " after" }]);
latestListener().onStatus?.("closed", { kind: "transport", message: "ack lost" });
expect(fetchMock.mock.calls.some(([_path, init]) => init?.method === "DELETE")).toBe(false);
latestListener().onStatus?.("connecting");
latestListener().onFrame(subscribedFrame());
await fireEvent.click(await screen.findByRole("button", { name: "Retry admission" }));
expect(multiplexer.sendWorkerMethod).toHaveBeenLastCalledWith(first);
protocolEvent({ event: "submission_accepted", data: { submission_request_id: first.params.submission_request_id, submission_id: "accepted", disposition: "queued" } });
await waitFor(() => expect(cm.state.doc.toString()).toBe(""));
view.unmount();
expect(fetchMock.mock.calls.some(([_path, init]) => init?.method === "DELETE")).toBe(false);
});
test("late acceptance after rejection does not clear newer draft edits", async () => {
const { cm, form } = await liveComposer();
cm.dispatch({ changes: { from: 0, insert: "original" }, selection: { anchor: 8 } });
await fireEvent.submit(form);
const first = multiplexer.sendWorkerMethod.mock.calls.at(-1)![0] as Extract<Method, { method: "submit" }>;
protocolEvent({ event: "submission_rejected", data: { submission_request_id: first.params.submission_request_id, message: "try later" } });
await waitFor(() => expect(cm.state.readOnly).toBe(false));
cm.dispatch({ changes: { from: 0, to: cm.state.doc.length, insert: "newer draft" }, selection: { anchor: 11 } });
protocolEvent({ event: "submission_accepted", data: { submission_request_id: first.params.submission_request_id, submission_id: "old", disposition: "queued" } });
await settleMicrotasks();
expect(cm.state.doc.toString()).toBe("newer draft");
});
test("unmounting unknown upload admission does not delete possibly accepted artifacts", async () => {
let complete!: attachmentsApi.AttachmentUploadCallbacks["complete"];
vi.spyOn(attachmentsApi, "uploadAttachment").mockImplementation((_path, _file, _id, callbacks) => {
complete = callbacks.complete;
return { abort: vi.fn() };
});
const { view, form, fetchMock } = await liveComposer();
await fireEvent.change(view.container.querySelector('input[type="file"]')!, { target: { files: [new File(["data"], "unknown.md", { type: "text/markdown" })] } });
complete({ artifact_id: "unknown-artifact", file_name: "unknown.md", media_type: "text/markdown", created_at_ms: 1, availability: "available", byte_len: 4, sha256: "abcd" });
await waitFor(() => expect(view.container.textContent).toContain("Attached unknown.md"));
await fireEvent.submit(form);
latestListener().onStatus?.("closed", { kind: "transport", message: "ack lost" });
view.unmount();
await settleMicrotasks();
expect(fetchMock.mock.calls.some(([_path, init]) => init?.method === "DELETE")).toBe(false);
});
test("cached unsent typed draft survives target round-trip and remains sendable", async () => {
const alert = vi.spyOn(alerts, "pushWorkspaceAlert");
const { cm, form, view } = await liveComposer();
await selectRun(cm);
await view.rerender({ data: pageData("worker-b", "runtime-b") });
await waitFor(() => expect(multiplexer.subscribe).toHaveBeenCalledTimes(2));
latestListener().onFrame(subscribedFrame());
await waitFor(() => expect(cm.state.doc.toString()).toBe(""));
await view.rerender({ data: pageData() });
await waitFor(() => expect(multiplexer.subscribe).toHaveBeenCalledTimes(3));
latestListener().onFrame(subscribedFrame());
await waitFor(() => expect(cm.dom.querySelector(".composer-typed-chip")).not.toBeNull());
await fireEvent.submit(form);
expect(alert).not.toHaveBeenCalledWith("error", expect.any(String), expect.any(Object));
expect(multiplexer.sendWorkerMethod).toHaveBeenLastCalledWith({ method: "submit", params: { submission_request_id: expect.any(String), input: [expect.objectContaining({ kind: "feature_invoke" })] } });
});
test("unknown Submit survives target ABA and cannot be deleted into a new admission", async () => {
const { cm, form, view } = await liveComposer();
await selectRun(cm);
await fireEvent.submit(form);
const first = multiplexer.sendWorkerMethod.mock.calls.at(-1)![0] as Extract<Method, { method: "submit" }>;
await waitFor(() => expect(cm.state.readOnly).toBe(true));
await view.rerender({ data: pageData("worker-b", "runtime-b") });
await waitFor(() => expect(multiplexer.subscribe).toHaveBeenCalledTimes(2));
latestListener().onFrame(subscribedFrame());
await waitFor(() => expect(cm.state.doc.toString()).toBe(""));
await view.rerender({ data: pageData() });
await waitFor(() => expect(multiplexer.subscribe).toHaveBeenCalledTimes(3));
latestListener().onFrame(subscribedFrame());
await waitFor(() => expect(cm.dom.querySelector(".composer-typed-chip")).not.toBeNull());
expect(cm.state.readOnly).toBe(true);
const doc = cm.state.doc.toString();
await fireEvent.keyDown(cm.contentDOM, { key: "Backspace" });
expect(cm.state.doc.toString()).toBe(doc);
await fireEvent.click(screen.getByRole("button", { name: "Retry admission" }));
expect(multiplexer.sendWorkerMethod).toHaveBeenLastCalledWith(first);
view.unmount();
});
test("cancelled attachment ignores late completion and deletes only abandoned staging", async () => {
let callbacks!: attachmentsApi.AttachmentUploadCallbacks;
const abort = vi.fn();
vi.spyOn(attachmentsApi, "uploadAttachment").mockImplementation((_path, _file, _id, value) => {
callbacks = value;
return { abort };
});
const { cm, view, fetchMock } = await liveComposer();
cm.dispatch({ changes: { from: 0, insert: "keep this" }, selection: { anchor: 4 } });
await fireEvent.change(view.container.querySelector('input[type="file"]')!, { target: { files: [new File(["data"], "late.md", { type: "text/markdown" })] } });
await fireEvent.click(screen.getByRole("button", { name: "Remove late.md" }));
expect(abort).toHaveBeenCalledOnce();
callbacks.complete({ artifact_id: "late-artifact", file_name: "late.md", media_type: "text/markdown", created_at_ms: 1, availability: "available", byte_len: 4, sha256: "abcd" });
await settleMicrotasks();
expect(view.container.textContent).not.toContain("Attached late.md");
expect(cm.state.doc.toString()).toBe("keep this");
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining("/attachments/late-artifact"), { method: "DELETE" });
undo(cm);
expect(cm.dom.textContent).not.toContain("Uploading late.md");
});
test.each(["notification_rejected", "notification_accepted"] as const)("Notify keeps text until authoritative %s", async (outcome) => {
const { cm } = await liveComposer();
protocolEvent({ event: "worker_state", data: { snapshot: { last_command_id: 0, state: { kind: "busy", state: { kind: "run", state: "running" } } } } });
cm.dispatch({ changes: { from: 0, insert: "通知" }, selection: { anchor: 2 } });
await fireEvent.click(await screen.findByRole("button", { name: "Notify Worker" }));
const first = multiplexer.sendWorkerMethod.mock.calls.at(-1)![0] as Extract<Method, { method: "notify" }>;
expect(cm.state.doc.toString()).toBe("通知");
protocolEvent(outcome === "notification_accepted"
? { event: outcome, data: { notification_request_id: first.params.notification_request_id } }
: { event: outcome, data: { notification_request_id: first.params.notification_request_id, message: "not running" } });
await waitFor(() => expect(cm.state.readOnly).toBe(false));
expect(cm.state.doc.toString()).toBe(outcome === "notification_accepted" ? "" : "通知");
if (outcome === "notification_rejected") {
await fireEvent.click(screen.getByRole("button", { name: "Notify Worker" }));
expect(multiplexer.sendWorkerMethod).toHaveBeenLastCalledWith(first);
}
});
beforeEach(() => {
multiplexer.listeners.length = 0;
multiplexer.close.mockClear();
@@ -293,6 +562,11 @@ test.each([
expect(form.querySelector('[role="alert"]')).toBeNull();
expect(form.textContent).not.toContain(message);
if (transportFailure) {
const method = multiplexer.sendWorkerMethod.mock.calls[0][0];
protocolEvent({ event: "submission_rejected", data: { submission_request_id: method.params.submission_request_id, message } });
await waitFor(() => expect(cm.state.readOnly).toBe(false));
}
cm.dispatch({ changes: { from: 0, to: cm.state.doc.length, insert: ":peer teammate" } });
await fireEvent.submit(form);
await waitFor(() => expect(multiplexer.sendWorkerMethod).toHaveBeenLastCalledWith({
@@ -430,7 +704,7 @@ test.each(["idle", "running", "paused"] as const)("Composer completion executes
expect(multiplexer.sendWorkerMethod.mock.calls.every(([method]) => method.method !== "submit" && method.method !== "cancel")).toBe(true);
});
test("file completions consume stale responses before the latest prefix and close with the connection", async () => {
test("file completion nonces reject stale replies without blocking newer requests", async () => {
vi.stubGlobal("fetch", vi.fn(async () => Response.json({ availability: "live_protocol" })));
const view = render(ConsolePage, { data: pageData() });
await waitFor(() => expect(multiplexer.subscribe).toHaveBeenCalledOnce());
@@ -439,23 +713,20 @@ test("file completions consume stale responses before the latest prefix and clos
const cm = EditorView.findFromDOM(view.container.querySelector(".cm-editor") as HTMLElement)!;
cm.focus();
cm.dispatch({ changes: { from: 0, insert: "@old" }, selection: { anchor: 4 } });
await waitFor(() => expect(multiplexer.sendWorkerMethod).toHaveBeenCalledWith({ method: "list_completions", params: { kind: "file", prefix: "old" } }));
await waitFor(() => expect(multiplexer.sendWorkerMethod).toHaveBeenCalledWith({ method: "list_completions", params: { kind: "file", prefix: "old", request_id: expect.any(String) } }));
const old = multiplexer.sendWorkerMethod.mock.calls.at(-1)![0].params.request_id;
cm.dispatch({ changes: { from: 1, to: 4, insert: "new" }, selection: { anchor: 4 } });
await waitFor(() => expect(multiplexer.sendWorkerMethod).toHaveBeenCalledWith({ method: "list_completions", params: { kind: "file", prefix: "new", request_id: expect.any(String) } }));
const current = multiplexer.sendWorkerMethod.mock.calls.at(-1)![0].params.request_id;
const receive = (value: string, prefix: string, request_id?: string) => protocolEvent({ event: "completions", data: { kind: "file", prefix, request_id, entries: [{ value, is_dir: false }] } });
receive("old-result", "old", old);
await settleMicrotasks();
expect(multiplexer.sendWorkerMethod).toHaveBeenCalledOnce();
const receive = (value: string) => latestListener().onFrame({
frame: "event", message: { event: "event", data: { payload: {
event: "worker_protocol", data: { event: { event: "completions", data: { kind: "file", entries: [{ value, is_dir: false }] } } },
} } },
});
receive("old-result");
await waitFor(() => expect(multiplexer.sendWorkerMethod).toHaveBeenCalledWith({ method: "list_completions", params: { kind: "file", prefix: "new" } }));
expect(screen.queryByRole("option")).toBeNull();
receive("new-result");
receive("new-result", "new", current);
expect((await screen.findByRole("option")).textContent).toContain("@new-result");
latestListener().onStatus?.("closed", { kind: "transport", message: "connection lost" });
await waitFor(() => expect(screen.queryByRole("listbox")).toBeNull());
receive("late-result");
receive("late-result", "new", current);
await settleMicrotasks();
expect(screen.queryByRole("listbox")).toBeNull();
});
@@ -199,3 +199,22 @@ Deno.test("Composer input uses boundary-aware Up and Down history navigation", a
assert(consoleSource.includes("historyScope={workspaceId}"));
assert(consoleSource.includes("composerInputElement?.recordHistory(value)"));
});
Deno.test("declarative invocation history persists typed values without sharing mutable arguments", () => {
const value: ComposerHistoryEntry = { preserveExactText: false, segments: [{ kind: "feature_invoke", invocation: {
invocation_id: "invoke-1", identity: "test/run", name: "run", arguments: [
{ name: "path", value: { kind: "string", value: "資料/a b" } },
{ name: "count", value: { kind: "integer", value: 2 } },
{ name: "enabled", value: { kind: "boolean", value: true } },
],
} }] };
const history = new ComposerHistory([value]);
const storage = memoryStorage();
saveComposerHistory(storage, "workspace-1", history);
const expected = [{ segments: value.segments, preserveExactText: false }];
assertEquals(loadComposerHistory(storage, "workspace-1").entries, expected);
const copy = history.entries[0].segments[0];
assert(copy.kind === "feature_invoke");
copy.invocation.arguments[0].value = { kind: "string", value: "modified" };
assertEquals(history.entries, expected);
});