diff --git a/Cargo.lock b/Cargo.lock index ce8416376..a288e1c66 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3483,6 +3483,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.11.0", + "thiserror 2.0.18", "tokio", "ts-rs", "uuid", diff --git a/crates/protocol/Cargo.toml b/crates/protocol/Cargo.toml index 5cfe93f7d..f0f705d35 100644 --- a/crates/protocol/Cargo.toml +++ b/crates/protocol/Cargo.toml @@ -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 } diff --git a/crates/protocol/src/invocation.rs b/crates/protocol/src/invocation.rs new file mode 100644 index 000000000..98c2275d5 --- /dev/null +++ b/crates/protocol/src/invocation.rs @@ -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, + }, + /// 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, + }, + 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, + #[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, +} + +#[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, + pub display_name: String, + pub description: String, + pub syntax: FeatureInvocationSyntax, + #[serde(default)] + pub arguments: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub client_adapter: Option, +} + +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, +} + +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, +} + +#[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, +) -> Result { + 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::>(); + let mut next_position = 0u16; + let mut parsed = Vec::::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 { + 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::(&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 { + 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::() + .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::(&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()) + ); + } +} diff --git a/crates/protocol/src/lib.rs b/crates/protocol/src/lib.rs index e043de416..f7dcd9160 100644 --- a/crates/protocol/src/lib.rs +++ b/crates/protocol/src/lib.rs @@ -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 +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 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, - #[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, - #[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, + #[serde(default, skip_serializing_if = "Option::is_none")] + context: Option, }, /// 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: ]` listings; the flattened user text keeps the literal /// `@` 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, + #[serde(default, skip_serializing_if = "Option::is_none")] + context: Option, entries: Vec, }, /// 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, } /// 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, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub usage: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub invocation: Option, } #[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::( + 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::(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"); diff --git a/crates/protocol/src/subscription.rs b/crates/protocol/src/subscription.rs index 642a1528d..a27bca31f 100644 --- a/crates/protocol/src/subscription.rs +++ b/crates/protocol/src/subscription.rs @@ -1197,6 +1197,8 @@ mod tests { method: crate::Method::ListCompletions { kind: crate::CompletionKind::File, prefix: "src/".to_string(), + request_id: None, + context: None, }, }, )); diff --git a/crates/protocol/src/typescript.rs b/crates/protocol/src/typescript.rs index c759b0992..c1f2d9b00 100644 --- a/crates/protocol/src/typescript.rs +++ b/crates/protocol/src/typescript.rs @@ -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::(&cfg, &mut output); push_decl::(&cfg, &mut output); push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); push_decl::(&cfg, &mut output); push_decl::(&cfg, &mut output); push_decl::(&cfg, &mut output); diff --git a/crates/runtime-api/src/lib.rs b/crates/runtime-api/src/lib.rs index eaf3135ef..6a3f62f3c 100644 --- a/crates/runtime-api/src/lib.rs +++ b/crates/runtime-api/src/lib.rs @@ -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, } #[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; diff --git a/crates/server-api/src/lib.rs b/crates/server-api/src/lib.rs index a100ca901..70eb88199 100644 --- a/crates/server-api/src/lib.rs +++ b/crates/server-api/src/lib.rs @@ -8233,7 +8233,7 @@ pub struct RuntimeWorkerInputRequest { pub kind: Option, pub content: String, #[serde(default, skip_serializing_if = "Option::is_none")] - pub segments: Option>, + pub segments: Option>, } #[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, } #[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, + pub entries: Vec, #[serde(default)] pub diagnostics: Vec, } @@ -12661,6 +12663,45 @@ mod tests { assert!(serde_json::from_value::(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::(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::( + serde_json::json!({"kind":"unknown"}) + ) + .is_err() + ); + assert!( + serde_json::from_value::( + 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("../.."); diff --git a/crates/server-api/src/repository_openapi_typescript.rs b/crates/server-api/src/repository_openapi_typescript.rs index a561bf71c..235c95458 100644 --- a/crates/server-api/src/repository_openapi_typescript.rs +++ b/crates/server-api/src/repository_openapi_typescript.rs @@ -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(); diff --git a/crates/session-store/src/system_item.rs b/crates/session-store/src/system_item.rs index 4d4a28b6a..4f1b68975 100644 --- a/crates/session-store/src/system_item.rs +++ b/crates/session-store/src/system_item.rs @@ -123,6 +123,14 @@ pub enum SystemItem { prompt_provenance: Option, }, + /// 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, + }, + /// `@` file reference resolution. `body` is the rendered /// LLM-context text (`[File: ]\n…` for regular files, /// `[Dir: ]\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 { diff --git a/crates/session-store/tests/session_test.rs b/crates/session-store/tests/session_test.rs index 5ec4fe61d..901c0578f 100644 --- a/crates/session-store/tests/session_test.rs +++ b/crates/session-store/tests/session_test.rs @@ -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(); diff --git a/crates/tui/README.md b/crates/tui/README.md index 9b70d54d7..cbcaf8c8e 100644 --- a/crates/tui/README.md +++ b/crates/tui/README.md @@ -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. diff --git a/crates/tui/src/app.rs b/crates/tui/src/app.rs index 1a1f7f156..750a30ba7 100644 --- a/crates/tui/src/app.rs +++ b/crates/tui/src/app.rs @@ -35,12 +35,29 @@ pub enum CommandCompletionApply { /// typing inside a `@` / `#` / `/` token. Cleared whenever the trigger /// is invalidated (cursor moved out, whitespace landed inside the /// token, the sigil was deleted, or the candidate was confirmed). +#[derive(Debug, Clone, PartialEq, Eq)] +struct CompletionSnapshot { + input_revision: u64, + generation: u64, + target: String, + worker_view: Option, +} + +struct PendingFeatureEdit { + invocation: protocol::FeatureInvocation, + request_id: String, + snapshot: CompletionSnapshot, +} + pub struct CompletionState { + pub request_id: String, + snapshot: CompletionSnapshot, pub kind: CompletionKind, /// Atom index of the leading sigil (`@` / `#` / `/`). pub prefix_start: usize, /// Text typed after the sigil (sigil itself excluded). pub prefix: String, + pub context: Option, /// Latest candidate set returned by the Worker for `(kind, prefix)`. /// Initially empty until `Event::Completions` lands. pub entries: Vec, @@ -301,6 +318,13 @@ pub struct App { /// Completion popup state, when an `@` / `#` / `/` token is in /// flight. `None` whenever the trigger conditions don't hold. pub completion: Option, + /// Invocation declarations explicitly selected from this composer's `/` + /// completion lane. Only these declarations may turn editable text into a + /// typed FeatureInvoke chip. + selected_feature_invocations: HashMap, + pending_feature_edit: Option, + completion_generation: u64, + completion_target: String, /// Dedicated main-view rewind picker state. pub rewind_picker: Option, rewind_request_pending: bool, @@ -335,6 +359,7 @@ pub struct App { /// Local submit state kept until the accepted run either completes /// normally or reports that the empty assistant turn was rolled back. pending_submit_rollback: Option, + retry_submission: Option, /// Last rolled-back submit that could not be restored because the /// composer already contained unsent user input. last_rolled_back_input: Option>, @@ -382,6 +407,10 @@ impl App { cache: FileCache::new(), assistant_streaming: false, completion: None, + selected_feature_invocations: HashMap::new(), + pending_feature_edit: None, + completion_generation: 0, + completion_target: protocol::new_submission_request_id(), rewind_picker: None, rewind_request_pending: false, rewind_refresh_fence: false, @@ -394,6 +423,7 @@ impl App { input_history: ComposerInputHistory::new(), input_history_store: None, pending_submit_rollback: None, + retry_submission: None, last_rolled_back_input: None, } } @@ -512,6 +542,7 @@ impl App { /// Cycle the presentation-only transcript/task view. Input and control /// methods continue to target the parent Worker regardless of selection. pub fn cycle_worker_view(&mut self) -> bool { + self.invalidate_completion_generation(); if self.internal_workers.is_empty() { self.selected_internal_worker_session_id = None; return false; @@ -571,38 +602,97 @@ impl App { } } + fn completion_snapshot(&self) -> CompletionSnapshot { + CompletionSnapshot { + input_revision: self.input.revision(), + generation: self.completion_generation, + target: self.completion_target.clone(), + worker_view: self.selected_internal_worker_session_id.clone(), + } + } + + pub(crate) fn set_completion_target(&mut self, target: String) { + if self.completion_target != target { + self.completion_target = target; + self.invalidate_completion_generation(); + self.selected_feature_invocations.clear(); + } + } + + /// Authority/snapshot/target changes revoke in-flight queries and visible candidates. + fn invalidate_completion_generation(&mut self) { + self.completion_generation = self.completion_generation.wrapping_add(1); + self.completion = None; + self.pending_feature_edit = None; + } + + #[cfg(test)] + pub(crate) fn completion_request_id(&self) -> Option { + self.completion + .as_ref() + .map(|state| state.request_id.clone()) + .or_else(|| { + self.pending_feature_edit + .as_ref() + .map(|edit| edit.request_id.clone()) + }) + } + /// Re-evaluate the completion popup against the current input. - /// Returns a `Method::ListCompletions` to send when the - /// `(kind, prefix_start, prefix)` triple changed; otherwise `None`. + /// Returns a fresh correlated `Method::ListCompletions` when the prefix, + /// context, token location, or composer/authority/target snapshot changes. + /// An unchanged active query returns `None`. /// Callers should invoke this after every input mutation that could /// move the cursor or change atoms. pub fn refresh_completion(&mut self) -> Option { + self.pending_feature_edit = None; if self.is_command_mode() { self.completion = None; return None; } - match self.input.pending_completion_prefix() { - Some((kind, start, prefix)) => { - let need_query = match &self.completion { - Some(c) => c.kind != kind || c.prefix_start != start || c.prefix != prefix, - None => true, - }; - let entries = match self.completion.take() { - Some(c) if c.kind == kind && c.prefix_start == start => c.entries, - _ => Vec::new(), - }; + let descriptors = self + .selected_feature_invocations + .values() + .cloned() + .collect::>(); + let pending = self + .input + .pending_feature_argument_completion(&descriptors) + .map(|(kind, start, prefix, context)| (kind, start, prefix, Some(context))) + .or_else(|| { + self.input + .pending_completion_prefix() + .map(|(kind, start, prefix)| (kind, start, prefix, None)) + }); + match pending { + Some((kind, start, prefix, context)) => { + let snapshot = self.completion_snapshot(); + if self.completion.as_ref().is_some_and(|state| { + state.kind == kind + && state.prefix_start == start + && state.prefix == prefix + && state.context == context + && state.snapshot == snapshot + }) { + return None; + } + let request_id = protocol::new_submission_request_id(); self.completion = Some(CompletionState { + request_id: request_id.clone(), + snapshot, kind, prefix_start: start, prefix: prefix.clone(), - entries, + context: context.clone(), + entries: Vec::new(), selected: 0, }); - if need_query { - Some(Method::ListCompletions { kind, prefix }) - } else { - None - } + Some(Method::ListCompletions { + kind, + prefix, + context, + request_id: Some(request_id), + }) } None => { self.completion = None; @@ -632,7 +722,7 @@ impl App { } pub fn cancel_completion(&mut self) { - self.completion = None; + self.invalidate_completion_generation(); } /// Tab path: insert the popup-selected entry's value (with a @@ -643,21 +733,132 @@ impl App { /// Returns the follow-up `Method::ListCompletions` to send when /// the new prefix differs from the old one. pub fn apply_completion_text(&mut self) -> Option { + if self + .completion + .as_ref() + .is_some_and(|state| state.snapshot != self.completion_snapshot()) + { + self.cancel_completion(); + return None; + } let state = self.completion.as_ref()?; if state.entries.is_empty() { return None; } - let entry = &state.entries[state.selected]; - let text = if entry.is_dir { - format!("{}/", entry.value) - } else { - entry.value.clone() + let entry = state.entries[state.selected].clone(); + let mut typed_start = state.prefix_start + 1; + let text = match state.kind { + CompletionKind::File => { + if entry.is_dir { + format!("{}/", entry.value) + } else { + entry.value.clone() + } + } + CompletionKind::Feature => { + let descriptor = entry.invocation.clone()?; + if descriptor.validate().is_err() + || !(entry.value == descriptor.name + || descriptor.aliases.contains(&entry.value)) + { + return None; + } + self.selected_feature_invocations + .insert(descriptor.identity.to_string(), descriptor.clone()); + self.input + .replace_feature_name_completion(typed_start, &format!("{}(", entry.value)); + self.input + .select_feature_invocation(state.prefix_start, descriptor); + self.input_history.cancel_browse(); + return self.refresh_completion(); + } + CompletionKind::FeatureArgument => { + if self.input.can_complete_argument_name(state.prefix_start) + && let Some(name) = entry.value.strip_suffix('=') + && state + .context + .as_ref() + .and_then(|context| { + self.selected_feature_invocations + .get(&context.invocation.to_string()) + }) + .is_some_and(|descriptor| { + descriptor + .arguments + .iter() + .any(|argument| argument.name == name) + }) + { + self.input + .replace_argument_completion(typed_start, &entry.value); + self.input_history.cancel_browse(); + return self.refresh_completion(); + } + if state + .context + .as_ref() + .and_then(|context| context.argument.as_ref()) + .is_some() + { + if typed_start > 0 && self.input.char_at(typed_start - 1) == Some('"') { + typed_start -= 1; + } + let context = state.context.as_ref()?; + let descriptor = self + .selected_feature_invocations + .get(&context.invocation.to_string())?; + let argument = descriptor + .arguments + .iter() + .find(|argument| Some(&argument.name) == context.argument.as_ref())?; + match &argument.value_type { + protocol::InvocationArgumentType::Integer => { + entry.value.parse::().ok()?.to_string() + } + protocol::InvocationArgumentType::Boolean + if matches!(entry.value.as_str(), "true" | "false") => + { + entry.value.clone() + } + protocol::InvocationArgumentType::Boolean => return None, + _ => protocol::quote_invocation_string(&if entry.is_dir { + format!("{}/", entry.value.trim_end_matches('/')) + } else { + entry.value.clone() + }), + } + } else { + let context = state.context.as_ref()?; + let descriptor = self + .selected_feature_invocations + .get(&context.invocation.to_string())?; + if !entry.value.ends_with('=') + || !descriptor + .arguments + .iter() + .any(|argument| argument.name == entry.value.trim_end_matches('=')) + { + return None; + } + entry.value.clone() + } + } }; - // `prefix_start` indexes the sigil atom; the text we want to - // replace lives just after it (sigil itself stays). - let typed_start = state.prefix_start + 1; + // `prefix_start` indexes the sigil atom for Feature/file names and one + // atom before the active replacement range for Feature arguments. + let text = + if state.kind == CompletionKind::FeatureArgument && entry.is_dir && text.ends_with('"') + { + text[..text.len() - 1].to_string() + } else { + text + }; self.input_history.cancel_browse(); - self.input.replace_with_text_at(typed_start, &text); + if state.kind == CompletionKind::FeatureArgument { + self.input.replace_argument_completion(typed_start, &text); + } else { + self.input.replace_with_text_at(typed_start, &text); + } self.refresh_completion() } @@ -681,6 +882,14 @@ impl App { /// so a race-y top entry shouldn't block confirmation when the /// typed text matches another entry. pub fn chipify_completion_if_exact_match(&mut self) -> bool { + if self + .completion + .as_ref() + .is_some_and(|state| state.snapshot != self.completion_snapshot()) + { + self.cancel_completion(); + return false; + } let Some(state) = self.completion.as_ref() else { return false; }; @@ -714,6 +923,7 @@ impl App { self.input_history.cancel_browse(); match kind { CompletionKind::File => self.input.replace_with_file_ref(start, value), + CompletionKind::Feature | CompletionKind::FeatureArgument => return false, } self.completion = None; true @@ -729,6 +939,14 @@ impl App { /// for drill-in — chip-ifying a directory on Enter would strand /// the user with no way to inspect children. pub fn chipify_selected_completion_if_committable(&mut self) -> bool { + if self + .completion + .as_ref() + .is_some_and(|state| state.snapshot != self.completion_snapshot()) + { + self.cancel_completion(); + return false; + } let Some(state) = self.completion.as_ref() else { return false; }; @@ -745,12 +963,98 @@ impl App { self.input_history.cancel_browse(); match kind { CompletionKind::File => self.input.replace_with_file_ref(start, value), + CompletionKind::Feature | CompletionKind::FeatureArgument => return false, } self.completion = None; true } + pub fn is_client_file_completion(&self, context: &protocol::CompletionContext) -> bool { + self.selected_feature_invocations + .get(&context.invocation.to_string()) + .is_some_and(|descriptor| { + descriptor.arguments.iter().any(|argument| { + Some(&argument.name) == context.argument.as_ref() + && (matches!( + argument.completion, + protocol::InvocationCompletion::ClientFile + ) || matches!( + argument.value_type, + protocol::InvocationArgumentType::ClientFile + )) + }) + }) + } + + pub fn attachment_invocations(&self) -> Vec<(String, std::path::PathBuf)> { + self.input + .submit_segments() + .into_iter() + .filter_map(|segment| { + let Segment::FeatureInvoke { invocation } = segment else { + return None; + }; + let descriptor = self + .selected_feature_invocations + .get(&invocation.identity.to_string())?; + if descriptor.client_adapter != Some(protocol::InvocationClientAdapter::Attachment) + { + return None; + } + invocation.arguments.iter().find_map(|argument| { + let schema = descriptor + .arguments + .iter() + .find(|schema| schema.name == argument.name)?; + match (&schema.value_type, &argument.value) { + ( + protocol::InvocationArgumentType::ClientFile, + protocol::InvocationValue::String(path), + ) if !path.is_empty() => Some(( + invocation.invocation_id.clone(), + std::path::PathBuf::from(path), + )), + _ => None, + } + }) + }) + .collect() + } + + /// Alt+Enter reopens an adjacent chip using freshly discovered enabled metadata. + pub fn edit_adjacent_feature_invocation(&mut self) -> Option { + let invocation = self.input.adjacent_feature_invocation()?; + let prefix = invocation.name.clone(); + let request_id = protocol::new_submission_request_id(); + self.pending_feature_edit = Some(PendingFeatureEdit { + invocation, + request_id: request_id.clone(), + snapshot: self.completion_snapshot(), + }); + self.completion = None; + Some(Method::ListCompletions { + request_id: Some(request_id), + kind: CompletionKind::Feature, + prefix, + context: None, + }) + } + pub fn submit_input(&mut self) -> Option { + if self.input.has_attachment_stages() { + self.push_error("Attachment is still staging or failed; wait, retry with Alt+Enter, or delete its chip."); + return None; + } + let confirming = self.input.has_selected_feature_invocations(); + if let Err(error) = self.input.finalize_feature_invocations() { + self.push_error(format!("Invalid Feature invocation: {error}")); + return None; + } + if confirming || !self.attachment_invocations().is_empty() { + // Console stages adapters in-place; never record local paths in history or send them. + self.completion = None; + return None; + } let segments = self.input.submit_segments(); if segments_are_blank(&segments) { // Empty Enter only does something meaningful when the Worker @@ -769,15 +1073,23 @@ impl App { } pub fn submit_notify_input(&mut self) -> Option { + if self.input.has_attachment_stages() || self.input.has_selected_feature_invocations() { + self.push_error("Notify accepts text only; confirm or remove Feature invocations and staging chips first."); + return None; + } let segments = self.input.submit_segments(); if segments_are_blank(&segments) { return None; } - if segments - .iter() - .any(|segment| matches!(segment, Segment::UploadedFile { .. })) - { - self.push_error("Notify accepts text only; remove attachments or queue a Submit."); + if segments.iter().any(|segment| { + matches!( + segment, + Segment::UploadedFile { .. } | Segment::FeatureInvoke { .. } + ) + }) { + self.push_error( + "Notify accepts text only; remove attachments or Feature invocations or queue a Submit.", + ); return None; } let message = Segment::flatten_to_text(&segments); @@ -794,7 +1106,8 @@ impl App { return; }; self.pending_submit_rollback = None; - if self.input.is_empty() { + if self.input.is_empty() || self.input.submit_segments() == *input { + self.retry_submission = Some(method.clone()); self.input.replace_with_segments(input); self.completion = None; } else { @@ -816,6 +1129,11 @@ impl App { block_start: self.blocks.len(), turn_before: self.turn_index, }); + if let Some(method @ Method::Submit { .. }) = self.retry_submission.take() { + if matches!(&method, Method::Submit { input, .. } if *input == segments) { + return method; + } + } Method::Submit { submission_request_id: protocol::new_submission_request_id(), input: segments, @@ -862,7 +1180,10 @@ impl App { } pub fn browse_input_history_older(&mut self) -> bool { - if self.input_history.entries.is_empty() { + if self.input.has_attachment_stages() + || self.input.has_selected_feature_invocations() + || self.input_history.entries.is_empty() + { return false; } let draft = self.input.submit_segments(); @@ -1182,7 +1503,21 @@ impl App { } match event { - Event::SubmissionAccepted { .. } | Event::NotificationAccepted { .. } => {} + Event::SubmissionAccepted { + submission_request_id, + .. + } => { + if matches!(&self.retry_submission, Some(Method::Submit { submission_request_id: id, .. }) if *id == submission_request_id) + { + if let Some(Method::Submit { input, .. }) = self.retry_submission.take() { + if self.input.submit_segments() == input { + self.input.clear(); + self.completion = None; + } + } + } + } + Event::NotificationAccepted { .. } => {} Event::SubmissionRejected { message, .. } | Event::NotificationRejected { message, .. } => self.push_error(message), Event::PendingSubmissionsChanged { pending } => { @@ -1577,6 +1912,7 @@ impl App { self.apply_worker_state_snapshot(&state); self.restore_snapshot(&session, greeting, in_flight); self.replace_internal_worker_snapshots(internal_workers); + return self.refresh_completion(); } Event::InternalWorker { worker, @@ -1606,13 +1942,94 @@ impl App { // Command telemetry is an operational Web Console surface. The // TUI continues to render the final Bash ToolResult from history. Event::Command { .. } => {} - Event::Completions { kind, entries } => { - // Apply only if the popup is still on the same - // (kind, prefix) the request was issued for; an - // out-of-date reply (the user typed past it) is dropped. - if let Some(state) = self.completion.as_mut() - && state.kind == kind + Event::Completions { + kind, + prefix, + request_id, + context, + entries, + } => { + let snapshot = self.completion_snapshot(); + if kind == CompletionKind::Feature + && context.is_none() + && self.pending_feature_edit.as_ref().is_some_and(|edit| { + edit.invocation.name == prefix + && request_id.as_ref() == Some(&edit.request_id) + && edit.snapshot == snapshot + }) { + let invocation = self.pending_feature_edit.take().unwrap().invocation; + if let Some(descriptor) = entries + .iter() + .filter_map(|entry| entry.invocation.as_ref()) + .find(|descriptor| { + descriptor.identity == invocation.identity + && descriptor.validate().is_ok() + }) + { + self.selected_feature_invocations + .insert(descriptor.identity.to_string(), descriptor.clone()); + self.input + .edit_feature_invocation(&invocation.invocation_id, descriptor.clone()); + } else { + self.push_error("Feature invocation is unknown or disabled; its typed chip was preserved."); + } + return self.refresh_completion(); + } + // Apply only if the popup is still on the same request context; + // stale argument/provider replies cannot cross invocation scope. + if let Some(state) = self.completion.as_mut() + && request_id.as_ref() == Some(&state.request_id) + && state.snapshot == snapshot + && state.kind == kind + && state.prefix == prefix + && state.context == context + { + let mut entries = entries; + if kind == CompletionKind::Feature { + let mut expanded = Vec::new(); + for entry in entries { + if let Some(descriptor) = &entry.invocation + && entry.value == descriptor.name + && descriptor.validate().is_ok() + { + for name in std::iter::once(&descriptor.name) + .chain(descriptor.aliases.iter()) + .filter(|name| name.starts_with(&prefix)) + { + expanded.push(CompletionEntry { + value: name.clone(), + ..entry.clone() + }); + } + } else { + expanded.push(entry); + } + } + entries = expanded; + } else if kind == CompletionKind::FeatureArgument + && prefix.bytes().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_' + }) + && self.input.can_complete_argument_name(state.prefix_start) + && let Some(descriptor) = context.as_ref().and_then(|context| { + self.selected_feature_invocations + .get(&context.invocation.to_string()) + }) + { + for argument in &descriptor.arguments { + let value = format!("{}=", argument.name); + if argument.name.starts_with(&prefix) + && !entries.iter().any(|entry| entry.value == value) + { + entries.push(CompletionEntry { + value, + description: argument.description.clone(), + ..Default::default() + }); + } + } + } state.entries = entries; state.selected = 0; } @@ -1682,6 +2099,7 @@ impl App { ); } Event::Shutdown => { + self.invalidate_completion_generation(); self.mark_orphan_compacts_incomplete(); self.quit = true; } @@ -1890,6 +2308,7 @@ impl App { } pub fn enter_command_mode(&mut self) { + self.invalidate_completion_generation(); self.input_mode = CommandInputMode::Command; self.completion = None; self.command_completion_selected = None; @@ -1897,6 +2316,7 @@ impl App { } pub fn exit_command_mode(&mut self) { + self.invalidate_completion_generation(); self.input_mode = CommandInputMode::Composer; self.command_input.clear(); self.command_completion_selected = None; @@ -2024,6 +2444,7 @@ impl App { } pub fn request_rewind_picker(&mut self) -> Option { + self.invalidate_completion_generation(); // Rewind is a parent Worker control surface. Bring the parent transcript // back into view before presenting diagnostics or the picker. self.selected_internal_worker_session_id = None; @@ -2411,6 +2832,7 @@ impl App { } fn remove_internal_worker(&mut self, worker: InternalWorkerRef, revision: u64) { + self.invalidate_completion_generation(); let session_id = worker.session_id; let Some(index) = self .internal_workers @@ -2495,6 +2917,7 @@ impl App { session: &protocol::SessionSnapshot, greeting: Option, ) { + self.invalidate_completion_generation(); self.run_error_messages.clear(); self.turn_index = 0; self.blocks.clear(); @@ -2615,7 +3038,8 @@ impl App { session_store::SystemItem::WorkerEvent { event, .. } => { self.blocks.push(Block::WorkerEvent { event }); } - session_store::SystemItem::FileAttachment { body, .. } + session_store::SystemItem::FeatureInvocationResult { body, .. } + | session_store::SystemItem::FileAttachment { body, .. } | session_store::SystemItem::SkillActivation { body, .. } | session_store::SystemItem::ResidentSummaryRefresh { body, .. } | session_store::SystemItem::Interrupt { body, .. } => { @@ -3179,7 +3603,7 @@ mod completion_flow_tests { app.insert_char('@'); let method = app.refresh_completion(); match method { - Some(Method::ListCompletions { kind, prefix }) => { + Some(Method::ListCompletions { kind, prefix, .. }) => { assert_eq!(kind, CompletionKind::File); assert_eq!(prefix, ""); } @@ -3196,7 +3620,7 @@ mod completion_flow_tests { app.insert_char('s'); let method = app.refresh_completion(); match method { - Some(Method::ListCompletions { kind, prefix }) => { + Some(Method::ListCompletions { kind, prefix, .. }) => { assert_eq!(kind, CompletionKind::File); assert_eq!(prefix, "s"); } @@ -3204,6 +3628,29 @@ mod completion_flow_tests { } } + #[test] + fn stale_completion_reply_for_prior_prefix_is_ignored() { + let mut app = App::new("test".into()); + for c in "@a".chars() { + app.insert_char(c); + } + let _ = app.refresh_completion(); + app.insert_char('b'); + let _ = app.refresh_completion(); + app.handle_worker_event(Event::Completions { + request_id: app.completion_request_id(), + kind: CompletionKind::File, + prefix: "a".into(), + context: None, + entries: vec![CompletionEntry { + value: "alpha".into(), + ..CompletionEntry::default() + }], + }); + assert_eq!(app.completion.as_ref().unwrap().prefix, "ab"); + assert!(app.completion.as_ref().unwrap().entries.is_empty()); + } + #[test] fn space_after_token_clears_completion_state() { let mut app = App::new("test".into()); @@ -3228,6 +3675,7 @@ mod completion_flow_tests { app.completion.as_mut().unwrap().entries = vec![CompletionEntry { value: "src/main.rs".into(), is_dir: false, + ..CompletionEntry::default() }]; // Tab path: text inserted, popup re-triggered with new prefix // (still File kind since the typed range stays after `@`). @@ -3250,6 +3698,7 @@ mod completion_flow_tests { app.completion.as_mut().unwrap().entries = vec![CompletionEntry { value: "crates".into(), is_dir: true, + ..CompletionEntry::default() }]; let _ = app.apply_completion_text(); // Typed prefix advances to `crates/` so the next query can @@ -3269,6 +3718,7 @@ mod completion_flow_tests { app.completion.as_mut().unwrap().entries = vec![CompletionEntry { value: "src/main.rs".into(), is_dir: false, + ..CompletionEntry::default() }]; assert!(app.chipify_completion_if_exact_match()); assert!(app.completion.is_none()); @@ -3287,6 +3737,7 @@ mod completion_flow_tests { app.completion.as_mut().unwrap().entries = vec![CompletionEntry { value: "src/main.rs".into(), is_dir: false, + ..CompletionEntry::default() }]; // typed = "s", expected = "src/main.rs" → no match, no chip. assert!(!app.chipify_completion_if_exact_match()); @@ -3307,6 +3758,7 @@ mod completion_flow_tests { app.completion.as_mut().unwrap().entries = vec![CompletionEntry { value: "crates".into(), is_dir: true, + ..CompletionEntry::default() }]; assert!(app.chipify_completion_if_exact_match()); let segs = app.input.submit_segments(); @@ -3321,6 +3773,7 @@ mod completion_flow_tests { app.completion.as_mut().unwrap().entries = vec![CompletionEntry { value: "crates".into(), is_dir: true, + ..CompletionEntry::default() }]; assert!(app.chipify_completion_if_exact_match()); let segs = app.input.submit_segments(); @@ -3342,10 +3795,12 @@ mod completion_flow_tests { CompletionEntry { value: "crates/client".into(), is_dir: true, + ..CompletionEntry::default() }, CompletionEntry { value: "crates/agen".into(), is_dir: true, + ..CompletionEntry::default() }, ]; assert!(app.chipify_completion_if_exact_match()); @@ -3366,6 +3821,7 @@ mod completion_flow_tests { app.completion.as_mut().unwrap().entries = vec![CompletionEntry { value: "crates".into(), is_dir: true, + ..CompletionEntry::default() }]; assert!(!app.chipify_selected_completion_if_committable()); // Popup is still active so the caller can fall through to @@ -3386,6 +3842,7 @@ mod completion_flow_tests { app.completion.as_mut().unwrap().entries = vec![CompletionEntry { value: "README.md".into(), is_dir: false, + ..CompletionEntry::default() }]; assert!(app.chipify_selected_completion_if_committable()); app.insert_char(' '); @@ -3408,6 +3865,7 @@ mod completion_flow_tests { app.completion.as_mut().unwrap().entries = vec![CompletionEntry { value: "README.md".into(), is_dir: false, + ..CompletionEntry::default() }]; assert!(app.chipify_selected_completion_if_committable()); assert!(app.completion.is_none()); @@ -3427,6 +3885,7 @@ mod completion_flow_tests { app.completion.as_mut().unwrap().entries = vec![CompletionEntry { value: "crates/client".into(), is_dir: true, + ..CompletionEntry::default() }]; assert!(!app.chipify_completion_if_exact_match()); let segs = app.input.submit_segments(); @@ -3447,10 +3906,12 @@ mod completion_flow_tests { CompletionEntry { value: "src/main.rs.bak".into(), is_dir: false, + ..CompletionEntry::default() }, CompletionEntry { value: "src/main.rs".into(), is_dir: false, + ..CompletionEntry::default() }, ]; // selected stays at 0 (the non-matching one) but find() should @@ -5100,3 +5561,312 @@ fn apply_cache_update( _ => {} } } + +#[cfg(test)] +mod completion_correlation_tests { + use super::*; + + fn reply(request: &Method, value: &str) -> Event { + let Method::ListCompletions { + kind, + prefix, + context, + request_id, + } = request + else { + panic!("completion query expected") + }; + assert!(request_id.as_ref().is_some_and(|id| !id.is_empty())); + Event::Completions { + kind: *kind, + prefix: prefix.clone(), + context: context.clone(), + request_id: request_id.clone(), + entries: vec![CompletionEntry { + value: value.into(), + ..Default::default() + }], + } + } + + fn request_id(request: &Method) -> &str { + let Method::ListCompletions { + request_id: Some(id), + .. + } = request + else { + panic!("correlated completion query expected") + }; + id + } + + fn authority_snapshot(scope: &str) -> Event { + Event::Snapshot { + session: protocol::SessionSnapshot { + entries: Vec::new(), + pending_submissions: Default::default(), + }, + greeting: protocol::Greeting { + worker_name: "test".into(), + cwd: "/tmp".into(), + provider: "test".into(), + model: "test".into(), + reasoning: None, + scope_summary: scope.into(), + tools: vec![], + context_window: 0, + context_tokens: 0, + context_usage: None, + }, + state: protocol::WorkerStateSnapshot::from(WorkerStatus::Idle), + in_flight: Default::default(), + internal_workers: vec![], + } + } + + #[test] + fn identical_file_prefix_aba_rejects_old_nonce_and_accepts_latest_query() { + let mut app = App::new("test".into()); + app.input.insert_str("@same"); + let first = app.refresh_completion().unwrap(); + assert!( + app.refresh_completion().is_none(), + "an unchanged request is not reissued" + ); + app.insert_char('x'); + let second = app.refresh_completion().unwrap(); + app.delete_char_before(); + let third = app.refresh_completion().unwrap(); + assert_ne!(request_id(&first), request_id(&second)); + assert_ne!(request_id(&first), request_id(&third)); + app.handle_worker_event(reply(&first, "old")); + assert!(app.completion.as_ref().unwrap().entries.is_empty()); + app.handle_worker_event(reply(&third, "current")); + app.handle_worker_event(reply(&first, "late old")); + assert_eq!(app.completion.as_ref().unwrap().entries[0].value, "current"); + } + + #[test] + fn identical_argument_context_aba_rejects_reply_before_and_after_refresh() { + let mut app = App::new("test".into()); + crate::invocation_tests::select(&mut app, crate::invocation_tests::descriptor(), "run"); + app.input.insert_str("\"資料/a"); + let first = app.refresh_completion().unwrap(); + app.input.insert_char('x'); + app.input.delete_before(); + // Prefix, context, token location, and cursor are identical; semantic revision is not. + app.handle_worker_event(reply(&first, "old")); + assert!(app.completion.as_ref().unwrap().entries.is_empty()); + let second = app.refresh_completion().unwrap(); + assert_ne!(request_id(&first), request_id(&second)); + app.handle_worker_event(reply(&first, "old")); + assert!(app.completion.as_ref().unwrap().entries.is_empty()); + app.handle_worker_event(reply(&second, "資料/current")); + assert_eq!( + app.completion.as_ref().unwrap().entries[0].value, + "資料/current" + ); + } + + #[test] + fn identical_cursor_aba_and_same_prefix_at_another_location_are_fenced() { + let mut app = App::new("test".into()); + app.input.insert_str("@same @same"); + let last_location = app.refresh_completion().unwrap(); + app.input.move_home(); + for _ in 0..5 { + app.input.move_right(); + } + let first_location = app.refresh_completion().unwrap(); + app.handle_worker_event(reply(&last_location, "wrong location")); + assert!(app.completion.as_ref().unwrap().entries.is_empty()); + app.input.move_end(); + let returned_location = app.refresh_completion().unwrap(); + app.handle_worker_event(reply(&last_location, "old same location")); + app.handle_worker_event(reply(&first_location, "wrong location")); + assert!(app.completion.as_ref().unwrap().entries.is_empty()); + assert_ne!(request_id(&last_location), request_id(&returned_location)); + app.input.move_left(); + app.input.move_right(); + app.handle_worker_event(reply(&returned_location, "cursor ABA before refresh")); + assert!(app.completion.as_ref().unwrap().entries.is_empty()); + let current = app.refresh_completion().unwrap(); + app.handle_worker_event(reply(¤t, "current")); + assert_eq!(app.completion.as_ref().unwrap().entries[0].value, "current"); + } + + #[test] + fn visible_candidates_cannot_be_inserted_after_unrefreshed_aba_edit() { + let mut app = App::new("test".into()); + app.input.insert_str("@same"); + let request = app.refresh_completion().unwrap(); + app.handle_worker_event(reply(&request, "candidate")); + app.input.move_left(); + app.input.move_right(); + assert!(app.apply_completion_text().is_none()); + assert_eq!(app.input.plain_text(), "@same"); + assert!(app.completion.is_none()); + } + + #[test] + fn cancelled_and_reopened_identical_feature_discovery_gets_new_nonce() { + let mut app = App::new("test".into()); + app.input.insert_str("/"); + let first = app.refresh_completion().unwrap(); + app.cancel_completion(); + let current = app.refresh_completion().unwrap(); + assert_ne!(request_id(&first), request_id(¤t)); + app.handle_worker_event(reply(&first, "old feature")); + assert!(app.completion.as_ref().unwrap().entries.is_empty()); + app.handle_worker_event(reply(¤t, "current feature")); + assert_eq!( + app.completion.as_ref().unwrap().entries[0].value, + "current feature" + ); + } + + #[test] + fn permission_snapshot_generation_aba_revokes_queries_even_when_scope_returns_to_original() { + let mut app = App::new("test".into()); + app.handle_worker_event(authority_snapshot("Writable: /tmp")); + crate::invocation_tests::select(&mut app, crate::invocation_tests::descriptor(), "run"); + app.input.insert_str("\"資料/a"); + let first = app.refresh_completion().unwrap(); + let denied = app + .handle_worker_event(authority_snapshot("Readable: /tmp")) + .unwrap(); + let current = app + .handle_worker_event(authority_snapshot("Writable: /tmp")) + .unwrap(); + assert_ne!(request_id(&first), request_id(&denied)); + assert_ne!(request_id(&first), request_id(¤t)); + app.handle_worker_event(reply(&first, "old permission generation")); + app.handle_worker_event(reply(&denied, "intermediate generation")); + assert!(app.completion.as_ref().unwrap().entries.is_empty()); + app.handle_worker_event(reply(¤t, "資料/current")); + assert_eq!( + app.completion.as_ref().unwrap().entries[0].value, + "資料/current" + ); + } + + #[test] + fn actual_worker_target_switch_aba_revokes_identical_queries() { + let mut app = App::new("test".into()); + app.set_completion_target("workspace/runtime/worker-a".into()); + app.input.insert_str("@same"); + let first = app.refresh_completion().unwrap(); + app.set_completion_target("workspace/runtime/worker-b".into()); + let other = app.refresh_completion().unwrap(); + app.set_completion_target("workspace/runtime/worker-a".into()); + let current = app.refresh_completion().unwrap(); + assert_ne!(request_id(&first), request_id(¤t)); + app.handle_worker_event(reply(&first, "worker-a old")); + app.handle_worker_event(reply(&other, "worker-b")); + assert!(app.completion.as_ref().unwrap().entries.is_empty()); + app.handle_worker_event(reply(¤t, "worker-a current")); + assert_eq!( + app.completion.as_ref().unwrap().entries[0].value, + "worker-a current" + ); + } + + #[test] + fn presentation_worker_view_switch_aba_revokes_queries_without_redirecting_parent_controls() { + let mut app = App::new("test".into()); + app.handle_worker_event(Event::InternalWorker { + worker: InternalWorkerRef { + session_id: "child".into(), + name: "child".into(), + parent_session_id: Some("parent".into()), + kind: protocol::InternalWorkerKind::SubWorker, + }, + revision: 1, + event: Box::new(Event::WorkerState { + snapshot: protocol::WorkerStateSnapshot::from(WorkerStatus::Idle), + }), + }); + app.input.insert_str("@same"); + let first = app.refresh_completion().unwrap(); + assert!(app.cycle_worker_view()); + let child_view = app.refresh_completion().unwrap(); + assert!(app.cycle_worker_view()); + let current = app.refresh_completion().unwrap(); + assert!(app.selected_internal_worker_session_id.is_none()); + app.handle_worker_event(reply(&first, "old parent")); + app.handle_worker_event(reply(&child_view, "old child view")); + assert!(app.completion.as_ref().unwrap().entries.is_empty()); + app.handle_worker_event(reply(¤t, "current parent")); + assert_eq!( + app.completion.as_ref().unwrap().entries[0].value, + "current parent" + ); + } + + #[test] + fn feature_chip_reedit_is_nonce_and_snapshot_correlated() { + let descriptor = crate::invocation_tests::descriptor(); + let invocation = protocol::parse_feature_invocation("/run(x)", 0, &descriptor, "stable-id") + .unwrap() + .invocation; + let mut app = App::new("test".into()); + app.input + .replace_with_segments(&[Segment::FeatureInvoke { invocation }]); + let first = app.edit_adjacent_feature_invocation().unwrap(); + app.input.move_home(); + app.input.move_end(); + let mut event = reply(&first, "run"); + if let Event::Completions { entries, .. } = &mut event { + entries[0].invocation = Some(descriptor.clone()); + } + app.handle_worker_event(event); + assert!(matches!( + app.input.submit_segments().as_slice(), + [Segment::FeatureInvoke { .. }] + )); + let current = app.edit_adjacent_feature_invocation().unwrap(); + assert_ne!(request_id(&first), request_id(¤t)); + let mut old = reply(&first, "run"); + if let Event::Completions { entries, .. } = &mut old { + entries[0].invocation = Some(descriptor.clone()); + } + app.handle_worker_event(old); + assert!(matches!( + app.input.submit_segments().as_slice(), + [Segment::FeatureInvoke { .. }] + )); + let mut event = reply(¤t, "run"); + if let Event::Completions { entries, .. } = &mut event { + entries[0].invocation = Some(descriptor); + } + app.handle_worker_event(event); + assert_eq!(app.input.plain_text(), "/run(path=\"x\")"); + app.delete_char_before(); // Reopen the quoted argument prefix. + let arguments = app.refresh_completion().unwrap(); + assert_ne!(request_id(¤t), request_id(&arguments)); + } + + #[test] + fn uncorrelated_legacy_reply_and_wrong_context_are_never_applied_to_correlated_queries() { + let mut app = App::new("test".into()); + app.input.insert_str("@same"); + let request = app.refresh_completion().unwrap(); + let mut missing = reply(&request, "missing nonce"); + if let Event::Completions { request_id, .. } = &mut missing { + *request_id = None; + } + app.handle_worker_event(missing); + assert!(app.completion.as_ref().unwrap().entries.is_empty()); + let mut mismatched = reply(&request, "wrong context"); + if let Event::Completions { context, .. } = &mut mismatched { + *context = Some(protocol::CompletionContext { + invocation: protocol::FeatureInvocationIdentity("plugin:other/run".into()), + argument: None, + }); + } + app.handle_worker_event(mismatched); + assert!(app.completion.as_ref().unwrap().entries.is_empty()); + app.handle_worker_event(reply(&request, "current")); + assert_eq!(app.completion.as_ref().unwrap().entries[0].value, "current"); + } +} diff --git a/crates/tui/src/console/mod.rs b/crates/tui/src/console/mod.rs index 559139b39..03083575d 100644 --- a/crates/tui/src/console/mod.rs +++ b/crates/tui/src/console/mod.rs @@ -121,56 +121,23 @@ fn copy_selection_to_terminal(app: &mut App) -> bool { copy_selection_to_writer(app, &mut stdout) } -type AttachmentUploadResult = Result; - -fn mark_attachments_after_transport_acceptance( - pending: &mut Vec, - awaiting_acceptance: &mut Vec, -) { - awaiting_acceptance.append(pending); +struct AttachmentUploadResult { + id: String, + result: Result, } - -fn reconcile_attachment_submission( - pending: &mut Vec, - awaiting_acceptance: &mut Vec, - event: &Event, -) -> bool { - match event { - Event::UserMessage { segments, .. } => { - let accepted_ids = segments - .iter() - .filter_map(|segment| match segment { - Segment::UploadedFile { file } => Some(file.artifact_id.as_str()), - _ => None, - }) - .collect::>(); - let awaited_ids = awaiting_acceptance - .iter() - .map(|file| file.artifact_id.as_str()) - .collect::>(); - let accepts_exact_submission = !awaited_ids.is_empty() && accepted_ids == awaited_ids; - if accepts_exact_submission { - awaiting_acceptance.clear(); - } - accepts_exact_submission - } - Event::Error { .. } if !awaiting_acceptance.is_empty() => { - pending.append(awaiting_acceptance); - false - } - _ => false, - } +struct StagedUpload { + path: PathBuf, + task: Option>, + reference: Option, } - struct ConsoleConnection { client: Client, standalone_host: Option, backend_target: Option, - pending_attachments: Vec, - awaiting_attachment_acceptance: Vec, - upload_tasks: Vec>, + staged_uploads: HashMap, + typed_submissions: HashMap, + rejected_typed_submit: Option, active_uploads: usize, - upload_ids: HashMap, } async fn upload_client_path( @@ -206,7 +173,20 @@ async fn upload_client_path( "attachment file type is not supported", ) })?; - let bytes = tokio::fs::read(path).await?; + use tokio::io::AsyncReadExt; + let mut bytes = Vec::new(); + tokio::fs::File::open(path) + .await? + .take(10 * 1024 * 1024 + 1) + .read_to_end(&mut bytes) + .await?; + if bytes.len() > 10 * 1024 * 1024 { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "attachment exceeds the 10 MiB limit", + ) + .into()); + } target .upload_file_with_id(upload_id, file_name, media_type, bytes) .await @@ -238,11 +218,10 @@ impl ConsoleConnection { client, standalone_host: Some(host), backend_target: None, - pending_attachments: Vec::new(), - awaiting_attachment_acceptance: Vec::new(), - upload_tasks: Vec::new(), + staged_uploads: HashMap::new(), + typed_submissions: HashMap::new(), + rejected_typed_submit: None, active_uploads: 0, - upload_ids: HashMap::new(), } } @@ -251,11 +230,10 @@ impl ConsoleConnection { client, standalone_host: None, backend_target: Some(target), - pending_attachments: Vec::new(), - awaiting_attachment_acceptance: Vec::new(), - upload_tasks: Vec::new(), + staged_uploads: HashMap::new(), + typed_submissions: HashMap::new(), + rejected_typed_submit: None, active_uploads: 0, - upload_ids: HashMap::new(), } } @@ -268,27 +246,30 @@ impl ConsoleConnection { } async fn send(&mut self, method: &Method) -> Result<(), Box> { - let mut prepared = method.clone(); - let carries_attachments = - matches!(prepared, Method::Submit { .. }) && !self.pending_attachments.is_empty(); - if let Method::Submit { input, .. } = &mut prepared { - input.extend( - self.pending_attachments - .iter() - .cloned() - .map(|file| Segment::UploadedFile { file }), - ); - } - self.client.send(&prepared).await?; - if carries_attachments { - self.mark_attachments_awaiting_acceptance(); + // A failed write may still have delivered a complete request. Track ownership + // before transport I/O and fence deletion until authoritative admission. + if let Method::Submit { + submission_request_id, + input, + } = method + { + if input.iter().any(|segment| { + matches!( + segment, + Segment::UploadedFile { .. } | Segment::FeatureInvoke { .. } + ) + }) { + self.typed_submissions + .insert(submission_request_id.clone(), method.clone()); + } } + self.client.send(method).await?; Ok(()) } - fn start_upload( &mut self, path: PathBuf, + id: String, result_tx: mpsc::UnboundedSender, ) -> Result<(), Box> { let target = self.backend_target.clone().ok_or_else(|| { @@ -297,73 +278,173 @@ impl ConsoleConnection { "client-local file upload is available only for Backend Workers", ) })?; - self.upload_tasks.retain(|task| !task.is_finished()); - let upload_id = self - .upload_ids - .entry(path.clone()) - .or_insert_with(|| uuid::Uuid::now_v7().to_string()) - .clone(); - self.active_uploads = self.active_uploads.saturating_add(1); - self.upload_tasks.push(tokio::spawn(async move { - let result = upload_client_path(&target, &path, &upload_id) + if self + .staged_uploads + .get(&id) + .is_some_and(|upload| upload.task.is_some()) + { + return Ok(()); + } + self.active_uploads += 1; + let upload_id = id.clone(); + let upload_path = path.clone(); + let task = tokio::spawn(async move { + let result = upload_client_path(&target, &upload_path, &upload_id) .await .map_err(|error| error.to_string()); - let _ = result_tx.send(result); - })); + let _ = result_tx.send(AttachmentUploadResult { + id: upload_id, + result, + }); + }); + self.staged_uploads.insert( + id, + StagedUpload { + path, + task: Some(task), + reference: None, + }, + ); Ok(()) } - - fn finish_upload(&mut self) { - self.active_uploads = self.active_uploads.saturating_sub(1); - self.upload_tasks.retain(|task| !task.is_finished()); - } - fn has_active_uploads(&self) -> bool { self.active_uploads != 0 } - fn observe_worker_event(&mut self, event: &Event) -> bool { - let attachments_accepted = reconcile_attachment_submission( - &mut self.pending_attachments, - &mut self.awaiting_attachment_acceptance, - event, - ); - if attachments_accepted { - self.upload_ids.clear(); + match event { + Event::SubmissionAccepted { + submission_request_id, + .. + } => { + if let Some(Method::Submit { input, .. }) = + self.typed_submissions.remove(submission_request_id) + { + self.release_accepted_attachments(&input); + return true; + } + } + Event::SubmissionRejected { + submission_request_id, + .. + } => { + self.rejected_typed_submit = self.typed_submissions.remove(submission_request_id); + } + Event::UserMessage { segments, .. } => { + self.release_accepted_attachments(segments); + self.typed_submissions.retain(|_, method| !matches!(method, Method::Submit { input, .. } if input == segments)); + } + _ => {} } - attachments_accepted + false } - - fn mark_attachments_awaiting_acceptance(&mut self) { - mark_attachments_after_transport_acceptance( - &mut self.pending_attachments, - &mut self.awaiting_attachment_acceptance, - ); + fn release_accepted_attachments(&mut self, segments: &[Segment]) { + let ids = segments + .iter() + .filter_map(|segment| match segment { + Segment::UploadedFile { file } => Some(file.artifact_id.as_str()), + _ => None, + }) + .collect::>(); + self.staged_uploads.retain(|_, upload| { + !upload + .reference + .as_ref() + .is_some_and(|reference| ids.contains(&reference.artifact_id.as_str())) + }); } - - async fn clear_pending_attachments(&mut self) { - for task in self.upload_tasks.drain(..) { + fn attachment_is_submitting(&self, artifact_id: &str) -> bool { + self.typed_submissions.values().any(|method| matches!(method, Method::Submit { input, .. } + if input.iter().any(|segment| matches!(segment, Segment::UploadedFile { file } if file.artifact_id == artifact_id)))) + } + async fn delete_draft_attachment(&mut self, reference: &UploadedFileRef) { + if self.attachment_is_submitting(&reference.artifact_id) { + return; + } + let id = self.staged_uploads.iter().find_map(|(id, upload)| { + upload + .reference + .as_ref() + .filter(|owned| owned.artifact_id == reference.artifact_id) + .map(|_| id.clone()) + }); + // History/accepted/restored files are not this connection's unsent staging. + if let Some(id) = id { + self.cancel_stage(&id).await; + } + } + async fn cancel_stage(&mut self, id: &str) { + let Some(upload) = self.staged_uploads.remove(id) else { + return; + }; + if let Some(task) = upload.task { + self.active_uploads = self.active_uploads.saturating_sub(1); task.abort(); } - self.active_uploads = 0; - let upload_ids = std::mem::take(&mut self.upload_ids) - .into_values() - .collect::>(); - let references = std::mem::take(&mut self.pending_attachments); if let Some(target) = &self.backend_target { - for upload_id in upload_ids { - let _ = target.cancel_file_upload(&upload_id).await; - } - for reference in references { + let _ = target.cancel_file_upload(id).await; + if let Some(reference) = upload.reference { let _ = target.delete_uploaded_file(&reference.artifact_id).await; } } } - + async fn finish_upload(&mut self, app: &mut App, result: AttachmentUploadResult) { + let Some(upload) = self.staged_uploads.get_mut(&result.id) else { + if let (Some(target), Ok(reference)) = (&self.backend_target, result.result) { + if reference.source_entry_id.is_none() { + let _ = target.delete_uploaded_file(&reference.artifact_id).await; + } + } + return; + }; + if upload.task.take().is_some() { + self.active_uploads = self.active_uploads.saturating_sub(1); + } + match result.result { + Ok(reference) => { + if reference.source_entry_id.is_some() { + // An idempotent retry may return an already committed resource. + self.staged_uploads.remove(&result.id); + app.input + .finish_attachment_stage(&result.id, Some(reference)); + return; + } + upload.reference = Some(reference.clone()); + if !app + .input + .finish_attachment_stage(&result.id, Some(reference)) + { + self.cancel_stage(&result.id).await; + } else { + app.completion = None; + app.clear_actionbar_notice(); + } + } + Err(error) => { + if !app.input.finish_attachment_stage(&result.id, None) { + self.cancel_stage(&result.id).await; + } else { + app.push_error(format!( + "Attachment upload failed: {error}. Alt+Enter on the failed chip retries." + )); + } + } + } + } async fn shutdown(&mut self) -> Result<(), Box> { - self.pending_attachments - .append(&mut self.awaiting_attachment_acceptance); - self.clear_pending_attachments().await; + let ids = + self.staged_uploads + .iter() + .filter(|(_, upload)| { + !upload.reference.as_ref().is_some_and(|reference| { + self.attachment_is_submitting(&reference.artifact_id) + }) + }) + .map(|(id, _)| id.clone()) + .collect::>(); + for id in ids { + self.cancel_stage(&id).await; + } + // Disconnect leaves in-flight acceptance ambiguous; never delete those resources. if let Some(host) = self.standalone_host.take() { host.shutdown().await?; } @@ -478,6 +559,15 @@ async fn run_backend_console( let mut terminal = enter_fullscreen()?; let workspace_root = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")); let mut app = App::new_with_persistent_input_history(worker_label, &workspace_root); + app.set_completion_target( + serde_json::to_string(&( + &target.base_url, + &target.workspace_id, + &target.runtime_id, + &target.worker_id, + )) + .expect("serializing a Backend target cannot fail"), + ); app.connected = connected; let mut connection = ConsoleConnection::with_backend_target(client, target); let result = run_loop(&mut terminal, &mut app, &mut connection).await; @@ -852,7 +942,7 @@ where })) } upload = upload_rx.recv() => { - LoopInput::Upload(upload.unwrap_or_else(|| Err("attachment upload queue stopped".into()))) + LoopInput::Upload(upload.unwrap_or_else(|| AttachmentUploadResult { id: String::new(), result: Err("attachment upload queue stopped".into()) })) } event = pod_next, if connected => LoopInput::Worker(event), _ = animation_tick, if animate => LoopInput::Tick, @@ -950,40 +1040,21 @@ async fn run_loop( LoopInput::Terminal(term_event) => { handle_terminal_event(app, client, &upload_tx, term_event?).await?; } - LoopInput::Upload(result) => { - client.finish_upload(); - match result { - Ok(reference) => { - app.flash_actionbar_notice( - format!( - "[{} · {} bytes · ready] Send a message or use /clear-attachments.", - reference.file_name, reference.byte_len - ), - ActionbarNoticeLevel::Info, - ActionbarNoticeSource::Tui, - Duration::from_secs(60 * 60), - ); - if !client - .pending_attachments - .iter() - .any(|pending| pending.artifact_id == reference.artifact_id) - { - client.pending_attachments.push(reference); - } - } - Err(error) => app.push_error(format!("Attachment upload failed: {error}")), - } - } + LoopInput::Upload(result) => client.finish_upload(app, result).await, LoopInput::Worker(event) => match event? { Some(ev) => { if client.observe_worker_event(&ev) { app.clear_actionbar_notice(); } + if let Some(method) = client.rejected_typed_submit.take() { + app.restore_unsent_run(&method); + } if let Some(method) = app.handle_worker_event(ev) { send_console_method(app, client, &method).await?; } } None => { + app.cancel_completion(); app.connected = false; app.mark_orphan_compacts_incomplete(); app.push_error("Connection lost"); @@ -998,25 +1069,48 @@ async fn run_loop( Ok(()) } -fn attachment_command_path(method: &Method) -> Option { - let Method::Submit { input, .. } = method else { - return None; +async fn client_file_completions(prefix: &str) -> Vec { + let (directory, leaf, displayed) = match prefix.rsplit_once('/') { + Some((directory, leaf)) => ( + if directory.is_empty() { "/" } else { directory }, + leaf, + format!("{directory}/"), + ), + None => (".", prefix, String::new()), }; - let [Segment::Text { content }] = input.as_slice() else { - return None; + let Ok(mut reader) = tokio::fs::read_dir(directory).await else { + return Vec::new(); }; - let path = content.strip_prefix("/attach ")?.trim(); - (!path.is_empty()).then(|| PathBuf::from(path)) -} - -fn is_clear_attachments_command(method: &Method) -> bool { - let Method::Submit { input, .. } = method else { - return false; - }; - matches!( - input.as_slice(), - [Segment::Text { content }] if content.trim() == "/clear-attachments" - ) + let mut entries = Vec::new(); + // Keep directory discovery bounded and local; never send a client path to Worker. + let mut visited = 0; + while let Ok(Some(entry)) = reader.next_entry().await { + visited += 1; + if visited > 4096 { + break; + } + let Some(name) = entry.file_name().to_str().map(str::to_owned) else { + continue; + }; + if !name.starts_with(leaf) { + continue; + } + let is_dir = entry + .metadata() + .await + .is_ok_and(|metadata| metadata.is_dir()); + if !is_dir && attachment_media_type(&entry.path()).is_none() { + continue; + } + entries.push(protocol::CompletionEntry { + value: format!("{displayed}{name}"), + is_dir, + ..Default::default() + }); + } + entries.sort_by(|a, b| a.value.cmp(&b.value)); + entries.truncate(100); + entries } async fn send_console_method( @@ -1024,10 +1118,29 @@ async fn send_console_method( client: &mut ConsoleConnection, method: &Method, ) -> Result<(), Box> { + if let Method::ListCompletions { + kind: protocol::CompletionKind::FeatureArgument, + prefix, + context: Some(context), + request_id, + } = method + { + if app.is_client_file_completion(context) { + let entries = client_file_completions(prefix).await; + app.handle_worker_event(Event::Completions { + request_id: request_id.clone(), + kind: protocol::CompletionKind::FeatureArgument, + prefix: prefix.clone(), + context: Some(context.clone()), + entries, + }); + return Ok(()); + } + } if matches!(method, Method::Submit { .. }) && client.has_active_uploads() { app.restore_unsent_run(method); app.flash_actionbar_notice( - "Attachment upload is still in progress; wait or use /clear-attachments.", + "Attachment upload is still in progress; wait for its composer chip.", ActionbarNoticeLevel::Info, ActionbarNoticeSource::Tui, Duration::from_secs(60 * 60), @@ -1035,16 +1148,21 @@ async fn send_console_method( return Ok(()); } - let sends_attachments = - matches!(method, Method::Submit { .. }) && !client.pending_attachments.is_empty(); + let sends_attachments = matches!( + method, + Method::Submit { input, .. } + if input.iter().any(|segment| matches!(segment, Segment::UploadedFile { .. })) + ); if let Err(error) = client.send(method).await { - if sends_attachments { + if sends_attachments + || matches!(method, Method::Submit { input, .. } if input.iter().any(|segment| matches!(segment, Segment::FeatureInvoke { .. }))) + { app.restore_unsent_run(method); app.push_error(format!( - "Attachment submission failed: {error}. Pending attachments were retained; retry Send." + "Typed input submission failed: {error}. The composer was restored; retry Send." )); app.flash_actionbar_notice( - "Attachment submission failed; pending attachments are ready to retry.", + "Typed input submission failed; composer chips are ready to retry.", ActionbarNoticeLevel::Error, ActionbarNoticeSource::Tui, Duration::from_secs(60 * 60), @@ -1072,31 +1190,57 @@ async fn handle_terminal_event( ) -> Result<(), Box> { match event { TermEvent::Key(key) => { + if key.code == KeyCode::Enter + && key.modifiers == KeyModifiers::ALT + && key.kind == crossterm::event::KeyEventKind::Press + { + if let Some(id) = app.input.adjacent_failed_attachment() { + if let Some(upload) = client.staged_uploads.get(&id) { + let path = upload.path.clone(); + client.start_upload(path, id.clone(), upload_tx.clone())?; + app.input.retry_attachment_stage(&id); + } + return Ok(()); + } + } if let Some(method) = handle_key(app, key) { - if let Some(path) = attachment_command_path(&method) { - match client.start_upload(path, upload_tx.clone()) { - Ok(()) => app.flash_actionbar_notice( - "Uploading attachment… Use /clear-attachments to cancel.", + send_console_method(app, client, &method).await?; + } + for (invocation_id, path) in app.attachment_invocations() { + let id = uuid::Uuid::now_v7().to_string(); + let file_name = path + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_default(); + match client.start_upload(path, id.clone(), upload_tx.clone()) { + Ok(()) => { + app.input.stage_attachment(&invocation_id, id, file_name); + app.flash_actionbar_notice( + "Uploading attachment; delete its chip to cancel.", ActionbarNoticeLevel::Info, ActionbarNoticeSource::Tui, Duration::from_secs(30), - ), - Err(error) => { - app.push_error(format!("Attachment upload failed: {error}")); - } + ); } - } else if is_clear_attachments_command(&method) { - client.clear_pending_attachments().await; - app.flash_actionbar_notice( - "Removed pending attachments.", - ActionbarNoticeLevel::Info, - ActionbarNoticeSource::Tui, - Duration::from_secs(4), - ); - } else { - send_console_method(app, client, &method).await?; + Err(error) => app.push_error(format!("Attachment upload failed: {error}")), } } + let visible_stages = app.input.attachment_stage_ids(); + let orphaned = client + .staged_uploads + .iter() + .filter(|(id, upload)| upload.reference.is_none() && !visible_stages.contains(id)) + .map(|(id, _)| id.clone()) + .collect::>(); + for id in orphaned { + client.cancel_stage(&id).await; + } + for id in app.input.take_removed_attachment_stages() { + client.cancel_stage(&id).await; + } + for reference in app.input.take_removed_uploaded_files() { + client.delete_draft_attachment(&reference).await; + } } TermEvent::Mouse(mouse) => { handle_mouse(app, mouse); @@ -1171,6 +1315,18 @@ fn apply_composer_edit_action(app: &mut App, action: ComposerEditAction) -> Opti } fn handle_key(app: &mut App, key: KeyEvent) -> Option { + if key.kind == crossterm::event::KeyEventKind::Release + || (key.code == KeyCode::Enter && key.kind == crossterm::event::KeyEventKind::Repeat) + { + return None; + } + if key.code == KeyCode::Enter + && key.modifiers == KeyModifiers::ALT + && !app.is_command_mode() + && app.input.adjacent_feature_invocation().is_some() + { + return app.edit_adjacent_feature_invocation(); + } let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); let shift = key.modifiers.contains(KeyModifiers::SHIFT); let alt = key.modifiers.contains(KeyModifiers::ALT); @@ -1582,23 +1738,387 @@ mod tests { } #[test] - fn client_local_attachment_commands_are_typed_and_do_not_send_the_path() { - let attach = Method::Submit { - submission_request_id: protocol::new_submission_request_id(), - input: vec![Segment::text("/attach /tmp/report.md")], - }; - assert_eq!( - attachment_command_path(&attach), - Some(PathBuf::from("/tmp/report.md")) + fn feature_invocation_alt_enter_reopens_chip_but_preserves_plain_newline_and_key_release_behavior() + { + use crate::invocation_tests::descriptor; + let invocation = protocol::parse_feature_invocation("/run(x)", 0, &descriptor(), "id") + .unwrap() + .invocation; + let mut app = App::new("test".into()); + app.input + .replace_with_segments(&[Segment::FeatureInvoke { invocation }]); + assert!(matches!( + handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::ALT)), + Some(Method::ListCompletions { + kind: protocol::CompletionKind::Feature, + .. + }) + )); + app.handle_worker_event(Event::Completions { + request_id: app.completion_request_id(), + kind: protocol::CompletionKind::Feature, + prefix: "run".into(), + context: None, + entries: vec![protocol::CompletionEntry { + value: "run".into(), + invocation: Some(descriptor()), + ..Default::default() + }], + }); + assert_eq!(app.input.plain_text(), "/run(path=\"x\")"); + let mut plain = App::new("test".into()); + plain.input.insert_str("committed 日本語"); + handle_key(&mut plain, KeyEvent::new(KeyCode::Enter, KeyModifiers::ALT)); + assert_eq!(plain.input.plain_text(), "committed 日本語\n"); + let before = plain.input.submit_segments(); + handle_key( + &mut plain, + KeyEvent { + code: KeyCode::Enter, + modifiers: KeyModifiers::NONE, + kind: crossterm::event::KeyEventKind::Release, + state: crossterm::event::KeyEventState::NONE, + }, ); - assert!(!is_clear_attachments_command(&attach)); + assert_eq!(plain.input.submit_segments(), before); + } - let clear = Method::Submit { - submission_request_id: protocol::new_submission_request_id(), - input: vec![Segment::text("/clear-attachments")], + fn attachment_test_connection() -> ConsoleConnection { + ConsoleConnection { + client: Client::new(FailOnceSocket { + fail_next_send: false, + }), + standalone_host: None, + backend_target: None, + staged_uploads: HashMap::new(), + typed_submissions: HashMap::new(), + rejected_typed_submit: None, + active_uploads: 0, + } + } + + #[tokio::test] + async fn attachment_staging_completion_replaces_its_chip_not_the_current_cursor() { + use crate::invocation_tests::{descriptor, uploaded_file}; + let mut app = App::new("test".into()); + let invocation = protocol::parse_feature_invocation("/run(x)", 0, &descriptor(), "invoke") + .unwrap() + .invocation; + app.input.replace_with_segments(&[ + Segment::FeatureInvoke { invocation }, + Segment::text(" following prose"), + ]); + app.input + .stage_attachment("invoke", "stage".into(), "a.txt".into()); + app.input.move_home(); + app.input.insert_str("prefix "); + let mut connection = attachment_test_connection(); + connection.staged_uploads.insert( + "stage".into(), + StagedUpload { + path: "a.txt".into(), + task: None, + reference: None, + }, + ); + connection + .finish_upload( + &mut app, + AttachmentUploadResult { + id: "stage".into(), + result: Ok(uploaded_file()), + }, + ) + .await; + assert_eq!( + app.input.submit_segments(), + vec![ + Segment::text("prefix "), + Segment::UploadedFile { + file: uploaded_file() + }, + Segment::text(" following prose") + ] + ); + app.input.insert_str("cursor remains "); + assert!(app.input.plain_text().starts_with("prefix cursor remains ")); + } + + #[tokio::test] + async fn attachment_removed_staging_and_failed_retry_keep_exact_resource_ownership() { + use crate::invocation_tests::{descriptor, uploaded_file}; + let mut app = App::new("test".into()); + let invocation = protocol::parse_feature_invocation("/run(x)", 0, &descriptor(), "invoke") + .unwrap() + .invocation; + app.input + .replace_with_segments(&[Segment::FeatureInvoke { invocation }]); + app.input + .stage_attachment("invoke", "stage".into(), "a.txt".into()); + let mut connection = attachment_test_connection(); + connection.staged_uploads.insert( + "stage".into(), + StagedUpload { + path: "a.txt".into(), + task: None, + reference: None, + }, + ); + connection + .finish_upload( + &mut app, + AttachmentUploadResult { + id: "stage".into(), + result: Err("temporary".into()), + }, + ) + .await; + assert_eq!( + app.input.adjacent_failed_attachment().as_deref(), + Some("stage") + ); + assert_eq!( + connection.staged_uploads["stage"].path, + PathBuf::from("a.txt") + ); + app.input.retry_attachment_stage("stage"); + assert!(app.input.adjacent_failed_attachment().is_none()); + app.input.delete_word_before(); + for id in app.input.take_removed_attachment_stages() { + connection.cancel_stage(&id).await; + } + assert!(connection.staged_uploads.is_empty()); + connection + .finish_upload( + &mut app, + AttachmentUploadResult { + id: "stage".into(), + result: Ok(uploaded_file()), + }, + ) + .await; + assert!(app.input.is_empty()); + assert!(connection.staged_uploads.is_empty()); + } + + #[tokio::test] + async fn attachment_acceptance_transfers_ownership_before_queue_execution_and_history_delete() { + use crate::invocation_tests::uploaded_file; + let mut connection = attachment_test_connection(); + let reference = uploaded_file(); + connection.staged_uploads.insert( + "stage".into(), + StagedUpload { + path: "a.txt".into(), + task: None, + reference: Some(reference.clone()), + }, + ); + let method = Method::Submit { + submission_request_id: "request".into(), + input: vec![Segment::UploadedFile { + file: reference.clone(), + }], }; - assert!(is_clear_attachments_command(&clear)); - assert_eq!(attachment_command_path(&clear), None); + connection.send(&method).await.unwrap(); + connection.delete_draft_attachment(&reference).await; + assert_eq!( + connection.staged_uploads.len(), + 1, + "in-flight resources must be protected" + ); + connection.observe_worker_event(&Event::Error { + code: protocol::ErrorCode::ProviderError, + message: "unrelated provider error".into(), + }); + assert_eq!(connection.typed_submissions.len(), 1); + assert!( + !connection.observe_worker_event(&Event::SubmissionAccepted { + submission_request_id: "other".into(), + submission_id: "other-submission".into(), + disposition: protocol::SubmissionDisposition::Queued + }) + ); + assert!(connection.observe_worker_event(&Event::SubmissionAccepted { + submission_request_id: "request".into(), + submission_id: "submission".into(), + disposition: protocol::SubmissionDisposition::Queued + })); + assert!(connection.staged_uploads.is_empty()); + connection.delete_draft_attachment(&reference).await; + connection.shutdown().await.unwrap(); + assert!( + connection.staged_uploads.is_empty(), + "accepted resources never reenter unsent ownership" + ); + } + + #[tokio::test] + async fn attachment_rejection_is_request_scoped_and_restores_typed_retry() { + use crate::invocation_tests::uploaded_file; + let mut connection = attachment_test_connection(); + let reference = uploaded_file(); + connection.staged_uploads.insert( + "stage".into(), + StagedUpload { + path: "a.txt".into(), + task: None, + reference: Some(reference.clone()), + }, + ); + let first = Method::Submit { + submission_request_id: "first".into(), + input: vec![Segment::UploadedFile { + file: reference.clone(), + }], + }; + let second = Method::Submit { + submission_request_id: "second".into(), + input: vec![Segment::UploadedFile { + file: UploadedFileRef { + artifact_id: "second-artifact".into(), + ..reference.clone() + }, + }], + }; + connection.send(&first).await.unwrap(); + connection.send(&second).await.unwrap(); + connection.observe_worker_event(&Event::SubmissionRejected { + submission_request_id: "first".into(), + message: "rejected".into(), + }); + assert_eq!(connection.typed_submissions.len(), 1); + assert!(connection.typed_submissions.contains_key("second")); + let mut app = App::new("test".into()); + app.restore_unsent_run(&connection.rejected_typed_submit.take().unwrap()); + assert_eq!( + app.input.submit_segments(), + vec![Segment::UploadedFile { + file: reference.clone() + }] + ); + connection.delete_draft_attachment(&reference).await; + assert!(connection.staged_uploads.is_empty()); + } + + #[tokio::test] + async fn attachment_retry_returning_committed_resource_never_acquires_delete_ownership() { + use crate::invocation_tests::{descriptor, uploaded_file}; + let invocation = protocol::parse_feature_invocation("/run(x)", 0, &descriptor(), "invoke") + .unwrap() + .invocation; + let mut app = App::new("test".into()); + app.input + .replace_with_segments(&[Segment::FeatureInvoke { invocation }]); + app.input + .stage_attachment("invoke", "stage".into(), "a.txt".into()); + let mut connection = attachment_test_connection(); + connection.staged_uploads.insert( + "stage".into(), + StagedUpload { + path: "a.txt".into(), + task: None, + reference: None, + }, + ); + let file = UploadedFileRef { + source_entry_id: Some("committed-entry".into()), + ..uploaded_file() + }; + connection + .finish_upload( + &mut app, + AttachmentUploadResult { + id: "stage".into(), + result: Ok(file.clone()), + }, + ) + .await; + assert_eq!( + app.input.submit_segments(), + vec![Segment::UploadedFile { file: file.clone() }] + ); + assert!(connection.staged_uploads.is_empty()); + app.input.delete_before(); + connection.delete_draft_attachment(&file).await; + assert!(connection.staged_uploads.is_empty()); + } + + #[tokio::test] + async fn attachment_local_completion_aba_echoes_nonce_and_ignores_prior_request() { + use crate::invocation_tests::{attachment_descriptor, select}; + let temp = tempfile::tempdir().unwrap(); + std::fs::write(temp.path().join("資料 a.txt"), "data").unwrap(); + let mut app = App::new("test".into()); + select(&mut app, attachment_descriptor(), "add-file"); + let prefix = format!("{}/", temp.path().display()); + app.input + .insert_str(protocol::quote_invocation_string(&prefix).trim_end_matches('"')); + let old = app.refresh_completion().unwrap(); + app.insert_char('x'); + app.delete_char_before(); + let current = app.refresh_completion().unwrap(); + let mut connection = attachment_test_connection(); + connection.client = Client::new(FailOnceSocket { + fail_next_send: true, + }); + send_console_method(&mut app, &mut connection, &old) + .await + .unwrap(); + assert!(app.completion.as_ref().unwrap().entries.is_empty()); + send_console_method(&mut app, &mut connection, ¤t) + .await + .unwrap(); + assert_eq!(app.completion.as_ref().unwrap().entries.len(), 1); + assert!( + app.completion.as_ref().unwrap().entries[0] + .value + .ends_with("資料 a.txt") + ); + } + + #[tokio::test] + async fn attachment_client_file_completion_is_local_bounded_and_safe_for_unicode_paths() { + use crate::invocation_tests::{attachment_descriptor, select}; + let temp = tempfile::tempdir().unwrap(); + std::fs::write(temp.path().join("資料 a.txt"), "data").unwrap(); + std::fs::write(temp.path().join("unsafe.exe"), "data").unwrap(); + std::fs::create_dir(temp.path().join("nested dir")).unwrap(); + let mut app = App::new("test".into()); + select(&mut app, attachment_descriptor(), "add-file"); + let prefix = format!("{}/", temp.path().display()); + app.input + .insert_str(&protocol::quote_invocation_string(&prefix).trim_end_matches('"')); + let method = app.refresh_completion().unwrap(); + let mut connection = attachment_test_connection(); + // If completion is accidentally sent to Worker this deliberately failing transport fails the test. + connection.client = Client::new(FailOnceSocket { + fail_next_send: true, + }); + send_console_method(&mut app, &mut connection, &method) + .await + .unwrap(); + let entries = &app.completion.as_ref().unwrap().entries; + assert_eq!(entries.len(), 2); + assert!( + entries + .iter() + .any(|entry| entry.value.ends_with("資料 a.txt")) + ); + assert!( + entries + .iter() + .any(|entry| entry.is_dir && entry.value.ends_with("nested dir")) + ); + assert!( + !entries + .iter() + .any(|entry| entry.value.ends_with("unsafe.exe")) + ); + } + + #[test] + fn attachment_media_types_remain_restricted() { assert_eq!( attachment_media_type(Path::new("report.webp")), Some("image/webp") @@ -1652,15 +2172,15 @@ mod tests { }), standalone_host: None, backend_target: None, - pending_attachments: vec![file.clone()], - awaiting_attachment_acceptance: Vec::new(), - upload_tasks: Vec::new(), + staged_uploads: HashMap::new(), + typed_submissions: HashMap::new(), + rejected_typed_submit: None, active_uploads: 0, - upload_ids: HashMap::new(), }; let mut app = App::new("worker".into()); app.input.insert_str("inspect"); + app.input.insert_uploaded_file(file.clone()); connection.active_uploads = 1; let blocked = app.submit_input().unwrap(); @@ -1668,36 +2188,41 @@ mod tests { send_console_method(&mut app, &mut connection, &blocked) .await .unwrap(); - assert_eq!(app.input.plain_text(), "inspect"); - assert_eq!(connection.pending_attachments, vec![file.clone()]); - assert!(connection.awaiting_attachment_acceptance.is_empty()); + assert!(matches!(app.input.submit_segments().as_slice(), + [Segment::Text { content }, Segment::UploadedFile { file: restored }] + if content == "inspect" && restored == &file)); + assert!(connection.staged_uploads.is_empty()); + assert!(connection.typed_submissions.is_empty()); connection.active_uploads = 0; let failed = app.submit_input().unwrap(); send_console_method(&mut app, &mut connection, &failed) .await .unwrap(); - assert_eq!(app.input.plain_text(), "inspect"); - assert_eq!(connection.pending_attachments, vec![file.clone()]); - assert!(connection.awaiting_attachment_acceptance.is_empty()); + assert!(matches!(app.input.submit_segments().as_slice(), + [Segment::Text { content }, Segment::UploadedFile { file: restored }] + if content == "inspect" && restored == &file)); + assert!(connection.staged_uploads.is_empty()); + assert_eq!(connection.typed_submissions.len(), 1); let retry = app.submit_input().unwrap(); + assert_eq!( + serde_json::to_value(&retry).unwrap(), + serde_json::to_value(&failed).unwrap() + ); send_console_method(&mut app, &mut connection, &retry) .await .unwrap(); assert!(app.input.is_empty()); - assert!(connection.pending_attachments.is_empty()); - assert_eq!( - connection.awaiting_attachment_acceptance, - vec![file.clone()] - ); + assert!(connection.staged_uploads.is_empty()); + assert_eq!(connection.typed_submissions.len(), 1); connection.observe_worker_event(&Event::UserMessage { entry_id: None, segments: vec![Segment::text("inspect"), Segment::UploadedFile { file }], }); - assert!(connection.pending_attachments.is_empty()); - assert!(connection.awaiting_attachment_acceptance.is_empty()); + assert!(connection.staged_uploads.is_empty()); + assert!(connection.typed_submissions.is_empty()); } #[tokio::test] @@ -1718,15 +2243,15 @@ mod tests { }), standalone_host: None, backend_target: None, - pending_attachments: vec![file.clone()], - awaiting_attachment_acceptance: Vec::new(), - upload_tasks: Vec::new(), + staged_uploads: HashMap::new(), + typed_submissions: HashMap::new(), + rejected_typed_submit: None, active_uploads: 0, - upload_ids: HashMap::new(), }; let mut app = App::new("worker".into()); app.set_worker_status(WorkerStatus::Running); app.input.insert_str("queued inspect"); + app.input.insert_uploaded_file(file.clone()); let method = app .submit_input() .expect("running Submit is sent immediately"); @@ -1735,63 +2260,46 @@ mod tests { .await .unwrap(); - assert_eq!(app.input.plain_text(), "queued inspect"); - assert_eq!(connection.pending_attachments, vec![file]); - assert!(connection.awaiting_attachment_acceptance.is_empty()); + assert!(matches!(app.input.submit_segments().as_slice(), + [Segment::Text { content }, Segment::UploadedFile { file: restored }] + if content == "queued inspect" && restored == &file)); + assert!(connection.staged_uploads.is_empty()); + assert_eq!(connection.typed_submissions.len(), 1); + assert_eq!( + serde_json::to_value(app.submit_input().unwrap()).unwrap(), + serde_json::to_value(&method).unwrap() + ); } #[test] - fn partial_attachment_echo_does_not_acknowledge_an_atomic_submission() { - let first = UploadedFileRef { - artifact_id: "artifact-1".into(), - file_name: "one.txt".into(), - media_type: "text/plain".into(), - created_at_ms: 1, - availability: UploadedFileAvailability::Available, - byte_len: 1, - sha256: "a".repeat(64), - source_entry_id: None, - }; - let second = UploadedFileRef { - artifact_id: "artifact-2".into(), - file_name: "two.txt".into(), - ..first.clone() - }; - let mut pending = Vec::new(); - let mut awaiting = vec![first.clone(), second.clone()]; - let expected = awaiting.clone(); - - for segments in [ - vec![Segment::UploadedFile { - file: first.clone(), - }], - vec![ - Segment::UploadedFile { - file: second.clone(), - }, - Segment::UploadedFile { - file: first.clone(), - }, - ], - vec![ - Segment::UploadedFile { - file: first.clone(), - }, - Segment::UploadedFile { - file: first.clone(), - }, - ], - ] { - assert!(!reconcile_attachment_submission( - &mut pending, - &mut awaiting, - &Event::UserMessage { - entry_id: None, - segments, - }, - )); - assert_eq!(awaiting, expected); - assert!(pending.is_empty()); + fn typed_retry_late_acceptance_clears_only_the_matching_restored_draft() { + for changed in [false, true] { + let mut app = App::new("worker".into()); + app.input + .insert_uploaded_file(crate::invocation_tests::uploaded_file()); + let method = app.submit_input().unwrap(); + app.restore_unsent_run(&method); + if changed { + app.input.insert_str("new edit"); + } + let before = app.input.submit_segments(); + let Method::Submit { + submission_request_id, + .. + } = method + else { + unreachable!() + }; + app.handle_worker_event(Event::SubmissionAccepted { + submission_request_id, + submission_id: "durable".into(), + disposition: protocol::SubmissionDisposition::Queued, + }); + if changed { + assert_eq!(app.input.submit_segments(), before); + } else { + assert!(app.input.is_empty()); + } } } @@ -1935,7 +2443,12 @@ mod tests { sha256: "a".repeat(64), source_entry_id: None, }; - upload_tx.send(Ok(file.clone())).unwrap(); + upload_tx + .send(AttachmentUploadResult { + id: "upload-1".into(), + result: Ok(file.clone()), + }) + .unwrap(); match next_loop_input( &mut terminal_rx, @@ -1947,7 +2460,13 @@ mod tests { ) .await { - LoopInput::Upload(Ok(received)) => assert_eq!(received, file), + LoopInput::Upload(AttachmentUploadResult { + id, + result: Ok(received), + }) => { + assert_eq!(id, "upload-1"); + assert_eq!(received, file); + } _ => panic!("expected attachment upload result"), } } @@ -3008,6 +3527,7 @@ mod tests { app.completion.as_mut().unwrap().entries = vec![protocol::CompletionEntry { value: "src/main.rs".into(), is_dir: false, + ..protocol::CompletionEntry::default() }]; app.handle_worker_event(Event::InternalWorker { worker: protocol::InternalWorkerRef { diff --git a/crates/tui/src/input.rs b/crates/tui/src/input.rs index 2c1368456..f7fec6a76 100644 --- a/crates/tui/src/input.rs +++ b/crates/tui/src/input.rs @@ -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 { + unquoted_char_indices(value).find_map(|(index, ch)| (ch == target).then_some(index)) +} + +fn last_unquoted_char(value: &str, target: char) -> Option { + unquoted_char_indices(value) + .filter_map(|(index, ch)| (ch == target).then_some(index)) + .last() +} + +fn unquoted_char_indices(value: &str) -> impl Iterator + '_ { + 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, + selected_invocations: Vec, + removed_attachment_stages: Vec, /// 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, /// 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::(); + 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) { + self.bump_revision(); + let inserted = atoms.len(); + self.adjust_selection(start, end, inserted); + let removed = self.atoms.splice(start..end, atoms).collect::>(); + 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 { + [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 { + 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 { + [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, + ) -> 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 { + std::mem::take(&mut self.removed_attachment_stages) + } + /// Replace `atoms[start..self.cursor]` (the in-flight `@` / /// active `@` 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 { + 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::>()?; + let open = invocation.find('(')?; + let arguments = &invocation[open + 1..]; + if first_unquoted_char(arguments, ')').is_some() { + return None; + } + let current_start = last_unquoted_char(arguments, ',').map_or(0, |index| index + 1); + let current = &arguments[current_start..]; + let leading = current.len() - current.trim_start().len(); + let current = ¤t[leading..]; + let (argument, prefix_offset, mut prefix) = + if let Some(equal) = first_unquoted_char(current, '=') { + let name = current[..equal].trim(); + let mut value = ¤t[equal + 1..]; + let mut offset = equal + 1; + let value_leading = value.len() - value.trim_start().len(); + value = value.trim_start(); + offset += value_leading; + if let Some(unquoted) = value.strip_prefix('"') { + value = unquoted; + offset += 1; + } + (Some(name.to_string()), offset, value.to_string()) + } else { + let mut previous_start = 0; + let mut position = 0; + for comma in unquoted_char_indices(&arguments[..current_start]) + .filter_map(|(index, ch)| (ch == ',').then_some(index)) + { + if first_unquoted_char(&arguments[previous_start..comma], '=').is_none() { + position += 1; + } + previous_start = comma + 1; + } + let positional = descriptor + .arguments + .iter() + .find(|argument| argument.position == Some(position)); + if (current.is_empty() && positional.is_none()) + || (current.bytes().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_' + }) && positional.is_none()) + { + (None, 0, current.to_string()) + } else { + let mut value = current; + let mut offset = 0; + if let Some(unquoted) = value.strip_prefix('"') { + value = unquoted; + offset = 1; + } + ( + positional.map(|argument| argument.name.clone()), + offset, + value.to_string(), + ) + } + }; + if prefix_offset > 0 && current[..prefix_offset].ends_with('"') { + if serde_json::from_str::(&format!("\"{}", prefix)).is_ok() { + return None; + } + prefix = serde_json::from_str::(&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 `@` / @@ -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 { + 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("

