M0: compilable skeleton — Kigi 0.1.0 fork surgery

Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.

Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
  kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
  ptyctl, ptyctl-cli, third_party/ unchanged; proto package
  xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
  KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
  (templates re-encrypted)

Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
  trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
  module & dc_log, heap-profile uploader, auth-diagnostics uploader,
  session-analytics halves of feedback; local zero-egress observability
  preserved in new kigi-log crate (unified log, --debug firehose,
  subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
  direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
  relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
  ~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
  kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
  session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
  shell util

Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
  https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
  https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
  Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted

Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
  workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
  all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
  exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
  insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean

Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
  (new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
  fast-worktree); RSS measurement tests serialized via serial_test

Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
  notices sustained; kigi-tools ported-code notices extended; README,
  CONTRIBUTING, SECURITY, AGENTS.md rewritten

Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
This commit is contained in:
2026-07-17 05:31:01 -04:00
commit d6c20fc13f
2612 changed files with 1353757 additions and 0 deletions
@@ -0,0 +1,645 @@
//! Achievement-triggered goal summarizer subagent runner.
//!
//! Mirrors [`crate::session::goal_strategist`] in shape but fires on the
//! OPPOSITE condition: exactly ONCE after a goal is verified-ACHIEVED, to
//! produce the closing user-facing summary that becomes the last thing the
//! user reads.
//!
//! Fail-OPEN: the goal is already complete before it runs, so any failure
//! (transport / runtime / cancel / empty output) is logged via
//! `GoalSummarizerFailOpen` and ignored — completion is never blocked.
//!
//! Read-only: the summary IS the subagent's terminal output (no file
//! read-back), and the spawn pins a read-only capability mode.
use crate::session::events::{Event, GoalSummarizerFailReason};
use crate::session::goal_planner::{
GOAL_ROLE_SUBAGENT_TYPE, RoleRenderedPrompt, RoleSpawnOverride, SpawnError,
spawn_with_fail_open_retry,
};
use crate::session::goal_role_tools::RoleToolNames;
use kigi_file_utils::events::EventWriter;
use std::path::Path;
use std::sync::Arc;
// Constants
/// Same general-purpose inventory the other goal roles use; the read-only
/// capability mode (set on the spawn) narrows it to inspect-only tools. A
/// configured `agent_type` selects the HARNESS, not this subagent type.
const GOAL_SUMMARIZER_SUBAGENT_TYPE: &str = GOAL_ROLE_SUBAGENT_TYPE;
/// Description shown in the pager subagent strip and matched by the e2e
/// coordinator stub to distinguish summarizer spawns from other roles.
pub(crate) const GOAL_SUMMARIZER_SUBAGENT_DESCRIPTION: &str = "goal summarizer";
const GOAL_SUMMARIZER_PROMPT_TEMPLATE: &str = include_str!("templates/goal_summarizer_prompt.md");
/// Hard backstop on the surfaced summary length, in chars (`chars().take` is
/// char-boundary-safe). Sits well above a compliant summary — it only clips a
/// model that ignores the prompt's word cap.
const GOAL_SUMMARIZER_SUMMARY_MAX_CHARS: usize = 1200;
// Outcome + spawner abstraction
/// Result of the one summarizer attempt. `Summarized` carries the closing
/// summary text the caller surfaces to the user. `FailOpen` carries the reason
/// — every variant is logged and ignored at the call site (the goal stays
/// completed; only the closing summary is skipped).
#[derive(Debug, Clone)]
#[expect(
dead_code,
reason = "`latency_ms` is consumed by telemetry / future use"
)]
pub(crate) enum GoalSummarizerOutcome {
Summarized {
summary: String,
latency_ms: u64,
},
FailOpen {
reason: GoalSummarizerFailReason,
latency_ms: u64,
},
}
/// Subagent spawn abstraction. Production uses [`ChannelSpawner`]; tests use
/// `MockSpawner` (defined in the tests module). `SpawnError` is reused from the
/// planner module rather than re-declared.
#[async_trait::async_trait]
pub(crate) trait GoalSummarizerSpawner: Send + Sync {
/// Spawn under `id` and return the terminal response (the summary) when the
/// subagent finishes. `prompt` carries the configured-pair render
/// (`primary`) and the default-toolset retry render (`fallback`); the
/// summarizer always inherits, so only `primary` is used.
async fn spawn_summarizer(
&self,
id: &str,
prompt: RoleRenderedPrompt,
) -> Result<String, SpawnError>;
}
// Production spawner
pub(crate) struct ChannelSpawner {
pub(crate) event_tx: tokio::sync::mpsc::UnboundedSender<
kigi_tools::implementations::grok_build::task::types::SubagentEvent,
>,
pub(crate) parent_session_id: String,
pub(crate) parent_prompt_id: Option<String>,
pub(crate) cwd: Option<String>,
/// Event sink for the spawn-and-retry-once fail-open telemetry; `None` in
/// tests / when no event log is wired.
pub(crate) events: Option<EventWriter>,
}
#[async_trait::async_trait]
impl GoalSummarizerSpawner for ChannelSpawner {
async fn spawn_summarizer(
&self,
id: &str,
prompt: RoleRenderedPrompt,
) -> Result<String, SpawnError> {
// The summarizer always inherits the current model + general-purpose
// toolset (no per-role model key), so the wrapper runs a single attempt.
let override_ = RoleSpawnOverride::default();
spawn_with_fail_open_retry(
"summarizer",
None,
&override_,
self.events.as_ref(),
prompt,
|model, harness, prompt| self.send_one(id, prompt, model, harness),
)
.await
}
}
impl ChannelSpawner {
/// Send one spawn (model + harness override resolved by the caller) and
/// await its terminal result. The subagent_type is always
/// [`GOAL_SUMMARIZER_SUBAGENT_TYPE`]; `harness_agent_type` selects the
/// harness flavor (`None` ⇒ session harness). Pins a read-only capability
/// mode so the subagent can inspect but never edit or execute.
async fn send_one(
&self,
id: &str,
prompt: String,
model: Option<String>,
harness_agent_type: Option<String>,
) -> Result<String, SpawnError> {
use kigi_tool_types::SubagentCapabilityMode;
use kigi_tools::implementations::grok_build::task::types::{
SubagentEvent, SubagentRequest, SubagentRuntimeOverrides,
};
let (result_tx, result_rx) = tokio::sync::oneshot::channel();
let request = SubagentRequest {
id: id.to_string(),
prompt,
description: GOAL_SUMMARIZER_SUBAGENT_DESCRIPTION.to_string(),
subagent_type: GOAL_SUMMARIZER_SUBAGENT_TYPE.to_string(),
parent_session_id: self.parent_session_id.clone(),
parent_prompt_id: self.parent_prompt_id.clone(),
resume_from: None,
cwd: self.cwd.clone(),
runtime_overrides: SubagentRuntimeOverrides {
model,
harness_agent_type,
capability_mode: Some(SubagentCapabilityMode::ReadOnly),
..Default::default()
},
run_in_background: false,
// Harness-internal: never surface to the model's idle reminder.
surface_completion: false,
fork_context: false,
result_tx,
};
if self
.event_tx
.send(SubagentEvent::Spawn(Box::new(request)))
.is_err()
{
return Err(SpawnError::Transport(
"subagent coordinator channel closed".to_string(),
));
}
let result = result_rx
.await
.map_err(|_| SpawnError::Transport("subagent result channel dropped".to_string()))?;
if !result.success {
let message = result.error.unwrap_or_else(|| "unknown error".to_string());
return Err(SpawnError::Runtime {
message,
cancelled: result.cancelled,
});
}
Ok(result.output.to_string())
}
}
// Runner
pub(crate) struct GoalSummarizerInputs<'a> {
pub objective: &'a str,
/// The verifier-judged plan (acceptance criteria); read for context. May
/// not exist on disk (planner disabled).
pub plan_file: &'a Path,
/// Path string of the verifier's rescued final details file (used only to
/// substitute `{DETAILS_FILE}` in the prompt; the read-only subagent opens
/// it itself). `None` renders the `(unavailable)` sentinel.
pub details_file: Option<&'a str>,
/// Absolute path to the session traces dir; the summarizer may skim
/// `chat_history.jsonl` for intent.
pub session_traces_dir: &'a Path,
pub attempt: u32,
pub model_id: &'a str,
/// Resolved §7 tool names for the inherited parent toolset.
pub tool_names: &'a RoleToolNames,
}
/// Run the one summarizer attempt. Fail-OPEN: every failure path returns
/// `FailOpen { reason }` and the caller logs it and completes the goal — the
/// summary is the only thing skipped, never the completion.
pub(crate) async fn run_goal_summarizer(
spawner: Arc<dyn GoalSummarizerSpawner>,
inputs: GoalSummarizerInputs<'_>,
emit_event: &dyn Fn(Event),
) -> GoalSummarizerOutcome {
let started = std::time::Instant::now();
emit_event(Event::GoalSummarizerFired {
attempt: inputs.attempt,
model_id: inputs.model_id.to_string(),
});
let plan_file_str = inputs.plan_file.to_string_lossy();
let details_str = inputs.details_file.unwrap_or("(unavailable)");
let traces_dir_str = inputs.session_traces_dir.to_string_lossy();
let with_paths = GOAL_SUMMARIZER_PROMPT_TEMPLATE
.replace("{PLAN_FILE}", &plan_file_str)
.replace("{DETAILS_FILE}", details_str)
.replace("{SESSION_TRACES_DIR}", &traces_dir_str);
let rendered = inputs.tool_names.apply(&with_paths);
let mut prompt_text = String::with_capacity(rendered.len() + inputs.objective.len() + 32);
prompt_text.push_str(&rendered);
prompt_text.push_str("\n\nOBJECTIVE:\n");
prompt_text.push_str(inputs.objective);
prompt_text.push('\n');
// Inherit path (default override never retries): only `primary` is read, so
// `fallback` stays empty — no needless clone of the rendered prompt.
let prompt = RoleRenderedPrompt {
primary: prompt_text,
fallback: String::new(),
};
let spawn_id = uuid::Uuid::now_v7().to_string();
let response = match spawner.spawn_summarizer(&spawn_id, prompt).await {
Ok(text) => text,
Err(SpawnError::Transport(detail)) => {
tracing::warn!(error = %detail, "goal summarizer: transport error; failing open");
return record_fail_open(
GoalSummarizerFailReason::Transport,
inputs.attempt,
started,
emit_event,
);
}
Err(SpawnError::Runtime { message, cancelled }) => {
let reason = if cancelled {
GoalSummarizerFailReason::Aborted
} else {
GoalSummarizerFailReason::Runtime
};
tracing::warn!(
error = %message,
cancelled,
"goal summarizer: subagent runtime error; failing open",
);
return record_fail_open(reason, inputs.attempt, started, emit_event);
}
};
let trimmed = response.trim();
if trimmed.is_empty() {
tracing::info!("goal summarizer: empty summary; failing open");
return record_fail_open(
GoalSummarizerFailReason::EmptySummary,
inputs.attempt,
started,
emit_event,
);
}
// Backstop the prompt's word cap (a model can ignore it). A strict char
// prefix has fewer bytes than the whole, so the byte-length compare below
// detects a real cut.
let mut summary: String = trimmed
.chars()
.take(GOAL_SUMMARIZER_SUMMARY_MAX_CHARS)
.collect();
if summary.len() < trimmed.len() {
summary.push_str(" […]");
}
let latency_ms = started.elapsed().as_millis() as u64;
emit_event(Event::GoalSummarizerCompleted {
attempt: inputs.attempt,
latency_ms,
});
GoalSummarizerOutcome::Summarized {
summary,
latency_ms,
}
}
fn record_fail_open(
reason: GoalSummarizerFailReason,
attempt: u32,
started: std::time::Instant,
emit_event: &dyn Fn(Event),
) -> GoalSummarizerOutcome {
let latency_ms = started.elapsed().as_millis() as u64;
emit_event(Event::GoalSummarizerFailOpen {
reason: reason.as_const_str(),
attempt,
latency_ms,
});
GoalSummarizerOutcome::FailOpen { reason, latency_ms }
}
// Tests
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
#[test]
fn prompt_template_bakes_the_conciseness_cap() {
// The numeric ceilings ARE the contract (exact pins); the surrounding
// phrasing is matched case-insensitively to survive harmless rewording.
let t = GOAL_SUMMARIZER_PROMPT_TEMPLATE;
let lower = t.to_lowercase();
assert!(lower.contains("hard limit"), "cap must be a HARD LIMIT");
assert!(t.contains("80 words"), "cap must state the 80-word limit");
assert!(t.contains("4 bullets"), "cap must state the 4-bullet limit");
assert!(
lower.contains("read-only"),
"summarizer prompt must forbid edits",
);
}
#[test]
fn prompt_render_resolves_tool_placeholders_and_inlines_inputs() {
let dir = tmp_dir("render");
let plan = dir.join("plan.md");
let details = dir.join("details.md");
let plan_file_str = plan.to_string_lossy();
let details_str = details.to_string_lossy();
let traces_dir_str = dir.to_string_lossy();
let with_paths = GOAL_SUMMARIZER_PROMPT_TEMPLATE
.replace("{PLAN_FILE}", &plan_file_str)
.replace("{DETAILS_FILE}", &details_str)
.replace("{SESSION_TRACES_DIR}", &traces_dir_str);
let rendered = RoleToolNames::inherit_defaults().apply(&with_paths);
// §7 tool placeholders resolve to the default grok-build names.
assert!(rendered.contains("read_file"));
assert!(rendered.contains("grep"));
assert!(rendered.contains("list_dir"));
crate::session::goal_role_tools::tests::assert_no_tool_placeholders(&rendered);
}
/// Deterministic spawner: returns a fixed response (or error) and records
/// the prompt it was handed.
struct MockSpawner {
response: Result<String, SpawnError>,
last_prompt: Mutex<Option<String>>,
}
impl MockSpawner {
fn ok(summary: &str) -> Self {
Self {
response: Ok(summary.to_string()),
last_prompt: Mutex::new(None),
}
}
fn fails(err: SpawnError) -> Self {
Self {
response: Err(err),
last_prompt: Mutex::new(None),
}
}
}
#[async_trait::async_trait]
impl GoalSummarizerSpawner for MockSpawner {
async fn spawn_summarizer(
&self,
#[allow(unused_variables)] id: &str,
prompt: RoleRenderedPrompt,
) -> Result<String, SpawnError> {
*self.last_prompt.lock().unwrap() = Some(prompt.primary);
match &self.response {
Ok(text) => Ok(text.clone()),
Err(SpawnError::Transport(s)) => Err(SpawnError::Transport(s.clone())),
Err(SpawnError::Runtime { message, cancelled }) => Err(SpawnError::Runtime {
message: message.clone(),
cancelled: *cancelled,
}),
}
}
}
fn collect_events() -> (Arc<Mutex<Vec<String>>>, impl Fn(Event) + Send + Sync) {
let log: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let log_clone = log.clone();
let emit = move |e: Event| {
let tag = match e {
Event::GoalSummarizerFired { .. } => "fired".to_string(),
Event::GoalSummarizerCompleted { .. } => "completed".to_string(),
Event::GoalSummarizerFailOpen { reason, .. } => format!("fail_open:{reason}"),
other => format!("other:{other:?}"),
};
log_clone.lock().unwrap().push(tag);
};
(log, emit)
}
fn tmp_dir(name: &str) -> PathBuf {
let tmp = std::env::temp_dir().join(format!(
"goal-summarizer-{name}-{}",
uuid::Uuid::new_v4().simple()
));
std::fs::create_dir_all(&tmp).unwrap();
tmp
}
fn inputs<'a>(plan: &'a Path, tool_names: &'a RoleToolNames) -> GoalSummarizerInputs<'a> {
GoalSummarizerInputs {
objective: "do X",
plan_file: plan,
details_file: None,
session_traces_dir: plan.parent().unwrap(),
attempt: 2,
model_id: "grok-test",
tool_names,
}
}
#[tokio::test]
async fn success_path_emits_fired_and_completed_and_returns_summary() {
let dir = tmp_dir("happy");
let plan = dir.join("plan.md");
let tn = RoleToolNames::inherit_defaults();
let spawner = Arc::new(MockSpawner::ok(
" Shipped the feature.\n\n- did a thing\n ",
));
let (log, emit) = collect_events();
let outcome = run_goal_summarizer(spawner, inputs(&plan, &tn), &emit).await;
match outcome {
GoalSummarizerOutcome::Summarized { summary, .. } => {
assert!(summary.starts_with("Shipped the feature."));
assert!(summary.contains("did a thing"));
}
other => panic!("expected Summarized, got {other:?}"),
}
assert_eq!(log.lock().unwrap().as_slice(), ["fired", "completed"]);
}
#[tokio::test]
async fn empty_summary_fails_open() {
let dir = tmp_dir("empty");
let plan = dir.join("plan.md");
let tn = RoleToolNames::inherit_defaults();
let spawner = Arc::new(MockSpawner::ok(" \n \t "));
let (log, emit) = collect_events();
let outcome = run_goal_summarizer(spawner, inputs(&plan, &tn), &emit).await;
assert!(matches!(
outcome,
GoalSummarizerOutcome::FailOpen {
reason: GoalSummarizerFailReason::EmptySummary,
..
}
));
assert!(
log.lock()
.unwrap()
.iter()
.any(|t| t == "fail_open:empty_summary")
);
}
#[tokio::test]
async fn transport_error_fails_open() {
let dir = tmp_dir("transport");
let plan = dir.join("plan.md");
let tn = RoleToolNames::inherit_defaults();
let spawner = Arc::new(MockSpawner::fails(SpawnError::Transport("closed".into())));
let (log, emit) = collect_events();
let outcome = run_goal_summarizer(spawner, inputs(&plan, &tn), &emit).await;
assert!(matches!(
outcome,
GoalSummarizerOutcome::FailOpen {
reason: GoalSummarizerFailReason::Transport,
..
}
));
assert!(
log.lock()
.unwrap()
.iter()
.any(|t| t == "fail_open:transport")
);
}
#[tokio::test]
async fn runtime_cancelled_maps_to_aborted() {
let dir = tmp_dir("aborted");
let plan = dir.join("plan.md");
let tn = RoleToolNames::inherit_defaults();
let spawner = Arc::new(MockSpawner::fails(SpawnError::Runtime {
message: "user aborted".into(),
cancelled: true,
}));
let (log, emit) = collect_events();
let outcome = run_goal_summarizer(spawner, inputs(&plan, &tn), &emit).await;
assert!(matches!(
outcome,
GoalSummarizerOutcome::FailOpen {
reason: GoalSummarizerFailReason::Aborted,
..
}
));
assert!(log.lock().unwrap().iter().any(|t| t == "fail_open:aborted"));
}
#[tokio::test]
async fn runtime_not_cancelled_maps_to_runtime_wire_string() {
let dir = tmp_dir("runtime");
let plan = dir.join("plan.md");
let tn = RoleToolNames::inherit_defaults();
let spawner = Arc::new(MockSpawner::fails(SpawnError::Runtime {
message: "subagent crashed".into(),
cancelled: false,
}));
let (log, emit) = collect_events();
let outcome = run_goal_summarizer(spawner, inputs(&plan, &tn), &emit).await;
assert!(matches!(
outcome,
GoalSummarizerOutcome::FailOpen {
reason: GoalSummarizerFailReason::Runtime,
..
}
));
// Pins the `"runtime"` wire const emitted on `GoalSummarizerFailOpen`.
assert!(log.lock().unwrap().iter().any(|t| t == "fail_open:runtime"));
}
#[tokio::test]
async fn oversized_summary_is_truncated_with_marker() {
let dir = tmp_dir("toolong");
let plan = dir.join("plan.md");
let tn = RoleToolNames::inherit_defaults();
// A runaway wall of text well past the cap.
let wall = "x".repeat(GOAL_SUMMARIZER_SUMMARY_MAX_CHARS + 500);
let spawner = Arc::new(MockSpawner::ok(&wall));
let (_log, emit) = collect_events();
let outcome = run_goal_summarizer(spawner, inputs(&plan, &tn), &emit).await;
let GoalSummarizerOutcome::Summarized { summary, .. } = outcome else {
panic!("expected Summarized");
};
assert!(summary.ends_with(" […]"), "truncation marker appended");
assert_eq!(
summary.chars().count(),
GOAL_SUMMARIZER_SUMMARY_MAX_CHARS + " […]".chars().count(),
"capped at the limit (+ marker)",
);
}
#[tokio::test]
async fn compliant_summary_is_returned_unchanged() {
let dir = tmp_dir("compliant");
let plan = dir.join("plan.md");
let tn = RoleToolNames::inherit_defaults();
let spawner = Arc::new(MockSpawner::ok("Shipped X.\n- a\n- b\nVerified by tests."));
let (_log, emit) = collect_events();
let outcome = run_goal_summarizer(spawner, inputs(&plan, &tn), &emit).await;
let GoalSummarizerOutcome::Summarized { summary, .. } = outcome else {
panic!("expected Summarized");
};
assert_eq!(summary, "Shipped X.\n- a\n- b\nVerified by tests.");
assert!(!summary.contains("[…]"));
}
#[tokio::test]
async fn prompt_carries_objective() {
let dir = tmp_dir("prompt");
let plan = dir.join("plan.md");
let tn = RoleToolNames::inherit_defaults();
let spawner = Arc::new(MockSpawner::ok("ok"));
let captured = spawner.clone();
let (_log, emit) = collect_events();
let _ = run_goal_summarizer(spawner, inputs(&plan, &tn), &emit).await;
let prompt = captured.last_prompt.lock().unwrap().clone().unwrap();
assert!(prompt.contains("OBJECTIVE:\ndo X"));
}
#[tokio::test]
async fn channel_spawner_request_is_harness_internal_and_read_only() {
use kigi_tool_types::SubagentCapabilityMode;
use kigi_tools::implementations::grok_build::task::types::{SubagentEvent, SubagentResult};
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let spawner = ChannelSpawner {
event_tx: tx,
parent_session_id: "parent".into(),
parent_prompt_id: None,
cwd: None,
events: None,
};
let handle = tokio::spawn(async move {
let _ = spawner
.spawn_summarizer(
"sum-id",
RoleRenderedPrompt {
primary: "prompt".into(),
fallback: "prompt".into(),
},
)
.await;
});
let SubagentEvent::Spawn(request) = rx.recv().await.expect("spawn event") else {
panic!("expected Spawn");
};
assert!(
!request.surface_completion,
"summarizer subagent must not surface to the idle reminder"
);
assert_eq!(request.description, GOAL_SUMMARIZER_SUBAGENT_DESCRIPTION);
assert_eq!(
request.runtime_overrides.capability_mode,
Some(SubagentCapabilityMode::ReadOnly),
"summarizer must spawn with a read-only toolset",
);
let _ = request.result_tx.send(SubagentResult::default());
handle.await.unwrap();
}
}