Synced from monorepo
Synced from monorepo Changes: - Temporarily disable session share link creation in the TUI - Do not approve plan on empty Enter from the revise prompt - Expose chat product Skills via ACP available_commands_update - Return immediately from a blocking wait on an already-completed ACP task - Split headless pager module for clearer structure - Stop git worktree prune from removing user registrations on resume - Use compaction sampler tokenizer for item token counts - Opt-in extra root CAs via GROK_EXTRA_CA_BUNDLE - Cancel all session subagents when the user stops - Let the session persistence actor exit when its session ends - Make fullscreen terminal resize much cheaper on long sessions - Report honestly from kill_task when an ACP task does not exist - Hide /usage for external-auth deployments - Forward the history-load trailer’s computer_reason to the client - Remove ineffective no-op tool reminder - Declare slash-command screen-mode support in one place - Keep settings enum picker on the committed value until Enter - Reap a PTY’s full process tree - Stream tool calls from headless mode over ACP - Bridge gateway task lifecycle to ACP for chat session background tasks - Don’t warn about truncated history on a suppressed replay - Fit full-replace summarizer input and recover on context-length errors - Stop dropping agents over an unrecognized frontmatter color - Add /undo as a slash alias for /rewind - Harden sleep/wake token-refresh paths against forced re-login - Add session/list ACP method - Give each sampling backend its own conversion module - Treat an unenrolled child process as a lint error - Suppress the cancelled marker on send-now wake turns - Stop tearing down Roslyn on every edit, and read C# diagnostics Source-Revision: 2a28b4a86cfc4a4c133c35b7fc2a6a9964387c39
This commit is contained in:
parent
500129c714
commit
dd04f397b1
367 changed files with 29489 additions and 10051 deletions
|
|
@ -793,7 +793,7 @@ pub struct AgentDefinition {
|
|||
pub isolation: Option<IsolationMode>,
|
||||
#[serde(default)]
|
||||
pub background: Option<bool>,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "deserialize_agent_color")]
|
||||
pub color: Option<AgentColor>,
|
||||
#[serde(default)]
|
||||
pub initial_prompt: Option<String>,
|
||||
|
|
@ -1060,11 +1060,13 @@ const _: () =
|
|||
Eq,
|
||||
Deserialize,
|
||||
serde::Serialize,
|
||||
AsRefStr,
|
||||
EnumString,
|
||||
IntoStaticStr,
|
||||
strum::EnumCount,
|
||||
)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
#[strum(serialize_all = "lowercase")]
|
||||
#[strum(serialize_all = "lowercase", ascii_case_insensitive)]
|
||||
pub enum AgentColor {
|
||||
Red,
|
||||
Blue,
|
||||
|
|
@ -1081,6 +1083,35 @@ impl AgentColor {
|
|||
];
|
||||
}
|
||||
const _: () = assert!(AgentColor::VALID_VALUES.len() == <AgentColor as strum::EnumCount>::COUNT);
|
||||
/// Never fails: `color` is decorative, but a rejected value fails the whole
|
||||
/// frontmatter parse, and discovery skips agents that fail to parse — so a
|
||||
/// typo'd or hex color would silently make the agent unspawnable.
|
||||
///
|
||||
/// Frontmatter is only ever decoded by `serde_yaml`, so the intermediate value
|
||||
/// is captured as `serde_yaml::Value` (total for YAML — tagged scalars and
|
||||
/// maps with non-string keys included, which have no `serde_json::Value`
|
||||
/// form). Unrecognized values are dropped to `None` with a warning rather
|
||||
/// than mapped to a stand-in color the author never wrote.
|
||||
fn deserialize_agent_color<'de, D>(deserializer: D) -> Result<Option<AgentColor>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
use std::str::FromStr;
|
||||
let Some(value) = Option::<serde_yaml::Value>::deserialize(deserializer)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let parsed = value
|
||||
.as_str()
|
||||
.and_then(|name| AgentColor::from_str(name.trim()).ok());
|
||||
if parsed.is_none() {
|
||||
tracing::warn!(
|
||||
color = ?value,
|
||||
valid = ?AgentColor::VALID_VALUES,
|
||||
"unrecognized agent color, ignoring"
|
||||
);
|
||||
}
|
||||
Ok(parsed)
|
||||
}
|
||||
/// Agent memory scope. Distinct from `storage::MemoryScope` (global-vs-workspace write target).
|
||||
#[derive(
|
||||
Debug,
|
||||
|
|
@ -2096,8 +2127,10 @@ description: Minimal agent
|
|||
}
|
||||
for color in AgentColor::VALID_VALUES {
|
||||
let c = format!("---\nname: t\ndescription: t\ncolor: {color}\n---\n");
|
||||
assert!(
|
||||
AgentDefinition::parse(&c).unwrap().color.is_some(),
|
||||
let parsed = AgentDefinition::parse(&c).unwrap().color;
|
||||
assert_eq!(
|
||||
parsed.map(<&'static str>::from),
|
||||
Some(*color),
|
||||
"color: {color}"
|
||||
);
|
||||
}
|
||||
|
|
@ -2110,6 +2143,33 @@ description: Minimal agent
|
|||
}
|
||||
}
|
||||
#[test]
|
||||
fn unparseable_color_is_dropped_instead_of_dropping_the_agent() {
|
||||
for (declared, expected) in [
|
||||
("Purple", Some(AgentColor::Purple)),
|
||||
(" CYAN ", Some(AgentColor::Cyan)),
|
||||
("teal", None),
|
||||
("\"#ff0000\"", None),
|
||||
("chartreuse", None),
|
||||
("42", None),
|
||||
("[red, blue]", None),
|
||||
("!custom x", None),
|
||||
("{1: 2}", None),
|
||||
] {
|
||||
let c = format!("---\nname: t\ndescription: t\ncolor: {declared}\n---\n");
|
||||
let def = AgentDefinition::parse(&c)
|
||||
.unwrap_or_else(|e| panic!("color {declared} must not fail the parse: {e}"));
|
||||
assert_eq!(def.color, expected, "color: {declared}");
|
||||
assert_eq!(def.name, "t");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn absent_or_null_color_stays_none() {
|
||||
let def = AgentDefinition::parse("---\nname: t\ndescription: t\n---\n").unwrap();
|
||||
assert!(def.color.is_none());
|
||||
let def = AgentDefinition::parse("---\nname: t\ndescription: t\ncolor:\n---\n").unwrap();
|
||||
assert!(def.color.is_none());
|
||||
}
|
||||
#[test]
|
||||
fn test_parse_missing_name() {
|
||||
let content = r#"---
|
||||
description: No name
|
||||
|
|
|
|||
|
|
@ -399,14 +399,9 @@ fn all_subagents_with_plugins_and_home(
|
|||
if path.extension().and_then(|e| e.to_str()) != Some("md") {
|
||||
continue;
|
||||
}
|
||||
// Use frontmatter-only parsing for untrusted plugins
|
||||
let def = if plugin.trusted {
|
||||
AgentDefinition::from_file(&path).ok()
|
||||
} else {
|
||||
AgentDefinition::from_file_frontmatter_only(&path).ok()
|
||||
let Some(def) = load_plugin_agent_definition(plugin, &path) else {
|
||||
continue;
|
||||
};
|
||||
let Some(mut def) = def else { continue };
|
||||
def.plugin_name = Some(plugin.name.clone());
|
||||
|
||||
let qualified_name = format!("{}:{}", plugin.name, def.name);
|
||||
|
||||
|
|
@ -481,17 +476,11 @@ fn by_name_in_cwd_with_plugins_and_home(
|
|||
{
|
||||
for agent_dir in &plugin.agent_dirs {
|
||||
let agent_file = agent_dir.join(format!("{agent_name}.md"));
|
||||
if agent_file.is_file() {
|
||||
let load_fn = if plugin.trusted {
|
||||
AgentDefinition::from_file
|
||||
} else {
|
||||
AgentDefinition::from_file_frontmatter_only
|
||||
};
|
||||
if let Ok(mut def) = load_fn(&agent_file) {
|
||||
def.plugin_name = Some(plugin_name.to_string());
|
||||
substitute_plugin_vars(&mut def, plugin);
|
||||
return Some(def);
|
||||
}
|
||||
if agent_file.is_file()
|
||||
&& let Some(mut def) = load_plugin_agent_definition(plugin, &agent_file)
|
||||
{
|
||||
substitute_plugin_vars(&mut def, plugin);
|
||||
return Some(def);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -510,13 +499,7 @@ fn by_name_in_cwd_with_plugins_and_home(
|
|||
}
|
||||
if matches.len() == 1 {
|
||||
let (plugin, agent_file) = &matches[0];
|
||||
let load_fn = if plugin.trusted {
|
||||
AgentDefinition::from_file
|
||||
} else {
|
||||
AgentDefinition::from_file_frontmatter_only
|
||||
};
|
||||
if let Ok(mut def) = load_fn(agent_file) {
|
||||
def.plugin_name = Some(plugin.name.clone());
|
||||
if let Some(mut def) = load_plugin_agent_definition(plugin, agent_file) {
|
||||
substitute_plugin_vars(&mut def, plugin);
|
||||
return Some(def);
|
||||
}
|
||||
|
|
@ -533,6 +516,37 @@ fn by_name_in_cwd_with_plugins_and_home(
|
|||
None
|
||||
}
|
||||
|
||||
/// Load one plugin-provided agent file, tagged with its owning plugin.
|
||||
///
|
||||
/// Untrusted plugins are parsed frontmatter-only so their prompt body never
|
||||
/// reaches the model before the plugin is trusted. A parse failure drops the
|
||||
/// agent from discovery entirely, so it is logged rather than swallowed.
|
||||
fn load_plugin_agent_definition(
|
||||
plugin: &crate::plugins::LoadedPlugin,
|
||||
path: &Path,
|
||||
) -> Option<AgentDefinition> {
|
||||
let loaded = if plugin.trusted {
|
||||
AgentDefinition::from_file(path)
|
||||
} else {
|
||||
AgentDefinition::from_file_frontmatter_only(path)
|
||||
};
|
||||
match loaded {
|
||||
Ok(mut def) => {
|
||||
def.plugin_name = Some(plugin.name.clone());
|
||||
Some(def)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
plugin = %plugin.name,
|
||||
path = %path.display(),
|
||||
error = %e,
|
||||
"Failed to parse plugin agent definition, skipping"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Expand `${CLAUDE_PLUGIN_ROOT}` / `${CLAUDE_PLUGIN_DATA}` (and the Grok
|
||||
/// aliases) in a plugin agent's body so the model receives absolute paths,
|
||||
/// matching the expected load-time resolution for these variables.
|
||||
|
|
@ -1368,6 +1382,44 @@ mod tests {
|
|||
assert!(entries.iter().any(|e| e.name == "plugin-one:reviewer"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_agent_with_unrecognized_color_is_still_discovered() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let cwd = tmp.path().join("workspace");
|
||||
let home = tmp.path().join("home");
|
||||
fs::create_dir_all(&cwd).unwrap();
|
||||
fs::create_dir_all(&home).unwrap();
|
||||
|
||||
let plugin_root = tempfile::tempdir().unwrap();
|
||||
let plugin_agents = plugin_root.path().join("agents");
|
||||
fs::create_dir_all(&plugin_agents).unwrap();
|
||||
fs::write(
|
||||
plugin_agents.join("painter.md"),
|
||||
"---\nname: painter\ndescription: Plugin painter\ncolor: chartreuse\n---\nBody.\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let registry = make_plugin_registry("plugin-one", PluginScope::User, vec![plugin_agents]);
|
||||
let entries = all_subagents_with_plugins_and_home(
|
||||
&cwd,
|
||||
&HashMap::new(),
|
||||
Some(®istry),
|
||||
Some(&home),
|
||||
Some(&home.join(".grok")),
|
||||
);
|
||||
assert!(entries.iter().any(|e| e.name == "plugin-one:painter"));
|
||||
|
||||
let def = by_name_in_cwd_with_plugins_and_home(
|
||||
"plugin-one:painter",
|
||||
&cwd,
|
||||
Some(®istry),
|
||||
Some(&home),
|
||||
Some(&home.join(".grok")),
|
||||
)
|
||||
.expect("agent must resolve despite the unrecognized color");
|
||||
assert_eq!(def.color, None, "unrecognized color must be dropped");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_by_name_in_cwd_with_plugins_prefers_native_over_plugin_bare_name() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
|
|
|||
Loading…
Reference in a new issue