§9 acceptance: grep-zero sweep — every internal x.ai/grok identifier renamed
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.
This commit is contained in:
@@ -0,0 +1,828 @@
|
||||
//! 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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user