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:
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user