Synced from monorepo

Synced from monorepo

Changes:
- Workspace server: surface preview-proxy metrics through the hub metric pump
- Shell: reclaim a session’s retained state in one entry
- Shell: reclaim a session’s resident state in one entry
- Pager: withhold key event types from Alacritty builds that double keys
- Tools: cancel a session’s subagents when it closes
- Pager: keep the whole plan in scrollback and separate reasoning from output in minimal mode
- Pager: probe terminal version over DA2 and include it with feedback
- SuperGrok Plus: identity, CLI, and analytics tier surfaces
- Shell: inherit the session process scope into subagents
- Pager: build @-file-search matcher lazily on first use
- Tools: fix description and output contradictions in tool definitions
- Workspace: degrade @-file-search instead of aborting on thread exhaustion
- Tools: reap a session’s LSP servers when it closes
- Tools: fix contradictions and defects in tool descriptions, schemas, and harness pools
- MCP: reap stdio MCP children on session close
- Shell: reuse spawn-time skill discovery for session telemetry
- Tools: stop leaking shell-wrapper positional params into sourced scripts (fixes activate_conda under persistent/static shell)
- Shell: self-heal corrupt session-search SQLite cache
- Workspace: cap workspace-server tokio workers on many-core hosts
- Shell: reap a session’s child processes when it closes
- Crash handler: capture SIGABRT so panic-aborts leave crash reports
- CLI chat proxy: team-scoped Grok Code managed-config admin routes
- MCP: add CLI enable/disable for MCP servers
- Shell: cap tokio worker threads for startup thread demand
- Workspace: harden git_commit and add git_sync_base operation
- Circuit breaker: add feature-gated gRPC retry policy

Source-Revision: 2a818575225183d8ca915f5632a09b8067b5156a
This commit is contained in:
grokkybara[bot] 2026-07-28 22:50:19 +00:00
commit 5da6962e4a
192 changed files with 10337 additions and 3421 deletions

View file

@ -7,9 +7,12 @@ version = "0.1.0"
[features]
# Exposes force_half_open() and other test hooks for cross-crate tests.
test-hooks = []
# Adds `GrpcRetryPolicy` for classifying `tonic::Code` (pulls in tonic).
grpc = ["dep:tonic"]
[dependencies]
log = { workspace = true }
tonic = { workspace = true, optional = true }
[lints]
workspace = true

View file

