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).
646 lines
23 KiB
Rust
646 lines
23 KiB
Rust
//! 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();
|
|
}
|
|
}
|