") - } + Atom::Unknown + | Atom::AttachmentStage { .. } + | Atom::Paste(_) + | Atom::PasteArtifact(_) + | Atom::UploadedFile(_) + | Atom::FileRef(_) + | Atom::FeatureInvoke(_) + | Atom::FlowRef(_) => out.push_str("

"), } } 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()); + } +} diff --git a/crates/tui/src/invocation_tests.rs b/crates/tui/src/invocation_tests.rs new file mode 100644 index 000000000..b7f6a8a40 --- /dev/null +++ b/crates/tui/src/invocation_tests.rs @@ -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 { + 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 { + let state = app.completion.as_ref().expect("argument completion"); + app.handle_worker_event(Event::Completions { + request_id: app.completion_request_id(), + kind: state.kind, + prefix: state.prefix.clone(), + context: state.context.clone(), + entries: vec![CompletionEntry { + value: value.into(), + is_dir, + ..Default::default() + }], + }); + app.apply_completion_text() +} + +#[test] +fn feature_invocation_discovery_alias_selection_and_confirmation_are_occurrence_scoped() { + let mut app = App::new("test".into()); + app.input.insert_str("説明 "); + assert!( + matches!(select(&mut app, descriptor(), "execute"), Some(Method::ListCompletions { + kind: CompletionKind::FeatureArgument, context: Some(CompletionContext { argument: Some(argument), .. }), .. + }) if argument == "path") + ); + app.input + .insert_str("\"資料/a b\") after /execute(\"not selected\")"); + assert!( + app.submit_input().is_none(), + "first Enter confirms typed chips, not Send" + ); + let segments = app.input.submit_segments(); + assert!( + matches!(&segments[1], Segment::FeatureInvoke { invocation } if invocation.identity == descriptor().identity && invocation.name == "execute") + ); + assert!( + matches!(segments.last(), Some(Segment::Text { content }) if content == " after /execute(\"not selected\")") + ); + assert!(matches!(app.submit_input(), Some(Method::Submit { input, .. }) if input == segments)); +} + +#[test] +fn feature_invocation_raw_text_paste_and_unknown_payloads_do_not_dispatch() { + let mut app = App::new("test".into()); + app.input + .insert_str("/run(x) /attach(/tmp/a.txt) /clear-attachments"); + assert!( + matches!(app.submit_input(), Some(Method::Submit { input, .. }) if matches!(input.as_slice(), [Segment::Text { .. }])) + ); + let unknown: Segment = + serde_json::from_str(r#"{"kind":"future_invocation","identity":"plugin:test/run"}"#) + .unwrap(); + app.input.replace_with_segments(&[unknown]); + assert_eq!(app.input.submit_segments(), vec![Segment::Unknown]); + let mut input = InputBuffer::new(); + input.insert_paste("/run(x) ".repeat(20)); + input.finalize_feature_invocations().unwrap(); + assert!(matches!( + input.submit_segments().as_slice(), + [Segment::Paste { .. }] + )); +} + +#[test] +fn feature_invocation_argument_insertion_quotes_unicode_spaces_escapes_and_keeps_prose() { + let mut app = App::new("test".into()); + app.input.insert_str("界 "); + select(&mut app, descriptor(), "run"); + let value = "資料/a b/quote\"slash\\end"; + complete(&mut app, value, false); + app.input.insert_str(", mode=sa"); + app.refresh_completion(); + complete(&mut app, "safe", false); + app.input.insert_str(", count=1"); + app.refresh_completion(); + complete(&mut app, "12", false); + app.input.insert_str(", force=f"); + app.refresh_completion(); + complete(&mut app, "false", false); + app.input.insert_str(") trailing prose"); + assert!(app.submit_input().is_none()); + let segments = app.input.submit_segments(); + let Segment::FeatureInvoke { invocation } = &segments[1] else { + panic!("typed chip expected") + }; + assert_eq!( + invocation.arguments[0].value, + InvocationValue::String(value.into()) + ); + assert_eq!(invocation.arguments[2].value, InvocationValue::Integer(12)); + assert_eq!( + invocation.arguments[3].value, + InvocationValue::Boolean(false) + ); + assert!( + matches!(segments.last(), Some(Segment::Text { content }) if content == " trailing prose") + ); +} + +#[test] +fn feature_invocation_argument_names_and_directory_drill_in_are_safe() { + let mut app = App::new("test".into()); + select(&mut app, descriptor(), "run"); + assert!( + matches!(complete(&mut app, "資料 dir", true), Some(Method::ListCompletions { prefix, .. }) if prefix == "資料 dir/") + ); + complete(&mut app, "資料 dir/file name.txt", false); + app.input.insert_str(", mo"); + app.refresh_completion(); + assert!( + app.completion + .as_ref() + .unwrap() + .context + .as_ref() + .unwrap() + .argument + .is_none() + ); + assert!( + matches!(complete(&mut app, "mode=", false), Some(Method::ListCompletions { context: Some(CompletionContext { argument: Some(argument), .. }), .. }) if argument == "mode") + ); + complete(&mut app, "fast", false); + app.input.insert_char(')'); + assert!(app.submit_input().is_none()); + assert!(matches!( + app.input.submit_segments().as_slice(), + [Segment::FeatureInvoke { .. }] + )); +} + +#[test] +fn feature_invocation_completion_replaces_middle_value_without_suffix_corruption() { + let mut input = InputBuffer::new(); + input.insert_str("界 /run(path= \"資料/old name\", mode=safe) prose"); + input.select_feature_invocation(2, descriptor()); + input.move_home(); + for _ in 0.."界 /run(path= \"資料/ol".chars().count() { + input.move_right(); + } + let (_, before, prefix, context) = input + .pending_feature_argument_completion(&[descriptor()]) + .unwrap(); + assert_eq!(prefix, "資料/ol"); + assert_eq!(context.argument.as_deref(), Some("path")); + let start = before; // preceding atom is the opening quote + input.replace_argument_completion(start, "e_invocation_string("資料/new name")); + assert_eq!( + input.plain_text(), + "界 /run(path= \"資料/new name\", mode=safe) prose" + ); + input.finalize_feature_invocations().unwrap(); + assert!( + matches!(input.submit_segments().last(), Some(Segment::Text { content }) if content == " prose") + ); +} + +#[test] +fn feature_invocation_validation_is_atomic_for_multiple_selected_calls() { + for invalid in [ + "/run()", + "/run(x, nope=y)", + "/run(x, mode=unknown)", + "/run(x, count=oops)", + "/run(x, force=yes)", + "/run(x, path=y)", + "/run(\"unfinished", + ] { + let mut input = InputBuffer::new(); + input.insert_str("/run(ok) then "); + let second = input.plain_text().chars().count(); + input.insert_str(invalid); + input.select_feature_invocation(0, descriptor()); + input.select_feature_invocation(second, descriptor()); + let before = input.submit_segments(); + assert!(input.finalize_feature_invocations().is_err(), "{invalid}"); + assert_eq!(input.submit_segments(), before); + } +} + +#[test] +fn feature_invocation_selection_tracks_cursor_edits_and_does_not_survive_deleting_its_sigil() { + let mut input = InputBuffer::new(); + input.insert_str("/run(x)"); + input.select_feature_invocation(0, descriptor()); + input.move_home(); + input.insert_str("prefix "); + input.finalize_feature_invocations().unwrap(); + assert!(matches!( + &input.submit_segments()[1], + Segment::FeatureInvoke { .. } + )); + input.replace_with_segments(&[Segment::text("/run(y)")]); + input.select_feature_invocation(0, descriptor()); + input.move_home(); + input.delete_after(); + input.insert_char('/'); + input.finalize_feature_invocations().unwrap(); + assert_eq!(input.submit_segments(), vec![Segment::text("/run(y)")]); +} + +#[test] +fn feature_invocation_chip_reedit_preserves_identity_id_and_typed_arguments() { + let parsed = parse_feature_invocation( + "/execute(\"a b\", mode=safe, count=2)", + 0, + &descriptor(), + "stable-id", + ) + .unwrap() + .invocation; + let segments = vec![ + Segment::FeatureInvoke { + invocation: parsed.clone(), + }, + Segment::UploadedFile { + file: uploaded_file(), + }, + Segment::Flow { + selector: "review".into(), + }, + Segment::FileRef { + path: "src/".into(), + }, + ]; + let mut input = InputBuffer::new(); + input.replace_with_segments(&segments); + assert_eq!(input.submit_segments(), segments); + assert!(input.edit_feature_invocation("stable-id", descriptor())); + input.finalize_feature_invocations().unwrap(); + assert_eq!(input.submit_segments(), segments); + assert!(!input.edit_feature_invocation("stable-id", attachment_descriptor())); +} + +#[test] +fn feature_invocation_reedit_unknown_disabled_or_stale_keeps_original_chip() { + let invocation = parse_feature_invocation("/run(x)", 0, &descriptor(), "id") + .unwrap() + .invocation; + let mut app = App::new("test".into()); + let segments = vec![Segment::FeatureInvoke { invocation }]; + app.input.replace_with_segments(&segments); + assert!(matches!( + app.edit_adjacent_feature_invocation(), + Some(Method::ListCompletions { .. }) + )); + app.handle_worker_event(Event::Completions { + request_id: app.completion_request_id(), + kind: CompletionKind::Feature, + prefix: "run".into(), + context: None, + entries: Vec::new(), + }); + assert_eq!(app.input.submit_segments(), segments); + app.edit_adjacent_feature_invocation(); + app.input.move_home(); + app.refresh_completion(); + app.handle_worker_event(Event::Completions { + request_id: app.completion_request_id(), + kind: CompletionKind::Feature, + prefix: "run".into(), + context: None, + entries: vec![CompletionEntry { + value: "run".into(), + invocation: Some(descriptor()), + ..Default::default() + }], + }); + assert_eq!(app.input.submit_segments(), segments); +} + +#[test] +fn feature_invocation_generic_attachment_adapter_never_records_or_sends_local_path() { + let mut app = App::new("test".into()); + select(&mut app, attachment_descriptor(), "add-file"); + app.input.insert_str("\"/tmp/資料 a.txt\") keep this prose"); + assert!(app.submit_input().is_none()); + assert_eq!(app.input_history_len(), 0); + let adapters = app.attachment_invocations(); + assert_eq!(adapters.len(), 1); + assert_eq!(adapters[0].1.to_str().unwrap(), "/tmp/資料 a.txt"); + app.input + .stage_attachment(&adapters[0].0, "staging-id".into(), "資料 a.txt".into()); + assert!(app.submit_input().is_none()); + assert!(app.submit_notify_input().is_none()); + app.input + .finish_attachment_stage("staging-id", Some(uploaded_file())); + let Method::Submit { input, .. } = app.submit_input().unwrap() else { + panic!("submit expected") + }; + assert!(matches!(&input[0], Segment::UploadedFile { .. })); + assert!(!serde_json::to_string(&input).unwrap().contains("/tmp/")); + assert!( + matches!(input.last(), Some(Segment::Text { content }) if content == " keep this prose") + ); +} + +#[test] +fn feature_invocation_staging_deletion_and_late_completion_do_not_resurrect_chips() { + for delete in [0, 1, 2] { + let invocation = parse_feature_invocation("/run(x)", 0, &descriptor(), "invoke") + .unwrap() + .invocation; + let mut input = InputBuffer::new(); + input.replace_with_segments(&[Segment::FeatureInvoke { invocation }]); + input.stage_attachment("invoke", "stage".into(), "a.txt".into()); + match delete { + 0 => input.delete_before(), + 1 => { + input.move_left(); + input.delete_after(); + } + _ => input.delete_word_before(), + } + assert_eq!(input.take_removed_attachment_stages(), vec!["stage"]); + assert!(!input.finish_attachment_stage("stage", Some(uploaded_file()))); + assert!(input.is_empty()); + } +} + +#[test] +fn feature_invocation_typed_history_draft_transport_and_rewind_restore_without_flattening() { + let invocation = parse_feature_invocation( + "/run(\"資料/a b\", mode=safe)", + 0, + &descriptor(), + "stable-id", + ) + .unwrap() + .invocation; + let segments = vec![ + Segment::text("before "), + Segment::FeatureInvoke { invocation }, + Segment::UploadedFile { + file: uploaded_file(), + }, + Segment::Paste { + id: 2, + chars: 4, + lines: 1, + content: "data".into(), + }, + Segment::FileRef { + path: "src/".into(), + }, + Segment::Flow { + selector: "review".into(), + }, + ]; + let mut app = App::new("test".into()); + app.input.replace_with_segments(&segments); + let method = app.submit_input().unwrap(); + app.restore_unsent_run(&method); + assert_eq!(app.input.submit_segments(), segments); + app.input.clear(); + let draft = vec![ + Segment::text("draft "), + Segment::UploadedFile { + file: uploaded_file(), + }, + ]; + app.input.replace_with_segments(&draft); + app.input.move_home(); + assert!(app.browse_input_history_older()); + assert_eq!(app.input.submit_segments(), segments); + assert!(app.browse_input_history_newer()); + assert_eq!(app.input.submit_segments(), draft); + app.input.clear(); + app.handle_worker_event(Event::RewindApplied { + session: SessionSnapshot { + entries: vec![], + pending_submissions: PendingSubmissionsSnapshot::default(), + }, + input: segments.clone(), + summary: RewindSummary { + truncated_to_entries: 1, + discarded_entries: 2, + tool_side_effect_warning: false, + }, + }); + assert_eq!(app.input.submit_segments(), segments); +} + +#[test] +fn feature_invocation_stale_argument_context_and_unvalidated_discovery_entries_are_ignored() { + let mut app = App::new("test".into()); + select(&mut app, descriptor(), "run"); + let state = app.completion.as_ref().unwrap(); + let prefix = state.prefix.clone(); + let mut context = state.context.clone().unwrap(); + context.invocation = FeatureInvocationIdentity("plugin:other/run".into()); + app.handle_worker_event(Event::Completions { + request_id: app.completion_request_id(), + kind: CompletionKind::FeatureArgument, + prefix, + context: Some(context), + entries: vec![CompletionEntry { + value: "unsafe".into(), + ..Default::default() + }], + }); + assert!(app.completion.as_ref().unwrap().entries.is_empty()); + let mut app = App::new("test".into()); + app.insert_char('/'); + app.refresh_completion(); + let mut invalid = descriptor(); + invalid.identity = FeatureInvocationIdentity("unqualified".into()); + app.handle_worker_event(Event::Completions { + request_id: app.completion_request_id(), + kind: CompletionKind::Feature, + prefix: String::new(), + context: None, + entries: vec![CompletionEntry { + value: "run".into(), + invocation: Some(invalid), + ..Default::default() + }], + }); + assert!(app.apply_completion_text().is_none()); + assert_eq!(app.input.plain_text(), "/"); +} + +#[test] +fn feature_invocation_multiple_selected_calls_preserve_prose_and_source_qualified_identity() { + let mut app = App::new("test".into()); + select(&mut app, descriptor(), "run"); + app.input.insert_str("x) prose "); + let mut other = descriptor(); + other.identity = FeatureInvocationIdentity("plugin:other/second".into()); + other.name = "second".into(); + other.aliases.clear(); + select(&mut app, other.clone(), "second"); + app.input.insert_str("\"y z\") done"); + assert!(app.submit_input().is_none()); + let segments = app.input.submit_segments(); + assert!( + matches!(&segments[0], Segment::FeatureInvoke { invocation } if invocation.identity == descriptor().identity) + ); + assert!(matches!(&segments[1], Segment::Text { content } if content == " prose ")); + assert!( + matches!(&segments[2], Segment::FeatureInvoke { invocation } if invocation.identity == other.identity) + ); + assert!(matches!(&segments[3], Segment::Text { content } if content == " done")); +} + +#[test] +fn feature_invocation_range_replacement_reports_each_removed_upload_and_stage() { + let invocation = parse_feature_invocation("/run(x)", 0, &descriptor(), "id") + .unwrap() + .invocation; + let mut input = InputBuffer::new(); + input.replace_with_segments(&[ + Segment::UploadedFile { + file: uploaded_file(), + }, + Segment::text(" "), + Segment::FeatureInvoke { invocation }, + ]); + input.stage_attachment("id", "stage".into(), "a.txt".into()); + input.replace_with_text_at(0, "replacement"); + assert_eq!(input.take_removed_uploaded_files(), vec![uploaded_file()]); + assert_eq!(input.take_removed_attachment_stages(), vec!["stage"]); + assert_eq!(input.plain_text(), "replacement"); +} + +#[test] +fn feature_invocation_argument_positions_ignore_previous_named_arguments() { + let mut input = InputBuffer::new(); + input.insert_str("/run(mode=safe, \"資料/a"); + input.select_feature_invocation(0, descriptor()); + let (_, _, prefix, context) = input + .pending_feature_argument_completion(&[descriptor()]) + .unwrap(); + assert_eq!(context.argument.as_deref(), Some("path")); + assert_eq!(prefix, "資料/a"); +} + +#[test] +fn feature_invocation_name_completion_replaces_suffix_at_middle_cursor() { + let mut app = App::new("test".into()); + app.input.insert_str("/run() prose"); + app.input.move_home(); + app.input.move_right(); + app.input.move_right(); + app.refresh_completion(); + app.handle_worker_event(Event::Completions { + request_id: app.completion_request_id(), + kind: CompletionKind::Feature, + prefix: "r".into(), + context: None, + entries: vec![CompletionEntry { + value: "run".into(), + invocation: Some(descriptor()), + ..Default::default() + }], + }); + app.apply_completion_text(); + assert_eq!(app.input.plain_text(), "/run() prose"); +} + +#[test] +fn feature_invocation_discovery_expands_aliases_from_host_metadata() { + let mut app = App::new("test".into()); + app.input.insert_str("/exe"); + app.refresh_completion(); + app.handle_worker_event(Event::Completions { + request_id: app.completion_request_id(), + kind: CompletionKind::Feature, + prefix: "exe".into(), + context: None, + entries: vec![CompletionEntry { + value: "run".into(), + invocation: Some(descriptor()), + ..Default::default() + }], + }); + assert_eq!(app.completion.as_ref().unwrap().entries[0].value, "execute"); + app.apply_completion_text(); + assert_eq!(app.input.plain_text(), "/execute("); +} + +#[test] +fn feature_invocation_named_arguments_are_suggested_alongside_positional_values() { + let mut app = App::new("test".into()); + select(&mut app, descriptor(), "run"); + app.input.insert_str("pa"); + app.refresh_completion(); + let state = app.completion.as_ref().unwrap(); + app.handle_worker_event(Event::Completions { + request_id: app.completion_request_id(), + kind: state.kind, + prefix: state.prefix.clone(), + context: state.context.clone(), + entries: Vec::new(), + }); + assert_eq!(app.completion.as_ref().unwrap().entries[0].value, "path="); + app.apply_completion_text(); + assert_eq!(app.input.plain_text(), "/run(path="); + app.input.insert_str("\"資料/path\")"); + assert!(app.submit_input().is_none()); + assert!(matches!( + app.input.submit_segments().as_slice(), + [Segment::FeatureInvoke { .. }] + )); +} diff --git a/crates/tui/src/lib.rs b/crates/tui/src/lib.rs index 5bc9ca090..359063b6e 100644 --- a/crates/tui/src/lib.rs +++ b/crates/tui/src/lib.rs @@ -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; diff --git a/crates/tui/src/ui.rs b/crates/tui/src/ui.rs index e7f453b57..de5492ac3 100644 --- a/crates/tui/src/ui.rs +++ b/crates/tui/src/ui.rs @@ -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(); diff --git a/crates/worker-runtime/src/execution.rs b/crates/worker-runtime/src/execution.rs index a832d5350..e169e6dfe 100644 --- a/crates/worker-runtime/src/execution.rs +++ b/crates/worker-runtime/src/execution.rs @@ -720,6 +720,7 @@ pub trait WorkerExecutionBackend: Send + Sync + 'static { _handle: &WorkerExecutionHandle, _kind: protocol::CompletionKind, _prefix: &str, + _context: Option<&protocol::CompletionContext>, ) -> Vec { Vec::new() } @@ -942,8 +943,10 @@ impl WorkerExecutionBackendRef { handle: &WorkerExecutionHandle, kind: protocol::CompletionKind, prefix: &str, + context: Option<&protocol::CompletionContext>, ) -> Vec { - self.backend.worker_completions(handle, kind, prefix) + self.backend + .worker_completions(handle, kind, prefix, context) } pub(crate) fn stop_worker_operation( diff --git a/crates/worker-runtime/src/http_server.rs b/crates/worker-runtime/src/http_server.rs index b4967073d..657aa566c 100644 --- a/crates/worker-runtime/src/http_server.rs +++ b/crates/worker-runtime/src/http_server.rs @@ -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, } #[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 { diff --git a/crates/worker-runtime/src/http_server/runtime_management_api.rs b/crates/worker-runtime/src/http_server/runtime_management_api.rs index c6992e09e..46f606d85 100644 --- a/crates/worker-runtime/src/http_server/runtime_management_api.rs +++ b/crates/worker-runtime/src/http_server/runtime_management_api.rs @@ -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)?; diff --git a/crates/worker-runtime/src/runtime.rs b/crates/worker-runtime/src/runtime.rs index 96622fb2f..93e82971f 100644 --- a/crates/worker-runtime/src/runtime.rs +++ b/crates/worker-runtime/src/runtime.rs @@ -2430,9 +2430,10 @@ impl Runtime { worker_ref: &WorkerRef, kind: protocol::CompletionKind, prefix: &str, + context: Option<&protocol::CompletionContext>, ) -> Result, 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, 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, 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>, contexts: Mutex>, dispatched_inputs: Mutex>, + completion_queries: Mutex< + Vec<( + protocol::CompletionKind, + String, + Option, + )>, + >, repository_accesses: Mutex>, repository_access_available: AtomicBool, working_directory_requests: Mutex>, @@ -6677,6 +6720,23 @@ mod tests { } } + fn worker_completions( + &self, + _handle: &WorkerExecutionHandle, + kind: protocol::CompletionKind, + prefix: &str, + context: Option<&protocol::CompletionContext>, + ) -> Vec { + 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(); diff --git a/crates/worker-runtime/src/worker_backend.rs b/crates/worker-runtime/src/worker_backend.rs index 293a2ea5f..698388853 100644 --- a/crates/worker-runtime/src/worker_backend.rs +++ b/crates/worker-runtime/src/worker_backend.rs @@ -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 { 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() } diff --git a/crates/worker/src/controller.rs b/crates/worker/src/controller.rs index 950a81a55..b0f34fca4 100644 --- a/crates/worker/src/controller.rs +++ b/crates/worker/src/controller.rs @@ -184,6 +184,7 @@ impl WorkerHandle { &self, kind: protocol::CompletionKind, prefix: &str, + context: Option<&protocol::CompletionContext>, ) -> Vec { 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() { diff --git a/crates/worker/src/feature.rs b/crates/worker/src/feature.rs index 41dfdcc56..3d3ea8983 100644 --- a/crates/worker/src/feature.rs +++ b/crates/worker/src/feature.rs @@ -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, pub requires_services: Vec, pub protocol_providers: Vec, + /// Declarative, user-selected chat invocations contributed by this Feature. + /// Metadata is public; handlers are registered separately during install. + pub chat_invocations: Vec, } 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, pub resolved_service_requirements: Vec, pub protocol_providers: Vec, + pub installed_chat_invocations: Vec, pub skipped: Vec, pub diagnostics: Vec, } @@ -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, protocol_providers: HashSet, + chat_invocations: Vec, } 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, pub services: FeatureServiceRegistry, pub background_tasks: FeatureBackgroundTaskRegistry, + pub chat_invocations: FeatureInvocationRegistry, pub plan_error: Option, } @@ -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; diff --git a/crates/worker/src/feature/builtin.rs b/crates/worker/src/feature/builtin.rs index b1206d1bc..b4b305d35 100644 --- a/crates/worker/src/feature/builtin.rs +++ b/crates/worker/src/feature/builtin.rs @@ -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; diff --git a/crates/worker/src/feature/builtin/chat_invocation.rs b/crates/worker/src/feature/builtin/chat_invocation.rs new file mode 100644 index 000000000..576ce09c9 --- /dev/null +++ b/crates/worker/src/feature/builtin/chat_invocation.rs @@ -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 { + Err(FeatureInvocationHandlerError::failed( + "attachment invocation reached the Worker without client-local upload staging", + )) + } +} diff --git a/crates/worker/src/feature/invocation.rs b/crates/worker/src/feature/invocation.rs new file mode 100644 index 000000000..6e6700de6 --- /dev/null +++ b/crates/worker/src/feature/invocation.rs @@ -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, +) -> Vec { + 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; +} + +#[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) -> Self { + Self { + message: message.into(), + outcome_unknown: false, + } + } + + pub fn outcome_unknown(message: impl Into) -> 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; +} + +#[derive(Clone)] +struct RegisteredInvocation { + descriptor: FeatureInvocationDescriptor, + handler: Arc, + completion_provider: Option>, +} + +/// 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, + names: BTreeMap, +} + +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 { + 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 { + 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 { + 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 { + 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 { + 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, + completion_provider: Option>, + ) -> 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::>(); + 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, +} + +impl InvocationContributionRegistrar<'_> { + pub fn register( + &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( + &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, + provider: Option>, + ) -> 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); + + #[async_trait] + impl FeatureInvocationHandler for CountingHandler { + async fn invoke( + &self, + _context: FeatureInvocationContext, + invocation: &FeatureInvocation, + ) -> Result { + 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); + #[async_trait] + impl FeatureInvocationCompletionProvider for CountingCompletion { + async fn complete( + &self, + _completion: &InvocationCompletion, + _argument: &str, + _prefix: &str, + ) -> Vec { + 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, + calls: Arc, + } + #[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 { + CountingHandler(self.calls.clone()) + .invoke(context, invocation) + .await + } + } + + struct TestProvider { + entries: Vec, + revoke: Option>, + } + #[async_trait] + impl FeatureInvocationCompletionProvider for TestProvider { + async fn complete( + &self, + _completion: &InvocationCompletion, + _argument: &str, + _prefix: &str, + ) -> Vec { + 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); + } +} diff --git a/crates/worker/src/ipc/interceptor.rs b/crates/worker/src/ipc/interceptor.rs index 2b403cd39..e37a30aa3 100644 --- a/crates/worker/src/ipc/interceptor.rs +++ b/crates/worker/src/ipc/interceptor.rs @@ -399,8 +399,23 @@ impl Interceptor 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::>(); 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 for WorkerInterceptor { self.attach_prompt_provenance(&mut extras); let items: Vec = 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}")), } }) diff --git a/crates/worker/src/ipc/protocol_session.rs b/crates/worker/src/ipc/protocol_session.rs index 2e84facd2..85769210f 100644 --- a/crates/worker/src/ipc/protocol_session.rs +++ b/crates/worker/src/ipc/protocol_session.rs @@ -121,9 +121,22 @@ pub async fn dispatch_worker_protocol_method( method: Method, ) -> Option { 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; diff --git a/crates/worker/src/shared_state.rs b/crates/worker/src/shared_state.rs index 46d0690d0..a9106e7e7 100644 --- a/crates/worker/src/shared_state.rs +++ b/crates/worker/src/shared_state.rs @@ -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, + feature_invocations: RwLock, 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); } diff --git a/crates/worker/src/worker.rs b/crates/worker/src/worker.rs index 353bb234c..cf60dee24 100644 --- a/crates/worker/src/worker.rs +++ b/crates/worker/src/worker.rs @@ -56,8 +56,8 @@ use crate::feature::session::{ SessionExtensionHandle, }; use crate::feature::{ - FeatureInstructionDeclaration, FeatureInstructionId, FeatureRegistryBuilder, - FeatureRegistryInstallReport, dedupe_instruction_contributions, + FeatureInstructionDeclaration, FeatureInstructionId, FeatureInvocationRegistry, + FeatureRegistryBuilder, FeatureRegistryInstallReport, dedupe_instruction_contributions, }; use crate::hook::{ BeforeSessionRewriteAction, BeforeSessionRewriteContext, Hook, HookHistoryRange, @@ -82,6 +82,101 @@ const MAX_PENDING_SUBMISSION_BYTES: u64 = 1024 * 1024; const MAX_PENDING_ARTIFACT_REFS: usize = 64; const MAX_ACTIVATION_REQUEST_ID_BYTES: usize = 128; const MAX_SUBMISSION_RECEIPTS: usize = 128; +const MAX_INVOCATION_RECEIPTS: usize = 1024; +const MAX_INVOCATION_IDENTITY_BYTES: usize = 256; + +/// A durable start without a matching result is intentionally not retryable. +/// This is a conservative fence, not an exactly-once business-operation ledger. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct InvocationReceipt { + invocation_id: String, + identity: protocol::FeatureInvocationIdentity, + payload_digest: String, + result: Option, +} + +impl InvocationReceipt { + fn new( + invocation: &protocol::FeatureInvocation, + result: Option, + ) -> Self { + Self { + invocation_id: invocation.invocation_id.clone(), + identity: invocation.identity.clone(), + payload_digest: invocation_payload_digest(invocation), + result, + } + } + + fn matches(&self, invocation: &protocol::FeatureInvocation) -> bool { + self.invocation_id == invocation.invocation_id + && self.identity == invocation.identity + && self.payload_digest == invocation_payload_digest(invocation) + } + + fn is_valid(&self) -> bool { + !self.invocation_id.trim().is_empty() + && self.invocation_id.len() <= protocol::invocation::MAX_FEATURE_INVOCATION_ID_BYTES + && !self.identity.0.is_empty() + && self.identity.0.len() <= MAX_INVOCATION_IDENTITY_BYTES + && self.payload_digest.len() == 64 + && self + .payload_digest + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + && self.result.as_ref().is_none_or(|result| { + result.invocation_id == self.invocation_id + && result.identity == self.identity + && crate::feature::invocation::invocation_result_is_bounded(result) + && (result.status == protocol::FeatureInvocationStatus::Succeeded + || result.context.is_none()) + }) + } +} + +fn invocation_payload_digest(invocation: &protocol::FeatureInvocation) -> String { + submission_payload_digest(&[Segment::FeatureInvoke { + invocation: invocation.clone(), + }]) +} + +fn validate_activation_invocation_ids(input: &[Segment]) -> Result<(), String> { + if input + .iter() + .any(|segment| matches!(segment, Segment::Unknown)) + { + return Err( + "unknown input segment rejected; unsupported intent must not be discarded".into(), + ); + } + const MAX_INVOCATIONS_PER_SUBMISSION: usize = 8; + let mut ids = std::collections::BTreeSet::new(); + for invocation in input.iter().filter_map(|segment| match segment { + Segment::FeatureInvoke { invocation } => Some(invocation), + _ => None, + }) { + if invocation.invocation_id.trim().is_empty() + || invocation.invocation_id.len() + > protocol::invocation::MAX_FEATURE_INVOCATION_ID_BYTES + || invocation.identity.0.is_empty() + || invocation.identity.0.len() > MAX_INVOCATION_IDENTITY_BYTES + { + return Err( + "invocation id/identity is empty or exceeds the recovery key byte limit".into(), + ); + } + if !ids.insert(&invocation.invocation_id) { + return Err("duplicate invocation id in one submission".into()); + } + } + if ids.len() > MAX_INVOCATIONS_PER_SUBMISSION { + return Err(format!( + "at most {MAX_INVOCATIONS_PER_SUBMISSION} Feature invocations are allowed per submission" + )); + } + Ok(()) +} #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub(crate) struct PendingSubmission { @@ -142,6 +237,12 @@ pub(crate) struct PendingActivationState { pending_notifications: VecDeque, receipts: VecDeque, notification_receipts: VecDeque, + /// Retained across queue checkpoints, compaction, rewind and restore. Do not + /// evict starts/results: eviction would silently permit repeated effects. + #[serde(default)] + invocation_receipts: Vec, + #[serde(default)] + invocation_recovery_blocked: bool, } fn deserialize_activating_notifications<'de, D>( @@ -308,6 +409,7 @@ fn pending_submission_preview(input: &[Segment]) -> String { Segment::UploadedFile { file } => ["[Attached file: ", &file.file_name, "]"], Segment::PasteArtifact { .. } => ["[Large paste]", "", ""], Segment::FileRef { path } => ["@", path, ""], + Segment::FeatureInvoke { invocation } => ["[Feature: ", &invocation.name, "]"], Segment::Flow { selector } => ["[Flow: ", selector, "]"], Segment::Unknown => ["[Unknown input]", "", ""], }; @@ -1493,6 +1595,8 @@ pub(crate) enum PendingSubmissionError { RequestIdLimit, #[error("submission input must contain at least one typed segment")] EmptyInput, + #[error("Feature invocation rejected: {0}")] + InvalidInvocation(String), #[error("submission request id was already used with a different payload")] IdempotencyConflict, #[error("pending submission queue is full (maximum {MAX_PENDING_SUBMISSIONS})")] @@ -1652,6 +1756,8 @@ where if input.is_empty() { return Err(PendingSubmissionError::EmptyInput); } + validate_activation_invocation_ids(&input) + .map_err(PendingSubmissionError::InvalidInvocation)?; let payload_digest = submission_payload_digest(&input); let _append_guard = self .writer @@ -2290,6 +2396,19 @@ impl WorkerSession { return; }; if let Ok(mut state) = serde_json::from_value::(payload.clone()) { + let mut ids = std::collections::BTreeSet::new(); + if state.invocation_receipts.len() > MAX_INVOCATION_RECEIPTS + || state + .invocation_receipts + .iter() + .any(|receipt| !receipt.is_valid() || !ids.insert(&receipt.invocation_id)) + { + self.pending_activations + .lock() + .expect("pending activation state poisoned") + .invocation_recovery_blocked = true; + return; + } if let Some(activating) = state.activating.take() { state.pending.push_front(activating); state.revision = state.revision.saturating_add(1); @@ -2304,6 +2423,13 @@ impl WorkerSession { .pending_activations .lock() .expect("pending activation state poisoned") = state; + } else { + // A malformed checkpoint must never turn a durable start into a + // new executable invocation on restore. + self.pending_activations + .lock() + .expect("pending activation state poisoned") + .invocation_recovery_blocked = true; } } @@ -2401,6 +2527,8 @@ pub struct Worker { hook_registry: Option>, /// Executable background tasks registered by successfully installed features. feature_background_tasks: FeatureBackgroundTaskRegistry, + /// Enabled chat invocation metadata and executable handlers. + feature_invocations: FeatureInvocationRegistry, /// Internal Workers install an explicit Feature composition and disable /// manifest-derived lifecycle Features before controller startup. manifest_lifecycle_features_enabled: bool, @@ -2739,6 +2867,7 @@ impl Worker { hook_builder: HookRegistryBuilder::new(), hook_registry: None, feature_background_tasks: FeatureBackgroundTaskRegistry::default(), + feature_invocations: FeatureInvocationRegistry::default(), manifest_lifecycle_features_enabled: true, interceptor_installed: false, compact_state: None, @@ -3144,6 +3273,10 @@ impl Worker { self.manifest_lifecycle_features_enabled } + pub fn feature_invocations(&self) -> FeatureInvocationRegistry { + self.feature_invocations.clone() + } + /// Install enabled feature modules into the Worker host surfaces. pub fn install_features( &mut self, @@ -3155,6 +3288,7 @@ impl Worker { return report; } self.feature_background_tasks = report.background_tasks.clone(); + self.feature_invocations = report.chat_invocations.clone(); for instruction in report.installed_instruction_contributions() { self.register_feature_instruction(instruction); } @@ -4289,6 +4423,7 @@ impl Worker { St: Clone + 'static, F: FnOnce(), { + validate_activation_invocation_ids(&input).map_err(WorkerError::FeatureInvocation)?; let (mut input, pending_flow_state, flow_projection) = self.prepare_flow_input(input)?; let projected_entry_ids = if flow_projection.is_some() { (0..input.len()) @@ -4366,6 +4501,27 @@ impl Worker { self.user_segments.push(input.clone()); on_input_committed(); + // Explicit invocation handlers run only after the typed input is durable + // and before any dependent LLM request. They execute in source order; + // the first failure fences later invocations in the same submission. + let has_invocations = input + .iter() + .any(|segment| matches!(segment, Segment::FeatureInvoke { .. })); + match self.execute_feature_invocations(&input).await { + Ok(false) => {} + Ok(true) => { + self.materialize_committed_activation_history(&projected_input); + return Err(WorkerError::FeatureInvocation( + "activation preparation failed or has an unknown outcome; dependent LLM request was not sent".into(), + )); + } + Err(error) => { + self.materialize_committed_activation_history(&projected_input); + return Err(error); + } + } + let committed_activation_input = has_invocations.then(|| projected_input.clone()); + // Resolve `@` file refs to system messages stashed for the // WorkerInterceptor to attach right after the user message. Resolution // failures are non-fatal alerts. @@ -4413,7 +4569,12 @@ impl Worker { self.engine = Some(locked.unlock()); self.session.note_mutation(); - if self.should_rollback_empty_turn(&result, &rollback_snapshot) { + if let Some(input) = &committed_activation_input { + // A prompt hook may cancel before Engine materializes input/results. + // Business preparation is already durable and cannot be rolled back. + self.materialize_committed_activation_history(input); + } + if !has_invocations && self.should_rollback_empty_turn(&result, &rollback_snapshot) { self.rollback_empty_turn(rollback_snapshot)?; return Ok(WorkerRunResult::RolledBack); } @@ -4421,6 +4582,253 @@ impl Worker { self.handle_worker_result(result, history_before).await } + async fn execute_feature_invocations(&self, segments: &[Segment]) -> Result + where + St: Clone + 'static, + { + use protocol::{FeatureInvocationResult, FeatureInvocationStatus}; + let invocations = segments + .iter() + .filter_map(|segment| match segment { + Segment::FeatureInvoke { invocation } => Some(invocation), + _ => None, + }) + .collect::>(); + let mut prior_failure = false; + for invocation in invocations { + let failed = |status, message: String| FeatureInvocationResult { + invocation_id: invocation.invocation_id.clone(), + identity: invocation.identity.clone(), + status, + message, + context: None, + }; + let handle = self.pending_submission_handle(); + // Use the queue's append/state lock order. Never hold a lock while + // awaiting business execution. A durable start without result fences + // both restart and same-process replay. + let (existing, recovery_blocked) = { + let state = handle + .state + .lock() + .expect("pending activation state poisoned"); + ( + state + .invocation_receipts + .iter() + .find(|receipt| receipt.invocation_id == invocation.invocation_id) + .cloned(), + state.invocation_recovery_blocked, + ) + }; + let result = if recovery_blocked { + failed( + FeatureInvocationStatus::OutcomeUnknown, + "invocation recovery checkpoint is invalid; execution is fenced".into(), + ) + } else if prior_failure { + failed( + FeatureInvocationStatus::Failed, + "not executed because an earlier invocation in this submission failed".into(), + ) + } else if let Some(receipt) = &existing { + if !receipt.matches(invocation) { + failed( + FeatureInvocationStatus::Failed, + "invocation id was already used with a different payload".into(), + ) + } else { + let replay = receipt.result.clone().unwrap_or_else(|| { + failed( + FeatureInvocationStatus::OutcomeUnknown, + "a durable invocation start has no result; do not retry automatically" + .into(), + ) + }); + if replay.status == FeatureInvocationStatus::Succeeded { + // Recorded success is reusable context, not authority to + // resurrect a capability removed from this composition. + match self.feature_invocations.validate_metadata(invocation) { + Ok(()) => replay, + Err(error) => failed(FeatureInvocationStatus::Failed, error.message), + } + } else { + replay + } + } + } else if let Err(error) = self.feature_invocations.validate(invocation) { + failed(FeatureInvocationStatus::Failed, error.message) + } else { + let started = { + let _append = handle + .writer + .state + .append_lock + .lock() + .expect("segment append lock poisoned"); + let mut state = handle + .state + .lock() + .expect("pending activation state poisoned"); + if state.invocation_receipts.len() >= MAX_INVOCATION_RECEIPTS { + false + } else { + let mut started = state.clone(); + started + .invocation_receipts + .push(InvocationReceipt::new(invocation, None)); + handle + .persist_locked(&started) + .map_err(|error| WorkerError::FeatureInvocation(error.to_string()))?; + *state = started; + true + } + }; + if !started { + failed(FeatureInvocationStatus::Failed, + "invocation recovery receipt capacity reached; refusing untracked execution".into()) + } else { + match self.feature_invocations.invoke(invocation).await { + Ok(result) => result, + Err(error) => failed( + if error.outcome_unknown { + FeatureInvocationStatus::OutcomeUnknown + } else { + FeatureInvocationStatus::Failed + }, + error.message, + ), + } + } + }; + prior_failure |= result.status != FeatureInvocationStatus::Succeeded; + let body = match (&result.status, &result.context) { + (FeatureInvocationStatus::Succeeded, Some(context)) => format!( + "[Feature invocation succeeded: {}]\n{}", + result.identity, context + ), + (FeatureInvocationStatus::Succeeded, None) => format!( + "[Feature invocation succeeded: {}] {}", + result.identity, result.message + ), + (FeatureInvocationStatus::Failed, _) => format!( + "[Feature invocation failed: {}] {}", + result.identity, result.message + ), + (FeatureInvocationStatus::OutcomeUnknown, _) => format!( + "[Feature invocation outcome unknown: {}] {} Do not retry automatically.", + result.identity, result.message + ), + }; + // Commit result and recovery checkpoint atomically with the existing + // AnnotatedSystemItem lifecycle. Failed appends leave the start fenced. + let _append = handle + .writer + .state + .append_lock + .lock() + .expect("segment append lock poisoned"); + let mut state = handle + .state + .lock() + .expect("pending activation state poisoned"); + let mut completed = state.clone(); + if let Some(receipt) = completed + .invocation_receipts + .iter_mut() + .find(|receipt| receipt.matches(invocation) && receipt.result.is_none()) + { + receipt.result = Some(result.clone()); + } else if existing.is_none() + && completed.invocation_receipts.len() < MAX_INVOCATION_RECEIPTS + { + completed + .invocation_receipts + .push(InvocationReceipt::new(invocation, Some(result.clone()))); + } + let item = SystemItem::FeatureInvocationResult { result, body }; + let metadata = new_history_metadata( + WorkerHistoryProvenance::BackendInstruction { operation_id: None }, + None, + ); + let history = HistoryEntry::new(item.to_history_item(), metadata.clone()); + handle + .writer + .append_entry_locked(LogEntry::AnnotatedSystemItem { + ts: segment_log::now_millis(), + entry: session_store::LoggedSystemHistoryEntry { item, metadata }, + extensions: vec![pending_activation_extension(&completed)], + })?; + *state = completed; + self.pending_committed_history + .lock() + .expect("pending committed history poisoned") + .push_back(history); + } + Ok(prior_failure) + } + + fn materialize_committed_activation_history( + &mut self, + input: &[HistoryEntry], + ) { + let pending = self + .pending_committed_history + .lock() + .expect("pending committed history poisoned") + .drain(..) + .collect::>(); + for entry in input.iter().cloned().chain(pending) { + if !self + .session + .history() + .entries() + .iter() + .any(|existing| existing.annotation.entry_id == entry.annotation.entry_id) + { + self.session.history_mut().push_entry(entry); + } + } + self.session.note_mutation(); + } + + fn ensure_invocation_preparation_complete(&self) -> Result<(), WorkerError> { + let state = self + .session + .pending_activations + .lock() + .expect("pending activation state poisoned"); + if state.invocation_recovery_blocked { + return Err(WorkerError::FeatureInvocation( + "invocation recovery checkpoint is invalid".into(), + )); + } + if let Some(input) = self.user_segments.last() { + for invocation in input.iter().filter_map(|segment| match segment { + Segment::FeatureInvoke { invocation } => Some(invocation), + _ => None, + }) { + let succeeded = state.invocation_receipts.iter().any(|receipt| { + receipt.matches(invocation) + && receipt.result.as_ref().is_some_and(|result| { + result.status == protocol::FeatureInvocationStatus::Succeeded + }) + }); + if !succeeded + || self + .feature_invocations + .validate_metadata(invocation) + .is_err() + { + return Err(WorkerError::FeatureInvocation( + "activation preparation is failed, incomplete, or outcome unknown; submit fresh input instead of resuming".into(), + )); + } + } + } + Ok(()) + } + /// Resolve every `Segment::FileRef` in `segments` to a `[File: ]` /// or shallow `[Dir: ]` system message via `WorkerFsView`. Resolution /// failures (out-of-scope, not-found, binary, I/O, unsupported symlink @@ -4732,6 +5140,7 @@ impl Worker { where St: Clone + 'static, { + self.ensure_invocation_preparation_complete()?; debug_assert!( matches!( kind, @@ -4788,6 +5197,7 @@ impl Worker { where St: Clone + 'static, { + self.ensure_invocation_preparation_complete()?; self.prepare_for_run().await?; self.resume_prepared(source).await } @@ -6703,6 +7113,7 @@ where hook_builder: HookRegistryBuilder::new(), hook_registry: None, feature_background_tasks: FeatureBackgroundTaskRegistry::default(), + feature_invocations: FeatureInvocationRegistry::default(), manifest_lifecycle_features_enabled: true, interceptor_installed: false, compact_state: None, @@ -6980,6 +7391,7 @@ where hook_builder: HookRegistryBuilder::new(), hook_registry: None, feature_background_tasks: FeatureBackgroundTaskRegistry::default(), + feature_invocations: FeatureInvocationRegistry::default(), manifest_lifecycle_features_enabled: false, interceptor_installed: false, compact_state: None, @@ -7105,6 +7517,7 @@ where hook_builder: HookRegistryBuilder::new(), hook_registry: None, feature_background_tasks: FeatureBackgroundTaskRegistry::default(), + feature_invocations: FeatureInvocationRegistry::default(), manifest_lifecycle_features_enabled: true, interceptor_installed: false, compact_state: None, @@ -7486,6 +7899,7 @@ where hook_builder: HookRegistryBuilder::new(), hook_registry: None, feature_background_tasks: FeatureBackgroundTaskRegistry::default(), + feature_invocations: FeatureInvocationRegistry::default(), manifest_lifecycle_features_enabled: true, interceptor_installed: false, compact_state: None, @@ -8219,6 +8633,9 @@ fn preview_segments(segments: &[Segment]) -> String { preview.push('@'); preview.push_str(path); } + Segment::FeatureInvoke { invocation } => { + preview.push_str(&invocation.display_input()); + } Segment::Flow { selector } => { preview.push_str("[Flow: "); preview.push_str(selector); @@ -8335,6 +8752,9 @@ pub enum WorkerError { message: String, }, + #[error("Feature invocation rejected: {0}")] + FeatureInvocation(String), + #[error("Flow input rejected: {0}")] FlowInput(String), @@ -9635,6 +10055,7 @@ mod build_summary_prompt_tests { struct FailRunTerminalStore { inner: session_store::FsStore, fail_run_terminal: Arc, + invocation_failure: Arc, } impl Store for FailRunTerminalStore { @@ -9651,6 +10072,35 @@ mod build_summary_prompt_tests { "synthetic terminal append failure", ))); } + let invocation_kind = match entry { + LogEntry::Extension { + domain, payload, .. + } if domain == SESSION_PENDING_ACTIVATIONS_EXTENSION_DOMAIN + && payload["invocation_receipts"] + .as_array() + .is_some_and(|receipts| { + receipts.iter().any(|receipt| receipt["result"].is_null()) + }) => + { + 1 + } + LogEntry::AnnotatedSystemItem { entry, .. } + if matches!(entry.item, SystemItem::FeatureInvocationResult { .. }) => + { + 2 + } + _ => 0, + }; + if invocation_kind != 0 + && self + .invocation_failure + .compare_exchange(invocation_kind, 0, Ordering::SeqCst, Ordering::SeqCst) + .is_ok() + { + return Err(StoreError::Io(std::io::Error::other( + "synthetic invocation append failure", + ))); + } self.inner.append(session_id, segment_id, entry) } @@ -10342,6 +10792,7 @@ mod build_summary_prompt_tests { let store = FailRunTerminalStore { inner: session_store::FsStore::new(dir.path().join("sessions")).unwrap(), fail_run_terminal: Arc::new(AtomicBool::new(false)), + invocation_failure: Arc::new(AtomicUsize::new(0)), }; let mut worker = Worker::new( minimal_manifest(), @@ -13297,12 +13748,447 @@ permission = "write" assert!(matches!(submission, PendingActivation::Submission(_))); } + #[derive(Clone)] + struct RecoveryInvocationFeature { + calls: Arc, + sink: SegmentLogSink, + } + + fn recovery_invocation_descriptor() -> protocol::FeatureInvocationDescriptor { + protocol::FeatureInvocationDescriptor { + identity: protocol::FeatureInvocationIdentity("builtin:recovery-test/prepare".into()), + name: "prepare".into(), + aliases: Vec::new(), + display_name: "Prepare".into(), + description: "Test recovery".into(), + syntax: protocol::FeatureInvocationSyntax::Parenthesized, + arguments: Vec::new(), + client_adapter: None, + } + } + + fn recovery_invocation(id: &str) -> Segment { + Segment::FeatureInvoke { + invocation: protocol::FeatureInvocation { + invocation_id: id.into(), + identity: recovery_invocation_descriptor().identity, + name: "prepare".into(), + arguments: Vec::new(), + }, + } + } + + impl crate::feature::FeatureModule for RecoveryInvocationFeature { + fn descriptor(&self) -> crate::feature::FeatureDescriptor { + crate::feature::FeatureDescriptor::builtin("recovery-test", "Recovery test") + .with_chat_invocation(recovery_invocation_descriptor()) + } + fn install( + &self, + context: &mut crate::feature::FeatureInstallContext<'_>, + ) -> Result<(), crate::feature::FeatureInstallError> { + context + .chat_invocations() + .register(recovery_invocation_descriptor(), self.clone()) + } + } + + #[async_trait] + impl crate::feature::FeatureInvocationHandler for RecoveryInvocationFeature { + async fn invoke( + &self, + context: crate::feature::FeatureInvocationContext, + invocation: &protocol::FeatureInvocation, + ) -> Result + { + let entries = self.sink.subscribe_with_snapshot().0; + let start = entries + .iter() + .rev() + .find_map(|entry| match entry { + LogEntry::Extension { + domain, payload, .. + } if domain == SESSION_PENDING_ACTIVATIONS_EXTENSION_DOMAIN => { + serde_json::from_value::(payload.clone()).ok() + } + _ => None, + }) + .expect("durable start must precede handler"); + assert!( + start + .invocation_receipts + .iter() + .any(|receipt| { receipt.matches(invocation) && receipt.result.is_none() }) + ); + assert_eq!(context.invocation_id, invocation.invocation_id); + self.calls.fetch_add(1, Ordering::SeqCst); + if invocation.invocation_id == "incomplete" { + futures::future::pending::<()>().await; + } + Ok(protocol::FeatureInvocationResult { + invocation_id: invocation.invocation_id.clone(), + identity: invocation.identity.clone(), + status: protocol::FeatureInvocationStatus::Succeeded, + message: "done".into(), + context: Some("prepared recovery context".into()), + }) + } + } + + fn install_recovery_invocation( + worker: &mut Worker, + calls: Arc, + ) { + let feature = RecoveryInvocationFeature { + calls, + sink: worker.sink(), + }; + assert!( + !worker + .install_features(FeatureRegistryBuilder::new().with_module(feature)) + .has_errors() + ); + } + + #[tokio::test] + async fn feature_invocation_restart_reuses_completed_results_and_fences_incomplete_starts() { + let runtime = tempfile::tempdir().unwrap(); + let _sandbox = worker_allocation::test_util::RuntimeDirSandbox::new(runtime.path()); + for id in ["completed", "incomplete"] { + let dir = tempfile::tempdir().unwrap(); + let cwd = dir.path().join("workspace"); + std::fs::create_dir_all(&cwd).unwrap(); + let mut engine = + Engine::<_, Mutable, SessionHistoryMetadata>::new_annotated(NoopClient); + engine.set_system_prompt("recovery test system prompt"); + let mut manifest = minimal_manifest(); + manifest.scope.allow[0].target = cwd.clone(); + let mut original = Worker::new( + manifest, + engine, + session_store::FsStore::new(dir.path().join("sessions")).unwrap(), + WorkerWorkspaceContext::local_filesystem(None), + WorkerFilesystemAuthority::local(cwd.clone(), cwd.clone()), + Scope::writable(&cwd).unwrap(), + ) + .await + .unwrap(); + let calls = Arc::new(AtomicUsize::new(0)); + install_recovery_invocation(&mut original, calls.clone()); + let input = vec![recovery_invocation(id)]; + if id == "incomplete" { + assert!( + tokio::time::timeout(Duration::from_millis(20), original.run(input.clone())) + .await + .is_err() + ); + } else { + let _ = original.run(input.clone()).await; + let entries = original.sink().subscribe_with_snapshot().0; + assert!(entries.iter().any(|entry| match entry { + LogEntry::AnnotatedSystemItem { + entry, extensions, .. + } => { + matches!(entry.item, SystemItem::FeatureInvocationResult { .. }) + && extensions.iter().any(|extension| { + extension.domain == SESSION_PENDING_ACTIVATIONS_EXTENSION_DOMAIN + && serde_json::from_value::( + extension.payload.clone(), + ) + .unwrap() + .invocation_receipts + .iter() + .any(|receipt| receipt.result.is_some()) + }) + } + _ => false, + })); + } + assert_eq!(calls.load(Ordering::SeqCst), 1); + let session = original.session_id(); + let segment = original.segment_id(); + let store = session_store::CombinedStore::new( + original.store.clone(), + session_store::FsWorkerStore::new(dir.path().join("workers")).unwrap(), + ); + let manifest = original.manifest.clone(); + drop(original); + let cwd = dir.path().join("workspace"); + let mut restored = Worker::, _>::restore_from_manifest_with_context_and_model_client( + session, segment, manifest, store, + crate::prompt::source::PromptCatalogSource::builtins_only(), + WorkerWorkspaceContext::local_filesystem(None), + WorkerFilesystemAuthority::local(cwd.clone(), cwd), + Some(Box::new(NoopClient)), + ).await.unwrap(); + install_recovery_invocation(&mut restored, calls.clone()); + if id == "incomplete" { + assert!(restored.resume().await.is_err()); + assert!( + restored + .run_for_notification(protocol::InvokeKind::Notify) + .await + .is_err() + ); + assert!(restored.run(input).await.is_err()); + let last = restored + .session + .pending_activations + .lock() + .unwrap() + .invocation_receipts[0] + .result + .clone() + .unwrap(); + assert_eq!( + last.status, + protocol::FeatureInvocationStatus::OutcomeUnknown + ); + } else { + let _ = restored.run(input).await; + assert!(restored.ensure_invocation_preparation_complete().is_ok()); + } + assert_eq!( + calls.load(Ordering::SeqCst), + 1, + "restore/replay must not repeat business execution" + ); + } + } + + #[tokio::test] + async fn feature_invocation_queue_receipts_prevent_activation_and_business_replay() { + let (_dir, mut worker) = rewind_test_worker().await; + let calls = Arc::new(AtomicUsize::new(0)); + install_recovery_invocation(&mut worker, calls.clone()); + let input = vec![recovery_invocation("queued")]; + let handle = worker.pending_submission_handle(); + let accepted = handle + .accept("request-1".into(), input.clone(), false) + .unwrap(); + let repeated = handle + .accept("request-1".into(), input.clone(), false) + .unwrap(); + assert_eq!(accepted.submission_id, repeated.submission_id); + assert_eq!(handle.snapshot().submissions.len(), 1); + let PendingActivation::Submission(pending) = + handle.prepare_next_activation(None).unwrap().unwrap(); + let id = pending.submission_id.clone(); + let extension = handle.activation_extension(); + let finished = handle.clone(); + let _ = worker + .run_with_input_extensions_and_commit_hook( + pending.input, + vec![extension], + pending.provenance, + move || finished.finish_activation(&id), + ) + .await; + assert_eq!(calls.load(Ordering::SeqCst), 1); + let replay = handle + .accept("request-1".into(), input.clone(), false) + .unwrap(); + assert!(replay.activation.is_none()); + assert!(handle.prepare_next_activation(None).unwrap().is_none()); + let _ = worker.run(input).await; + assert_eq!(calls.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn feature_invocation_start_and_result_append_failures_fence_execution_and_replay() { + for failure in [1, 2] { + let dir = tempfile::tempdir().unwrap(); + let store = FailRunTerminalStore { + inner: session_store::FsStore::new(dir.path().join("sessions")).unwrap(), + fail_run_terminal: Arc::new(AtomicBool::new(false)), + invocation_failure: Arc::new(AtomicUsize::new(failure)), + }; + let mut worker = Worker::new( + minimal_manifest(), + Engine::<_, Mutable, SessionHistoryMetadata>::new_annotated(NoopClient), + store, + WorkerWorkspaceContext::no_workspace(), + WorkerFilesystemAuthority::None, + Scope::empty(), + ) + .await + .unwrap(); + let calls = Arc::new(AtomicUsize::new(0)); + install_recovery_invocation(&mut worker, calls.clone()); + let input = vec![recovery_invocation("fault")]; + assert!(worker.run(input.clone()).await.is_err()); + assert_eq!( + calls.load(Ordering::SeqCst), + if failure == 1 { 0 } else { 1 } + ); + assert!(worker.resume().await.is_err()); + if failure == 1 { + // No start became durable and no effect ran; an explicit fresh + // submission may execute after the append fault is corrected. + let _ = worker.run(input).await; + } else { + // Effect ran but result did not become durable. Do not invoke + // the handler a second time even when the Store is healthy. + assert!(worker.run(input).await.is_err()); + let result = worker + .session + .pending_activations + .lock() + .unwrap() + .invocation_receipts[0] + .result + .clone() + .unwrap(); + assert_eq!( + result.status, + protocol::FeatureInvocationStatus::OutcomeUnknown + ); + } + assert_eq!(calls.load(Ordering::SeqCst), 1); + } + } + + #[tokio::test] + async fn feature_invocation_corrupt_checkpoint_fails_closed() { + let (_dir, mut worker) = rewind_test_worker().await; + let calls = Arc::new(AtomicUsize::new(0)); + install_recovery_invocation(&mut worker, calls.clone()); + worker.session.restore_pending_activations(&[( + SESSION_PENDING_ACTIVATIONS_EXTENSION_DOMAIN.into(), + serde_json::json!({"invocation_receipts": "corrupt"}), + )]); + assert!(worker.run(vec![recovery_invocation("new")]).await.is_err()); + assert_eq!(calls.load(Ordering::SeqCst), 0); + } + + #[test] + fn feature_invocation_receipts_retain_digest_not_arguments_and_detect_payload_changes() { + let Segment::FeatureInvoke { mut invocation } = recovery_invocation("large") else { + unreachable!() + }; + invocation.arguments = (0..32) + .map(|index| protocol::InvocationArgumentValue { + name: format!("arg{index}"), + value: protocol::InvocationValue::String("private-argument".repeat(1024)), + }) + .collect(); + let receipt = InvocationReceipt::new(&invocation, None); + assert!(receipt.matches(&invocation)); + assert!(receipt.is_valid()); + let serialized = serde_json::to_vec(&receipt).unwrap(); + assert!(serialized.len() < 512); + assert!( + !String::from_utf8(serialized) + .unwrap() + .contains("private-argument") + ); + let mut altered = invocation.clone(); + altered.arguments[0].value = protocol::InvocationValue::String("different".into()); + assert!(!receipt.matches(&altered)); + altered = invocation.clone(); + altered.name = "alias".into(); + assert!(!receipt.matches(&altered)); + altered = invocation.clone(); + altered.arguments.reverse(); + assert!(!receipt.matches(&altered)); + let state = PendingActivationState { + invocation_receipts: vec![receipt; MAX_INVOCATION_RECEIPTS], + ..PendingActivationState::default() + }; + assert!(serde_json::to_vec(&state).unwrap().len() < 512 * MAX_INVOCATION_RECEIPTS); + } + + #[test] + fn feature_invocation_structurally_valid_corrupt_receipts_fail_closed_on_restore() { + let Segment::FeatureInvoke { invocation } = recovery_invocation("corrupt") else { + unreachable!() + }; + let receipt = InvocationReceipt::new(&invocation, None); + let valid = serde_json::to_value(PendingActivationState { + invocation_receipts: vec![receipt], + ..PendingActivationState::default() + }) + .unwrap(); + let mut variants = Vec::new(); + let mut digest = valid.clone(); + digest["invocation_receipts"][0]["payload_digest"] = serde_json::json!("not-sha256"); + variants.push(digest); + let mut duplicate = valid.clone(); + duplicate["invocation_receipts"] + .as_array_mut() + .unwrap() + .push(valid["invocation_receipts"][0].clone()); + variants.push(duplicate); + let mut identity = valid.clone(); + identity["invocation_receipts"][0]["result"] = + serde_json::to_value(protocol::FeatureInvocationResult { + invocation_id: "foreign".into(), + identity: invocation.identity.clone(), + status: protocol::FeatureInvocationStatus::Succeeded, + message: "done".into(), + context: None, + }) + .unwrap(); + variants.push(identity); + let mut oversized = valid.clone(); + oversized["invocation_receipts"][0]["result"] = + serde_json::to_value(protocol::FeatureInvocationResult { + invocation_id: invocation.invocation_id.clone(), + identity: invocation.identity.clone(), + status: protocol::FeatureInvocationStatus::Succeeded, + message: "done".into(), + context: Some("x".repeat(16385)), + }) + .unwrap(); + variants.push(oversized); + let mut old_shape = valid; + old_shape["invocation_receipts"][0]["invocation"] = + serde_json::to_value(invocation).unwrap(); + variants.push(old_shape); + for payload in variants { + let mut session = WorkerSession::new(session_store::new_session_id(), Vec::new()); + session.restore_pending_activations(&[( + SESSION_PENDING_ACTIVATIONS_EXTENSION_DOMAIN.into(), + payload, + )]); + assert!( + session + .pending_activations + .lock() + .unwrap() + .invocation_recovery_blocked + ); + } + } + + #[tokio::test] + async fn feature_invocation_unknown_segments_rejected_at_queue_admission() { + let (_dir, worker) = rewind_test_worker().await; + let handle = worker.pending_submission_handle(); + for activate_now in [true, false] { + let before = handle.persisted_entries_for_test().len(); + assert!( + handle + .accept( + "unknown".into(), + vec![Segment::text("intent"), Segment::Unknown], + activate_now + ) + .is_err() + ); + assert_eq!(handle.persisted_entries_for_test().len(), before); + assert!(handle.snapshot().submissions.is_empty()); + } + } + #[test] fn restoring_an_in_flight_activation_requeues_it_at_the_fifo_head() { let mut session = WorkerSession::new(session_store::new_session_id(), Vec::new()); let state = PendingActivationState { revision: 4, next_activation_sequence: 3, + invocation_receipts: Vec::new(), + invocation_recovery_blocked: false, activating: Some(PendingSubmission { submission_request_id: "request-1".into(), source_namespace: "direct:test".into(), diff --git a/crates/worker/tests/feature_invocation_test.rs b/crates/worker/tests/feature_invocation_test.rs new file mode 100644 index 000000000..c2b31a171 --- /dev/null +++ b/crates/worker/tests/feature_invocation_test.rs @@ -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>>, + requests: Arc>>, + available: Arc, +} + +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 { + Box::new(self.clone()) + } + async fn stream( + &self, + request: Request, + ) -> Result> + 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 { + 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; +async fn fixture(trace: Trace) -> (tempfile::TempDir, Worker) { + 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) -> Vec { + 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 for TracePromptHook { + async fn call( + &self, + _input: &worker::hook::PromptSubmitInfo, + ) -> Result { + 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::(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::(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::(request).is_err()); + } +} diff --git a/crates/workspace-server/src/hosts.rs b/crates/workspace-server/src/hosts.rs index b9f3ed39a..12619f7da 100644 --- a/crates/workspace-server/src/hosts.rs +++ b/crates/workspace-server/src/hosts.rs @@ -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, } #[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(); diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index d851e0bc2..0af6df646 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -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) - .map_err(|error| invalid_contract_value("Worker input segment", error)) - }) - .collect::>>() - }) - .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 { 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::>>()?, + 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, }, }, ), diff --git a/docs/README.md b/docs/README.md index 91da1a8d8..c2dd751f9 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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 diff --git a/docs/design/feature-chat-invocations.md b/docs/design/feature-chat-invocations.md new file mode 100644 index 000000000..4ce1f675b --- /dev/null +++ b/docs/design/feature-chat-invocations.md @@ -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. diff --git a/docs/report/2026-10-05-subworker-ticket-context.md b/docs/report/2026-10-05-subworker-ticket-context.md new file mode 100644 index 000000000..e05394172 --- /dev/null +++ b/docs/report/2026-10-05-subworker-ticket-context.md @@ -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. + diff --git a/web/workspace/deno.json b/web/workspace/deno.json index 378b1cf3c..a2c791543 100644 --- a/web/workspace/deno.json +++ b/web/workspace/deno.json @@ -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", diff --git a/web/workspace/src/lib/generated/companion-api.ts b/web/workspace/src/lib/generated/companion-api.ts index ddba41954..ebab758b8 100644 --- a/web/workspace/src/lib/generated/companion-api.ts +++ b/web/workspace/src/lib/generated/companion-api.ts @@ -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 diff --git a/web/workspace/src/lib/generated/protocol-validator.ts b/web/workspace/src/lib/generated/protocol-validator.ts index 75e2fdc48..824f113ec 100644 --- a/web/workspace/src/lib/generated/protocol-validator.ts +++ b/web/workspace/src/lib/generated/protocol-validator.ts @@ -21,7 +21,7 @@ const MAX_CORRELATION_ID_BYTES = 128; const MAX_RESOURCE_ID_BYTES = 256; const MAX_WORKER_IDS_PER_SELECTOR = 256; const MAX_REJECTION_MESSAGE_BYTES = 1024; -const subscriptionFrameSchema: unknown = {"$defs":{"Alert":{"additionalProperties":false,"description":"User-facing alert emitted from the Worker layer.\n\nThis is a separate channel from `tracing` (developer logs): entries\nhere are assembled explicitly by the Worker when a condition should be\nsurfaced to the person driving the client. Keep messages short and\nhuman-readable.","properties":{"level":{"$ref":"#/$defs/AlertLevel"},"message":{"type":"string"},"source":{"$ref":"#/$defs/AlertSource"},"timestamp_ms":{"description":"Milliseconds since the Unix epoch.","format":"int64","maximum":9007199254740991.0,"minimum":-9007199254740991.0,"type":"integer"}},"required":["level","source","message","timestamp_ms"],"type":"object"},"AlertLevel":{"enum":["warn","error"],"type":"string"},"AlertSource":{"enum":["worker","engine","compactor","agents_md"],"type":"string"},"CommandEvent":{"oneOf":[{"additionalProperties":false,"properties":{"command_id":{"type":"string"},"kind":{"const":"started","type":"string"},"observed_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"tool_call_id":{"type":["string","null"]}},"required":["kind","command_id","observed_at_ms"],"type":"object"},{"additionalProperties":false,"properties":{"command_id":{"type":"string"},"content":{"type":"string"},"end_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"kind":{"const":"output","type":"string"},"observed_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"start_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"stream":{"$ref":"#/$defs/CommandStream"}},"required":["kind","command_id","stream","start_offset","end_offset","content","observed_at_ms"],"type":"object"},{"additionalProperties":false,"properties":{"command_id":{"type":"string"},"exit_code":{"format":"int32","maximum":2147483647.0,"minimum":-2147483648.0,"type":["integer","null"]},"kind":{"const":"terminal","type":"string"},"observed_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"status":{"$ref":"#/$defs/CommandStatus"},"stderr_end_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"stdout_end_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","command_id","status","stdout_end_offset","stderr_end_offset","observed_at_ms"],"type":"object"}]},"CommandSnapshot":{"additionalProperties":false,"properties":{"command_id":{"type":"string"},"exit_code":{"format":"int32","maximum":2147483647.0,"minimum":-2147483648.0,"type":["integer","null"]},"last_output_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"observed_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"started_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"status":{"$ref":"#/$defs/CommandStatus"},"stderr":{"$ref":"#/$defs/CommandStreamSlice"},"stdout":{"$ref":"#/$defs/CommandStreamSlice"},"tool_call_id":{"type":["string","null"]}},"required":["command_id","status","started_at_ms","observed_at_ms","stdout","stderr"],"type":"object"},"CommandStatus":{"description":"Live provider-owned command status. These values are operational Console\nstate only and are never appended to Worker history.","enum":["running","completed","failed","timed_out","cancelled"],"type":"string"},"CommandStream":{"enum":["stdout","stderr"],"type":"string"},"CommandStreamSlice":{"additionalProperties":false,"properties":{"content":{"type":"string"},"end_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"start_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"truncated":{"type":"boolean"}},"required":["start_offset","end_offset","content","truncated"],"type":"object"},"CompactionLifecycle":{"additionalProperties":false,"description":"Stable parent-owned lifecycle for one compaction run.","properties":{"compaction_id":{"type":"string"},"ended_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"error":{"type":["string","null"]},"internal_worker":{"anyOf":[{"$ref":"#/$defs/InternalWorkerRef"},{"type":"null"}]},"new_segment_id":{"type":["string","null"]},"revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"schema_version":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"started_at_ms":{"description":"Milliseconds since the Unix epoch.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"state":{"$ref":"#/$defs/CompactionLifecycleState"},"summary":{"type":["string","null"]}},"required":["schema_version","compaction_id","revision","state","started_at_ms"],"type":"object"},"CompactionLifecycleState":{"enum":["running","done","failed","interrupted"],"type":"string"},"CompactionPhase":{"enum":["preparing","summarizing","committing"],"type":"string"},"CompactionTrigger":{"enum":["manual","pre_run","request_threshold"],"type":"string"},"CompletionEntry":{"additionalProperties":false,"description":"One completion candidate for a prefix query.\n\n`value` is a path (file kind).","properties":{"is_dir":{"default":false,"type":"boolean"},"value":{"type":"string"}},"required":["value","is_dir"],"type":"object"},"CompletionKind":{"description":"Kind of completion requested by `Method::ListCompletions`.\n\nMirrors the completion prefix sigil: `@` → `File`.","enum":["file"],"type":"string"},"ContextTokenSource":{"description":"Provenance of the current-context token count in a [`Greeting`].","oneOf":[{"const":"measured","description":"Provider-reported input occupancy from the most recent LLM request.","type":"string"},{"const":"estimated","description":"Current history projected from the Worker's token accounting records.","type":"string"}]},"ContextUsage":{"additionalProperties":false,"description":"Current prompt-context occupancy, distinct from cumulative run traffic.","properties":{"source":{"$ref":"#/$defs/ContextTokenSource"},"tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["tokens","source"],"type":"object"},"ErrorCode":{"enum":["already_running","not_running","not_paused","provider_error","tool_error","invalid_request","internal"],"type":"string"},"Event":{"oneOf":[{"additionalProperties":false,"description":"Durable Submit acceptance. A `Queued` receipt follows the durable FIFO\ncheckpoint. A `Started` receipt follows the pending activation checkpoint;\nthe corresponding [`Event::UserMessage`] marks the later history commit.\nRepeating the same request id and exact payload returns the same receipt\nwithout appending or activating twice.","properties":{"data":{"additionalProperties":false,"properties":{"disposition":{"$ref":"#/$defs/SubmissionDisposition"},"submission_id":{"type":"string"},"submission_request_id":{"type":"string"}},"required":["submission_request_id","submission_id","disposition"],"type":"object"},"event":{"const":"submission_accepted","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Correlated rejection before durable acceptance.","properties":{"data":{"additionalProperties":false,"properties":{"message":{"type":"string"},"submission_request_id":{"type":"string"}},"required":["submission_request_id","message"],"type":"object"},"event":{"const":"submission_rejected","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Durable notification acceptance. The request identity is persisted with\nthe notification receipt before this event is emitted. Repeating the\nsame request id and exact payload returns the same acknowledgement\nwithout appending the notification twice.","properties":{"data":{"additionalProperties":false,"properties":{"notification_request_id":{"type":"string"}},"required":["notification_request_id"],"type":"object"},"event":{"const":"notification_accepted","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Correlated notification rejection before durable acceptance.","properties":{"data":{"additionalProperties":false,"properties":{"message":{"type":"string"},"notification_request_id":{"type":"string"}},"required":["notification_request_id","message"],"type":"object"},"event":{"const":"notification_rejected","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Revisioned FIFO replacement following enqueue, activation, cancel, or clear.","properties":{"data":{"additionalProperties":false,"properties":{"pending":{"$ref":"#/$defs/PendingSubmissionsSnapshot"}},"required":["pending"],"type":"object"},"event":{"const":"pending_submissions_changed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A user input message persisted as `LogEntry::AnnotatedUserInput` and\nactivated for a turn. Broadcast to every subscribed client so TUI / GUI\ninstances show the same user line that reconnect snapshots replay.","properties":{"data":{"additionalProperties":false,"properties":{"entry_id":{"description":"Stable durable history identity. Older mixed-version producers omit it.","type":["string","null"]},"segments":{"items":{"$ref":"#/$defs/Segment"},"type":"array"}},"required":["segments"],"type":"object"},"event":{"const":"user_message","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A canonical assistant/tool entry has committed to durable history.\nStreaming events remain the in-flight authority; this event supplies the\nstable identity used to reconcile that live block with paged history.","properties":{"data":{"additionalProperties":false,"properties":{"entry":{"$ref":"#/$defs/SessionSnapshotEntry"}},"required":["entry"],"type":"object"},"event":{"const":"session_entry_committed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"One agent-injected system item committed to history.\n\nCarries the JSON form of `session_store::SystemItem`. Covers\n`Method::Notify` echoes, child-Worker lifecycle events from\n`Method::WorkerEvent`, `@` resolution payloads, and any future\nClients dispatch on the `kind` tag for typed rendering instead\nof parsing free-text prefixes like `[Notification] …` or\n`[File: …]`.\n\nOne event per `LogEntry::AnnotatedSystemItem` commit. Disk-side and\nwire-side are 1:1.","properties":{"data":{"additionalProperties":false,"properties":{"entry_id":{"description":"Stable durable history identity. Synthetic compatibility events omit it.","type":["string","null"]},"item":true},"required":["item"],"type":"object"},"event":{"const":"system_item","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A new self-driving cycle has begun (IDLE → active transition).\n\nMarker event for the start of an Invoke range; the range extends\nimplicitly until the next `InvokeStart`. Fires for every accepted\n`Method::Submit` (kind=`UserSend`), `Method::Notify` (kind=`Notify`),\n`Method::WorkerEvent` re-injection (kind=`WorkerEvent`), and any other\nIDLE-breaking trigger. Mid-run interrupts (e.g. hook output,\ntyped system reminder insertion that doesn't break IDLE) do not\nemit `InvokeStart` — they appear as `SystemItem` only.\n\nCarries `kind` only; the payload (user text / notify message /\nworker event body) is delivered separately via the immediately\nfollowing `UserMessage` / `SystemItem` event.","properties":{"data":{"additionalProperties":false,"properties":{"kind":{"$ref":"#/$defs/InvokeKind"}},"required":["kind"],"type":"object"},"event":{"const":"invoke_start","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"One AgentTurn boundary opened. An AgentTurn is a maximal run of\nLLM generation calls whose input messages are identical (i.e.\nretries from network errors / 5xx / stream disconnects collapse\ninto the same AgentTurn). When the input changes (a new tool\nresult lands, a user interrupts, etc.), the next LLM call belongs\nto a new AgentTurn.\n\n`turn` is the AgentTurn index from `Engine::turn_count`.\n\nCurrently retry is not yet implemented (`agen-stream-continuation`)\nso AgentTurn and `LlmCall` fire 1:1, but the contract here is\nthe AgentTurn boundary; consumers that want per-LLM-call signals\nmust subscribe to `LlmCallStart` / `LlmCallEnd` instead.","properties":{"data":{"additionalProperties":false,"properties":{"turn":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["turn"],"type":"object"},"event":{"const":"turn_start","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"AgentTurn closed.","properties":{"data":{"additionalProperties":false,"properties":{"result":{"$ref":"#/$defs/TurnResult"},"turn":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["turn","result"],"type":"object"},"event":{"const":"turn_end","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"One LLM generation call started (1 request → 1 generation, retry\nincluded). Multiple `LlmCall*` pairs may fire inside a single\n`TurnStart` / `TurnEnd` pair when a request is retried; today\nthey fire 1:1 because retry is not implemented.\n\n`llm_call` is the worker-wide running counter from\n`Engine::llm_call_count`.","properties":{"data":{"additionalProperties":false,"properties":{"llm_call":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["llm_call"],"type":"object"},"event":{"const":"llm_call_start","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"LLM generation call ended.","properties":{"data":{"additionalProperties":false,"properties":{"llm_call":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["llm_call"],"type":"object"},"event":{"const":"llm_call_end","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A transport-level LLM request retry has been scheduled.\n\nThis is operational state for clients to render while the worker is\nwaiting in backoff. It is not part of conversation history.","properties":{"data":{"additionalProperties":false,"properties":{"elapsed_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"error":{"type":"string"},"failed_attempt":{"description":"The attempt that just failed. 1 origin.","format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"llm_call":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"max_attempts":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"status":{"format":"uint16","maximum":65535.0,"minimum":0.0,"type":["integer","null"]},"wait_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["llm_call","failed_attempt","max_attempts","wait_ms","elapsed_ms","error"],"type":"object"},"event":{"const":"llm_retry","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Stream generation was interrupted after events had begun and the worker\nis continuing with a follow-up LLM request.","properties":{"data":{"additionalProperties":false,"properties":{"attempt":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"llm_call":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"max_attempts":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"reason":{"type":"string"}},"required":["llm_call","attempt","max_attempts","reason"],"type":"object"},"event":{"const":"llm_continuation","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"text":{"type":"string"}},"required":["text"],"type":"object"},"event":{"const":"text_delta","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"text":{"type":"string"}},"required":["text"],"type":"object"},"event":{"const":"text_done","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A reasoning / thinking block has started.\n\nAlways paired with a `ThinkingDone`. `ThinkingDelta` is optional:\nsome providers (or some configurations) emit thinking metadata\nwithout plaintext, in which case Start → Done arrive with no\ndeltas in between. Multiple thinking blocks per turn are allowed.","properties":{"event":{"const":"thinking_start","type":"string"}},"required":["event"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"text":{"type":"string"}},"required":["text"],"type":"object"},"event":{"const":"thinking_delta","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Thinking block completed. `text` is the full accumulated body\n(empty string when the provider didn't emit plaintext).","properties":{"data":{"additionalProperties":false,"properties":{"text":{"type":"string"}},"required":["text"],"type":"object"},"event":{"const":"thinking_done","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name"],"type":"object"},"event":{"const":"tool_call_start","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"id":{"type":"string"},"json":{"type":"string"}},"required":["id","json"],"type":"object"},"event":{"const":"tool_call_args_delta","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"arguments":{"type":"string"},"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name","arguments"],"type":"object"},"event":{"const":"tool_call_done","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"disposition":{"anyOf":[{"$ref":"#/$defs/ToolResultDisposition"},{"type":"null"}]},"id":{"type":"string"},"is_error":{"default":false,"type":"boolean"},"output":{"description":"Full tool output. Absent when the tool chose to return\nsummary-only, or when the result was pruned.","type":["string","null"]},"summary":{"description":"Short human-readable summary. Always present; used by clients\nthat only want a 1-line rendering (e.g. collapsed views).","type":"string"}},"required":["id","summary","is_error"],"type":"object"},"event":{"const":"tool_result","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Token accounting for one LLM request.\n\n`input_tokens` is the prompt prefix occupancy (cache reads /\ncache writes included), as normalised by the worker layer.\n`cache_read_input_tokens` is the cache-hit subset of that\noccupancy; subtracting it yields the \"net upload\" the client\nactually paid full price to send on this request, which is what\nthe TUI status line accumulates per turn.","properties":{"data":{"additionalProperties":false,"properties":{"cache_read_input_tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"input_tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"output_tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]}},"type":"object"},"event":{"const":"usage","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Authoritative current-context occupancy after a non-request boundary.\n\nUnlike [`Event::Usage`], this is not cumulative request traffic. It lets\nconnected clients converge with reconnect snapshots after run completion,\ncompaction, rewind, and other history rewrites. `None` explicitly means\nthe current value is unavailable and stale occupancy must be cleared.","properties":{"data":{"additionalProperties":false,"properties":{"usage":{"anyOf":[{"$ref":"#/$defs/ContextUsage"},{"type":"null"}]}},"type":"object"},"event":{"const":"context_usage","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"result":{"$ref":"#/$defs/RunResult"}},"required":["result"],"type":"object"},"event":{"const":"run_end","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"code":{"$ref":"#/$defs/ErrorCode"},"message":{"type":"string"}},"required":["code","message"],"type":"object"},"event":{"const":"error","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Sent exactly once at the start of every client connection.\n\n`entries` is the session-log mirror at subscribe time, serialised\nas the JSON form of `session_store::LogEntry`. This is the\nbulk-reconstruction lane: clients walk the entries to seed their\nderived view.\n\n`greeting` and `status` accompany the snapshot so clients render\nworker identity and current controller state without an extra round\ntrip.\n\nLive updates after the snapshot arrive through the streaming\nevents (`TextDelta` / `ToolCall*` / `ToolResult` / etc.) plus\nrole-specific entry events (`SegmentRotated` / `SystemItem`) —\nthere is no generic \"every committed entry\" broadcast.","properties":{"data":{"additionalProperties":false,"properties":{"greeting":{"$ref":"#/$defs/Greeting"},"in_flight":{"$ref":"#/$defs/InFlightSnapshot","description":"Unfinished model output that has already streamed in the current\nrun but is not yet represented by committed snapshot entries."},"internal_workers":{"description":"Parent-owned Internal Worker sessions visible to this client.\nService-private Internal Workers are deliberately excluded.","items":{"$ref":"#/$defs/InternalWorkerSnapshot"},"type":"array"},"session":{"$ref":"#/$defs/SessionSnapshot"},"state":{"$ref":"#/$defs/WorkerStateSnapshot","description":"Full revisioned live execution state. `Stopped` remains Runtime\ncatalog authority and is deliberately not represented here."}},"required":["session","greeting","state"],"type":"object"},"event":{"const":"snapshot","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A live event from a parent-owned Internal Worker. The payload reuses the\nnormal Worker event vocabulary while the wrapper carries stable origin\nidentity and a per-child revision fence.","properties":{"data":{"additionalProperties":false,"properties":{"event":{"$ref":"#/$defs/Event"},"revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"worker":{"$ref":"#/$defs/InternalWorkerRef"}},"required":["worker","revision","event"],"type":"object"},"event":{"const":"internal_worker","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Terminal removal fence for one parent-owned Internal Worker session.\n\nClients discard the matching child and descendants, then ignore later\nnested events for this identity until an authoritative snapshot replaces\nthe projection.","properties":{"data":{"additionalProperties":false,"properties":{"revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"worker":{"$ref":"#/$defs/InternalWorkerRef"}},"required":["worker","revision"],"type":"object"},"event":{"const":"internal_worker_removed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Server-side segment log rotated to a fresh `SegmentStart`.\n\nFires on compaction and on auto-fork when the store head drifts\nA compaction/fork has replaced the authoritative segment. Clients drop\ntheir derived view and reseed from the canonical committed snapshot.","properties":{"data":{"additionalProperties":false,"properties":{"session":{"$ref":"#/$defs/SessionSnapshot"}},"required":["session"],"type":"object"},"event":{"const":"segment_rotated","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Current Worker controller status. Broadcast on every controller-level\ntransition and included in `History` snapshots for late attach.","properties":{"data":{"additionalProperties":false,"properties":{"snapshot":{"$ref":"#/$defs/WorkerStateSnapshot"}},"required":["snapshot"],"type":"object"},"event":{"const":"worker_state","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"acknowledgement":{"$ref":"#/$defs/WorkerCommandAcknowledgement"}},"required":["acknowledgement"],"type":"object"},"event":{"const":"command_acknowledged","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Bounded, provider-owned command telemetry for the live Console. This is\nintentionally not a history entry and is reconstructed from\n`Snapshot.in_flight.commands` after reconnect.","properties":{"data":{"additionalProperties":false,"properties":{"event":{"$ref":"#/$defs/CommandEvent"}},"required":["event"],"type":"object"},"event":{"const":"command","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Reply to `Method::ListCompletions`. Delivered only to the\nrequesting socket (not broadcast). `entries` is empty when no\ncandidates match or when the requested kind has no resolver\nwired up.","properties":{"data":{"additionalProperties":false,"properties":{"entries":{"items":{"$ref":"#/$defs/CompletionEntry"},"type":"array"},"kind":{"$ref":"#/$defs/CompletionKind"}},"required":["kind","entries"],"type":"object"},"event":{"const":"completions","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Reply to `Method::ListRewindTargets`. Clients should only open a picker\nin response to their own pending request; the event may be broadcast.","properties":{"data":{"additionalProperties":false,"properties":{"head_entries":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"targets":{"items":{"$ref":"#/$defs/RewindTarget"},"type":"array"}},"required":["head_entries","targets"],"type":"object"},"event":{"const":"rewind_targets","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A rewind has truncated the authoritative session. `session` is the\nretained canonical snapshot clients should use to reseed display state.","properties":{"data":{"additionalProperties":false,"properties":{"input":{"items":{"$ref":"#/$defs/Segment"},"type":"array"},"session":{"$ref":"#/$defs/SessionSnapshot"},"summary":{"$ref":"#/$defs/RewindSummary"}},"required":["session","input","summary"],"type":"object"},"event":{"const":"rewind_applied","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Reply to `Method::ListWorkers`. Payload is a stable JSON value so the Worker\ncrate can evolve discovery fields without introducing a protocol\ndependency on session-store.","properties":{"data":{"additionalProperties":false,"properties":{"workers":true},"required":["workers"],"type":"object"},"event":{"const":"workers_listed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Reply to `Method::RestoreWorker`.","properties":{"data":{"additionalProperties":false,"properties":{"result":true},"required":["result"],"type":"object"},"event":{"const":"worker_restored","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Reply to `Method::RegisterPeer`.","properties":{"data":{"additionalProperties":false,"properties":{"result":true},"required":["result"],"type":"object"},"event":{"const":"peer_registered","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"$ref":"#/$defs/Alert"},"event":{"const":"alert","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Latest memory extract/consolidation lifecycle event for UI observability.\n\nThis is not part of LLM history or prompt context; clients may display it\nbriefly as operational status.","properties":{"data":{"$ref":"#/$defs/MemoryWorkerEvent"},"event":{"const":"memory_worker","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Runtime-only compaction progress. `None` clears the current status.\nThis never enters Session history and carries no operation or Segment identity.","properties":{"data":{"additionalProperties":false,"properties":{"compaction":{"anyOf":[{"$ref":"#/$defs/InFlightCompaction"},{"type":"null"}]}},"type":"object"},"event":{"const":"compaction_progress","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Legacy compaction lifecycle event retained for wire read compatibility.","properties":{"data":{"additionalProperties":false,"properties":{"lifecycle":{"$ref":"#/$defs/CompactionLifecycle"}},"required":["lifecycle"],"type":"object"},"event":{"const":"compact_start","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Compaction completed and the session was rotated.","properties":{"data":{"additionalProperties":false,"properties":{"lifecycle":{"$ref":"#/$defs/CompactionLifecycle"}},"required":["lifecycle"],"type":"object"},"event":{"const":"compact_done","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Compaction failed or was cancelled. The session is unchanged.","properties":{"data":{"additionalProperties":false,"properties":{"lifecycle":{"$ref":"#/$defs/CompactionLifecycle"}},"required":["lifecycle"],"type":"object"},"event":{"const":"compact_failed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"event":{"const":"shutdown","type":"string"}},"required":["event"],"type":"object"}]},"EventSubscriptionSelector":{"oneOf":[{"additionalProperties":false,"properties":{"topic":{"const":"runtime_workers","type":"string"}},"required":["topic"],"type":"object"},{"additionalProperties":false,"properties":{"topic":{"const":"worker_lifecycle","type":"string"},"worker_ids":{"$ref":"#/$defs/SubscriptionWorkerIds"}},"required":["topic","worker_ids"],"type":"object"},{"additionalProperties":false,"properties":{"runtime_id":{"type":["string","null"]},"topic":{"const":"worker_protocol","type":"string"},"worker_id":{"$ref":"#/$defs/SubscriptionWorkerId"}},"required":["topic","worker_id"],"type":"object"},{"additionalProperties":false,"description":"Server-derived Workspace projection. Workspace identity comes from the\nauthenticated connection and is deliberately absent from this selector.","properties":{"topic":{"const":"workspace_workers","type":"string"}},"required":["topic"],"type":"object"},{"additionalProperties":false,"description":"Typed extension point for the later Workdir subscription slice.","properties":{"topic":{"const":"workspace_workdirs","type":"string"}},"required":["topic"],"type":"object"}]},"Greeting":{"additionalProperties":false,"description":"Worker self-description rendered by clients when a session starts empty.\n\nSnapshot metadata contains only resolved public execution information so\nclients do not need their own copy of the manifest.","properties":{"context_tokens":{"default":0,"description":"Legacy numeric mirror retained for mixed-version clients.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"context_usage":{"anyOf":[{"$ref":"#/$defs/ContextUsage"},{"type":"null"}],"description":"Typed current-context value. Older snapshots omit it; clients may fall\nback to `context_tokens` only when `context_window` is known."},"context_window":{"default":0,"description":"Resolved effective model context window in tokens. Legacy producers use\nzero when this is unavailable; clients must treat that as unknown.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"cwd":{"type":"string"},"model":{"type":"string"},"provider":{"type":"string"},"reasoning":{"anyOf":[{"$ref":"#/$defs/ReasoningConfig"},{"type":"null"}],"description":"Effective reasoning control. Absent means unspecified or unavailable."},"scope_summary":{"type":"string"},"tools":{"items":{"type":"string"},"type":"array"},"worker_name":{"type":"string"}},"required":["worker_name","cwd","provider","model","scope_summary","tools","context_tokens","context_window"],"type":"object"},"InFlightBlock":{"oneOf":[{"additionalProperties":false,"properties":{"finished":{"type":"boolean"},"kind":{"const":"text","type":"string"},"text":{"type":"string"}},"required":["kind","text"],"type":"object"},{"additionalProperties":false,"properties":{"finished":{"type":"boolean"},"kind":{"const":"thinking","type":"string"},"text":{"type":"string"}},"required":["kind","text"],"type":"object"},{"additionalProperties":false,"properties":{"args":{"type":"string"},"id":{"type":"string"},"kind":{"const":"tool_call","type":"string"},"name":{"type":"string"},"state":{"$ref":"#/$defs/InFlightToolCallState"}},"required":["kind","id","name","args"],"type":"object"}]},"InFlightCompaction":{"additionalProperties":false,"properties":{"phase":{"$ref":"#/$defs/CompactionPhase"},"started_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"trigger":{"$ref":"#/$defs/CompactionTrigger"}},"required":["phase","started_at_ms","trigger"],"type":"object"},"InFlightSnapshot":{"additionalProperties":false,"description":"Unfinished model output and active command state included in\n`Event::Snapshot` for clients that attach while work is still streaming.\n\nThese blocks are presentation state only: they are reconstructed from the\nactive Worker controller and must not be treated as committed assistant\nhistory. Finalized assistant items continue to come from ordinary snapshot\nentries.","properties":{"blocks":{"items":{"$ref":"#/$defs/InFlightBlock"},"type":"array"},"commands":{"items":{"$ref":"#/$defs/CommandSnapshot"},"type":"array"},"compaction":{"anyOf":[{"$ref":"#/$defs/InFlightCompaction"},{"type":"null"}],"description":"The currently running compaction, if any.\n\nThis is lifecycle progress only. Candidate history and the staged\nSegment remain private until the Segment is activated atomically."}},"type":"object"},"InFlightToolCallState":{"enum":["pending","streaming_args","done"],"type":"string"},"InternalWorkerKind":{"description":"Presentation category for an Internal Worker exposed through its parent's\nprotocol stream. Internal Workers never become independently addressable\nprotocol subjects.","oneOf":[{"enum":["sub_worker"],"type":"string"},{"additionalProperties":false,"properties":{"service":{"additionalProperties":false,"properties":{"kind":{"type":"string"}},"required":["kind"],"type":"object"}},"required":["service"],"type":"object"}]},"InternalWorkerRef":{"additionalProperties":false,"description":"Stable presentation identity for one parent-owned Internal Worker session.\n`name` is display-only; `session_id` is the identity used by clients.","properties":{"kind":{"$ref":"#/$defs/InternalWorkerKind"},"name":{"type":"string"},"parent_session_id":{"type":["string","null"]},"session_id":{"type":"string"}},"required":["session_id","name","kind"],"type":"object"},"InternalWorkerSnapshot":{"additionalProperties":false,"description":"Reconnect state for one visible Internal Worker. The revision fences live\n`Event::InternalWorker` updates that raced with parent snapshot assembly.","properties":{"error":{"type":["string","null"]},"greeting":{"anyOf":[{"$ref":"#/$defs/Greeting"},{"type":"null"}],"description":"Public execution metadata for this child. Older snapshots and service\nworkers may omit it; clients must never substitute the parent's values."},"in_flight":{"$ref":"#/$defs/InFlightSnapshot"},"internal_workers":{"items":{"$ref":"#/$defs/InternalWorkerSnapshot"},"type":"array"},"revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"session":{"$ref":"#/$defs/SessionSnapshot"},"status":{"$ref":"#/$defs/WorkerStatus","default":"idle"},"worker":{"$ref":"#/$defs/InternalWorkerRef"}},"required":["worker","revision","session","status"],"type":"object"},"InvokeKind":{"description":"Kind of trigger that opened a new Invoke (IDLE → active) range.\n\nOne Invoke groups all entries from this trigger up to the next\n`Invoke` marker. The kind is the only payload — content (user text,\nnotify message, worker event body) is delivered by the immediately\nfollowing Turn entry, not by the marker itself.","oneOf":[{"const":"user_send","description":"`Method::Submit` — a user submission.","type":"string"},{"const":"notify","description":"`Method::Notify` — free-text notification injected into history.","type":"string"},{"const":"worker_event","description":"`Method::WorkerEvent` — typed lifecycle report from a child Worker.","type":"string"},{"const":"system_reminder","description":"A typed system reminder that crosses an IDLE boundary (mid-run\nreminders that don't break IDLE are SystemItem-only and do not\nopen a new Invoke).","type":"string"},{"const":"wakeup","description":"Cron / RemoteTrigger style scheduled wake-up. Reserved; no\nproducer is wired today but the variant is part of the initial\nset so future schedulers don't have to amend the wire enum.","type":"string"}]},"MemoryWorkerEvent":{"additionalProperties":false,"properties":{"message":{"description":"Human-readable compact form for actionbar rendering.","type":"string"},"reason":{"type":"string"},"run_id":{"type":"string"},"status":{"type":"string"},"timestamp_ms":{"description":"Milliseconds since the Unix epoch.","format":"int64","maximum":9007199254740991.0,"minimum":-9007199254740991.0,"type":"integer"},"trigger":{"type":"string"},"worker":{"type":"string"}},"required":["worker","status","run_id","trigger","reason","message","timestamp_ms"],"type":"object"},"Method":{"oneOf":[{"additionalProperties":false,"description":"Durably accept typed input for immediate activation or the session FIFO.\nRunning or Paused Workers queue Submit for a later turn; use Notify for\nadvisory information to incorporate into work already in progress.\n\n`submission_request_id` is generated by the authenticated caller and is\nused only for idempotent retry. Worker allocates the durable\n`submission_id` returned by [`Event::SubmissionAccepted`].","properties":{"method":{"const":"submit","type":"string"},"params":{"additionalProperties":false,"properties":{"input":{"items":{"$ref":"#/$defs/Segment"},"type":"array"},"submission_request_id":{"type":"string"}},"required":["submission_request_id","input"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Human-readable text injected into the target Worker's LLM context as a\nnon-blocking system message. Accepted notifications are delivered at the\nnext between-turn boundary, or start a coalesced notification run while\nthe Worker is idle. No side effects beyond LLM context; use\n`WorkerEvent` for typed lifecycle reports.","properties":{"method":{"const":"notify","type":"string"},"params":{"additionalProperties":false,"properties":{"message":{"type":"string"},"notification_request_id":{"type":"string"}},"required":["notification_request_id","message"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Typed lifecycle report from a child Worker to its direct parent.","properties":{"method":{"const":"worker_event","type":"string"},"params":{"$ref":"#/$defs/WorkerEvent"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Return the authoritative FIFO summary without exposing queued payloads.","properties":{"method":{"const":"list_pending_submissions","type":"string"}},"required":["method"],"type":"object"},{"additionalProperties":false,"description":"Remove one queued submission. Running or already activated submissions\nare immutable and therefore cannot be cancelled here.","properties":{"method":{"const":"cancel_pending_submission","type":"string"},"params":{"additionalProperties":false,"properties":{"expected_revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"submission_id":{"type":"string"}},"required":["submission_id","expected_revision"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Remove every queued submission while preserving the active run.","properties":{"method":{"const":"clear_pending_submissions","type":"string"},"params":{"additionalProperties":false,"properties":{"expected_revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["expected_revision"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Activate the next queued submission while the Worker is idle. This is an\nexplicit recovery operation and never resumes a paused run implicitly.","properties":{"method":{"const":"continue_pending","type":"string"},"params":{"additionalProperties":false,"properties":{"expected_head_id":{"type":"string"},"expected_revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["expected_revision","expected_head_id"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"properties":{"method":{"const":"resume","type":"string"},"params":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandEnvelope"}},"required":["command"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"properties":{"method":{"const":"cancel","type":"string"},"params":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandEnvelope"}},"required":["command"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Stop the in-flight turn and transition to `Paused`.\n\nUnlike `Cancel` (which discards and returns to `Idle`), a paused\nWorker can resume the interrupted work via `Resume`, or accept a\nfresh `Submit` (orphan `tool_use` items are closed with a\nsynthetic tool result before the new user message is appended).","properties":{"method":{"const":"pause","type":"string"},"params":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandEnvelope"}},"required":["command"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Request an explicit compaction while the Worker is otherwise idle.\n\nThis is a typed control method: clients must not send `compact` as a\n`Method::Submit` user message.","properties":{"method":{"const":"compact","type":"string"},"params":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandEnvelope"}},"required":["command"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Ask the Worker to list valid rewind targets from its authoritative session log.","properties":{"method":{"const":"list_rewind_targets","type":"string"}},"required":["method"],"type":"object"},{"additionalProperties":false,"description":"Truncate the current session back to the selected rewind target and\nreturn the selected user input to the client composer.","properties":{"method":{"const":"rewind_to","type":"string"},"params":{"additionalProperties":false,"properties":{"expected_head_entries":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"target":{"$ref":"#/$defs/RewindTargetId"}},"required":["target","expected_head_entries"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"properties":{"method":{"const":"shutdown","type":"string"},"params":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandEnvelope"}},"required":["command"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Request a list of completion candidates from the Worker.\n\nReply is sent on the same socket as `Event::Completions` (not\nbroadcast). The IPC server handles this directly and writes\nthe response straight back to the requesting socket.","properties":{"method":{"const":"list_completions","type":"string"},"params":{"additionalProperties":false,"properties":{"kind":{"$ref":"#/$defs/CompletionKind"},"prefix":{"type":"string"}},"required":["kind","prefix"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"List Workers visible to this Worker from durable Worker state and the spawned-child\nregistry. This is not a host-wide Worker universe query.","properties":{"method":{"const":"list_workers","type":"string"}},"required":["method"],"type":"object"},{"additionalProperties":false,"description":"Restore a visible stopped/restorable Worker, or report that it is already\nlive. Missing state and not-visible state are distinct errors.","properties":{"method":{"const":"restore_worker","type":"string"},"params":{"additionalProperties":false,"properties":{"name":{"type":"string"}},"required":["name"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Register another existing Worker as a reciprocal peer of this Worker.\n\nThis is metadata/control state only: it does not ask the target's live\ncontroller for consent, and it must not grant delegated scope,\nspawned-child ownership, output cursors, or child lifecycle authority.","properties":{"method":{"const":"register_peer","type":"string"},"params":{"additionalProperties":false,"properties":{"name":{"type":"string"}},"required":["name"],"type":"object"}},"required":["method","params"],"type":"object"}]},"PasteArtifactAvailability":{"enum":["available","unavailable","integrity_failed"],"type":"string"},"PasteArtifactMediaType":{"description":"One typed piece of a user submission.\n\n`Method::Submit` and `Event::UserMessage` carry `Vec`. Dumb\nclients (CLI piping, scripts) only need to produce a single\n`Segment::Text`; richer clients (TUI / GUI) construct typed atoms\n(paste chips, file refs) and\nsend them through directly so the Worker side never has to re-parse a\nflattened string.\n\nForward compat: payloads with unknown `kind` deserialize to\n`Segment::Unknown`. Worker treats this the same as known-but-unresolved\nvariants — emits an alert and inserts a `[unknown input segment]`\nplaceholder into the LLM context so neither user nor LLM is blind to\nthe dropped intent.","enum":["text_plain_utf8"],"type":"string"},"PasteArtifactRef":{"additionalProperties":false,"description":"Session-owned reference to a large pasted-input artifact.\n\nThe reference contains only bounded integrity and provenance metadata. The\nartifact body remains in session storage and is available to the model only\nthrough the scoped paste-artifact tools installed by Worker.","properties":{"artifact_id":{"type":"string"},"availability":{"$ref":"#/$defs/PasteArtifactAvailability","description":"Availability observed when this immutable reference was committed.\nReads revalidate storage and integrity rather than trusting this field."},"byte_len":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"char_count":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"created_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"line_count":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"media_type":{"$ref":"#/$defs/PasteArtifactMediaType"},"sha256":{"type":"string"},"source_entry_id":{"type":"string"}},"required":["artifact_id","created_at_ms","media_type","availability","byte_len","char_count","line_count","sha256","source_entry_id"],"type":"object"},"PendingSubmissionSummary":{"additionalProperties":false,"description":"Bounded public projection of one pending submission. Only a short display\npreview is exposed; full payload segments and provenance remain in the log.","properties":{"accepted_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"byte_len":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"preview":{"description":"Single-line display text, at most 240 Unicode scalars plus an ellipsis.\nAbsent on older runtimes. Never used to reconstruct the submitted input.","type":["string","null"]},"segment_count":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"submission_id":{"type":"string"}},"required":["submission_id","accepted_at_ms","segment_count","byte_len"],"type":"object"},"PendingSubmissionsSnapshot":{"additionalProperties":false,"description":"Revisioned session-owned FIFO projection used by snapshots and live events.","properties":{"head_id":{"default":null,"type":["string","null"]},"notification_count":{"default":0,"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"notification_previews":{"description":"Ordered display-only previews of waiting notifications, each bounded like\nPendingSubmissionSummary::preview. Older runtimes omit this field.","items":{"type":"string"},"type":"array"},"revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"submissions":{"default":[],"items":{"$ref":"#/$defs/PendingSubmissionSummary"},"type":"array"}},"required":["revision","head_id","notification_count","submissions"],"type":"object"},"Permission":{"description":"Permission lattice used by [`ScopeRule`].\n\nThe derived `Ord` instance follows declaration order, so\n`Read < Write`. Allow rules grant the stated level (and by extension\neverything below); deny rules cap the effective level **strictly\nbelow** the stated level.","enum":["read","write"],"type":"string"},"ReasoningConfig":{"description":"Public reasoning configuration resolved for the model client.\n\nThis deliberately exposes only the effective provider control, not the\nWorker manifest or provider credentials. The tagged shape prevents token\nbudgets from being presented as effort labels.","oneOf":[{"additionalProperties":false,"properties":{"effort":{"type":"string"},"kind":{"const":"effort","type":"string"}},"required":["kind","effort"],"type":"object"},{"additionalProperties":false,"properties":{"budget_tokens":{"format":"int32","maximum":2147483647.0,"minimum":-2147483648.0,"type":"integer"},"kind":{"const":"budget_tokens","type":"string"}},"required":["kind","budget_tokens"],"type":"object"}]},"RewindSummary":{"additionalProperties":false,"properties":{"discarded_entries":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"tool_side_effect_warning":{"type":"boolean"},"truncated_to_entries":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["truncated_to_entries","discarded_entries","tool_side_effect_warning"],"type":"object"},"RewindTarget":{"additionalProperties":false,"properties":{"disabled_reason":{"type":["string","null"]},"eligible":{"type":"boolean"},"expected_head_entries":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"id":{"$ref":"#/$defs/RewindTargetId"},"preview":{"type":"string"},"timestamp_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"truncate_entries":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"turn_index":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"warning":{"type":["string","null"]}},"required":["id","expected_head_entries","truncate_entries","turn_index","preview","eligible"],"type":"object"},"RewindTargetId":{"additionalProperties":false,"properties":{"segment_id":{"type":"string"},"user_input_entry_index":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["segment_id","user_input_entry_index"],"type":"object"},"RunFailureKind":{"enum":["engine","compaction"],"type":"string"},"RunResult":{"oneOf":[{"enum":["finished","paused","limit_reached"],"type":"string"},{"const":"cancelled","description":"The logical Run ended because the user intentionally cancelled it after\nobservable work. Unlike `RolledBack`, committed history is retained.","type":"string"},{"const":"rolled_back","description":"The accepted Method::Submit produced no assistant/tool output before\nuser interruption, so the Worker rolled the submit-time turn state back\nto its pre-submit snapshot. Clients should treat the Worker as Idle and\nrestore the just-submitted input into the editable composer if desired.","type":"string"}]},"RunResumeSource":{"enum":["compaction","pause"],"type":"string"},"RunYieldReason":{"enum":["compaction"],"type":"string"},"ScopeRule":{"additionalProperties":false,"description":"A single allow or deny rule inside a scope configuration.","properties":{"permission":{"$ref":"#/$defs/Permission","description":"Permission level this rule grants (allow) or caps strictly below\n(deny)."},"recursive":{"default":true,"description":"When `false`, the rule only matches the target itself and its\ndirect children. Defaults to `true`.","type":"boolean"},"symlink_policy":{"$ref":"#/$defs/SymlinkPolicy","default":"resolved","description":"Which path identity an allow rule uses when symbolic links are\nencountered. Deny rules always inspect both identities."},"target":{"description":"Target path. Must be absolute by the time a `Scope` is built from\nthis rule — relative paths are resolved per-layer against the\nmanifest file's directory (cwd for overlay layers) before cascade\nmerge.","type":"string"}},"required":["target","permission","recursive","symlink_policy"],"type":"object"},"Segment":{"oneOf":[{"additionalProperties":false,"description":"Free-form text. The fallback every client can produce.","properties":{"content":{"type":"string"},"kind":{"const":"text","type":"string"}},"required":["kind","content"],"type":"object"},{"additionalProperties":false,"description":"Bracketed-paste capture from a TUI-style client. `id`, `chars`\nand `lines` carry the metadata needed to re-render a\n`[Clipboard #N | X chars, Y lines]` chip in `Event::UserMessage`\nre-broadcast.","properties":{"chars":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"content":{"type":"string"},"id":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"kind":{"const":"paste","type":"string"},"lines":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"}},"required":["kind","id","chars","lines","content"],"type":"object"},{"additionalProperties":false,"description":"Internal reference produced when Worker stores a large `Paste` before\ncommitting input. Clients may receive this in history/event projections;\nthe body is intentionally absent.","properties":{"artifact":{"$ref":"#/$defs/PasteArtifactRef"},"kind":{"const":"paste_artifact","type":"string"}},"required":["kind","artifact"],"type":"object"},{"additionalProperties":false,"description":"Client-local file uploaded into the owning Worker session before submit.\nThe Session Log stores only this immutable reference, never file bytes or\nthe client's local path.","properties":{"file":{"$ref":"#/$defs/UploadedFileRef"},"kind":{"const":"uploaded_file","type":"string"}},"required":["kind","file"],"type":"object"},{"additionalProperties":false,"description":"`@` file-system reference. Worker resolves readable files to\n`[File: ]` attachments and readable normal directories to shallow\n`[Dir: ]` listings; the flattened user text keeps the literal\n`@` placeholder either way.","properties":{"kind":{"const":"file_ref","type":"string"},"path":{"type":"string"}},"required":["kind","path"],"type":"object"},{"additionalProperties":false,"description":"Source-qualified request for the host to start a Flow before committing\nthis user input. Runtime resolves it through Workspace authority and\nreplaces it with the entered state's instructions before Worker-side\ninput resolution.","properties":{"kind":{"const":"flow","type":"string"},"selector":{"type":"string"}},"required":["kind","selector"],"type":"object"},{"additionalProperties":false,"description":"Unknown variant from a newer client. Worker treats this as an\nunresolved input — surfaces an alert and inserts a placeholder.\nRound-trip is lossy: re-serializing yields `{\"kind\":\"unknown\"}`.","properties":{"kind":{"const":"unknown","type":"string"}},"required":["kind"],"type":"object"}]},"SessionContentPart":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"text","type":"string"},"text":{"type":"string"}},"required":["kind","text"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"refusal","type":"string"},"refusal":{"type":"string"}},"required":["kind","refusal"],"type":"object"}]},"SessionEntryProvenance":{"enum":["human_input","worker_input","flow_instruction","backend_instruction","model_output","tool_output","derived_summary","legacy_unknown"],"type":"string"},"SessionMessageRole":{"enum":["user","assistant"],"type":"string"},"SessionSnapshot":{"additionalProperties":false,"description":"Canonical, storage-independent projection of committed session history.\n\nWorker protocols expose this DTO instead of append-log records. New\nstorage variants can therefore be added without teaching every client how\nto replay the durable log format.","properties":{"entries":{"items":{"$ref":"#/$defs/SessionSnapshotEntry"},"type":"array"},"pending_submissions":{"$ref":"#/$defs/PendingSubmissionsSnapshot","default":{"head_id":null,"notification_count":0,"revision":0,"submissions":[]}}},"required":["entries","pending_submissions"],"type":"object"},"SessionSnapshotEntry":{"oneOf":[{"additionalProperties":false,"properties":{"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"user_input","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"segments":{"items":{"$ref":"#/$defs/Segment"},"type":"array"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","segments","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"content":{"items":{"$ref":"#/$defs/SessionContentPart"},"type":"array"},"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"message","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"role":{"$ref":"#/$defs/SessionMessageRole"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","role","content","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"arguments":{"type":"string"},"call_id":{"type":"string"},"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"tool_call","type":"string"},"name":{"type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","call_id","name","arguments","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"attachments":{"items":{"$ref":"#/$defs/SessionToolAttachment"},"type":"array"},"call_id":{"type":"string"},"content":{"type":["string","null"]},"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"is_error":{"type":"boolean"},"kind":{"const":"tool_result","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"summary":{"type":"string"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","call_id","summary","is_error","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"content":{"type":"string"},"data":true,"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"item_kind":{"type":"string"},"kind":{"const":"system_item","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","item_kind","content","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"description":"Durable, non-terminal control return from one logical Run. This is not a\ncompleted run and must not be used to infer the current Worker state.","properties":{"active_run_turn_count":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"run_yielded","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"reason":{"$ref":"#/$defs/RunYieldReason"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","reason","active_run_turn_count","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"description":"Durable boundary immediately before Engine execution resumes. The source\ndistinguishes automatic compaction continuation from an intentional\nuser-pause resume; it does not prove that later model output occurred.","properties":{"active_run_turn_count":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"run_resumed","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"source":{"$ref":"#/$defs/RunResumeSource"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","source","active_run_turn_count","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"description":"Durable terminal for an intentional cancellation. Consumers must not\npresent this lifecycle result as an execution failure.","properties":{"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"run_cancelled","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"failure":{"anyOf":[{"$ref":"#/$defs/RunFailureKind"},{"type":"null"}]},"kind":{"const":"run_error","type":"string"},"message":{"type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","message","entry_id","timestamp","provenance"],"type":"object"}]},"SessionToolAttachment":{"additionalProperties":false,"properties":{"attachment_id":{"description":"Stable, bounded identity derived from the owning Session entry, attachment\nposition, and immutable image bytes. It is a locator, not a bearer secret.","type":"string"},"byte_len":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"media_type":{"type":"string"}},"required":["attachment_id","media_type","byte_len"],"type":"object"},"SubmissionDisposition":{"description":"Durable acceptance result for one idempotent Submit request.","oneOf":[{"enum":["started"],"type":"string"},{"const":"queued","description":"Input is durably held in the session FIFO until eligible for activation.","type":"string"}]},"SubscriptionEvent":{"oneOf":[{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"payload":{"$ref":"#/$defs/SubscriptionEventPayload"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["subscription_id","payload"],"type":"object"},"event":{"const":"event","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"code":{"$ref":"#/$defs/SubscriptionTerminationCode"},"message":{"type":"string"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["subscription_id","code","message"],"type":"object"},"event":{"const":"subscription_closed","type":"string"}},"required":["event","data"],"type":"object"}]},"SubscriptionEventPayload":{"oneOf":[{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"worker":{"$ref":"#/$defs/SubscriptionWorker"}},"required":["worker"],"type":"object"},"event":{"const":"worker_upserted","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"runtime_id":{"type":["string","null"]},"worker_id":{"$ref":"#/$defs/SubscriptionWorkerId"}},"required":["worker_id"],"type":"object"},"event":{"const":"worker_removed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"event":{"$ref":"#/$defs/Event"},"worker_id":{"$ref":"#/$defs/SubscriptionWorkerId"}},"required":["worker_id","event"],"type":"object"},"event":{"const":"worker_protocol","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"workdir":{"$ref":"#/$defs/WorkspaceSubscriptionWorkdir"}},"required":["workdir"],"type":"object"},"event":{"const":"workdir_upserted","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"working_directory_id":{"$ref":"#/$defs/SubscriptionWorkdirId"}},"required":["working_directory_id"],"type":"object"},"event":{"const":"workdir_removed","type":"string"}},"required":["event","data"],"type":"object"}]},"SubscriptionId":{"type":"string"},"SubscriptionRejectionCode":{"enum":["invalid_request","unsupported_protocol_version","unsupported_selector","unauthorized","resource_not_found","capacity_exceeded","internal"],"type":"string"},"SubscriptionRequest":{"oneOf":[{"additionalProperties":false,"properties":{"method":{"const":"subscribe_events","type":"string"},"params":{"additionalProperties":false,"properties":{"request_id":{"$ref":"#/$defs/SubscriptionRequestId"},"selector":{"$ref":"#/$defs/EventSubscriptionSelector"}},"required":["request_id","selector"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"properties":{"method":{"const":"unsubscribe_events","type":"string"},"params":{"additionalProperties":false,"properties":{"request_id":{"$ref":"#/$defs/SubscriptionRequestId"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["request_id","subscription_id"],"type":"object"}},"required":["method","params"],"type":"object"}]},"SubscriptionRequestId":{"type":"string"},"SubscriptionResponse":{"oneOf":[{"additionalProperties":false,"properties":{"payload":{"additionalProperties":false,"properties":{"request_id":{"$ref":"#/$defs/SubscriptionRequestId"},"selector":{"$ref":"#/$defs/EventSubscriptionSelector"},"snapshot":{"$ref":"#/$defs/SubscriptionSnapshot"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["request_id","subscription_id","selector","snapshot"],"type":"object"},"result":{"const":"subscribed","type":"string"}},"required":["result","payload"],"type":"object"},{"additionalProperties":false,"properties":{"payload":{"additionalProperties":false,"properties":{"request_id":{"$ref":"#/$defs/SubscriptionRequestId"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["request_id","subscription_id"],"type":"object"},"result":{"const":"unsubscribed","type":"string"}},"required":["result","payload"],"type":"object"},{"additionalProperties":false,"properties":{"payload":{"additionalProperties":false,"properties":{"code":{"$ref":"#/$defs/SubscriptionRejectionCode"},"message":{"type":"string"},"request_id":{"$ref":"#/$defs/SubscriptionRequestId"},"subscription_id":{"anyOf":[{"$ref":"#/$defs/SubscriptionId"},{"type":"null"}]}},"required":["request_id","code","message"],"type":"object"},"result":{"const":"subscription_rejected","type":"string"}},"required":["result","payload"],"type":"object"}]},"SubscriptionSnapshot":{"oneOf":[{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"workers":{"items":{"$ref":"#/$defs/SubscriptionWorker"},"type":"array"}},"required":["workers"],"type":"object"},"topic":{"const":"workers","type":"string"}},"required":["topic","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"events":{"items":{"$ref":"#/$defs/Event"},"type":"array"},"worker_id":{"$ref":"#/$defs/SubscriptionWorkerId"}},"required":["worker_id","events"],"type":"object"},"topic":{"const":"worker_protocol","type":"string"}},"required":["topic","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"workdirs":{"items":{"$ref":"#/$defs/WorkspaceSubscriptionWorkdir"},"type":"array"}},"required":["workdirs"],"type":"object"},"topic":{"const":"workspace_workdirs","type":"string"}},"required":["topic","data"],"type":"object"}]},"SubscriptionTerminationCode":{"enum":["lagged","resource_gone","unauthorized","server_shutdown"],"type":"string"},"SubscriptionWorkdirId":{"type":"string"},"SubscriptionWorker":{"additionalProperties":false,"properties":{"availability":{"$ref":"#/$defs/SubscriptionWorkerAvailability","default":"observed","description":"Freshness of the Runtime-backed observation carried by this projection.\n`Unavailable` preserves catalog membership without claiming that execution stopped."},"display_name":{"type":["string","null"]},"has_running_internal_workers":{"default":false,"type":"boolean"},"job":{"anyOf":[{"$ref":"#/$defs/SubscriptionWorkerJob"},{"type":"null"}],"description":"Backend-owned Job binding. Runtime producers leave this unset; the\nWorkspace projection enriches both snapshots and live updates."},"profile":{"type":["string","null"]},"resource_key":{"description":"Workspace-scoped canonical resource key. Runtime producers leave this unset;\nWorkspace-facing projections must populate it before publishing the Worker.","type":["string","null"]},"runtime_id":{"description":"Set by the Workspace Server when projecting a Runtime-owned Worker to clients.\nRuntime producers leave this unset because the connection identifies the Runtime.","type":["string","null"]},"state":{"$ref":"#/$defs/SubscriptionWorkerState","description":"Runtime catalog lifecycle compatibility projection; not foreground-state authority."},"workdir_attachments":{"items":{"$ref":"#/$defs/SubscriptionWorkerWorkdirAttachment"},"type":"array"},"worker_id":{"$ref":"#/$defs/SubscriptionWorkerId"},"worker_state":{"anyOf":[{"$ref":"#/$defs/WorkerStateSnapshot"},{"type":"null"}],"description":"Latest foreground state observed from the Worker. This remains\nabsent until an authoritative Worker snapshot/event has been applied."},"workspace_id":{"type":["string","null"]}},"required":["worker_id","state","availability","has_running_internal_workers"],"type":"object"},"SubscriptionWorkerAvailability":{"description":"Whether the Workspace Server has a current Runtime observation for a Worker.\n\n`Unavailable` deliberately does not imply `Stopped`: it means the last observed\nlifecycle state is retained while the Runtime observation source is unavailable.","enum":["observed","unavailable"],"type":"string"},"SubscriptionWorkerId":{"type":"string"},"SubscriptionWorkerIds":{"description":"A bounded, canonical worker-id set. Ordering on the wire and in equality/hash\nsemantics is lexical, so equivalent selectors aggregate to one upstream key.","items":{"$ref":"#/$defs/SubscriptionWorkerId"},"type":"array"},"SubscriptionWorkerJob":{"additionalProperties":false,"properties":{"attempt_id":{"type":"string"},"job_id":{"type":"string"},"purpose":{"type":"string"}},"required":["job_id","attempt_id","purpose"],"type":"object"},"SubscriptionWorkerProtocolMethod":{"additionalProperties":false,"properties":{"method":{"$ref":"#/$defs/Method"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["subscription_id","method"],"type":"object"},"SubscriptionWorkerState":{"enum":["idle","running","paused","stopped"],"type":"string"},"SubscriptionWorkerWorkdirAttachment":{"additionalProperties":false,"properties":{"alias":{"description":"Stable Worker-local routing alias.","type":"string"},"repository_key":{"type":["string","null"]},"working_directory_id":{"$ref":"#/$defs/SubscriptionWorkdirId"}},"required":["alias","working_directory_id"],"type":"object"},"SymlinkPolicy":{"description":"Symbolic-link identity used by one filesystem allow rule.\n\n`Resolved` is the least authority and the default: access is matched\nagainst the provider-resolved target. `Logical` intentionally grants the\npath as presented through the Workdir, even when it aliases another target.","enum":["resolved","logical"],"type":"string"},"ToolResultDisposition":{"enum":["success","error","interrupted","cancelled","outcome_unknown"],"type":"string"},"TurnResult":{"enum":["finished","paused"],"type":"string"},"UploadedFileAvailability":{"description":"Availability recorded for an uploaded client-local file.","enum":["available","unavailable","integrity_failed"],"type":"string"},"UploadedFileRef":{"additionalProperties":false,"description":"Session-owned immutable reference to a client-local uploaded file.\n\nUpload transports return an unbound reference. Worker fills\n`source_entry_id` immediately before the containing user input is committed;\ncommitted Session Log and public snapshot records therefore always retain\nthe durable source-entry identity without storing the file body.","properties":{"artifact_id":{"type":"string"},"availability":{"$ref":"#/$defs/UploadedFileAvailability"},"byte_len":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"created_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"file_name":{"type":"string"},"media_type":{"type":"string"},"sha256":{"type":"string"},"source_entry_id":{"type":["string","null"]}},"required":["artifact_id","file_name","media_type","created_at_ms","availability","byte_len","sha256"],"type":"object"},"WorkerBusyState":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"run","type":"string"},"state":{"$ref":"#/$defs/WorkerRunState"}},"required":["kind","state"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"maintenance","type":"string"},"state":{"$ref":"#/$defs/WorkerMaintenanceState"}},"required":["kind","state"],"type":"object"}]},"WorkerCommandAcknowledgement":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandKind"},"command_id":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"disposition":{"$ref":"#/$defs/WorkerCommandDisposition"},"state":{"$ref":"#/$defs/WorkerStateSnapshot","description":"The complete authoritative state observed after command admission."}},"required":["command_id","command","disposition","state"],"type":"object"},"WorkerCommandDisposition":{"enum":["accepted","stale_command_id","conflict","invalid_state"],"type":"string"},"WorkerCommandEnvelope":{"additionalProperties":false,"description":"Caller-owned identity for one state-changing Worker command.\n\nA controller accepts command ids in strictly increasing order. Exact retries\nof an accepted id must retain the same command kind.","properties":{"command_id":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["command_id"],"type":"object"},"WorkerCommandKind":{"enum":["resume","cancel","pause","compact","shutdown"],"type":"string"},"WorkerEvent":{"description":"Typed lifecycle events sent from a child Worker to its parent.\n\nDelivered as `Method::WorkerEvent` over the parent's Unix socket. The\nparent Controller always applies variant-specific side effects\n(registry / worker-allocation updates). Agent-visible variants are also\nqueued into the notification buffer; control-plane-only variants are\nnot injected into the parent's LLM context.\n\nTransport is fire-and-forget; receivers must tolerate out-of-order\ndelivery (e.g. `TurnEnded` arriving after `ShutDown` for the same\nchild Worker).","oneOf":[{"additionalProperties":false,"description":"Child finished one turn and is back to IDLE.","properties":{"kind":{"const":"turn_ended","type":"string"},"worker_name":{"type":"string"}},"required":["kind","worker_name"],"type":"object"},{"additionalProperties":false,"description":"Engine execution error occurred inside the child's turn.\n\nLimited to worker runtime failures (provider / tool errors) —\ndoes not include transient method-rejection responses such as\n`AlreadyRunning`.","properties":{"kind":{"const":"errored","type":"string"},"message":{"type":"string"},"worker_name":{"type":"string"}},"required":["kind","worker_name","message"],"type":"object"},{"additionalProperties":false,"description":"Child has stopped (controller loop is exiting).","properties":{"kind":{"const":"shut_down","type":"string"},"worker_name":{"type":"string"}},"required":["kind","worker_name"],"type":"object"},{"additionalProperties":false,"description":"Child SubWorker sub-delegated scope to a grandchild SubWorker via `SubWorkerSpawn`.\n\nControl-plane only: receivers apply registry side effects and\npropagate upward, but do not expose this as an agent notification.\n\nThe parent uses this to add the grandchild to its own\n`spawned_workers.json` so it can manage the grandchild directly\neven if the intermediate child dies. The parent then re-fires\nthis event upward (if it has a parent of its own) to maintain\nthe chain to root.","properties":{"kind":{"const":"scope_sub_delegated","type":"string"},"parent_worker":{"description":"Sub-delegating Worker (= the sender itself).","type":"string"},"scope":{"description":"Scope delegated to the grandchild.","items":{"$ref":"#/$defs/ScopeRule"},"type":"array"},"sub_socket":{"description":"Unix-socket path where the grandchild is reachable.","type":"string"},"sub_worker":{"description":"Name of the grandchild Worker.","type":"string"}},"required":["kind","parent_worker","sub_worker","sub_socket","scope"],"type":"object"}]},"WorkerMaintenanceState":{"enum":["compacting"],"type":"string"},"WorkerRunState":{"enum":["running","pausing","paused","cancelling"],"type":"string"},"WorkerState":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"idle","type":"string"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"busy","type":"string"},"state":{"$ref":"#/$defs/WorkerBusyState"}},"required":["kind","state"],"type":"object"}]},"WorkerStateSnapshot":{"additionalProperties":false,"properties":{"last_command_id":{"description":"Highest lifecycle command id observed by this controller instance.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"last_finished_submission_request_id":{"description":"Most recent Submit request whose controller Run has returned and whose\npost-Run state transition has been published. This is a teardown fence,\nnot a successful-result claim; cancelled and failed Runs may also finish.","type":["string","null"]},"state":{"$ref":"#/$defs/WorkerState"}},"required":["last_command_id","state"],"type":"object"},"WorkerStatus":{"enum":["idle","running","paused","stopped"],"type":"string"},"WorkspaceSubscriptionWorkdir":{"additionalProperties":false,"description":"Workspace-facing Workdir summary. Backend-generated Repository UUIDs never\nenter this DTO; Workspace Server must resolve the required Repository key.","properties":{"repository_key":{"type":"string"},"state":{"type":"string"},"working_directory_id":{"$ref":"#/$defs/SubscriptionWorkdirId"}},"required":["working_directory_id","repository_key","state"],"type":"object"}},"$schema":"https://json-schema.org/draft/2020-12/schema","description":"Direction is explicit at the outer envelope, while concrete message kinds\nremain typed within each lane.","oneOf":[{"additionalProperties":false,"properties":{"frame":{"const":"request","type":"string"},"message":{"$ref":"#/$defs/SubscriptionRequest"},"protocol_version":{"format":"uint16","maximum":65535.0,"minimum":0.0,"type":"integer"}},"required":["frame","message","protocol_version"],"type":"object"},{"additionalProperties":false,"properties":{"frame":{"const":"response","type":"string"},"message":{"$ref":"#/$defs/SubscriptionResponse"},"protocol_version":{"format":"uint16","maximum":65535.0,"minimum":0.0,"type":"integer"}},"required":["frame","message","protocol_version"],"type":"object"},{"additionalProperties":false,"properties":{"frame":{"const":"event","type":"string"},"message":{"$ref":"#/$defs/SubscriptionEvent"},"protocol_version":{"format":"uint16","maximum":65535.0,"minimum":0.0,"type":"integer"}},"required":["frame","message","protocol_version"],"type":"object"},{"additionalProperties":false,"properties":{"frame":{"const":"worker_protocol","type":"string"},"message":{"$ref":"#/$defs/SubscriptionWorkerProtocolMethod"},"protocol_version":{"format":"uint16","maximum":65535.0,"minimum":0.0,"type":"integer"}},"required":["frame","message","protocol_version"],"type":"object"}],"title":"SubscriptionFrame"}; +const subscriptionFrameSchema: unknown = {"$defs":{"Alert":{"additionalProperties":false,"description":"User-facing alert emitted from the Worker layer.\n\nThis is a separate channel from `tracing` (developer logs): entries\nhere are assembled explicitly by the Worker when a condition should be\nsurfaced to the person driving the client. Keep messages short and\nhuman-readable.","properties":{"level":{"$ref":"#/$defs/AlertLevel"},"message":{"type":"string"},"source":{"$ref":"#/$defs/AlertSource"},"timestamp_ms":{"description":"Milliseconds since the Unix epoch.","format":"int64","maximum":9007199254740991.0,"minimum":-9007199254740991.0,"type":"integer"}},"required":["level","source","message","timestamp_ms"],"type":"object"},"AlertLevel":{"enum":["warn","error"],"type":"string"},"AlertSource":{"enum":["worker","engine","compactor","agents_md"],"type":"string"},"CommandEvent":{"oneOf":[{"additionalProperties":false,"properties":{"command_id":{"type":"string"},"kind":{"const":"started","type":"string"},"observed_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"tool_call_id":{"type":["string","null"]}},"required":["kind","command_id","observed_at_ms"],"type":"object"},{"additionalProperties":false,"properties":{"command_id":{"type":"string"},"content":{"type":"string"},"end_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"kind":{"const":"output","type":"string"},"observed_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"start_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"stream":{"$ref":"#/$defs/CommandStream"}},"required":["kind","command_id","stream","start_offset","end_offset","content","observed_at_ms"],"type":"object"},{"additionalProperties":false,"properties":{"command_id":{"type":"string"},"exit_code":{"format":"int32","maximum":2147483647.0,"minimum":-2147483648.0,"type":["integer","null"]},"kind":{"const":"terminal","type":"string"},"observed_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"status":{"$ref":"#/$defs/CommandStatus"},"stderr_end_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"stdout_end_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","command_id","status","stdout_end_offset","stderr_end_offset","observed_at_ms"],"type":"object"}]},"CommandSnapshot":{"additionalProperties":false,"properties":{"command_id":{"type":"string"},"exit_code":{"format":"int32","maximum":2147483647.0,"minimum":-2147483648.0,"type":["integer","null"]},"last_output_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"observed_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"started_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"status":{"$ref":"#/$defs/CommandStatus"},"stderr":{"$ref":"#/$defs/CommandStreamSlice"},"stdout":{"$ref":"#/$defs/CommandStreamSlice"},"tool_call_id":{"type":["string","null"]}},"required":["command_id","status","started_at_ms","observed_at_ms","stdout","stderr"],"type":"object"},"CommandStatus":{"description":"Live provider-owned command status. These values are operational Console\nstate only and are never appended to Worker history.","enum":["running","completed","failed","timed_out","cancelled"],"type":"string"},"CommandStream":{"enum":["stdout","stderr"],"type":"string"},"CommandStreamSlice":{"additionalProperties":false,"properties":{"content":{"type":"string"},"end_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"start_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"truncated":{"type":"boolean"}},"required":["start_offset","end_offset","content","truncated"],"type":"object"},"CompactionLifecycle":{"additionalProperties":false,"description":"Stable parent-owned lifecycle for one compaction run.","properties":{"compaction_id":{"type":"string"},"ended_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"error":{"type":["string","null"]},"internal_worker":{"anyOf":[{"$ref":"#/$defs/InternalWorkerRef"},{"type":"null"}]},"new_segment_id":{"type":["string","null"]},"revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"schema_version":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"started_at_ms":{"description":"Milliseconds since the Unix epoch.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"state":{"$ref":"#/$defs/CompactionLifecycleState"},"summary":{"type":["string","null"]}},"required":["schema_version","compaction_id","revision","state","started_at_ms"],"type":"object"},"CompactionLifecycleState":{"enum":["running","done","failed","interrupted"],"type":"string"},"CompactionPhase":{"enum":["preparing","summarizing","committing"],"type":"string"},"CompactionTrigger":{"enum":["manual","pre_run","request_threshold"],"type":"string"},"CompletionContext":{"additionalProperties":false,"properties":{"argument":{"type":["string","null"]},"invocation":{"$ref":"#/$defs/FeatureInvocationIdentity"}},"required":["invocation"],"type":"object"},"CompletionEntry":{"additionalProperties":false,"description":"One completion candidate for a prefix query.","properties":{"description":{"type":["string","null"]},"invocation":{"anyOf":[{"$ref":"#/$defs/FeatureInvocationDescriptor"},{"type":"null"}]},"is_dir":{"default":false,"type":"boolean"},"usage":{"type":["string","null"]},"value":{"type":"string"}},"required":["value","is_dir"],"type":"object"},"CompletionKind":{"description":"Kind of completion requested by `Method::ListCompletions`.\n\n`File` is the existing `@` path lane. `Feature` lists installed chat\ninvocations. `FeatureArgument` resolves a declared argument using the\nsource-qualified [`CompletionContext`].","enum":["file","feature","feature_argument"],"type":"string"},"ContextTokenSource":{"description":"Provenance of the current-context token count in a [`Greeting`].","oneOf":[{"const":"measured","description":"Provider-reported input occupancy from the most recent LLM request.","type":"string"},{"const":"estimated","description":"Current history projected from the Worker's token accounting records.","type":"string"}]},"ContextUsage":{"additionalProperties":false,"description":"Current prompt-context occupancy, distinct from cumulative run traffic.","properties":{"source":{"$ref":"#/$defs/ContextTokenSource"},"tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["tokens","source"],"type":"object"},"ErrorCode":{"enum":["already_running","not_running","not_paused","provider_error","tool_error","invalid_request","internal"],"type":"string"},"Event":{"oneOf":[{"additionalProperties":false,"description":"Durable Submit acceptance. A `Queued` receipt follows the durable FIFO\ncheckpoint. A `Started` receipt follows the pending activation checkpoint;\nthe corresponding [`Event::UserMessage`] marks the later history commit.\nRepeating the same request id and exact payload returns the same receipt\nwithout appending or activating twice.","properties":{"data":{"additionalProperties":false,"properties":{"disposition":{"$ref":"#/$defs/SubmissionDisposition"},"submission_id":{"type":"string"},"submission_request_id":{"type":"string"}},"required":["submission_request_id","submission_id","disposition"],"type":"object"},"event":{"const":"submission_accepted","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Correlated rejection before durable acceptance.","properties":{"data":{"additionalProperties":false,"properties":{"message":{"type":"string"},"submission_request_id":{"type":"string"}},"required":["submission_request_id","message"],"type":"object"},"event":{"const":"submission_rejected","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Durable notification acceptance. The request identity is persisted with\nthe notification receipt before this event is emitted. Repeating the\nsame request id and exact payload returns the same acknowledgement\nwithout appending the notification twice.","properties":{"data":{"additionalProperties":false,"properties":{"notification_request_id":{"type":"string"}},"required":["notification_request_id"],"type":"object"},"event":{"const":"notification_accepted","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Correlated notification rejection before durable acceptance.","properties":{"data":{"additionalProperties":false,"properties":{"message":{"type":"string"},"notification_request_id":{"type":"string"}},"required":["notification_request_id","message"],"type":"object"},"event":{"const":"notification_rejected","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Revisioned FIFO replacement following enqueue, activation, cancel, or clear.","properties":{"data":{"additionalProperties":false,"properties":{"pending":{"$ref":"#/$defs/PendingSubmissionsSnapshot"}},"required":["pending"],"type":"object"},"event":{"const":"pending_submissions_changed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A user input message persisted as `LogEntry::AnnotatedUserInput` and\nactivated for a turn. Broadcast to every subscribed client so TUI / GUI\ninstances show the same user line that reconnect snapshots replay.","properties":{"data":{"additionalProperties":false,"properties":{"entry_id":{"description":"Stable durable history identity. Older mixed-version producers omit it.","type":["string","null"]},"segments":{"items":{"$ref":"#/$defs/Segment"},"type":"array"}},"required":["segments"],"type":"object"},"event":{"const":"user_message","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A canonical assistant/tool entry has committed to durable history.\nStreaming events remain the in-flight authority; this event supplies the\nstable identity used to reconcile that live block with paged history.","properties":{"data":{"additionalProperties":false,"properties":{"entry":{"$ref":"#/$defs/SessionSnapshotEntry"}},"required":["entry"],"type":"object"},"event":{"const":"session_entry_committed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"One agent-injected system item committed to history.\n\nCarries the JSON form of `session_store::SystemItem`. Covers\n`Method::Notify` echoes, child-Worker lifecycle events from\n`Method::WorkerEvent`, `@` resolution payloads, and any future\nClients dispatch on the `kind` tag for typed rendering instead\nof parsing free-text prefixes like `[Notification] …` or\n`[File: …]`.\n\nOne event per `LogEntry::AnnotatedSystemItem` commit. Disk-side and\nwire-side are 1:1.","properties":{"data":{"additionalProperties":false,"properties":{"entry_id":{"description":"Stable durable history identity. Synthetic compatibility events omit it.","type":["string","null"]},"item":true},"required":["item"],"type":"object"},"event":{"const":"system_item","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A new self-driving cycle has begun (IDLE → active transition).\n\nMarker event for the start of an Invoke range; the range extends\nimplicitly until the next `InvokeStart`. Fires for every accepted\n`Method::Submit` (kind=`UserSend`), `Method::Notify` (kind=`Notify`),\n`Method::WorkerEvent` re-injection (kind=`WorkerEvent`), and any other\nIDLE-breaking trigger. Mid-run interrupts (e.g. hook output,\ntyped system reminder insertion that doesn't break IDLE) do not\nemit `InvokeStart` — they appear as `SystemItem` only.\n\nCarries `kind` only; the payload (user text / notify message /\nworker event body) is delivered separately via the immediately\nfollowing `UserMessage` / `SystemItem` event.","properties":{"data":{"additionalProperties":false,"properties":{"kind":{"$ref":"#/$defs/InvokeKind"}},"required":["kind"],"type":"object"},"event":{"const":"invoke_start","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"One AgentTurn boundary opened. An AgentTurn is a maximal run of\nLLM generation calls whose input messages are identical (i.e.\nretries from network errors / 5xx / stream disconnects collapse\ninto the same AgentTurn). When the input changes (a new tool\nresult lands, a user interrupts, etc.), the next LLM call belongs\nto a new AgentTurn.\n\n`turn` is the AgentTurn index from `Engine::turn_count`.\n\nCurrently retry is not yet implemented (`agen-stream-continuation`)\nso AgentTurn and `LlmCall` fire 1:1, but the contract here is\nthe AgentTurn boundary; consumers that want per-LLM-call signals\nmust subscribe to `LlmCallStart` / `LlmCallEnd` instead.","properties":{"data":{"additionalProperties":false,"properties":{"turn":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["turn"],"type":"object"},"event":{"const":"turn_start","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"AgentTurn closed.","properties":{"data":{"additionalProperties":false,"properties":{"result":{"$ref":"#/$defs/TurnResult"},"turn":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["turn","result"],"type":"object"},"event":{"const":"turn_end","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"One LLM generation call started (1 request → 1 generation, retry\nincluded). Multiple `LlmCall*` pairs may fire inside a single\n`TurnStart` / `TurnEnd` pair when a request is retried; today\nthey fire 1:1 because retry is not implemented.\n\n`llm_call` is the worker-wide running counter from\n`Engine::llm_call_count`.","properties":{"data":{"additionalProperties":false,"properties":{"llm_call":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["llm_call"],"type":"object"},"event":{"const":"llm_call_start","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"LLM generation call ended.","properties":{"data":{"additionalProperties":false,"properties":{"llm_call":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["llm_call"],"type":"object"},"event":{"const":"llm_call_end","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A transport-level LLM request retry has been scheduled.\n\nThis is operational state for clients to render while the worker is\nwaiting in backoff. It is not part of conversation history.","properties":{"data":{"additionalProperties":false,"properties":{"elapsed_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"error":{"type":"string"},"failed_attempt":{"description":"The attempt that just failed. 1 origin.","format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"llm_call":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"max_attempts":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"status":{"format":"uint16","maximum":65535.0,"minimum":0.0,"type":["integer","null"]},"wait_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["llm_call","failed_attempt","max_attempts","wait_ms","elapsed_ms","error"],"type":"object"},"event":{"const":"llm_retry","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Stream generation was interrupted after events had begun and the worker\nis continuing with a follow-up LLM request.","properties":{"data":{"additionalProperties":false,"properties":{"attempt":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"llm_call":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"max_attempts":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"reason":{"type":"string"}},"required":["llm_call","attempt","max_attempts","reason"],"type":"object"},"event":{"const":"llm_continuation","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"text":{"type":"string"}},"required":["text"],"type":"object"},"event":{"const":"text_delta","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"text":{"type":"string"}},"required":["text"],"type":"object"},"event":{"const":"text_done","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A reasoning / thinking block has started.\n\nAlways paired with a `ThinkingDone`. `ThinkingDelta` is optional:\nsome providers (or some configurations) emit thinking metadata\nwithout plaintext, in which case Start → Done arrive with no\ndeltas in between. Multiple thinking blocks per turn are allowed.","properties":{"event":{"const":"thinking_start","type":"string"}},"required":["event"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"text":{"type":"string"}},"required":["text"],"type":"object"},"event":{"const":"thinking_delta","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Thinking block completed. `text` is the full accumulated body\n(empty string when the provider didn't emit plaintext).","properties":{"data":{"additionalProperties":false,"properties":{"text":{"type":"string"}},"required":["text"],"type":"object"},"event":{"const":"thinking_done","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name"],"type":"object"},"event":{"const":"tool_call_start","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"id":{"type":"string"},"json":{"type":"string"}},"required":["id","json"],"type":"object"},"event":{"const":"tool_call_args_delta","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"arguments":{"type":"string"},"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name","arguments"],"type":"object"},"event":{"const":"tool_call_done","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"disposition":{"anyOf":[{"$ref":"#/$defs/ToolResultDisposition"},{"type":"null"}]},"id":{"type":"string"},"is_error":{"default":false,"type":"boolean"},"output":{"description":"Full tool output. Absent when the tool chose to return\nsummary-only, or when the result was pruned.","type":["string","null"]},"summary":{"description":"Short human-readable summary. Always present; used by clients\nthat only want a 1-line rendering (e.g. collapsed views).","type":"string"}},"required":["id","summary","is_error"],"type":"object"},"event":{"const":"tool_result","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Token accounting for one LLM request.\n\n`input_tokens` is the prompt prefix occupancy (cache reads /\ncache writes included), as normalised by the worker layer.\n`cache_read_input_tokens` is the cache-hit subset of that\noccupancy; subtracting it yields the \"net upload\" the client\nactually paid full price to send on this request, which is what\nthe TUI status line accumulates per turn.","properties":{"data":{"additionalProperties":false,"properties":{"cache_read_input_tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"input_tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"output_tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]}},"type":"object"},"event":{"const":"usage","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Authoritative current-context occupancy after a non-request boundary.\n\nUnlike [`Event::Usage`], this is not cumulative request traffic. It lets\nconnected clients converge with reconnect snapshots after run completion,\ncompaction, rewind, and other history rewrites. `None` explicitly means\nthe current value is unavailable and stale occupancy must be cleared.","properties":{"data":{"additionalProperties":false,"properties":{"usage":{"anyOf":[{"$ref":"#/$defs/ContextUsage"},{"type":"null"}]}},"type":"object"},"event":{"const":"context_usage","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"result":{"$ref":"#/$defs/RunResult"}},"required":["result"],"type":"object"},"event":{"const":"run_end","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"code":{"$ref":"#/$defs/ErrorCode"},"message":{"type":"string"}},"required":["code","message"],"type":"object"},"event":{"const":"error","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Sent exactly once at the start of every client connection.\n\n`entries` is the session-log mirror at subscribe time, serialised\nas the JSON form of `session_store::LogEntry`. This is the\nbulk-reconstruction lane: clients walk the entries to seed their\nderived view.\n\n`greeting` and `status` accompany the snapshot so clients render\nworker identity and current controller state without an extra round\ntrip.\n\nLive updates after the snapshot arrive through the streaming\nevents (`TextDelta` / `ToolCall*` / `ToolResult` / etc.) plus\nrole-specific entry events (`SegmentRotated` / `SystemItem`) —\nthere is no generic \"every committed entry\" broadcast.","properties":{"data":{"additionalProperties":false,"properties":{"greeting":{"$ref":"#/$defs/Greeting"},"in_flight":{"$ref":"#/$defs/InFlightSnapshot","description":"Unfinished model output that has already streamed in the current\nrun but is not yet represented by committed snapshot entries."},"internal_workers":{"description":"Parent-owned Internal Worker sessions visible to this client.\nService-private Internal Workers are deliberately excluded.","items":{"$ref":"#/$defs/InternalWorkerSnapshot"},"type":"array"},"session":{"$ref":"#/$defs/SessionSnapshot"},"state":{"$ref":"#/$defs/WorkerStateSnapshot","description":"Full revisioned live execution state. `Stopped` remains Runtime\ncatalog authority and is deliberately not represented here."}},"required":["session","greeting","state"],"type":"object"},"event":{"const":"snapshot","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A live event from a parent-owned Internal Worker. The payload reuses the\nnormal Worker event vocabulary while the wrapper carries stable origin\nidentity and a per-child revision fence.","properties":{"data":{"additionalProperties":false,"properties":{"event":{"$ref":"#/$defs/Event"},"revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"worker":{"$ref":"#/$defs/InternalWorkerRef"}},"required":["worker","revision","event"],"type":"object"},"event":{"const":"internal_worker","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Terminal removal fence for one parent-owned Internal Worker session.\n\nClients discard the matching child and descendants, then ignore later\nnested events for this identity until an authoritative snapshot replaces\nthe projection.","properties":{"data":{"additionalProperties":false,"properties":{"revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"worker":{"$ref":"#/$defs/InternalWorkerRef"}},"required":["worker","revision"],"type":"object"},"event":{"const":"internal_worker_removed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Server-side segment log rotated to a fresh `SegmentStart`.\n\nFires on compaction and on auto-fork when the store head drifts\nA compaction/fork has replaced the authoritative segment. Clients drop\ntheir derived view and reseed from the canonical committed snapshot.","properties":{"data":{"additionalProperties":false,"properties":{"session":{"$ref":"#/$defs/SessionSnapshot"}},"required":["session"],"type":"object"},"event":{"const":"segment_rotated","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Current Worker controller status. Broadcast on every controller-level\ntransition and included in `History` snapshots for late attach.","properties":{"data":{"additionalProperties":false,"properties":{"snapshot":{"$ref":"#/$defs/WorkerStateSnapshot"}},"required":["snapshot"],"type":"object"},"event":{"const":"worker_state","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"acknowledgement":{"$ref":"#/$defs/WorkerCommandAcknowledgement"}},"required":["acknowledgement"],"type":"object"},"event":{"const":"command_acknowledged","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Bounded, provider-owned command telemetry for the live Console. This is\nintentionally not a history entry and is reconstructed from\n`Snapshot.in_flight.commands` after reconnect.","properties":{"data":{"additionalProperties":false,"properties":{"event":{"$ref":"#/$defs/CommandEvent"}},"required":["event"],"type":"object"},"event":{"const":"command","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Reply to `Method::ListCompletions`. Delivered only to the\nrequesting socket (not broadcast). `entries` is empty when no\ncandidates match or when the requested kind has no resolver\nwired up.","properties":{"data":{"additionalProperties":false,"properties":{"context":{"anyOf":[{"$ref":"#/$defs/CompletionContext"},{"type":"null"}]},"entries":{"items":{"$ref":"#/$defs/CompletionEntry"},"type":"array"},"kind":{"$ref":"#/$defs/CompletionKind"},"prefix":{"default":"","type":"string"},"request_id":{"type":["string","null"]}},"required":["kind","entries","prefix"],"type":"object"},"event":{"const":"completions","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Reply to `Method::ListRewindTargets`. Clients should only open a picker\nin response to their own pending request; the event may be broadcast.","properties":{"data":{"additionalProperties":false,"properties":{"head_entries":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"targets":{"items":{"$ref":"#/$defs/RewindTarget"},"type":"array"}},"required":["head_entries","targets"],"type":"object"},"event":{"const":"rewind_targets","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A rewind has truncated the authoritative session. `session` is the\nretained canonical snapshot clients should use to reseed display state.","properties":{"data":{"additionalProperties":false,"properties":{"input":{"items":{"$ref":"#/$defs/Segment"},"type":"array"},"session":{"$ref":"#/$defs/SessionSnapshot"},"summary":{"$ref":"#/$defs/RewindSummary"}},"required":["session","input","summary"],"type":"object"},"event":{"const":"rewind_applied","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Reply to `Method::ListWorkers`. Payload is a stable JSON value so the Worker\ncrate can evolve discovery fields without introducing a protocol\ndependency on session-store.","properties":{"data":{"additionalProperties":false,"properties":{"workers":true},"required":["workers"],"type":"object"},"event":{"const":"workers_listed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Reply to `Method::RestoreWorker`.","properties":{"data":{"additionalProperties":false,"properties":{"result":true},"required":["result"],"type":"object"},"event":{"const":"worker_restored","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Reply to `Method::RegisterPeer`.","properties":{"data":{"additionalProperties":false,"properties":{"result":true},"required":["result"],"type":"object"},"event":{"const":"peer_registered","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"$ref":"#/$defs/Alert"},"event":{"const":"alert","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Latest memory extract/consolidation lifecycle event for UI observability.\n\nThis is not part of LLM history or prompt context; clients may display it\nbriefly as operational status.","properties":{"data":{"$ref":"#/$defs/MemoryWorkerEvent"},"event":{"const":"memory_worker","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Runtime-only compaction progress. `None` clears the current status.\nThis never enters Session history and carries no operation or Segment identity.","properties":{"data":{"additionalProperties":false,"properties":{"compaction":{"anyOf":[{"$ref":"#/$defs/InFlightCompaction"},{"type":"null"}]}},"type":"object"},"event":{"const":"compaction_progress","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Legacy compaction lifecycle event retained for wire read compatibility.","properties":{"data":{"additionalProperties":false,"properties":{"lifecycle":{"$ref":"#/$defs/CompactionLifecycle"}},"required":["lifecycle"],"type":"object"},"event":{"const":"compact_start","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Compaction completed and the session was rotated.","properties":{"data":{"additionalProperties":false,"properties":{"lifecycle":{"$ref":"#/$defs/CompactionLifecycle"}},"required":["lifecycle"],"type":"object"},"event":{"const":"compact_done","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Compaction failed or was cancelled. The session is unchanged.","properties":{"data":{"additionalProperties":false,"properties":{"lifecycle":{"$ref":"#/$defs/CompactionLifecycle"}},"required":["lifecycle"],"type":"object"},"event":{"const":"compact_failed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"event":{"const":"shutdown","type":"string"}},"required":["event"],"type":"object"}]},"EventSubscriptionSelector":{"oneOf":[{"additionalProperties":false,"properties":{"topic":{"const":"runtime_workers","type":"string"}},"required":["topic"],"type":"object"},{"additionalProperties":false,"properties":{"topic":{"const":"worker_lifecycle","type":"string"},"worker_ids":{"$ref":"#/$defs/SubscriptionWorkerIds"}},"required":["topic","worker_ids"],"type":"object"},{"additionalProperties":false,"properties":{"runtime_id":{"type":["string","null"]},"topic":{"const":"worker_protocol","type":"string"},"worker_id":{"$ref":"#/$defs/SubscriptionWorkerId"}},"required":["topic","worker_id"],"type":"object"},{"additionalProperties":false,"description":"Server-derived Workspace projection. Workspace identity comes from the\nauthenticated connection and is deliberately absent from this selector.","properties":{"topic":{"const":"workspace_workers","type":"string"}},"required":["topic"],"type":"object"},{"additionalProperties":false,"description":"Typed extension point for the later Workdir subscription slice.","properties":{"topic":{"const":"workspace_workdirs","type":"string"}},"required":["topic"],"type":"object"}]},"FeatureInvocation":{"additionalProperties":false,"properties":{"arguments":{"items":{"$ref":"#/$defs/InvocationArgumentValue"},"type":"array"},"identity":{"$ref":"#/$defs/FeatureInvocationIdentity"},"invocation_id":{"description":"Stable client-generated id retained across queueing and transport retry.","type":"string"},"name":{"description":"Selected public name, retained for exact draft/history restoration. Host\nresolution is always by `identity`.","type":"string"}},"required":["invocation_id","identity","name","arguments"],"type":"object"},"FeatureInvocationDescriptor":{"additionalProperties":false,"properties":{"aliases":{"default":[],"items":{"type":"string"},"type":"array"},"arguments":{"default":[],"items":{"$ref":"#/$defs/InvocationArgumentDescriptor"},"type":"array"},"client_adapter":{"anyOf":[{"$ref":"#/$defs/InvocationClientAdapter"},{"type":"null"}]},"description":{"type":"string"},"display_name":{"type":"string"},"identity":{"$ref":"#/$defs/FeatureInvocationIdentity"},"name":{"type":"string"},"syntax":{"$ref":"#/$defs/FeatureInvocationSyntax"}},"required":["identity","name","display_name","description","syntax","aliases","arguments"],"type":"object"},"FeatureInvocationIdentity":{"description":"Source-qualified identity of one invocation contribution, for example\n`builtin:attachments/attach`. Display names and aliases are never authority.","type":"string"},"FeatureInvocationSyntax":{"oneOf":[{"const":"parenthesized","description":"`/name(value, key=value)` with JSON-style quoted strings and `)` as the\nexplicit boundary before following natural language.","type":"string"}]},"Greeting":{"additionalProperties":false,"description":"Worker self-description rendered by clients when a session starts empty.\n\nSnapshot metadata contains only resolved public execution information so\nclients do not need their own copy of the manifest.","properties":{"context_tokens":{"default":0,"description":"Legacy numeric mirror retained for mixed-version clients.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"context_usage":{"anyOf":[{"$ref":"#/$defs/ContextUsage"},{"type":"null"}],"description":"Typed current-context value. Older snapshots omit it; clients may fall\nback to `context_tokens` only when `context_window` is known."},"context_window":{"default":0,"description":"Resolved effective model context window in tokens. Legacy producers use\nzero when this is unavailable; clients must treat that as unknown.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"cwd":{"type":"string"},"model":{"type":"string"},"provider":{"type":"string"},"reasoning":{"anyOf":[{"$ref":"#/$defs/ReasoningConfig"},{"type":"null"}],"description":"Effective reasoning control. Absent means unspecified or unavailable."},"scope_summary":{"type":"string"},"tools":{"items":{"type":"string"},"type":"array"},"worker_name":{"type":"string"}},"required":["worker_name","cwd","provider","model","scope_summary","tools","context_tokens","context_window"],"type":"object"},"InFlightBlock":{"oneOf":[{"additionalProperties":false,"properties":{"finished":{"type":"boolean"},"kind":{"const":"text","type":"string"},"text":{"type":"string"}},"required":["kind","text"],"type":"object"},{"additionalProperties":false,"properties":{"finished":{"type":"boolean"},"kind":{"const":"thinking","type":"string"},"text":{"type":"string"}},"required":["kind","text"],"type":"object"},{"additionalProperties":false,"properties":{"args":{"type":"string"},"id":{"type":"string"},"kind":{"const":"tool_call","type":"string"},"name":{"type":"string"},"state":{"$ref":"#/$defs/InFlightToolCallState"}},"required":["kind","id","name","args"],"type":"object"}]},"InFlightCompaction":{"additionalProperties":false,"properties":{"phase":{"$ref":"#/$defs/CompactionPhase"},"started_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"trigger":{"$ref":"#/$defs/CompactionTrigger"}},"required":["phase","started_at_ms","trigger"],"type":"object"},"InFlightSnapshot":{"additionalProperties":false,"description":"Unfinished model output and active command state included in\n`Event::Snapshot` for clients that attach while work is still streaming.\n\nThese blocks are presentation state only: they are reconstructed from the\nactive Worker controller and must not be treated as committed assistant\nhistory. Finalized assistant items continue to come from ordinary snapshot\nentries.","properties":{"blocks":{"items":{"$ref":"#/$defs/InFlightBlock"},"type":"array"},"commands":{"items":{"$ref":"#/$defs/CommandSnapshot"},"type":"array"},"compaction":{"anyOf":[{"$ref":"#/$defs/InFlightCompaction"},{"type":"null"}],"description":"The currently running compaction, if any.\n\nThis is lifecycle progress only. Candidate history and the staged\nSegment remain private until the Segment is activated atomically."}},"type":"object"},"InFlightToolCallState":{"enum":["pending","streaming_args","done"],"type":"string"},"InternalWorkerKind":{"description":"Presentation category for an Internal Worker exposed through its parent's\nprotocol stream. Internal Workers never become independently addressable\nprotocol subjects.","oneOf":[{"enum":["sub_worker"],"type":"string"},{"additionalProperties":false,"properties":{"service":{"additionalProperties":false,"properties":{"kind":{"type":"string"}},"required":["kind"],"type":"object"}},"required":["service"],"type":"object"}]},"InternalWorkerRef":{"additionalProperties":false,"description":"Stable presentation identity for one parent-owned Internal Worker session.\n`name` is display-only; `session_id` is the identity used by clients.","properties":{"kind":{"$ref":"#/$defs/InternalWorkerKind"},"name":{"type":"string"},"parent_session_id":{"type":["string","null"]},"session_id":{"type":"string"}},"required":["session_id","name","kind"],"type":"object"},"InternalWorkerSnapshot":{"additionalProperties":false,"description":"Reconnect state for one visible Internal Worker. The revision fences live\n`Event::InternalWorker` updates that raced with parent snapshot assembly.","properties":{"error":{"type":["string","null"]},"greeting":{"anyOf":[{"$ref":"#/$defs/Greeting"},{"type":"null"}],"description":"Public execution metadata for this child. Older snapshots and service\nworkers may omit it; clients must never substitute the parent's values."},"in_flight":{"$ref":"#/$defs/InFlightSnapshot"},"internal_workers":{"items":{"$ref":"#/$defs/InternalWorkerSnapshot"},"type":"array"},"revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"session":{"$ref":"#/$defs/SessionSnapshot"},"status":{"$ref":"#/$defs/WorkerStatus","default":"idle"},"worker":{"$ref":"#/$defs/InternalWorkerRef"}},"required":["worker","revision","session","status"],"type":"object"},"InvocationArgumentDescriptor":{"additionalProperties":false,"properties":{"completion":{"$ref":"#/$defs/InvocationCompletion","default":{"kind":"none"}},"description":{"type":["string","null"]},"name":{"type":"string"},"position":{"description":"Zero-based positional index. `None` means named-only. A positional\nargument may also be supplied by name; this makes chip editing stable.","format":"uint16","maximum":65535.0,"minimum":0.0,"type":["integer","null"]},"required":{"default":false,"type":"boolean"},"value_type":{"$ref":"#/$defs/InvocationArgumentType"}},"required":["name","value_type","completion","required"],"type":"object"},"InvocationArgumentType":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"string","type":"string"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"integer","type":"string"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"boolean","type":"string"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"enum","type":"string"},"values":{"items":{"type":"string"},"type":"array"}},"required":["kind","values"],"type":"object"},{"additionalProperties":false,"description":"A Worker-readable path. This does not grant filesystem authority.","properties":{"kind":{"const":"worker_file","type":"string"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"description":"A resource selected and staged by the client. It must never be resolved\nas a path on the Worker host.","properties":{"kind":{"const":"client_file","type":"string"}},"required":["kind"],"type":"object"}]},"InvocationArgumentValue":{"additionalProperties":false,"properties":{"name":{"type":"string"},"value":{"$ref":"#/$defs/InvocationValue"}},"required":["name","value"],"type":"object"},"InvocationClientAdapter":{"oneOf":[{"const":"attachment","description":"The client stages a local file and replaces the draft invocation with an\n`UploadedFile` chip. Local paths are not sent to or persisted by Worker.","type":"string"}]},"InvocationCompletion":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"none","type":"string"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"static","type":"string"},"values":{"items":{"type":"string"},"type":"array"}},"required":["kind","values"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"worker_file","type":"string"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"client_file","type":"string"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"description":"Host-owned provider selected by source-qualified id. Metadata lookup is\nside-effect free; implementations never cross this wire boundary.","properties":{"kind":{"const":"provider","type":"string"},"provider":{"type":"string"}},"required":["kind","provider"],"type":"object"}]},"InvocationValue":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"string","type":"string"},"value":{"type":"string"}},"required":["kind","value"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"integer","type":"string"},"value":{"format":"int32","maximum":2147483647.0,"minimum":-2147483648.0,"type":"integer"}},"required":["kind","value"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"boolean","type":"string"},"value":{"type":"boolean"}},"required":["kind","value"],"type":"object"}]},"InvokeKind":{"description":"Kind of trigger that opened a new Invoke (IDLE → active) range.\n\nOne Invoke groups all entries from this trigger up to the next\n`Invoke` marker. The kind is the only payload — content (user text,\nnotify message, worker event body) is delivered by the immediately\nfollowing Turn entry, not by the marker itself.","oneOf":[{"const":"user_send","description":"`Method::Submit` — a user submission.","type":"string"},{"const":"notify","description":"`Method::Notify` — free-text notification injected into history.","type":"string"},{"const":"worker_event","description":"`Method::WorkerEvent` — typed lifecycle report from a child Worker.","type":"string"},{"const":"system_reminder","description":"A typed system reminder that crosses an IDLE boundary (mid-run\nreminders that don't break IDLE are SystemItem-only and do not\nopen a new Invoke).","type":"string"},{"const":"wakeup","description":"Cron / RemoteTrigger style scheduled wake-up. Reserved; no\nproducer is wired today but the variant is part of the initial\nset so future schedulers don't have to amend the wire enum.","type":"string"}]},"MemoryWorkerEvent":{"additionalProperties":false,"properties":{"message":{"description":"Human-readable compact form for actionbar rendering.","type":"string"},"reason":{"type":"string"},"run_id":{"type":"string"},"status":{"type":"string"},"timestamp_ms":{"description":"Milliseconds since the Unix epoch.","format":"int64","maximum":9007199254740991.0,"minimum":-9007199254740991.0,"type":"integer"},"trigger":{"type":"string"},"worker":{"type":"string"}},"required":["worker","status","run_id","trigger","reason","message","timestamp_ms"],"type":"object"},"Method":{"oneOf":[{"additionalProperties":false,"description":"Durably accept typed input for immediate activation or the session FIFO.\nRunning or Paused Workers queue Submit for a later turn; use Notify for\nadvisory information to incorporate into work already in progress.\n\n`submission_request_id` is generated by the authenticated caller and is\nused only for idempotent retry. Worker allocates the durable\n`submission_id` returned by [`Event::SubmissionAccepted`].","properties":{"method":{"const":"submit","type":"string"},"params":{"additionalProperties":false,"properties":{"input":{"items":{"$ref":"#/$defs/Segment"},"type":"array"},"submission_request_id":{"type":"string"}},"required":["submission_request_id","input"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Human-readable text injected into the target Worker's LLM context as a\nnon-blocking system message. Accepted notifications are delivered at the\nnext between-turn boundary, or start a coalesced notification run while\nthe Worker is idle. No side effects beyond LLM context; use\n`WorkerEvent` for typed lifecycle reports.","properties":{"method":{"const":"notify","type":"string"},"params":{"additionalProperties":false,"properties":{"message":{"type":"string"},"notification_request_id":{"type":"string"}},"required":["notification_request_id","message"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Typed lifecycle report from a child Worker to its direct parent.","properties":{"method":{"const":"worker_event","type":"string"},"params":{"$ref":"#/$defs/WorkerEvent"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Return the authoritative FIFO summary without exposing queued payloads.","properties":{"method":{"const":"list_pending_submissions","type":"string"}},"required":["method"],"type":"object"},{"additionalProperties":false,"description":"Remove one queued submission. Running or already activated submissions\nare immutable and therefore cannot be cancelled here.","properties":{"method":{"const":"cancel_pending_submission","type":"string"},"params":{"additionalProperties":false,"properties":{"expected_revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"submission_id":{"type":"string"}},"required":["submission_id","expected_revision"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Remove every queued submission while preserving the active run.","properties":{"method":{"const":"clear_pending_submissions","type":"string"},"params":{"additionalProperties":false,"properties":{"expected_revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["expected_revision"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Activate the next queued submission while the Worker is idle. This is an\nexplicit recovery operation and never resumes a paused run implicitly.","properties":{"method":{"const":"continue_pending","type":"string"},"params":{"additionalProperties":false,"properties":{"expected_head_id":{"type":"string"},"expected_revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["expected_revision","expected_head_id"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"properties":{"method":{"const":"resume","type":"string"},"params":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandEnvelope"}},"required":["command"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"properties":{"method":{"const":"cancel","type":"string"},"params":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandEnvelope"}},"required":["command"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Stop the in-flight turn and transition to `Paused`.\n\nUnlike `Cancel` (which discards and returns to `Idle`), a paused\nWorker can resume the interrupted work via `Resume`, or accept a\nfresh `Submit` (orphan `tool_use` items are closed with a\nsynthetic tool result before the new user message is appended).","properties":{"method":{"const":"pause","type":"string"},"params":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandEnvelope"}},"required":["command"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Request an explicit compaction while the Worker is otherwise idle.\n\nThis is a typed control method: clients must not send `compact` as a\n`Method::Submit` user message.","properties":{"method":{"const":"compact","type":"string"},"params":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandEnvelope"}},"required":["command"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Ask the Worker to list valid rewind targets from its authoritative session log.","properties":{"method":{"const":"list_rewind_targets","type":"string"}},"required":["method"],"type":"object"},{"additionalProperties":false,"description":"Truncate the current session back to the selected rewind target and\nreturn the selected user input to the client composer.","properties":{"method":{"const":"rewind_to","type":"string"},"params":{"additionalProperties":false,"properties":{"expected_head_entries":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"target":{"$ref":"#/$defs/RewindTargetId"}},"required":["target","expected_head_entries"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"properties":{"method":{"const":"shutdown","type":"string"},"params":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandEnvelope"}},"required":["command"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Request a list of completion candidates from the Worker.\n\nReply is sent on the same socket as `Event::Completions` (not\nbroadcast). The IPC server handles this directly and writes\nthe response straight back to the requesting socket.","properties":{"method":{"const":"list_completions","type":"string"},"params":{"additionalProperties":false,"properties":{"context":{"anyOf":[{"$ref":"#/$defs/CompletionContext"},{"type":"null"}]},"kind":{"$ref":"#/$defs/CompletionKind"},"prefix":{"type":"string"},"request_id":{"description":"Client query generation. Echoed verbatim so identical-prefix ABA\nresponses cannot cross edits, target switches, or permission changes.","type":["string","null"]}},"required":["kind","prefix"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"List Workers visible to this Worker from durable Worker state and the spawned-child\nregistry. This is not a host-wide Worker universe query.","properties":{"method":{"const":"list_workers","type":"string"}},"required":["method"],"type":"object"},{"additionalProperties":false,"description":"Restore a visible stopped/restorable Worker, or report that it is already\nlive. Missing state and not-visible state are distinct errors.","properties":{"method":{"const":"restore_worker","type":"string"},"params":{"additionalProperties":false,"properties":{"name":{"type":"string"}},"required":["name"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Register another existing Worker as a reciprocal peer of this Worker.\n\nThis is metadata/control state only: it does not ask the target's live\ncontroller for consent, and it must not grant delegated scope,\nspawned-child ownership, output cursors, or child lifecycle authority.","properties":{"method":{"const":"register_peer","type":"string"},"params":{"additionalProperties":false,"properties":{"name":{"type":"string"}},"required":["name"],"type":"object"}},"required":["method","params"],"type":"object"}]},"PasteArtifactAvailability":{"enum":["available","unavailable","integrity_failed"],"type":"string"},"PasteArtifactMediaType":{"description":"One typed piece of a user submission.\n\n`Method::Submit` and `Event::UserMessage` carry `Vec`. Dumb\nclients (CLI piping, scripts) only need to produce a single\n`Segment::Text`; richer clients (TUI / GUI) construct typed atoms\n(paste chips, file refs) and\nsend them through directly so the Worker side never has to re-parse a\nflattened string.\n\nForward compat: payloads with unknown `kind` deserialize to\n`Segment::Unknown`. Worker treats this the same as known-but-unresolved\nvariants — emits an alert and inserts a `[unknown input segment]`\nplaceholder into the LLM context so neither user nor LLM is blind to\nthe dropped intent.","enum":["text_plain_utf8"],"type":"string"},"PasteArtifactRef":{"additionalProperties":false,"description":"Session-owned reference to a large pasted-input artifact.\n\nThe reference contains only bounded integrity and provenance metadata. The\nartifact body remains in session storage and is available to the model only\nthrough the scoped paste-artifact tools installed by Worker.","properties":{"artifact_id":{"type":"string"},"availability":{"$ref":"#/$defs/PasteArtifactAvailability","description":"Availability observed when this immutable reference was committed.\nReads revalidate storage and integrity rather than trusting this field."},"byte_len":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"char_count":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"created_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"line_count":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"media_type":{"$ref":"#/$defs/PasteArtifactMediaType"},"sha256":{"type":"string"},"source_entry_id":{"type":"string"}},"required":["artifact_id","created_at_ms","media_type","availability","byte_len","char_count","line_count","sha256","source_entry_id"],"type":"object"},"PendingSubmissionSummary":{"additionalProperties":false,"description":"Bounded public projection of one pending submission. Only a short display\npreview is exposed; full payload segments and provenance remain in the log.","properties":{"accepted_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"byte_len":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"preview":{"description":"Single-line display text, at most 240 Unicode scalars plus an ellipsis.\nAbsent on older runtimes. Never used to reconstruct the submitted input.","type":["string","null"]},"segment_count":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"submission_id":{"type":"string"}},"required":["submission_id","accepted_at_ms","segment_count","byte_len"],"type":"object"},"PendingSubmissionsSnapshot":{"additionalProperties":false,"description":"Revisioned session-owned FIFO projection used by snapshots and live events.","properties":{"head_id":{"default":null,"type":["string","null"]},"notification_count":{"default":0,"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"notification_previews":{"description":"Ordered display-only previews of waiting notifications, each bounded like\nPendingSubmissionSummary::preview. Older runtimes omit this field.","items":{"type":"string"},"type":"array"},"revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"submissions":{"default":[],"items":{"$ref":"#/$defs/PendingSubmissionSummary"},"type":"array"}},"required":["revision","head_id","notification_count","submissions"],"type":"object"},"Permission":{"description":"Permission lattice used by [`ScopeRule`].\n\nThe derived `Ord` instance follows declaration order, so\n`Read < Write`. Allow rules grant the stated level (and by extension\neverything below); deny rules cap the effective level **strictly\nbelow** the stated level.","enum":["read","write"],"type":"string"},"ReasoningConfig":{"description":"Public reasoning configuration resolved for the model client.\n\nThis deliberately exposes only the effective provider control, not the\nWorker manifest or provider credentials. The tagged shape prevents token\nbudgets from being presented as effort labels.","oneOf":[{"additionalProperties":false,"properties":{"effort":{"type":"string"},"kind":{"const":"effort","type":"string"}},"required":["kind","effort"],"type":"object"},{"additionalProperties":false,"properties":{"budget_tokens":{"format":"int32","maximum":2147483647.0,"minimum":-2147483648.0,"type":"integer"},"kind":{"const":"budget_tokens","type":"string"}},"required":["kind","budget_tokens"],"type":"object"}]},"RewindSummary":{"additionalProperties":false,"properties":{"discarded_entries":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"tool_side_effect_warning":{"type":"boolean"},"truncated_to_entries":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["truncated_to_entries","discarded_entries","tool_side_effect_warning"],"type":"object"},"RewindTarget":{"additionalProperties":false,"properties":{"disabled_reason":{"type":["string","null"]},"eligible":{"type":"boolean"},"expected_head_entries":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"id":{"$ref":"#/$defs/RewindTargetId"},"preview":{"type":"string"},"timestamp_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"truncate_entries":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"turn_index":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"warning":{"type":["string","null"]}},"required":["id","expected_head_entries","truncate_entries","turn_index","preview","eligible"],"type":"object"},"RewindTargetId":{"additionalProperties":false,"properties":{"segment_id":{"type":"string"},"user_input_entry_index":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["segment_id","user_input_entry_index"],"type":"object"},"RunFailureKind":{"enum":["engine","compaction"],"type":"string"},"RunResult":{"oneOf":[{"enum":["finished","paused","limit_reached"],"type":"string"},{"const":"cancelled","description":"The logical Run ended because the user intentionally cancelled it after\nobservable work. Unlike `RolledBack`, committed history is retained.","type":"string"},{"const":"rolled_back","description":"The accepted Method::Submit produced no assistant/tool output before\nuser interruption, so the Worker rolled the submit-time turn state back\nto its pre-submit snapshot. Clients should treat the Worker as Idle and\nrestore the just-submitted input into the editable composer if desired.","type":"string"}]},"RunResumeSource":{"enum":["compaction","pause"],"type":"string"},"RunYieldReason":{"enum":["compaction"],"type":"string"},"ScopeRule":{"additionalProperties":false,"description":"A single allow or deny rule inside a scope configuration.","properties":{"permission":{"$ref":"#/$defs/Permission","description":"Permission level this rule grants (allow) or caps strictly below\n(deny)."},"recursive":{"default":true,"description":"When `false`, the rule only matches the target itself and its\ndirect children. Defaults to `true`.","type":"boolean"},"symlink_policy":{"$ref":"#/$defs/SymlinkPolicy","default":"resolved","description":"Which path identity an allow rule uses when symbolic links are\nencountered. Deny rules always inspect both identities."},"target":{"description":"Target path. Must be absolute by the time a `Scope` is built from\nthis rule — relative paths are resolved per-layer against the\nmanifest file's directory (cwd for overlay layers) before cascade\nmerge.","type":"string"}},"required":["target","permission","recursive","symlink_policy"],"type":"object"},"Segment":{"oneOf":[{"additionalProperties":false,"description":"Free-form text. The fallback every client can produce.","properties":{"content":{"type":"string"},"kind":{"const":"text","type":"string"}},"required":["kind","content"],"type":"object"},{"additionalProperties":false,"description":"Bracketed-paste capture from a TUI-style client. `id`, `chars`\nand `lines` carry the metadata needed to re-render a\n`[Clipboard #N | X chars, Y lines]` chip in `Event::UserMessage`\nre-broadcast.","properties":{"chars":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"content":{"type":"string"},"id":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"kind":{"const":"paste","type":"string"},"lines":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"}},"required":["kind","id","chars","lines","content"],"type":"object"},{"additionalProperties":false,"description":"Internal reference produced when Worker stores a large `Paste` before\ncommitting input. Clients may receive this in history/event projections;\nthe body is intentionally absent.","properties":{"artifact":{"$ref":"#/$defs/PasteArtifactRef"},"kind":{"const":"paste_artifact","type":"string"}},"required":["kind","artifact"],"type":"object"},{"additionalProperties":false,"description":"Client-local file uploaded into the owning Worker session before submit.\nThe Session Log stores only this immutable reference, never file bytes or\nthe client's local path.","properties":{"file":{"$ref":"#/$defs/UploadedFileRef"},"kind":{"const":"uploaded_file","type":"string"}},"required":["kind","file"],"type":"object"},{"additionalProperties":false,"description":"`@` file-system reference. Worker resolves readable files to\n`[File: ]` attachments and readable normal directories to shallow\n`[Dir: ]` listings; the flattened user text keeps the literal\n`@` placeholder either way.","properties":{"kind":{"const":"file_ref","type":"string"},"path":{"type":"string"}},"required":["kind","path"],"type":"object"},{"additionalProperties":false,"description":"Explicitly selected, source-qualified Feature invocation. The host never\npromotes free text to this variant and validates it against the installed\ndeclaration before execution.","properties":{"invocation":{"$ref":"#/$defs/FeatureInvocation"},"kind":{"const":"feature_invoke","type":"string"}},"required":["kind","invocation"],"type":"object"},{"additionalProperties":false,"description":"Source-qualified request for the host to start a Flow before committing\nthis user input. Runtime resolves it through Workspace authority and\nreplaces it with the entered state's instructions before Worker-side\ninput resolution.","properties":{"kind":{"const":"flow","type":"string"},"selector":{"type":"string"}},"required":["kind","selector"],"type":"object"},{"additionalProperties":false,"description":"Unknown variant from a newer client. Worker treats this as an\nunresolved input — surfaces an alert and inserts a placeholder.\nRound-trip is lossy: re-serializing yields `{\"kind\":\"unknown\"}`.","properties":{"kind":{"const":"unknown","type":"string"}},"required":["kind"],"type":"object"}]},"SessionContentPart":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"text","type":"string"},"text":{"type":"string"}},"required":["kind","text"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"refusal","type":"string"},"refusal":{"type":"string"}},"required":["kind","refusal"],"type":"object"}]},"SessionEntryProvenance":{"enum":["human_input","worker_input","flow_instruction","backend_instruction","model_output","tool_output","derived_summary","legacy_unknown"],"type":"string"},"SessionMessageRole":{"enum":["user","assistant"],"type":"string"},"SessionSnapshot":{"additionalProperties":false,"description":"Canonical, storage-independent projection of committed session history.\n\nWorker protocols expose this DTO instead of append-log records. New\nstorage variants can therefore be added without teaching every client how\nto replay the durable log format.","properties":{"entries":{"items":{"$ref":"#/$defs/SessionSnapshotEntry"},"type":"array"},"pending_submissions":{"$ref":"#/$defs/PendingSubmissionsSnapshot","default":{"head_id":null,"notification_count":0,"revision":0,"submissions":[]}}},"required":["entries","pending_submissions"],"type":"object"},"SessionSnapshotEntry":{"oneOf":[{"additionalProperties":false,"properties":{"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"user_input","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"segments":{"items":{"$ref":"#/$defs/Segment"},"type":"array"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","segments","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"content":{"items":{"$ref":"#/$defs/SessionContentPart"},"type":"array"},"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"message","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"role":{"$ref":"#/$defs/SessionMessageRole"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","role","content","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"arguments":{"type":"string"},"call_id":{"type":"string"},"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"tool_call","type":"string"},"name":{"type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","call_id","name","arguments","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"attachments":{"items":{"$ref":"#/$defs/SessionToolAttachment"},"type":"array"},"call_id":{"type":"string"},"content":{"type":["string","null"]},"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"is_error":{"type":"boolean"},"kind":{"const":"tool_result","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"summary":{"type":"string"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","call_id","summary","is_error","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"content":{"type":"string"},"data":true,"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"item_kind":{"type":"string"},"kind":{"const":"system_item","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","item_kind","content","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"description":"Durable, non-terminal control return from one logical Run. This is not a\ncompleted run and must not be used to infer the current Worker state.","properties":{"active_run_turn_count":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"run_yielded","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"reason":{"$ref":"#/$defs/RunYieldReason"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","reason","active_run_turn_count","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"description":"Durable boundary immediately before Engine execution resumes. The source\ndistinguishes automatic compaction continuation from an intentional\nuser-pause resume; it does not prove that later model output occurred.","properties":{"active_run_turn_count":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"run_resumed","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"source":{"$ref":"#/$defs/RunResumeSource"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","source","active_run_turn_count","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"description":"Durable terminal for an intentional cancellation. Consumers must not\npresent this lifecycle result as an execution failure.","properties":{"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"run_cancelled","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"failure":{"anyOf":[{"$ref":"#/$defs/RunFailureKind"},{"type":"null"}]},"kind":{"const":"run_error","type":"string"},"message":{"type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","message","entry_id","timestamp","provenance"],"type":"object"}]},"SessionToolAttachment":{"additionalProperties":false,"properties":{"attachment_id":{"description":"Stable, bounded identity derived from the owning Session entry, attachment\nposition, and immutable image bytes. It is a locator, not a bearer secret.","type":"string"},"byte_len":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"media_type":{"type":"string"}},"required":["attachment_id","media_type","byte_len"],"type":"object"},"SubmissionDisposition":{"description":"Durable acceptance result for one idempotent Submit request.","oneOf":[{"enum":["started"],"type":"string"},{"const":"queued","description":"Input is durably held in the session FIFO until eligible for activation.","type":"string"}]},"SubscriptionEvent":{"oneOf":[{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"payload":{"$ref":"#/$defs/SubscriptionEventPayload"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["subscription_id","payload"],"type":"object"},"event":{"const":"event","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"code":{"$ref":"#/$defs/SubscriptionTerminationCode"},"message":{"type":"string"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["subscription_id","code","message"],"type":"object"},"event":{"const":"subscription_closed","type":"string"}},"required":["event","data"],"type":"object"}]},"SubscriptionEventPayload":{"oneOf":[{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"worker":{"$ref":"#/$defs/SubscriptionWorker"}},"required":["worker"],"type":"object"},"event":{"const":"worker_upserted","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"runtime_id":{"type":["string","null"]},"worker_id":{"$ref":"#/$defs/SubscriptionWorkerId"}},"required":["worker_id"],"type":"object"},"event":{"const":"worker_removed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"event":{"$ref":"#/$defs/Event"},"worker_id":{"$ref":"#/$defs/SubscriptionWorkerId"}},"required":["worker_id","event"],"type":"object"},"event":{"const":"worker_protocol","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"workdir":{"$ref":"#/$defs/WorkspaceSubscriptionWorkdir"}},"required":["workdir"],"type":"object"},"event":{"const":"workdir_upserted","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"working_directory_id":{"$ref":"#/$defs/SubscriptionWorkdirId"}},"required":["working_directory_id"],"type":"object"},"event":{"const":"workdir_removed","type":"string"}},"required":["event","data"],"type":"object"}]},"SubscriptionId":{"type":"string"},"SubscriptionRejectionCode":{"enum":["invalid_request","unsupported_protocol_version","unsupported_selector","unauthorized","resource_not_found","capacity_exceeded","internal"],"type":"string"},"SubscriptionRequest":{"oneOf":[{"additionalProperties":false,"properties":{"method":{"const":"subscribe_events","type":"string"},"params":{"additionalProperties":false,"properties":{"request_id":{"$ref":"#/$defs/SubscriptionRequestId"},"selector":{"$ref":"#/$defs/EventSubscriptionSelector"}},"required":["request_id","selector"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"properties":{"method":{"const":"unsubscribe_events","type":"string"},"params":{"additionalProperties":false,"properties":{"request_id":{"$ref":"#/$defs/SubscriptionRequestId"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["request_id","subscription_id"],"type":"object"}},"required":["method","params"],"type":"object"}]},"SubscriptionRequestId":{"type":"string"},"SubscriptionResponse":{"oneOf":[{"additionalProperties":false,"properties":{"payload":{"additionalProperties":false,"properties":{"request_id":{"$ref":"#/$defs/SubscriptionRequestId"},"selector":{"$ref":"#/$defs/EventSubscriptionSelector"},"snapshot":{"$ref":"#/$defs/SubscriptionSnapshot"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["request_id","subscription_id","selector","snapshot"],"type":"object"},"result":{"const":"subscribed","type":"string"}},"required":["result","payload"],"type":"object"},{"additionalProperties":false,"properties":{"payload":{"additionalProperties":false,"properties":{"request_id":{"$ref":"#/$defs/SubscriptionRequestId"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["request_id","subscription_id"],"type":"object"},"result":{"const":"unsubscribed","type":"string"}},"required":["result","payload"],"type":"object"},{"additionalProperties":false,"properties":{"payload":{"additionalProperties":false,"properties":{"code":{"$ref":"#/$defs/SubscriptionRejectionCode"},"message":{"type":"string"},"request_id":{"$ref":"#/$defs/SubscriptionRequestId"},"subscription_id":{"anyOf":[{"$ref":"#/$defs/SubscriptionId"},{"type":"null"}]}},"required":["request_id","code","message"],"type":"object"},"result":{"const":"subscription_rejected","type":"string"}},"required":["result","payload"],"type":"object"}]},"SubscriptionSnapshot":{"oneOf":[{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"workers":{"items":{"$ref":"#/$defs/SubscriptionWorker"},"type":"array"}},"required":["workers"],"type":"object"},"topic":{"const":"workers","type":"string"}},"required":["topic","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"events":{"items":{"$ref":"#/$defs/Event"},"type":"array"},"worker_id":{"$ref":"#/$defs/SubscriptionWorkerId"}},"required":["worker_id","events"],"type":"object"},"topic":{"const":"worker_protocol","type":"string"}},"required":["topic","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"workdirs":{"items":{"$ref":"#/$defs/WorkspaceSubscriptionWorkdir"},"type":"array"}},"required":["workdirs"],"type":"object"},"topic":{"const":"workspace_workdirs","type":"string"}},"required":["topic","data"],"type":"object"}]},"SubscriptionTerminationCode":{"enum":["lagged","resource_gone","unauthorized","server_shutdown"],"type":"string"},"SubscriptionWorkdirId":{"type":"string"},"SubscriptionWorker":{"additionalProperties":false,"properties":{"availability":{"$ref":"#/$defs/SubscriptionWorkerAvailability","default":"observed","description":"Freshness of the Runtime-backed observation carried by this projection.\n`Unavailable` preserves catalog membership without claiming that execution stopped."},"display_name":{"type":["string","null"]},"has_running_internal_workers":{"default":false,"type":"boolean"},"job":{"anyOf":[{"$ref":"#/$defs/SubscriptionWorkerJob"},{"type":"null"}],"description":"Backend-owned Job binding. Runtime producers leave this unset; the\nWorkspace projection enriches both snapshots and live updates."},"profile":{"type":["string","null"]},"resource_key":{"description":"Workspace-scoped canonical resource key. Runtime producers leave this unset;\nWorkspace-facing projections must populate it before publishing the Worker.","type":["string","null"]},"runtime_id":{"description":"Set by the Workspace Server when projecting a Runtime-owned Worker to clients.\nRuntime producers leave this unset because the connection identifies the Runtime.","type":["string","null"]},"state":{"$ref":"#/$defs/SubscriptionWorkerState","description":"Runtime catalog lifecycle compatibility projection; not foreground-state authority."},"workdir_attachments":{"items":{"$ref":"#/$defs/SubscriptionWorkerWorkdirAttachment"},"type":"array"},"worker_id":{"$ref":"#/$defs/SubscriptionWorkerId"},"worker_state":{"anyOf":[{"$ref":"#/$defs/WorkerStateSnapshot"},{"type":"null"}],"description":"Latest foreground state observed from the Worker. This remains\nabsent until an authoritative Worker snapshot/event has been applied."},"workspace_id":{"type":["string","null"]}},"required":["worker_id","state","availability","has_running_internal_workers"],"type":"object"},"SubscriptionWorkerAvailability":{"description":"Whether the Workspace Server has a current Runtime observation for a Worker.\n\n`Unavailable` deliberately does not imply `Stopped`: it means the last observed\nlifecycle state is retained while the Runtime observation source is unavailable.","enum":["observed","unavailable"],"type":"string"},"SubscriptionWorkerId":{"type":"string"},"SubscriptionWorkerIds":{"description":"A bounded, canonical worker-id set. Ordering on the wire and in equality/hash\nsemantics is lexical, so equivalent selectors aggregate to one upstream key.","items":{"$ref":"#/$defs/SubscriptionWorkerId"},"type":"array"},"SubscriptionWorkerJob":{"additionalProperties":false,"properties":{"attempt_id":{"type":"string"},"job_id":{"type":"string"},"purpose":{"type":"string"}},"required":["job_id","attempt_id","purpose"],"type":"object"},"SubscriptionWorkerProtocolMethod":{"additionalProperties":false,"properties":{"method":{"$ref":"#/$defs/Method"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["subscription_id","method"],"type":"object"},"SubscriptionWorkerState":{"enum":["idle","running","paused","stopped"],"type":"string"},"SubscriptionWorkerWorkdirAttachment":{"additionalProperties":false,"properties":{"alias":{"description":"Stable Worker-local routing alias.","type":"string"},"repository_key":{"type":["string","null"]},"working_directory_id":{"$ref":"#/$defs/SubscriptionWorkdirId"}},"required":["alias","working_directory_id"],"type":"object"},"SymlinkPolicy":{"description":"Symbolic-link identity used by one filesystem allow rule.\n\n`Resolved` is the least authority and the default: access is matched\nagainst the provider-resolved target. `Logical` intentionally grants the\npath as presented through the Workdir, even when it aliases another target.","enum":["resolved","logical"],"type":"string"},"ToolResultDisposition":{"enum":["success","error","interrupted","cancelled","outcome_unknown"],"type":"string"},"TurnResult":{"enum":["finished","paused"],"type":"string"},"UploadedFileAvailability":{"description":"Availability recorded for an uploaded client-local file.","enum":["available","unavailable","integrity_failed"],"type":"string"},"UploadedFileRef":{"additionalProperties":false,"description":"Session-owned immutable reference to a client-local uploaded file.\n\nUpload transports return an unbound reference. Worker fills\n`source_entry_id` immediately before the containing user input is committed;\ncommitted Session Log and public snapshot records therefore always retain\nthe durable source-entry identity without storing the file body.","properties":{"artifact_id":{"type":"string"},"availability":{"$ref":"#/$defs/UploadedFileAvailability"},"byte_len":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"created_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"file_name":{"type":"string"},"media_type":{"type":"string"},"sha256":{"type":"string"},"source_entry_id":{"type":["string","null"]}},"required":["artifact_id","file_name","media_type","created_at_ms","availability","byte_len","sha256"],"type":"object"},"WorkerBusyState":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"run","type":"string"},"state":{"$ref":"#/$defs/WorkerRunState"}},"required":["kind","state"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"maintenance","type":"string"},"state":{"$ref":"#/$defs/WorkerMaintenanceState"}},"required":["kind","state"],"type":"object"}]},"WorkerCommandAcknowledgement":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandKind"},"command_id":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"disposition":{"$ref":"#/$defs/WorkerCommandDisposition"},"state":{"$ref":"#/$defs/WorkerStateSnapshot","description":"The complete authoritative state observed after command admission."}},"required":["command_id","command","disposition","state"],"type":"object"},"WorkerCommandDisposition":{"enum":["accepted","stale_command_id","conflict","invalid_state"],"type":"string"},"WorkerCommandEnvelope":{"additionalProperties":false,"description":"Caller-owned identity for one state-changing Worker command.\n\nA controller accepts command ids in strictly increasing order. Exact retries\nof an accepted id must retain the same command kind.","properties":{"command_id":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["command_id"],"type":"object"},"WorkerCommandKind":{"enum":["resume","cancel","pause","compact","shutdown"],"type":"string"},"WorkerEvent":{"description":"Typed lifecycle events sent from a child Worker to its parent.\n\nDelivered as `Method::WorkerEvent` over the parent's Unix socket. The\nparent Controller always applies variant-specific side effects\n(registry / worker-allocation updates). Agent-visible variants are also\nqueued into the notification buffer; control-plane-only variants are\nnot injected into the parent's LLM context.\n\nTransport is fire-and-forget; receivers must tolerate out-of-order\ndelivery (e.g. `TurnEnded` arriving after `ShutDown` for the same\nchild Worker).","oneOf":[{"additionalProperties":false,"description":"Child finished one turn and is back to IDLE.","properties":{"kind":{"const":"turn_ended","type":"string"},"worker_name":{"type":"string"}},"required":["kind","worker_name"],"type":"object"},{"additionalProperties":false,"description":"Engine execution error occurred inside the child's turn.\n\nLimited to worker runtime failures (provider / tool errors) —\ndoes not include transient method-rejection responses such as\n`AlreadyRunning`.","properties":{"kind":{"const":"errored","type":"string"},"message":{"type":"string"},"worker_name":{"type":"string"}},"required":["kind","worker_name","message"],"type":"object"},{"additionalProperties":false,"description":"Child has stopped (controller loop is exiting).","properties":{"kind":{"const":"shut_down","type":"string"},"worker_name":{"type":"string"}},"required":["kind","worker_name"],"type":"object"},{"additionalProperties":false,"description":"Child SubWorker sub-delegated scope to a grandchild SubWorker via `SubWorkerSpawn`.\n\nControl-plane only: receivers apply registry side effects and\npropagate upward, but do not expose this as an agent notification.\n\nThe parent uses this to add the grandchild to its own\n`spawned_workers.json` so it can manage the grandchild directly\neven if the intermediate child dies. The parent then re-fires\nthis event upward (if it has a parent of its own) to maintain\nthe chain to root.","properties":{"kind":{"const":"scope_sub_delegated","type":"string"},"parent_worker":{"description":"Sub-delegating Worker (= the sender itself).","type":"string"},"scope":{"description":"Scope delegated to the grandchild.","items":{"$ref":"#/$defs/ScopeRule"},"type":"array"},"sub_socket":{"description":"Unix-socket path where the grandchild is reachable.","type":"string"},"sub_worker":{"description":"Name of the grandchild Worker.","type":"string"}},"required":["kind","parent_worker","sub_worker","sub_socket","scope"],"type":"object"}]},"WorkerMaintenanceState":{"enum":["compacting"],"type":"string"},"WorkerRunState":{"enum":["running","pausing","paused","cancelling"],"type":"string"},"WorkerState":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"idle","type":"string"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"busy","type":"string"},"state":{"$ref":"#/$defs/WorkerBusyState"}},"required":["kind","state"],"type":"object"}]},"WorkerStateSnapshot":{"additionalProperties":false,"properties":{"last_command_id":{"description":"Highest lifecycle command id observed by this controller instance.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"last_finished_submission_request_id":{"description":"Most recent Submit request whose controller Run has returned and whose\npost-Run state transition has been published. This is a teardown fence,\nnot a successful-result claim; cancelled and failed Runs may also finish.","type":["string","null"]},"state":{"$ref":"#/$defs/WorkerState"}},"required":["last_command_id","state"],"type":"object"},"WorkerStatus":{"enum":["idle","running","paused","stopped"],"type":"string"},"WorkspaceSubscriptionWorkdir":{"additionalProperties":false,"description":"Workspace-facing Workdir summary. Backend-generated Repository UUIDs never\nenter this DTO; Workspace Server must resolve the required Repository key.","properties":{"repository_key":{"type":"string"},"state":{"type":"string"},"working_directory_id":{"$ref":"#/$defs/SubscriptionWorkdirId"}},"required":["working_directory_id","repository_key","state"],"type":"object"}},"$schema":"https://json-schema.org/draft/2020-12/schema","description":"Direction is explicit at the outer envelope, while concrete message kinds\nremain typed within each lane.","oneOf":[{"additionalProperties":false,"properties":{"frame":{"const":"request","type":"string"},"message":{"$ref":"#/$defs/SubscriptionRequest"},"protocol_version":{"format":"uint16","maximum":65535.0,"minimum":0.0,"type":"integer"}},"required":["frame","message","protocol_version"],"type":"object"},{"additionalProperties":false,"properties":{"frame":{"const":"response","type":"string"},"message":{"$ref":"#/$defs/SubscriptionResponse"},"protocol_version":{"format":"uint16","maximum":65535.0,"minimum":0.0,"type":"integer"}},"required":["frame","message","protocol_version"],"type":"object"},{"additionalProperties":false,"properties":{"frame":{"const":"event","type":"string"},"message":{"$ref":"#/$defs/SubscriptionEvent"},"protocol_version":{"format":"uint16","maximum":65535.0,"minimum":0.0,"type":"integer"}},"required":["frame","message","protocol_version"],"type":"object"},{"additionalProperties":false,"properties":{"frame":{"const":"worker_protocol","type":"string"},"message":{"$ref":"#/$defs/SubscriptionWorkerProtocolMethod"},"protocol_version":{"format":"uint16","maximum":65535.0,"minimum":0.0,"type":"integer"}},"required":["frame","message","protocol_version"],"type":"object"}],"title":"SubscriptionFrame"}; export const RUST_SERIALIZED_SUBSCRIPTION_FRAME_FIXTURES: readonly unknown[] = [{"protocol_version":2,"frame":"response","message":{"result":"subscribed","payload":{"request_id":"fixture-request","subscription_id":"fixture-subscription","selector":{"topic":"workspace_workers"},"snapshot":{"topic":"workers","data":{"workers":[{"worker_id":"fixture-worker","runtime_id":"fixture-runtime","resource_key":"fixture-worker-resource","availability":"observed","state":"idle","has_running_internal_workers":false,"workspace_id":"fixture-workspace","workdir_attachments":[{"alias":"external","working_directory_id":"fixture-external-workdir"}]}]}}}}},{"protocol_version":2,"frame":"event","message":{"event":"event","data":{"subscription_id":"fixture-subscription","payload":{"event":"worker_protocol","data":{"worker_id":"fixture-worker","event":{"event":"pending_submissions_changed","data":{"pending":{"revision":0,"notification_count":0,"head_id":null,"submissions":[]}}}}}}}},{"protocol_version":2,"frame":"event","message":{"event":"event","data":{"subscription_id":"fixture-subscription","payload":{"event":"worker_protocol","data":{"worker_id":"fixture-worker","event":{"event":"snapshot","data":{"session":{"pending_submissions":{"revision":0,"notification_count":0,"head_id":null,"submissions":[]},"entries":[]},"greeting":{"worker_name":"fixture-worker","cwd":"/fixture","provider":"openai_responses","model":"gpt-6-astra","reasoning":{"kind":"effort","effort":"high"},"scope_summary":"fixture scope","tools":["Read"],"context_window":272000,"context_tokens":142000,"context_usage":{"tokens":142000,"source":"measured"}},"state":{"last_command_id":0,"state":{"kind":"idle"}},"internal_workers":[{"worker":{"session_id":"fixture-child-session","name":"fixture-child","parent_session_id":"fixture-parent-session","kind":"sub_worker"},"revision":1,"session":{"pending_submissions":{"revision":0,"notification_count":0,"head_id":null,"submissions":[]},"entries":[]},"greeting":{"worker_name":"fixture-worker","cwd":"/fixture","provider":"openai_responses","model":"gpt-6-astra","reasoning":{"kind":"effort","effort":"high"},"scope_summary":"fixture scope","tools":["Read"],"context_window":272000,"context_tokens":142000,"context_usage":{"tokens":142000,"source":"measured"}},"status":"idle"}]}}}}}}}]; const utf8 = new TextEncoder(); diff --git a/web/workspace/src/lib/generated/protocol.ts b/web/workspace/src/lib/generated/protocol.ts index 94e91de69..926075657 100644 --- a/web/workspace/src/lib/generated/protocol.ts +++ b/web/workspace/src/lib/generated/protocol.ts @@ -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, } | { "kind": "worker_file" } | { "kind": "client_file" }; + +export type InvocationCompletion = { "kind": "none" } | { "kind": "static", values: Array, } | { "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, display_name: string, description: string, syntax: FeatureInvocationSyntax, arguments: Array, 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, }; + +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, } } | { "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, } } | { "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, } } | { "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, } } | { "event": "rewind_targets", "data": { head_entries: number, targets: Array, } } | { "event": "rewind_applied", "data": { session: SessionSnapshot, input: Array, 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, } } | { "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, } } | { "event": "rewind_targets", "data": { head_entries: number, targets: Array, } } | { "event": "rewind_applied", "data": { session: SessionSnapshot, input: Array, 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" }; diff --git a/web/workspace/src/lib/generated/repository-api.ts b/web/workspace/src/lib/generated/repository-api.ts index 052d8f330..abfc18435 100644 --- a/web/workspace/src/lib/generated/repository-api.ts +++ b/web/workspace/src/lib/generated/repository-api.ts @@ -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 diff --git a/web/workspace/src/lib/generated/runtime-api.ts b/web/workspace/src/lib/generated/runtime-api.ts index 2a3b02082..25f6a41ce 100644 --- a/web/workspace/src/lib/generated/runtime-api.ts +++ b/web/workspace/src/lib/generated/runtime-api.ts @@ -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; 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; identity: FeatureInvocationIdentity; invocation_id: string; name: string; }; + +export type FeatureInvocationDescriptor = { aliases?: Array; arguments?: Array; 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; } | { 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; } | { 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; 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; entries: Array; kind: unknown; prefix: string; runtime_id: string; worker_id: string; }; +export type RuntimeWorkerCompletionsResult = { diagnostics?: Array; entries: Array; kind: CompletionKind; prefix: string; runtime_id: string; worker_id: string; }; -export type RuntimeWorkerInputRequest = { content: string; kind?: string | null; segments?: Array | null; }; +export type RuntimeWorkerInputRequest = { content: string; kind?: string | null; segments?: Array | null; }; export type RuntimeWorkerInputResult = { diagnostics?: Array; runtime_id: string; state: WorkerOperationState; worker_id: string; }; @@ -127,6 +159,8 @@ export type RuntimeWorkerSummary = { diagnostics?: Array; 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; }; diff --git a/web/workspace/src/lib/generated/ticket-api.ts b/web/workspace/src/lib/generated/ticket-api.ts index 24d0a4a79..0c9515329 100644 --- a/web/workspace/src/lib/generated/ticket-api.ts +++ b/web/workspace/src/lib/generated/ticket-api.ts @@ -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 diff --git a/web/workspace/src/lib/generated/worker-launch-api.ts b/web/workspace/src/lib/generated/worker-launch-api.ts index 42881f6ce..36272cc8b 100644 --- a/web/workspace/src/lib/generated/worker-launch-api.ts +++ b/web/workspace/src/lib/generated/worker-launch-api.ts @@ -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; 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; }; diff --git a/web/workspace/src/lib/workspace/api/workers.test.ts b/web/workspace/src/lib/workspace/api/workers.test.ts index f76b0b404..247b7f41c 100644 --- a/web/workspace/src/lib/workspace/api/workers.test.ts +++ b/web/workspace/src/lib/workspace/api/workers.test.ts @@ -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"); +}); diff --git a/web/workspace/src/lib/workspace/api/workers.ts b/web/workspace/src/lib/workspace/api/workers.ts index 71c76c9f3..0cdb72e60 100644 --- a/web/workspace/src/lib/workspace/api/workers.ts +++ b/web/workspace/src/lib/workspace/api/workers.ts @@ -827,6 +827,45 @@ function uploadedFile( }; } +function invocationValue( + value: unknown, + label: string, +): Extract["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["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`) }; diff --git a/web/workspace/src/lib/workspace/console/ComposerInput.browser.spec.ts b/web/workspace/src/lib/workspace/console/ComposerInput.browser.spec.ts index db0498320..8e30a1786 100644 --- a/web/workspace/src/lib/workspace/console/ComposerInput.browser.spec.ts +++ b/web/workspace/src/lib/workspace/console/ComposerInput.browser.spec.ts @@ -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>>> = []; + const resolver = vi.fn(() => { const result = deferred>(); 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>(); + 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>(); + 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"]); +}); diff --git a/web/workspace/src/lib/workspace/console/ComposerInput.svelte b/web/workspace/src/lib/workspace/console/ComposerInput.svelte index 7241e14da..ff048e37f 100644 --- a/web/workspace/src/lib/workspace/console/ComposerInput.svelte +++ b/web/workspace/src/lib/workspace/console/ComposerInput.svelte @@ -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; + resolveFeatureCompletions?: (prefix: string, signal: AbortSignal) => Promise; + resolveFeatureArgumentCompletions?: ( + context: CompletionContext, + prefix: string, + signal: AbortSignal, + ) => Promise; + 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(); let composerHistory = new ComposerHistory(); let restoringHistory = false; let nextPasteId = 1; let nextPasteKey = 1; + let nextTypedKey = 1; + const selectedFeatureInvocations = new Map(); + type SelectedInvocation = { from: number; descriptor: FeatureInvocationDescriptor }; + const selectInvocation = StateEffect.define(); + const restoreSelectedInvocations = StateEffect.define(); + const selectedInvocationState = StateField.define({ + 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>({ + 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(); + 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 { + 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[] = []; + 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); diff --git a/web/workspace/src/lib/workspace/console/composer-command.ts b/web/workspace/src/lib/workspace/console/composer-command.ts index 10a219118..781e37faf 100644 --- a/web/workspace/src/lib/workspace/console/composer-command.ts +++ b/web/workspace/src/lib/workspace/console/composer-command.ts @@ -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: diff --git a/web/workspace/src/lib/workspace/console/composer-completion.test.ts b/web/workspace/src/lib/workspace/console/composer-completion.test.ts index b30738fef..2e7417745 100644 --- a/web/workspace/src/lib/workspace/console/composer-completion.test.ts +++ b/web/workspace/src/lib/workspace/console/composer-completion.test.ts @@ -23,7 +23,7 @@ function assertEquals(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/"); +}); diff --git a/web/workspace/src/lib/workspace/console/composer-completion.ts b/web/workspace/src/lib/workspace/console/composer-completion.ts index 4098f626f..7fc532b47 100644 --- a/web/workspace/src/lib/workspace/console/composer-completion.ts +++ b/web/workspace/src/lib/workspace/console/composer-completion.ts @@ -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 = { "?": "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, }; } diff --git a/web/workspace/src/lib/workspace/console/composer-delivery.test.ts b/web/workspace/src/lib/workspace/console/composer-delivery.test.ts index ebf3bfa32..25faa2a16 100644 --- a/web/workspace/src/lib/workspace/console/composer-delivery.test.ts +++ b/web/workspace/src/lib/workspace/console/composer-delivery.test.ts @@ -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, diff --git a/web/workspace/src/lib/workspace/console/composer-delivery.ts b/web/workspace/src/lib/workspace/console/composer-delivery.ts index f00f088e7..d79cf0c82 100644 --- a/web/workspace/src/lib/workspace/console/composer-delivery.ts +++ b/web/workspace/src/lib/workspace/console/composer-delivery.ts @@ -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(); + + 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 && diff --git a/web/workspace/src/lib/workspace/console/composer-draft.test.ts b/web/workspace/src/lib/workspace/console/composer-draft.test.ts index 49aefe764..356233a6b 100644 --- a/web/workspace/src/lib/workspace/console/composer-draft.test.ts +++ b/web/workspace/src/lib/workspace/console/composer-draft.test.ts @@ -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 " }, diff --git a/web/workspace/src/lib/workspace/console/composer-draft.ts b/web/workspace/src/lib/workspace/console/composer-draft.ts index 7f186ba04..fd413e791 100644 --- a/web/workspace/src/lib/workspace/console/composer-draft.ts +++ b/web/workspace/src/lib/workspace/console/composer-draft.ts @@ -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, +): 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, candidateTextPastes: readonly ComposerTextPaste[] = [], + typedRegistry: ReadonlyMap = 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; } diff --git a/web/workspace/src/lib/workspace/console/composer-history.ts b/web/workspace/src/lib/workspace/console/composer-history.ts index 6fa2770fc..7a258cb03 100644 --- a/web/workspace/src/lib/workspace/console/composer-history.ts +++ b/web/workspace/src/lib/workspace/console/composer-history.ts @@ -29,7 +29,7 @@ type ComposerHistoryStorage = Pick; 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 | 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; } diff --git a/web/workspace/src/lib/workspace/console/composer-invocation.test.ts b/web/workspace/src/lib/workspace/console/composer-invocation.test.ts new file mode 100644 index 000000000..178a3bce2 --- /dev/null +++ b/web/workspace/src/lib/workspace/console/composer-invocation.test.ts @@ -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"); + } +}); diff --git a/web/workspace/src/lib/workspace/console/composer-invocation.ts b/web/workspace/src/lib/workspace/console/composer-invocation.ts new file mode 100644 index 000000000..598f822b6 --- /dev/null +++ b/web/workspace/src/lib/workspace/console/composer-invocation.ts @@ -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; + } +} diff --git a/web/workspace/src/lib/workspace/console/file-completions.test.ts b/web/workspace/src/lib/workspace/console/file-completions.test.ts index 323757201..8dca9d882 100644 --- a/web/workspace/src/lib/workspace/console/file-completions.test.ts +++ b/web/workspace/src/lib/workspace/console/file-completions.test.ts @@ -1,70 +1,79 @@ import { FileCompletions } from "./file-completions.ts"; -import { deepStrictEqual as assertEquals, rejects } from "node:assert/strict"; - -async function assertRejects( - fn: () => Promise, - _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 }; -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/, + ); }); diff --git a/web/workspace/src/lib/workspace/console/file-completions.ts b/web/workspace/src/lib/workspace/console/file-completions.ts index 4ad688fc7..20044d510 100644 --- a/web/workspace/src/lib/workspace/console/file-completions.ts +++ b/web/workspace/src/lib/workspace/console/file-completions.ts @@ -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 | 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 { 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); } } diff --git a/web/workspace/src/lib/workspace/console/model.test.ts b/web/workspace/src/lib/workspace/console/model.test.ts index 0f6e54dc4..4642618a5 100644 --- a/web/workspace/src/lib/workspace/console/model.test.ts +++ b/web/workspace/src/lib/workspace/console/model.test.ts @@ -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")'); +}); diff --git a/web/workspace/src/lib/workspace/console/model.ts b/web/workspace/src/lib/workspace/console/model.ts index d9ee2f4f3..d522d562e 100644 --- a/web/workspace/src/lib/workspace/console/model.ts +++ b/web/workspace/src/lib/workspace/console/model.ts @@ -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": diff --git a/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts b/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts index ba0543d4d..5ab4b36f1 100644 --- a/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts +++ b/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts @@ -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", ); diff --git a/web/workspace/src/lib/workspace/protocol-validator.test.ts b/web/workspace/src/lib/workspace/protocol-validator.test.ts index a87a1e190..453df9808 100644 --- a/web/workspace/src/lib/workspace/protocol-validator.test.ts +++ b/web/workspace/src/lib/workspace/protocol-validator.test.ts @@ -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'); +}); diff --git a/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte index 683cd00cb..2459cecb9 100644 --- a/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte @@ -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(EMPTY_DRAFT); let attachments = $state([]); let nextAttachmentId = 1; + const stagedAttachmentPaths = new Map(); + const uploadReservations = new Map(); let fileInput: HTMLInputElement | null = null; let isDraggingFiles = $state(false); + const admissions = new ComposerAdmissions(); + let admission = $state.raw(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([]); 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(); + + 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([]); 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, + ): 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): 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 { + 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 { 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}