@ -0,0 +1,116 @@
//! [`GrpcRetryPolicy`] — classifies a `tonic::Code` into a [`Disposition`], the
//! gRPC analogue of [`crate::RetryPolicy`]. Behind the `grpc` feature.
use crate::retry_policy::Disposition;
use tonic::Code;
/// Maps a gRPC [`Code`] to a [`Disposition`].
pub struct GrpcRetryPolicy {
retryable: &'static [Code],
}
impl GrpcRetryPolicy {
/// Retry only transient connection errors (`Unavailable`, `Unknown`);
/// excluding `Internal`/`DeadlineExceeded` avoids amplifying a sick peer.
pub const DEFAULT: Self = Self::new(&[Code::Unavailable, Code::Unknown]);
/// Permissive preset: also retry `Internal` and `DeadlineExceeded`.
pub const PERMISSIVE: Self = Self::new(&[
Code::Unavailable,
Code::Unknown,
Code::Internal,
Code::DeadlineExceeded,
]);
/// Construct from an explicit retryable-code set.
pub const fn new(retryable: &'static [Code]) -> Self {
Self { retryable }
}
/// Classify `code`. Returns `None` for `Code::Ok` (success, not an error).
pub fn classify(&self, code: Code) -> Option<Disposition> {
match code {
Code::Ok => None,
c if self.is_retryable(c) => Some(Disposition::Retryable),
_ => Some(Disposition::Terminal),
}
}
/// `true` iff `code` is in the retryable set.
pub fn is_retryable(&self, code: Code) -> bool {
self.retryable.contains(&code)
}
}
impl Default for GrpcRetryPolicy {
fn default() -> Self {
Self::DEFAULT
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_retries_transient_codes() {
for c in [Code::Unavailable, Code::Unknown] {
assert!(GrpcRetryPolicy::DEFAULT.is_retryable(c));
assert_eq!(
GrpcRetryPolicy::DEFAULT.classify(c),
Some(Disposition::Retryable)
);
}
}
#[test]
fn default_excludes_internal_and_deadline_exceeded() {
for c in [Code::Internal, Code::DeadlineExceeded] {
assert!(!GrpcRetryPolicy::DEFAULT.is_retryable(c));
assert_eq!(
GrpcRetryPolicy::DEFAULT.classify(c),
Some(Disposition::Terminal)
);
}
}
#[test]
fn default_terminal_for_permanent_codes() {
for c in [
Code::NotFound,
Code::PermissionDenied,
Code::InvalidArgument,
Code::AlreadyExists,
Code::Unauthenticated,
] {
assert_eq!(
GrpcRetryPolicy::DEFAULT.classify(c),
Some(Disposition::Terminal)
);
}
}
#[test]
fn ok_classifies_as_none() {
assert_eq!(GrpcRetryPolicy::DEFAULT.classify(Code::Ok), None);
}
#[test]
fn permissive_also_retries_internal_and_deadline() {
for c in [
Code::Unavailable,
Code::Unknown,
Code::Internal,
Code::DeadlineExceeded,
] {
assert!(GrpcRetryPolicy::PERMISSIVE.is_retryable(c));
}
}
#[test]
fn custom_set_is_respected() {
let policy = GrpcRetryPolicy::new(&[Code::ResourceExhausted]);
assert!(policy.is_retryable(Code::ResourceExhausted));
assert!(!policy.is_retryable(Code::Unavailable));
}
}

View file

@ -1,14 +1,20 @@
//! Shared HTTP circuit breaker.
//! Shared circuit breaker.
//!
//! Sliding-window-with-min-samples algorithm: the breaker trips when
//! `sample_count >= min_samples AND error_rate >= error_rate_threshold`
//! over the live window. Server- and client-side consumers run the same
//! state machine and pick a preset via [`BreakerConfig::server`] or
//! [`BreakerConfig::client`].
//!
//! The breaker is protocol-agnostic (it operates on [`Outcome`]); classification
//! helpers exist for HTTP ([`RetryPolicy`]) and gRPC ([`GrpcRetryPolicy`], `grpc`
//! feature).
mod breaker;
mod clock;
mod config;
#[cfg(feature = "grpc")]
mod grpc;
mod observer;
mod registry;
mod retry_policy;
@ -20,6 +26,8 @@ pub use breaker::CircuitBreaker;
pub use clock::MockClock;
pub use clock::{Clock, SystemClock};
pub use config::{BreakerConfig, default_failure_codes, parse_failure_codes};
#[cfg(feature = "grpc")]
pub use grpc::GrpcRetryPolicy;
pub use observer::{NoopObserver, Observer};
pub use registry::CircuitBreakerRegistry;
pub use retry_policy::{Disposition, RetryPolicy};

View file

@ -6,7 +6,7 @@ edition.workspace = true
description = "SDK for the xAI Computer Hub: connection pool, transparent reconnect, tool harness, and tool-server runtime."
[features]
metrics = ["dep:prometheus"]
metrics = ["dep:prometheus", "dep:prometheus-parse"]
[dependencies]
tokio = { workspace = true, features = ["rt", "sync", "time", "macros"] }
@ -25,6 +25,7 @@ tracing-subscriber = { workspace = true }
url = { workspace = true }
http = { workspace = true }
prometheus = { workspace = true, optional = true }
prometheus-parse = { version = "0.2.5", optional = true }
reqwest = { workspace = true }
chrono = { workspace = true }
parking_lot = { workspace = true }

View file

@ -125,6 +125,138 @@ fn convert_families(families: &[MetricFamily]) -> Vec<Metric> {
out
}
/// Text-format exposition → OTLP, same shapes as [`convert_families`].
fn convert_text_exposition(text: &str, prefix: &str) -> Vec<Metric> {
let Ok(scrape) =
prometheus_parse::Scrape::parse(text.lines().map(|l| std::io::Result::Ok(l.to_owned())))
else {
return Vec::new();
};
let now = now_unix_nanos();
let cumulative = AggregationTemporality::Cumulative as i32;
let histogram_names: std::collections::HashSet<&str> = scrape
.samples
.iter()
.filter(|s| matches!(s.value, prometheus_parse::Value::Histogram(_)))
.map(|s| s.metric.as_str())
.collect();
let histogram_sums: std::collections::HashMap<(String, String), f64> = scrape
.samples
.iter()
.filter_map(|s| {
let base = s.metric.strip_suffix("_sum")?;
if !histogram_names.contains(base) {
return None;
}
let prometheus_parse::Value::Untyped(v) = s.value else {
return None;
};
Some(((base.to_owned(), s.labels.to_string()), v))
})
.collect();
let parsed_labels_to_kv = |labels: &prometheus_parse::Labels| -> Vec<KeyValue> {
let mut kv: Vec<KeyValue> = labels
.iter()
.map(|(k, v)| string_kv(k, v.clone()))
.collect();
kv.sort_by(|a, b| a.key.cmp(&b.key));
kv
};
let mut out = Vec::new();
for sample in &scrape.samples {
if !sample.metric.starts_with(prefix) {
continue;
}
let data = match &sample.value {
prometheus_parse::Value::Counter(v) => metric::Data::Sum(Sum {
data_points: vec![NumberDataPoint {
attributes: parsed_labels_to_kv(&sample.labels),
time_unix_nano: now,
value: Some(number_data_point::Value::AsDouble(*v)),
..Default::default()
}],
aggregation_temporality: cumulative,
is_monotonic: true,
}),
prometheus_parse::Value::Gauge(v) => metric::Data::Gauge(Gauge {
data_points: vec![NumberDataPoint {
attributes: parsed_labels_to_kv(&sample.labels),
time_unix_nano: now,
value: Some(number_data_point::Value::AsDouble(*v)),
..Default::default()
}],
}),
prometheus_parse::Value::Histogram(counts) => {
let mut counts = counts.clone();
counts.sort_by(|a, b| a.less_than.total_cmp(&b.less_than));
let mut bucket_counts = Vec::with_capacity(counts.len());
let mut explicit_bounds = Vec::with_capacity(counts.len().saturating_sub(1));
let mut prev = 0f64;
let mut total = 0u64;
for bucket in &counts {
bucket_counts.push((bucket.count - prev).max(0.0) as u64);
if bucket.less_than.is_finite() {
explicit_bounds.push(bucket.less_than);
} else {
total = bucket.count.max(0.0) as u64;
}
prev = bucket.count;
}
let sum = histogram_sums
.get(&(sample.metric.clone(), sample.labels.to_string()))
.copied();
metric::Data::Histogram(Histogram {
data_points: vec![HistogramDataPoint {
attributes: parsed_labels_to_kv(&sample.labels),
time_unix_nano: now,
count: total,
sum,
bucket_counts,
explicit_bounds,
..Default::default()
}],
aggregation_temporality: cumulative,
})
}
// Untyped = histogram `_sum`/`_count` twins, joined above.
prometheus_parse::Value::Summary(_) | prometheus_parse::Value::Untyped(_) => continue,
};
out.push(Metric {
name: sample.metric.clone(),
data: Some(data),
..Default::default()
});
}
out
}
/// Donates metrics from outside the default Prometheus registry.
pub struct DonatedMetricsSink {
exporter: Arc<MetricExporter>,
}
impl DonatedMetricsSink {
/// Returns the number of OTLP metrics queued (malformed input → 0).
pub fn export_text_exposition(&self, text: &str, prefix: &str) -> usize {
let metrics = convert_text_exposition(text, prefix);
let exported = metrics.len();
if exported > 0 {
self.exporter.export(metrics);
}
exported
}
}
/// `Some` while the donation pump is active; re-fetch per use (cheap).
pub fn active_metrics_sink() -> Option<DonatedMetricsSink> {
ACTIVE_METRIC_EXPORTER
.load_full()
.map(|exporter| DonatedMetricsSink { exporter })
}
/// Encodes batches of OTLP metrics onto the pump channel. Chunks at
/// [`MAX_METRICS_PER_DONATION`], drops payloads over
/// [`MAX_DONATION_BYTES`], and never blocks.
@ -299,6 +431,92 @@ mod tests {
.collect()
}
const PROXY_TEXT: &str = "\
# HELP preview_proxy_requests_total Proxied preview requests.\n\
# TYPE preview_proxy_requests_total counter\n\
preview_proxy_requests_total{visibility=\"public\",outcome=\"ok\"} 41\n\
preview_proxy_requests_total{visibility=\"private\",outcome=\"upstream_error\"} 2\n\
# HELP preview_proxy_active_ws_connections Live proxied WebSocket connections.\n\
# TYPE preview_proxy_active_ws_connections gauge\n\
preview_proxy_active_ws_connections 3\n\
# HELP preview_proxy_request_duration_seconds Preview request latency.\n\
# TYPE preview_proxy_request_duration_seconds histogram\n\
preview_proxy_request_duration_seconds_bucket{le=\"0.5\"} 4\n\
preview_proxy_request_duration_seconds_bucket{le=\"1\"} 6\n\
preview_proxy_request_duration_seconds_bucket{le=\"+Inf\"} 7\n\
preview_proxy_request_duration_seconds_sum 5.25\n\
preview_proxy_request_duration_seconds_count 7\n\
# TYPE other_family_total counter\n\
other_family_total 9\n";
#[test]
fn text_exposition_converts_and_filters_by_prefix() {
let metrics = convert_text_exposition(PROXY_TEXT, "preview_proxy_");
let by_name: std::collections::HashMap<&str, &Metric> =
metrics.iter().map(|m| (m.name.as_str(), m)).collect();
assert!(!by_name.contains_key("other_family_total"));
assert!(!by_name.contains_key("preview_proxy_request_duration_seconds_sum"));
assert!(!by_name.contains_key("preview_proxy_request_duration_seconds_count"));
let counters: Vec<&Metric> = metrics
.iter()
.filter(|m| m.name == "preview_proxy_requests_total")
.collect();
assert_eq!(2, counters.len());
let ok_point = counters
.iter()
.find_map(|m| match m.data.as_ref() {
Some(metric::Data::Sum(sum)) => {
assert!(sum.is_monotonic);
sum.data_points.iter().find(|p| {
label_map(&p.attributes).get("outcome").map(String::as_str) == Some("ok")
})
}
_ => None,
})
.expect("ok counter point");
assert_eq!(
Some(number_data_point::Value::AsDouble(41.0)),
ok_point.value
);
assert_eq!(
Some("public"),
label_map(&ok_point.attributes)
.get("visibility")
.map(String::as_str)
);
let Some(metric::Data::Gauge(gauge)) =
by_name["preview_proxy_active_ws_connections"].data.as_ref()
else {
panic!("gauge expected");
};
assert_eq!(
Some(number_data_point::Value::AsDouble(3.0)),
gauge.data_points[0].value
);
let Some(metric::Data::Histogram(hist)) = by_name["preview_proxy_request_duration_seconds"]
.data
.as_ref()
else {
panic!("histogram expected");
};
let point = &hist.data_points[0];
assert_eq!(vec![0.5, 1.0], point.explicit_bounds);
assert_eq!(vec![4, 2, 1], point.bucket_counts);
assert_eq!(7, point.count);
assert_eq!(Some(5.25), point.sum);
}
#[test]
fn text_exposition_tolerates_garbage_and_empty_input() {
assert!(convert_text_exposition("", "preview_proxy_").is_empty());
assert!(convert_text_exposition("not a metric line at all", "preview_proxy_").is_empty());
assert!(convert_text_exposition(PROXY_TEXT, "no_such_prefix_").is_empty());
}
#[test]
fn converts_counter_gauge_histogram_with_labels() {
let registry = Registry::new();

View file

@ -1,11 +1,16 @@
//! Lenient deserializers for tool-argument booleans: a boolean may arrive as a
//! JSON string (`"true"`) or number (`1`) when a client doesn't coerce args
//! against the tool schema. Accepted forms (strings case-insensitive, trimmed;
//! `null` is `false`):
//! Lenient deserializers for tool arguments whose wire shape models get
//! wrong in predictable ways.
//!
//! Booleans may arrive as a JSON string (`"true"`) or number (`1`) when a
//! client doesn't coerce args against the tool schema. Accepted forms
//! (strings case-insensitive, trimmed; `null` is `false`):
//!
//! | Truthy | Falsy |
//! |---------------------------------------|------------------------------------------------|
//! | `true`, `"true"`, `"yes"`, `"1"`, `1` | `false`, `"false"`, `"no"`, `"0"`, `0`, `null` |
//!
//! String lists (e.g. `task_ids`) may arrive as a bare string or number
//! instead of an array; see [`lenient_string_list_from_json`].
use serde::Deserialize;
@ -71,6 +76,43 @@ where
.ok_or_else(|| serde::de::Error::custom(invalid_bool_message(&value)))
}
/// Parse a JSON value into a list of strings, tolerating the shapes models
/// actually send:
///
/// - array of strings/numbers → each element as a string (`228` → `"228"`),
/// - bare string or number → one-element list,
/// - `null` → empty list.
///
/// Booleans, objects, and nested arrays are rejected (`None`).
pub fn lenient_string_list_from_json(value: &serde_json::Value) -> Option<Vec<String>> {
fn item_to_string(v: &serde_json::Value) -> Option<String> {
match v {
serde_json::Value::String(s) => Some(s.clone()),
serde_json::Value::Number(n) => Some(n.to_string()),
_ => None,
}
}
match value {
serde_json::Value::Array(items) => items.iter().map(item_to_string).collect(),
serde_json::Value::Null => Some(Vec::new()),
other => item_to_string(other).map(|s| vec![s]),
}
}
/// Deserialize a `Vec<String>` per [`lenient_string_list_from_json`]; pair
/// with `#[serde(default)]` so an absent key yields an empty list.
pub fn deserialize_lenient_string_list<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = serde_json::Value::deserialize(deserializer)?;
lenient_string_list_from_json(&value).ok_or_else(|| {
serde::de::Error::custom(format!(
"expected a list of string ids (or a single string), got {value}"
))
})
}
#[cfg(test)]
mod tests {
use super::*;
@ -192,4 +234,50 @@ mod tests {
assert_eq!(deser_opt_bool(r#"{"value":0}"#).unwrap(), Some(false));
assert!(deser_opt_bool(r#"{"value":"nope"}"#).is_err());
}
// ── lenient string lists ─────────────────────────────────────────────
#[test]
fn string_list_accepts_arrays_strings_and_numbers() {
assert_eq!(
lenient_string_list_from_json(&json!(["a", "b"])),
Some(vec!["a".to_string(), "b".to_string()])
);
assert_eq!(
lenient_string_list_from_json(&json!("abc")),
Some(vec!["abc".to_string()])
);
// A bare OS-PID-style number becomes a one-element string list so the
// tool can answer with a clean "Task 228 not found" instead of a
// deserialize error.
assert_eq!(
lenient_string_list_from_json(&json!(228)),
Some(vec!["228".to_string()])
);
assert_eq!(
lenient_string_list_from_json(&json!([1, "b"])),
Some(vec!["1".to_string(), "b".to_string()])
);
assert_eq!(lenient_string_list_from_json(&json!(null)), Some(vec![]));
}
#[test]
fn string_list_rejects_non_id_shapes() {
for v in [json!(true), json!({}), json!([["nested"]]), json!([true])] {
assert_eq!(lenient_string_list_from_json(&v), None, "should reject {v}");
}
}
#[test]
fn deserialize_string_list_reports_readable_error() {
#[derive(Debug, Deserialize)]
struct Wrapper {
#[serde(default, deserialize_with = "deserialize_lenient_string_list")]
value: Vec<String>,
}
let err = serde_json::from_str::<Wrapper>(r#"{"value":{}}"#).unwrap_err();
assert!(err.to_string().contains("expected a list of string ids"));
let ok: Wrapper = serde_json::from_str(r#"{}"#).unwrap();
assert!(ok.value.is_empty());
}
}

View file

@ -321,10 +321,22 @@ pub const MAX_MULTI_WAIT_IDS: usize = 20;
#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema)]
pub struct TaskOutputToolInput {
/// Task IDs to query. Pass one or more; a single task is a one-element list.
///
/// Lenient on the wire (invisible to the advertised schema — schemars
/// ignores serde aliases and custom deserializers): also accepts the
/// singular `task_id` key and a bare string/number instead of an array.
/// Models frequently mirror `kill_task`'s singular `task_id` here (in
/// soak rollouts 3 of 4 organic calls did) and previously hard-failed
/// with "Provide a non-empty task_ids list", after which they abandoned
/// the background-task workflow for shell polling.
#[schemars(
description = "Task IDs to get output from. Pass one or more; for a single task use a one-element array. With a positive timeout_ms, multiple ids wait until all complete. Omit timeout_ms or pass 0 for a non-blocking snapshot."
)]
#[serde(default)]
#[serde(
default,
alias = "task_id",
deserialize_with = "crate::serde_lenient::deserialize_lenient_string_list"
)]
pub task_ids: Vec<String>,
/// When set and positive, wait up to this many milliseconds; omit or `0` polls.
@ -775,7 +787,7 @@ Workspace boundary:
pub const GENERAL_PURPOSE_SUBAGENT: BuiltinSubagent = BuiltinSubagent {
name: "general-purpose",
description: "General purpose agent for multi-step tasks.",
tools_template: "Has access to all tools: \
tools_template: "Has access to: \
${{ tools.by_kind.execute }}, ${{ tools.by_kind.read }}, ${{ tools.by_kind.edit }}, \
${{ tools.by_kind.list }}, ${{ tools.by_kind.search }}, ${{ tools.by_kind.web_search }}, \
and ${{ tools.by_kind.plan }}.",
@ -796,10 +808,10 @@ pub const EXPLORE_SUBAGENT: BuiltinSubagent = BuiltinSubagent {
pub const PLAN_SUBAGENT: BuiltinSubagent = BuiltinSubagent {
name: "plan",
description: "Software architect for planning implementation strategies.",
tools_template: "Read-only \u{2014} has access to all tools except file editing \
(${{ tools.by_kind.edit }} is not available): \
tools_template: "Read-only \u{2014} has access to: \
${{ tools.by_kind.read }}, ${{ tools.by_kind.list }}, ${{ tools.by_kind.search }}, \
${{ tools.by_kind.web_search }}, and ${{ tools.by_kind.plan }}.",
${{ tools.by_kind.web_search }}, and ${{ tools.by_kind.plan }}. \
File editing and command execution are not available.",
prompt_template: PLAN_PROMPT,
};
@ -997,14 +1009,14 @@ pub fn build_task_output_description(naming: &TaskOutputToolNaming) -> String {
let target_suffix = lifecycle_target_suffix(monitor_present, subagent_present);
let mut sources: Vec<String> = Vec::new();
if let Some(p) = bash_background_param {
sources.push(format!("{p}=true commands"));
}
if let Some(p) = subagent_background_param {
sources.push(format!("{p}=true subagents"));
}
let sources = sources.join(" or ");
let sources = match (bash_background_param, subagent_background_param) {
// Both params share one client-facing name: don't repeat it.
(Some(b), Some(s)) if b == s => format!("{b}=true commands or subagents"),
(Some(b), Some(s)) => format!("{b}=true commands or {s}=true subagents"),
(Some(b), None) => format!("{b}=true commands"),
(None, Some(s)) => format!("{s}=true subagents"),
(None, None) => "background tasks".to_string(),
};
let monitor_note = monitor_task_id_note(monitor_tool, task_id_param);
let read_note = match read_tool {
@ -1040,14 +1052,14 @@ pub fn build_wait_tasks_description(naming: &WaitTasksToolNaming) -> String {
subagent_background_param,
} = *naming;
let mut sources: Vec<String> = Vec::new();
if let Some(p) = bash_background_param {
sources.push(format!("{p}=true"));
}
if let Some(p) = subagent_background_param {
sources.push(format!("{p}=true"));
}
let sources = sources.join(" or ");
let sources = match (bash_background_param, subagent_background_param) {
// Both params share one client-facing name: don't repeat it.
(Some(b), Some(s)) if b == s => format!("{b}=true commands or subagents"),
(Some(b), Some(s)) => format!("{b}=true commands or {s}=true subagents"),
(Some(b), None) => format!("{b}=true commands"),
(None, Some(s)) => format!("{s}=true subagents"),
(None, None) => "background tasks".to_string(),
};
format!(
"Wait for multiple background tasks or subagents to complete.\n\n\
@ -1157,6 +1169,54 @@ mod tests {
assert!(value.get("model").is_none());
}
#[test]
fn task_output_input_accepts_singular_task_id_alias() {
// Canonical plural form (unchanged).
let input: TaskOutputToolInput =
serde_json::from_str(r#"{"task_ids": ["a", "b"]}"#).unwrap();
assert_eq!(input.resolved_task_ids(), vec!["a", "b"]);
// Singular key with a bare string — the shape models organically send
// (mirroring kill_task's singular task_id).
let input: TaskOutputToolInput =
serde_json::from_str(r#"{"task_id": "abc-123", "timeout_ms": 0}"#).unwrap();
assert_eq!(input.resolved_task_ids(), vec!["abc-123"]);
assert_eq!(input.timeout_ms, Some(0));
// Singular key with an array also works.
let input: TaskOutputToolInput =
serde_json::from_str(r#"{"task_id": ["x", "y"]}"#).unwrap();
assert_eq!(input.resolved_task_ids(), vec!["x", "y"]);
// Plural key with a bare string.
let input: TaskOutputToolInput = serde_json::from_str(r#"{"task_ids": "solo"}"#).unwrap();
assert_eq!(input.resolved_task_ids(), vec!["solo"]);
// Bare number (observed: an OS PID) becomes a string id, so the tool
// answers "Task 228 not found" instead of a deserialize error.
let input: TaskOutputToolInput = serde_json::from_str(r#"{"task_id": 228}"#).unwrap();
assert_eq!(input.resolved_task_ids(), vec!["228"]);
}
#[test]
fn task_output_input_schema_does_not_advertise_the_alias() {
// The leniency is wire-only: the advertised schema must keep exactly
// the canonical properties (task_ids, timeout_ms) so tool-definition
// dumps and param randomization are unaffected.
let schema = serde_json::to_value(schemars::schema_for!(TaskOutputToolInput)).unwrap();
let props = schema["properties"].as_object().unwrap();
assert!(props.contains_key("task_ids"));
assert!(props.contains_key("timeout_ms"));
assert!(
!props.contains_key("task_id"),
"singular alias must not leak into the schema: {props:?}"
);
assert_eq!(props.len(), 2);
// And task_ids stays a plain string array.
assert_eq!(props["task_ids"]["type"], "array");
assert_eq!(props["task_ids"]["items"]["type"], "string");
}
#[test]
fn sanitize_optional_arg_moves_when_no_trim() {
assert_eq!(
@ -1338,12 +1398,12 @@ mod tests {
// Bare-kind naming reproduces the placeholder kinds verbatim.
assert_eq!(
GENERAL_PURPOSE_SUBAGENT.render_tools(&plain_tool_naming()),
"Has access to all tools: execute, read, edit, list, search, web_search, and plan."
"Has access to: execute, read, edit, list, search, web_search, and plan."
);
assert_eq!(
PLAN_SUBAGENT.render_tools(&plain_tool_naming()),
"Read-only \u{2014} has access to all tools except file editing (edit is not available): \
read, list, search, web_search, and plan."
"Read-only \u{2014} has access to: read, list, search, web_search, and plan. \
File editing and command execution are not available."
);
// Real tool names are substituted per kind.
@ -1512,7 +1572,7 @@ mod tests {
desc,
"Get output and status from a background task, monitor, or subagent.\n\n\
Usage notes:\n\
- Pass task_ids with one or more ids from background=true commands or background=true subagents (a monitor's task_id is returned by monitor); for a single task use a one-element array. Multiple ids with a positive timeout_ms wait until all complete\n\
- Pass task_ids with one or more ids from background=true commands or subagents (a monitor's task_id is returned by monitor); for a single task use a one-element array. Multiple ids with a positive timeout_ms wait until all complete\n\
- Omit timeout_ms or pass 0 for a non-blocking status snapshot; set a positive timeout_ms to wait up to that many milliseconds, capped at ~10 min\n\
- Returns current output, status, and exit code if completed\n\
- If output is large, use read_file on the output_file path"
@ -1553,7 +1613,7 @@ mod tests {
"Wait for multiple background tasks or subagents to complete.\n\n\
Prefer get_command_or_subagent_output with task_ids and a positive timeout_ms. This tool is kept for compatibility.\n\n\
Usage notes:\n\
- task_ids: list of task IDs from background=true or background=true\n\
- task_ids: list of task IDs from background=true commands or subagents\n\
- mode: 'wait_all' or 'wait_any'\n\
- timeout_ms: optional max wait, default 30s, capped at ~10 min"
);
@ -1566,7 +1626,9 @@ mod tests {
bash_background_param: None,
subagent_background_param: Some("run_in_background"),
});
assert!(desc.contains("- task_ids: list of task IDs from run_in_background=true\n"));
assert!(
desc.contains("- task_ids: list of task IDs from run_in_background=true subagents\n")
);
assert!(desc.contains("Prefer get_task_output with task_ids"));
}
}