The PRD's first acceptance gate now holds: grep -RinE '\bx\.ai\b|grok' crates/ --include='*.rs' → 0 matches (exempt: NOTICE and third-party license archives, README provenance, and the required 'Based on Grok Build Open Source' attribution, now sourced from version_attribution.txt). Wire-visible renames (both sides in this repo, changed in lockstep): - Auth method id 'grok.com' → 'kimi-code' (AuthMethodKind::KimiCode). - Every x.ai/* and _x.ai/* ACP ext method and meta key → kigi/* / _kigi/* (~200 names; grokShell → kigiShell). Session-file replay keeps a read-side alias for the legacy '_x.ai/session/update' method so existing updates.jsonl histories load; writes emit only the new name (both directions test-pinned). - Agent types grok-build* → kigi* with a documented legacy-prefix alias at resolution time so persisted sessions keep resolving. - ToolNamespace/BuiltinAgentName GrokBuild* → Kigi* (wire snake_case kigi/kigi_concise/kigi_hashline; schema regenerated); grok_build implementation dirs renamed to kigi*. - x-grok-* headers → x-kigi-*, __GROK_* sentinels → __KIGI_*, themes grokday/groknight → kigiday/kiginight (old persisted values fall back to the default theme), web_fetch allowlist xAI hosts → kimi.com + moonshot platforms, changelog CDN → this repo, grok-build changelog archives deleted. - BYOK default endpoint removed: [endpoints] api_base_url is now truly optional with NO default — consumers fail fast with the flag name when unset (no silent x.ai egress). Mock harnesses inject it explicitly. - System-prompt identity fixed: 'released by xAI' → 'an unofficial community CLI for Kimi' (template + regenerated encrypted form). Also repaired pre-existing grok-era test debt found by the sweep: the stale trace_classify default-model pin, the grok-pager UA label test, pty-harness stale-binary reuse and non-hermetic moonshot routing (a PTY test could previously reach the real api.moonshot.cn), and the outdated oauth fixture scope key. Gates: §9 grep 0; fmt clean; workspace check/clippy 0/0 (-D warnings); FULL cargo test --workspace: 234 suites, 21,961 passed, 0 failed; deny advisories ok.
829 lines
30 KiB
Rust
829 lines
30 KiB
Rust
//! Backend trait abstracting how subagent operations are dispatched.
|
|
//!
|
|
//! `SubagentBackend` decouples the tool implementations (`TaskTool`,
|
|
//! `TaskOutputTool`, `KillTaskTool`) from the transport mechanism used to
|
|
//! communicate with the subagent coordinator.
|
|
//!
|
|
//! Two implementations are planned:
|
|
//!
|
|
//! - [`ChannelBackend`] — wraps in-process `tokio::mpsc` channels used by
|
|
//! the local host shell. This is the only implementation today.
|
|
//! - `RemoteBackend` (future) — dispatches over a remote transport to an
|
|
//! out-of-process spawner.
|
|
|
|
use std::sync::Arc;
|
|
|
|
use tokio::sync::{mpsc, oneshot};
|
|
|
|
use super::types::{
|
|
SubagentCancelOutcome, SubagentCancelRequest, SubagentCancelTarget, SubagentDescribeOutcome,
|
|
SubagentDescribeRequest, SubagentEvent, SubagentQueryRequest, SubagentRequest, SubagentResult,
|
|
SubagentSnapshot, SubagentValidateTypeOutcome, SubagentValidateTypeRequest,
|
|
};
|
|
use crate::register_resource;
|
|
use kigi_tool_runtime::ToolError;
|
|
|
|
/// Abstraction over the mechanism used to spawn, query, and cancel subagents.
|
|
///
|
|
/// Injected into `Resources` as [`SubagentBackendResource`] so that
|
|
/// `TaskTool`, `TaskOutputTool`, and `KillTaskTool` can operate
|
|
/// identically regardless of the underlying transport.
|
|
#[async_trait::async_trait]
|
|
pub trait SubagentBackend: Send + Sync + 'static {
|
|
/// Spawn a subagent and await its result.
|
|
///
|
|
/// For blocking mode the caller awaits the returned future directly.
|
|
/// For background mode the caller spawns a tokio task around this call
|
|
/// and drops the receiver immediately.
|
|
async fn spawn(&self, request: SubagentRequest) -> Result<SubagentResult, ToolError>;
|
|
|
|
/// Query the current state of a subagent by ID.
|
|
///
|
|
/// When `block` is true the backend waits (up to `timeout_ms`) for the
|
|
/// subagent to reach a terminal state before responding.
|
|
async fn query(
|
|
&self,
|
|
id: &str,
|
|
block: bool,
|
|
timeout_ms: Option<u64>,
|
|
) -> Option<SubagentSnapshot>;
|
|
|
|
/// Request cancellation of a subagent by ID.
|
|
async fn cancel(&self, id: &str) -> SubagentCancelOutcome;
|
|
|
|
/// Validate a subagent type synchronously before spawning.
|
|
/// Returns `ValidationUnavailable` on channel close / responder drop / timeout.
|
|
async fn validate_type(
|
|
&self,
|
|
subagent_type: &str,
|
|
parent_session_id: &str,
|
|
) -> SubagentValidateTypeOutcome;
|
|
|
|
/// Describe a subagent type's resolved toolset (tool names + capability
|
|
/// flags) before spawning. Read-only: builds the agent definition and
|
|
/// applies the same parent-dependent toolset re-selection a spawn would,
|
|
/// then reports the result without starting a child session.
|
|
///
|
|
/// Returns [`SubagentDescribeOutcome::Unavailable`] on channel close /
|
|
/// responder drop / timeout (modeled exactly on [`Self::validate_type`]).
|
|
///
|
|
/// `harness_agent_type` is the `/goal`-only harness override (see
|
|
/// [`super::types::SubagentRuntimeOverrides::harness_agent_type`]); the
|
|
/// coordinator resolves the toolset for `(subagent_type,
|
|
/// harness_agent_type)`. `None` (every non-goal caller) defers the flavor
|
|
/// to the parent agent.
|
|
async fn describe_subagent_type(
|
|
&self,
|
|
subagent_type: &str,
|
|
harness_agent_type: Option<&str>,
|
|
parent_session_id: &str,
|
|
) -> SubagentDescribeOutcome;
|
|
}
|
|
|
|
/// Resource wrapper injected into every session's `Resources`.
|
|
///
|
|
/// Wraps an `Arc<dyn SubagentBackend>` so the backend can be shared across
|
|
/// concurrent tool invocations within the same session.
|
|
#[derive(Clone)]
|
|
pub struct SubagentBackendResource(pub Arc<dyn SubagentBackend>);
|
|
|
|
impl SubagentBackendResource {
|
|
pub fn backend(&self) -> &dyn SubagentBackend {
|
|
self.0.as_ref()
|
|
}
|
|
}
|
|
|
|
impl std::fmt::Debug for SubagentBackendResource {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.debug_struct("SubagentBackendResource").finish()
|
|
}
|
|
}
|
|
|
|
register_resource!("kigi", "SubagentBackendResource", SubagentBackendResource);
|
|
|
|
/// In-process channel-based backend for the local host shell.
|
|
///
|
|
/// Wraps a single `mpsc::UnboundedSender<SubagentEvent>` that carries
|
|
/// spawn, query, and cancel messages to the coordinator. The oneshot for
|
|
/// `spawn` is created inside the backend so callers never manage it.
|
|
pub struct ChannelBackend {
|
|
tx: mpsc::UnboundedSender<SubagentEvent>,
|
|
}
|
|
|
|
impl ChannelBackend {
|
|
pub fn new(tx: mpsc::UnboundedSender<SubagentEvent>) -> Self {
|
|
Self { tx }
|
|
}
|
|
}
|
|
|
|
#[async_trait::async_trait]
|
|
impl SubagentBackend for ChannelBackend {
|
|
async fn spawn(&self, request: SubagentRequest) -> Result<SubagentResult, ToolError> {
|
|
let (result_tx, result_rx) = oneshot::channel();
|
|
|
|
// Replace the dummy oneshot with our fresh one. Using struct update
|
|
// syntax (`..request`) ensures new fields added to `SubagentRequest`
|
|
// are forwarded automatically — a field-by-field copy would silently
|
|
// drop them.
|
|
self.tx
|
|
.send(SubagentEvent::Spawn(Box::new(SubagentRequest {
|
|
result_tx,
|
|
..request
|
|
})))
|
|
.map_err(|_| {
|
|
ToolError::custom(
|
|
"channel_closed",
|
|
"Subagent coordinator channel closed — cannot spawn subagent",
|
|
)
|
|
})?;
|
|
|
|
result_rx.await.map_err(|_| {
|
|
ToolError::custom(
|
|
"channel_closed",
|
|
"Subagent result channel dropped — child session may have crashed",
|
|
)
|
|
})
|
|
}
|
|
|
|
async fn query(
|
|
&self,
|
|
id: &str,
|
|
block: bool,
|
|
timeout_ms: Option<u64>,
|
|
) -> Option<SubagentSnapshot> {
|
|
let (respond_to, response_rx) = oneshot::channel();
|
|
let sent = self.tx.send(SubagentEvent::Query(SubagentQueryRequest {
|
|
subagent_id: id.to_string(),
|
|
block,
|
|
timeout_ms,
|
|
respond_to,
|
|
}));
|
|
if sent.is_err() {
|
|
return None;
|
|
}
|
|
response_rx.await.ok().flatten()
|
|
}
|
|
|
|
async fn cancel(&self, id: &str) -> SubagentCancelOutcome {
|
|
let (respond_to, response_rx) = oneshot::channel();
|
|
let sent = self.tx.send(SubagentEvent::Cancel(SubagentCancelRequest {
|
|
target: SubagentCancelTarget::SubagentId(id.to_string()),
|
|
respond_to,
|
|
}));
|
|
if sent.is_err() {
|
|
return SubagentCancelOutcome::NotFound;
|
|
}
|
|
response_rx.await.unwrap_or(SubagentCancelOutcome::NotFound)
|
|
}
|
|
|
|
async fn validate_type(
|
|
&self,
|
|
subagent_type: &str,
|
|
parent_session_id: &str,
|
|
) -> SubagentValidateTypeOutcome {
|
|
let (respond_to, response_rx) = oneshot::channel();
|
|
if self
|
|
.tx
|
|
.send(SubagentEvent::ValidateType(SubagentValidateTypeRequest {
|
|
subagent_type: subagent_type.to_string(),
|
|
parent_session_id: parent_session_id.to_string(),
|
|
respond_to,
|
|
}))
|
|
.is_err()
|
|
{
|
|
tracing::warn!(
|
|
subagent_type,
|
|
"coordinator validation channel closed, treating as ValidationUnavailable",
|
|
);
|
|
return SubagentValidateTypeOutcome::ValidationUnavailable;
|
|
}
|
|
let timeout = validate_type_timeout();
|
|
match tokio::time::timeout(timeout, response_rx).await {
|
|
Ok(Ok(outcome)) => outcome,
|
|
Ok(Err(_)) => {
|
|
tracing::warn!(
|
|
subagent_type,
|
|
"coordinator validation responder dropped, treating as ValidationUnavailable",
|
|
);
|
|
SubagentValidateTypeOutcome::ValidationUnavailable
|
|
}
|
|
Err(_) => {
|
|
tracing::warn!(
|
|
subagent_type,
|
|
timeout_ms = timeout.as_millis() as u64,
|
|
"coordinator validation timed out, treating as ValidationUnavailable",
|
|
);
|
|
SubagentValidateTypeOutcome::ValidationUnavailable
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn describe_subagent_type(
|
|
&self,
|
|
subagent_type: &str,
|
|
harness_agent_type: Option<&str>,
|
|
parent_session_id: &str,
|
|
) -> SubagentDescribeOutcome {
|
|
let (respond_to, response_rx) = oneshot::channel();
|
|
if self
|
|
.tx
|
|
.send(SubagentEvent::DescribeType(SubagentDescribeRequest {
|
|
subagent_type: subagent_type.to_string(),
|
|
harness_agent_type: harness_agent_type.map(str::to_string),
|
|
parent_session_id: parent_session_id.to_string(),
|
|
respond_to,
|
|
}))
|
|
.is_err()
|
|
{
|
|
tracing::warn!(
|
|
subagent_type,
|
|
"coordinator describe channel closed, treating as Unavailable",
|
|
);
|
|
return SubagentDescribeOutcome::Unavailable;
|
|
}
|
|
let timeout = validate_type_timeout();
|
|
match tokio::time::timeout(timeout, response_rx).await {
|
|
Ok(Ok(outcome)) => outcome,
|
|
Ok(Err(_)) => {
|
|
tracing::warn!(
|
|
subagent_type,
|
|
"coordinator describe responder dropped, treating as Unavailable",
|
|
);
|
|
SubagentDescribeOutcome::Unavailable
|
|
}
|
|
Err(_) => {
|
|
tracing::warn!(
|
|
subagent_type,
|
|
timeout_ms = timeout.as_millis() as u64,
|
|
"coordinator describe timed out, treating as Unavailable",
|
|
);
|
|
SubagentDescribeOutcome::Unavailable
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Default `validate_type` timeout. Override via [`VALIDATE_TYPE_TIMEOUT_ENV_VAR`].
|
|
pub const VALIDATE_TYPE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
|
|
|
|
/// Env-var override for [`VALIDATE_TYPE_TIMEOUT`] (positive milliseconds).
|
|
pub const VALIDATE_TYPE_TIMEOUT_ENV_VAR: &str = "XAI_VALIDATE_TYPE_TIMEOUT_MS";
|
|
|
|
/// Validation timeout, honoring the env-var override.
|
|
pub(crate) fn validate_type_timeout() -> std::time::Duration {
|
|
let raw = std::env::var(VALIDATE_TYPE_TIMEOUT_ENV_VAR).ok();
|
|
parse_timeout_ms(raw.as_deref())
|
|
.map(std::time::Duration::from_millis)
|
|
.unwrap_or(VALIDATE_TYPE_TIMEOUT)
|
|
}
|
|
|
|
/// Parse a positive `u64` millisecond value; `None` for unset, invalid, or zero.
|
|
pub(crate) fn parse_timeout_ms(value: Option<&str>) -> Option<u64> {
|
|
value?.parse::<u64>().ok().filter(|&ms| ms > 0)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::sync::Arc;
|
|
use tokio::sync::mpsc;
|
|
|
|
/// Helper: receive the next event, match the expected variant, or panic.
|
|
macro_rules! recv_event {
|
|
($rx:expr, Spawn) => {{
|
|
let event = $rx.recv().await.unwrap();
|
|
match event {
|
|
SubagentEvent::Spawn(inner) => *inner,
|
|
_ => panic!("Expected SubagentEvent::Spawn, got different variant"),
|
|
}
|
|
}};
|
|
($rx:expr, $variant:ident) => {{
|
|
let event = $rx.recv().await.unwrap();
|
|
match event {
|
|
SubagentEvent::$variant(inner) => inner,
|
|
_ => panic!(
|
|
"Expected SubagentEvent::{}, got different variant",
|
|
stringify!($variant)
|
|
),
|
|
}
|
|
}};
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn channel_backend_spawn_success() {
|
|
let (tx, mut rx) = mpsc::unbounded_channel::<SubagentEvent>();
|
|
let backend = ChannelBackend::new(tx);
|
|
|
|
let handle = tokio::spawn(async move {
|
|
let req = recv_event!(rx, Spawn);
|
|
assert_eq!(req.id, "test-id");
|
|
assert_eq!(req.prompt, "do something");
|
|
req.result_tx
|
|
.send(SubagentResult {
|
|
success: true,
|
|
output: Arc::from("done"),
|
|
subagent_id: "test-id".to_string(),
|
|
child_session_id: "test-id".to_string(),
|
|
tool_calls: 3,
|
|
turns: 1,
|
|
duration_ms: 500,
|
|
..Default::default()
|
|
})
|
|
.unwrap();
|
|
});
|
|
|
|
let (dummy_tx, _dummy_rx) = oneshot::channel();
|
|
let request = SubagentRequest {
|
|
id: "test-id".to_string(),
|
|
prompt: "do something".to_string(),
|
|
description: "test".to_string(),
|
|
subagent_type: "general-purpose".to_string(),
|
|
parent_session_id: "parent".to_string(),
|
|
parent_prompt_id: None,
|
|
resume_from: None,
|
|
cwd: None,
|
|
runtime_overrides: Default::default(),
|
|
run_in_background: false,
|
|
surface_completion: true,
|
|
fork_context: false,
|
|
result_tx: dummy_tx,
|
|
};
|
|
|
|
let result = backend.spawn(request).await.unwrap();
|
|
assert!(result.success);
|
|
assert_eq!(result.subagent_id, "test-id");
|
|
assert_eq!(result.tool_calls, 3);
|
|
|
|
handle.await.unwrap();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn channel_backend_spawn_closed_channel() {
|
|
let (tx, rx) = mpsc::unbounded_channel::<SubagentEvent>();
|
|
drop(rx);
|
|
|
|
let backend = ChannelBackend::new(tx);
|
|
|
|
let (dummy_tx, _dummy_rx) = oneshot::channel();
|
|
let request = SubagentRequest {
|
|
id: "test-id".to_string(),
|
|
prompt: "do something".to_string(),
|
|
description: "test".to_string(),
|
|
subagent_type: "general-purpose".to_string(),
|
|
parent_session_id: "parent".to_string(),
|
|
parent_prompt_id: None,
|
|
resume_from: None,
|
|
cwd: None,
|
|
runtime_overrides: Default::default(),
|
|
run_in_background: false,
|
|
surface_completion: true,
|
|
fork_context: false,
|
|
result_tx: dummy_tx,
|
|
};
|
|
|
|
let err = backend.spawn(request).await.unwrap_err();
|
|
assert!(err.to_string().contains("channel closed"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn channel_backend_query_found() {
|
|
let (tx, mut rx) = mpsc::unbounded_channel::<SubagentEvent>();
|
|
let backend = ChannelBackend::new(tx);
|
|
|
|
let handle = tokio::spawn(async move {
|
|
let req = recv_event!(rx, Query);
|
|
assert_eq!(req.subagent_id, "sub-1");
|
|
assert!(req.block);
|
|
assert_eq!(req.timeout_ms, Some(5000));
|
|
req.respond_to
|
|
.send(Some(SubagentSnapshot {
|
|
subagent_id: "sub-1".to_string(),
|
|
description: "find bugs".to_string(),
|
|
subagent_type: "explore".to_string(),
|
|
status: super::super::types::SubagentSnapshotStatus::Completed {
|
|
output: "result".to_string(),
|
|
tool_calls: 2,
|
|
turns: 1,
|
|
worktree_path: None,
|
|
},
|
|
started_at_epoch_ms: 1000,
|
|
duration_ms: 200,
|
|
persona: Some("reviewer".to_string()),
|
|
}))
|
|
.unwrap();
|
|
});
|
|
|
|
let snap = backend.query("sub-1", true, Some(5000)).await;
|
|
let snap = snap.expect("snapshot should be present");
|
|
assert_eq!(snap.subagent_id, "sub-1");
|
|
assert_eq!(snap.description, "find bugs");
|
|
assert_eq!(snap.subagent_type, "explore");
|
|
assert_eq!(snap.started_at_epoch_ms, 1000);
|
|
assert_eq!(snap.duration_ms, 200);
|
|
assert_eq!(snap.persona.as_deref(), Some("reviewer"));
|
|
match &snap.status {
|
|
super::super::types::SubagentSnapshotStatus::Completed {
|
|
output,
|
|
tool_calls,
|
|
turns,
|
|
worktree_path,
|
|
} => {
|
|
assert_eq!(output, "result");
|
|
assert_eq!(*tool_calls, 2);
|
|
assert_eq!(*turns, 1);
|
|
assert!(worktree_path.is_none());
|
|
}
|
|
other => panic!("Expected Completed, got {:?}", other),
|
|
}
|
|
|
|
handle.await.unwrap();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn channel_backend_query_non_blocking_passes_through() {
|
|
let (tx, mut rx) = mpsc::unbounded_channel::<SubagentEvent>();
|
|
let backend = ChannelBackend::new(tx);
|
|
|
|
let handle = tokio::spawn(async move {
|
|
let req = recv_event!(rx, Query);
|
|
assert_eq!(req.subagent_id, "sub-nb");
|
|
assert!(!req.block, "block should be false");
|
|
assert_eq!(req.timeout_ms, None, "timeout_ms should be None");
|
|
req.respond_to.send(None).unwrap();
|
|
});
|
|
|
|
let snap = backend.query("sub-nb", false, None).await;
|
|
assert!(snap.is_none());
|
|
|
|
handle.await.unwrap();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn channel_backend_query_not_found() {
|
|
let (tx, mut rx) = mpsc::unbounded_channel::<SubagentEvent>();
|
|
let backend = ChannelBackend::new(tx);
|
|
|
|
let handle = tokio::spawn(async move {
|
|
let req = recv_event!(rx, Query);
|
|
req.respond_to.send(None).unwrap();
|
|
});
|
|
|
|
let snap = backend.query("nonexistent", false, None).await;
|
|
assert!(snap.is_none());
|
|
|
|
handle.await.unwrap();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn channel_backend_cancel_success() {
|
|
let (tx, mut rx) = mpsc::unbounded_channel::<SubagentEvent>();
|
|
let backend = ChannelBackend::new(tx);
|
|
|
|
let handle = tokio::spawn(async move {
|
|
let req = recv_event!(rx, Cancel);
|
|
match &req.target {
|
|
SubagentCancelTarget::SubagentId(id) => assert_eq!(id, "sub-cancel"),
|
|
other => panic!("Expected SubagentId, got {:?}", other),
|
|
}
|
|
req.respond_to
|
|
.send(SubagentCancelOutcome::Cancelled)
|
|
.unwrap();
|
|
});
|
|
|
|
let outcome = backend.cancel("sub-cancel").await;
|
|
assert!(matches!(outcome, SubagentCancelOutcome::Cancelled));
|
|
|
|
handle.await.unwrap();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn channel_backend_cancel_closed_channel() {
|
|
let (tx, rx) = mpsc::unbounded_channel::<SubagentEvent>();
|
|
drop(rx);
|
|
|
|
let backend = ChannelBackend::new(tx);
|
|
|
|
let outcome = backend.cancel("sub-cancel").await;
|
|
assert!(matches!(outcome, SubagentCancelOutcome::NotFound));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn channel_backend_spawn_result_dropped() {
|
|
let (tx, mut rx) = mpsc::unbounded_channel::<SubagentEvent>();
|
|
let backend = ChannelBackend::new(tx);
|
|
|
|
let handle = tokio::spawn(async move {
|
|
let req = recv_event!(rx, Spawn);
|
|
drop(req.result_tx);
|
|
});
|
|
|
|
let (dummy_tx, _dummy_rx) = oneshot::channel();
|
|
let request = SubagentRequest {
|
|
id: "drop-test".to_string(),
|
|
prompt: "test".to_string(),
|
|
description: "test".to_string(),
|
|
subagent_type: "general-purpose".to_string(),
|
|
parent_session_id: "parent".to_string(),
|
|
parent_prompt_id: None,
|
|
resume_from: None,
|
|
cwd: None,
|
|
runtime_overrides: Default::default(),
|
|
run_in_background: false,
|
|
surface_completion: true,
|
|
fork_context: false,
|
|
result_tx: dummy_tx,
|
|
};
|
|
|
|
let err = backend.spawn(request).await.unwrap_err();
|
|
assert!(
|
|
err.to_string().contains("result channel dropped"),
|
|
"error: {err}"
|
|
);
|
|
|
|
handle.await.unwrap();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn channel_backend_query_closed_channel() {
|
|
let (tx, rx) = mpsc::unbounded_channel::<SubagentEvent>();
|
|
drop(rx);
|
|
|
|
let backend = ChannelBackend::new(tx);
|
|
|
|
let snap = backend.query("sub-1", false, None).await;
|
|
assert!(snap.is_none());
|
|
}
|
|
|
|
// ── validate_type ────────────────────────────────────────────────
|
|
|
|
#[tokio::test]
|
|
async fn channel_backend_validate_type_round_trips_outcome() {
|
|
let (tx, mut rx) = mpsc::unbounded_channel::<SubagentEvent>();
|
|
let backend = ChannelBackend::new(tx);
|
|
|
|
let handle = tokio::spawn(async move {
|
|
let event = rx.recv().await.unwrap();
|
|
match event {
|
|
SubagentEvent::ValidateType(req) => {
|
|
assert_eq!(req.subagent_type, "explore");
|
|
assert_eq!(req.parent_session_id, "parent-1");
|
|
req.respond_to
|
|
.send(SubagentValidateTypeOutcome::Ok)
|
|
.unwrap();
|
|
}
|
|
_ => panic!("Expected ValidateType event"),
|
|
}
|
|
});
|
|
|
|
let outcome = backend.validate_type("explore", "parent-1").await;
|
|
assert!(matches!(outcome, SubagentValidateTypeOutcome::Ok));
|
|
handle.await.unwrap();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn channel_backend_validate_type_propagates_unknown_outcome() {
|
|
let (tx, mut rx) = mpsc::unbounded_channel::<SubagentEvent>();
|
|
let backend = ChannelBackend::new(tx);
|
|
|
|
let handle = tokio::spawn(async move {
|
|
if let Some(SubagentEvent::ValidateType(req)) = rx.recv().await {
|
|
req.respond_to
|
|
.send(SubagentValidateTypeOutcome::Unknown {
|
|
available: vec!["explore".into(), "plan".into()],
|
|
})
|
|
.unwrap();
|
|
}
|
|
});
|
|
|
|
let outcome = backend.validate_type("invented", "p").await;
|
|
match outcome {
|
|
SubagentValidateTypeOutcome::Unknown { available } => {
|
|
assert_eq!(available, vec!["explore".to_string(), "plan".to_string()]);
|
|
}
|
|
other => panic!("expected Unknown, got {other:?}"),
|
|
}
|
|
handle.await.unwrap();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn channel_backend_validate_type_returns_validation_unavailable_when_channel_closed() {
|
|
let (tx, rx) = mpsc::unbounded_channel::<SubagentEvent>();
|
|
drop(rx);
|
|
let backend = ChannelBackend::new(tx);
|
|
let outcome = backend.validate_type("explore", "p").await;
|
|
assert!(matches!(
|
|
outcome,
|
|
SubagentValidateTypeOutcome::ValidationUnavailable
|
|
));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn channel_backend_validate_type_returns_validation_unavailable_when_responder_dropped() {
|
|
let (tx, mut rx) = mpsc::unbounded_channel::<SubagentEvent>();
|
|
let backend = ChannelBackend::new(tx);
|
|
let handle = tokio::spawn(async move {
|
|
if let Some(SubagentEvent::ValidateType(req)) = rx.recv().await {
|
|
drop(req.respond_to);
|
|
}
|
|
});
|
|
let outcome = backend.validate_type("explore", "p").await;
|
|
assert!(matches!(
|
|
outcome,
|
|
SubagentValidateTypeOutcome::ValidationUnavailable,
|
|
));
|
|
handle.await.unwrap();
|
|
}
|
|
|
|
use super::super::types::test_capture;
|
|
|
|
#[tokio::test(start_paused = true)]
|
|
async fn channel_backend_validate_type_logs_warn_on_timeout() {
|
|
let captured = test_capture::capture();
|
|
let (tx, mut rx) = mpsc::unbounded_channel::<SubagentEvent>();
|
|
let backend = ChannelBackend::new(tx);
|
|
|
|
// Coordinator receives but never replies; keeps the responder
|
|
// alive so the timeout arm fires (not responder-dropped).
|
|
let holder = tokio::spawn(async move {
|
|
if let Some(SubagentEvent::ValidateType(req)) = rx.recv().await {
|
|
std::mem::forget(req.respond_to);
|
|
std::future::pending::<()>().await;
|
|
}
|
|
});
|
|
|
|
let validate = tokio::spawn(async move { backend.validate_type("explore", "p").await });
|
|
tokio::time::advance(VALIDATE_TYPE_TIMEOUT + std::time::Duration::from_millis(1)).await;
|
|
let outcome = validate.await.unwrap();
|
|
assert!(matches!(
|
|
outcome,
|
|
SubagentValidateTypeOutcome::ValidationUnavailable
|
|
));
|
|
|
|
let mut events_rx = captured.events_rx;
|
|
let mut saw_timeout_warn = false;
|
|
while let Ok(event) = events_rx.try_recv() {
|
|
if event.level == tracing::Level::WARN
|
|
&& event.fields.contains("coordinator validation timed out")
|
|
&& event.fields.contains("subagent_type=explore")
|
|
&& event.fields.contains("timeout_ms=")
|
|
{
|
|
saw_timeout_warn = true;
|
|
break;
|
|
}
|
|
}
|
|
assert!(saw_timeout_warn, "must emit WARN with timeout_ms field");
|
|
|
|
holder.abort();
|
|
}
|
|
|
|
// ── describe_subagent_type ───────────────────────────────────────
|
|
|
|
#[tokio::test]
|
|
async fn channel_backend_describe_round_trips_summary() {
|
|
use super::super::types::{SubagentDescribeOutcome, SubagentTypeSummary};
|
|
use crate::types::tool::ToolKind;
|
|
|
|
let (tx, mut rx) = mpsc::unbounded_channel::<SubagentEvent>();
|
|
let backend = ChannelBackend::new(tx);
|
|
|
|
let handle = tokio::spawn(async move {
|
|
match rx.recv().await.unwrap() {
|
|
SubagentEvent::DescribeType(req) => {
|
|
assert_eq!(req.subagent_type, "explore");
|
|
assert_eq!(req.harness_agent_type.as_deref(), Some("cursor"));
|
|
assert_eq!(req.parent_session_id, "parent-1");
|
|
let mut summary = SubagentTypeSummary {
|
|
can_read: true,
|
|
can_search: true,
|
|
..Default::default()
|
|
};
|
|
summary
|
|
.tool_names
|
|
.insert(ToolKind::Read, "read_file".to_string());
|
|
req.respond_to
|
|
.send(SubagentDescribeOutcome::Ok(summary))
|
|
.unwrap();
|
|
}
|
|
_ => panic!("Expected DescribeType event"),
|
|
}
|
|
});
|
|
|
|
let outcome = backend
|
|
.describe_subagent_type("explore", Some("cursor"), "parent-1")
|
|
.await;
|
|
match outcome {
|
|
SubagentDescribeOutcome::Ok(summary) => {
|
|
assert!(summary.can_read && summary.can_search && !summary.can_execute);
|
|
assert_eq!(
|
|
summary.tool_names.get(&ToolKind::Read).unwrap(),
|
|
"read_file"
|
|
);
|
|
}
|
|
other => panic!("expected Ok, got {other:?}"),
|
|
}
|
|
handle.await.unwrap();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn channel_backend_describe_propagates_not_allowed_outcome() {
|
|
use super::super::types::SubagentDescribeOutcome;
|
|
|
|
let (tx, mut rx) = mpsc::unbounded_channel::<SubagentEvent>();
|
|
let backend = ChannelBackend::new(tx);
|
|
|
|
let handle = tokio::spawn(async move {
|
|
if let Some(SubagentEvent::DescribeType(req)) = rx.recv().await {
|
|
req.respond_to
|
|
.send(SubagentDescribeOutcome::NotAllowed {
|
|
allowed: vec!["explore".into()],
|
|
})
|
|
.unwrap();
|
|
}
|
|
});
|
|
|
|
match backend.describe_subagent_type("plan", None, "p").await {
|
|
SubagentDescribeOutcome::NotAllowed { allowed } => {
|
|
assert_eq!(allowed, vec!["explore".to_string()]);
|
|
}
|
|
other => panic!("expected NotAllowed, got {other:?}"),
|
|
}
|
|
handle.await.unwrap();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn channel_backend_describe_returns_unavailable_when_channel_closed() {
|
|
use super::super::types::SubagentDescribeOutcome;
|
|
let (tx, rx) = mpsc::unbounded_channel::<SubagentEvent>();
|
|
drop(rx);
|
|
let backend = ChannelBackend::new(tx);
|
|
assert!(matches!(
|
|
backend.describe_subagent_type("explore", None, "p").await,
|
|
SubagentDescribeOutcome::Unavailable
|
|
));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn channel_backend_describe_returns_unavailable_when_responder_dropped() {
|
|
use super::super::types::SubagentDescribeOutcome;
|
|
let (tx, mut rx) = mpsc::unbounded_channel::<SubagentEvent>();
|
|
let backend = ChannelBackend::new(tx);
|
|
let handle = tokio::spawn(async move {
|
|
if let Some(SubagentEvent::DescribeType(req)) = rx.recv().await {
|
|
drop(req.respond_to);
|
|
}
|
|
});
|
|
assert!(matches!(
|
|
backend.describe_subagent_type("explore", None, "p").await,
|
|
SubagentDescribeOutcome::Unavailable
|
|
));
|
|
handle.await.unwrap();
|
|
}
|
|
|
|
#[tokio::test(start_paused = true)]
|
|
async fn channel_backend_describe_returns_unavailable_on_timeout() {
|
|
use super::super::types::SubagentDescribeOutcome;
|
|
let (tx, mut rx) = mpsc::unbounded_channel::<SubagentEvent>();
|
|
let backend = ChannelBackend::new(tx);
|
|
|
|
let holder = tokio::spawn(async move {
|
|
if let Some(SubagentEvent::DescribeType(req)) = rx.recv().await {
|
|
std::mem::forget(req.respond_to);
|
|
std::future::pending::<()>().await;
|
|
}
|
|
});
|
|
|
|
let describe =
|
|
tokio::spawn(async move { backend.describe_subagent_type("explore", None, "p").await });
|
|
tokio::time::advance(VALIDATE_TYPE_TIMEOUT + std::time::Duration::from_millis(1)).await;
|
|
assert!(matches!(
|
|
describe.await.unwrap(),
|
|
SubagentDescribeOutcome::Unavailable
|
|
));
|
|
holder.abort();
|
|
}
|
|
|
|
#[test]
|
|
fn parse_timeout_ms_returns_none_for_unset() {
|
|
assert_eq!(parse_timeout_ms(None), None);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_timeout_ms_returns_none_for_unparseable() {
|
|
assert_eq!(parse_timeout_ms(Some("not-a-number")), None);
|
|
assert_eq!(parse_timeout_ms(Some("")), None);
|
|
assert_eq!(parse_timeout_ms(Some("3.14")), None);
|
|
assert_eq!(parse_timeout_ms(Some("-100")), None);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_timeout_ms_returns_none_for_zero() {
|
|
assert_eq!(parse_timeout_ms(Some("0")), None);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_timeout_ms_returns_value_for_positive_integer() {
|
|
assert_eq!(parse_timeout_ms(Some("5000")), Some(5000));
|
|
assert_eq!(parse_timeout_ms(Some("1")), Some(1));
|
|
}
|
|
}
|