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.
2120 lines
77 KiB
Rust
2120 lines
77 KiB
Rust
//! Headless single-turn mode (`kigi -p "prompt"`).
|
|
//!
|
|
//! Runs the agent in-process via
|
|
//! `spawn_kigi_shell`, sends the ACP lifecycle (init → auth → session → prompt),
|
|
//! streams text to stdout, and exits cleanly via `CancellationToken`.
|
|
|
|
use std::collections::HashSet;
|
|
use std::path::{Path, PathBuf};
|
|
use std::time::{Duration, Instant};
|
|
|
|
use anyhow::Result;
|
|
use clap::ValueEnum;
|
|
use tokio_util::sync::CancellationToken;
|
|
|
|
use agent_client_protocol as acp;
|
|
use kigi_acp_lib::{AcpAgentTx, AcpClientMessageBox, acp_send};
|
|
use kigi_shell::agent::auth_method::AuthMethodKind;
|
|
use kigi_shell::agent::config::Config as AgentConfig;
|
|
use kigi_shell::extensions::task::{CancelSubagentRequest, KillTaskRequest};
|
|
use kigi_shell::sampling::error::{RATE_LIMITED_ERROR_CODE, rate_limited_user_message};
|
|
use kigi_shell::sampling::types::{
|
|
REASONING_EFFORT_META_KEY, parse_canonical_effort_token, reasoning_effort_meta_value,
|
|
};
|
|
use kigi_shell::util::config as cli_config;
|
|
|
|
use crate::acp::model_state::{EffortTokenError, ModelState};
|
|
use crate::acp::spawn::spawn_kigi_shell;
|
|
use crate::client_identity::{HEADLESS_CLIENT_TYPE, PAGER_CLIENT_VERSION};
|
|
|
|
// ── Types ────────────────────────────────────────────────────────────────
|
|
|
|
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, ValueEnum)]
|
|
pub enum OutputFormat {
|
|
#[default]
|
|
Plain,
|
|
Json,
|
|
#[value(name = "streaming-json")]
|
|
StreamingJson,
|
|
}
|
|
|
|
pub fn parse_json_schema(input: &str) -> anyhow::Result<serde_json::Value> {
|
|
let schema: serde_json::Value = serde_json::from_str(input)
|
|
.map_err(|e| anyhow::anyhow!("--json-schema: invalid JSON: {e}"))?;
|
|
if !schema.is_object() {
|
|
anyhow::bail!("--json-schema: must be a JSON object describing a JSON Schema");
|
|
}
|
|
Ok(schema)
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub enum HeadlessPrompt {
|
|
Text(String),
|
|
Blocks(Vec<acp::ContentBlock>),
|
|
}
|
|
|
|
impl HeadlessPrompt {
|
|
/// Build from mutually-exclusive CLI prompt args. `None` = interactive mode.
|
|
pub fn from_args(
|
|
single: Option<&str>,
|
|
prompt_json: Option<&str>,
|
|
prompt_file: Option<&Path>,
|
|
) -> anyhow::Result<Option<Self>> {
|
|
if let Some(text) = single {
|
|
Self::from_text(text)
|
|
.map(Some)
|
|
.map_err(|e| anyhow::anyhow!("--single: {e}"))
|
|
} else if let Some(json_str) = prompt_json {
|
|
Self::from_json(json_str)
|
|
.map(Some)
|
|
.map_err(|e| anyhow::anyhow!("--prompt-json: {e}"))
|
|
} else if let Some(path) = prompt_file {
|
|
Self::from_file(path).map(Some)
|
|
} else {
|
|
Ok(None)
|
|
}
|
|
}
|
|
|
|
/// `.json` files are parsed as content blocks, everything else as text.
|
|
pub fn from_file(path: &Path) -> anyhow::Result<Self> {
|
|
let content = std::fs::read_to_string(path)
|
|
.map_err(|e| anyhow::anyhow!("Failed to read '{}': {e}", path.display()))?;
|
|
|
|
let context = |e| anyhow::anyhow!("'{}': {e}", path.display());
|
|
if path.extension().and_then(|e| e.to_str()) == Some("json") {
|
|
Self::from_json(&content).map_err(context)
|
|
} else {
|
|
Self::from_text(&content).map_err(context)
|
|
}
|
|
}
|
|
|
|
fn from_text(text: &str) -> anyhow::Result<Self> {
|
|
let trimmed = text.trim();
|
|
if trimmed.is_empty() {
|
|
anyhow::bail!("prompt is empty");
|
|
}
|
|
Ok(Self::Text(trimmed.to_string()))
|
|
}
|
|
|
|
fn from_json(json_str: &str) -> anyhow::Result<Self> {
|
|
let blocks = parse_prompt_json(json_str)?;
|
|
Ok(Self::Blocks(blocks))
|
|
}
|
|
|
|
pub fn into_content_blocks(self) -> Vec<acp::ContentBlock> {
|
|
match self {
|
|
Self::Text(text) => vec![acp::ContentBlock::Text(acp::TextContent::new(text))],
|
|
Self::Blocks(blocks) => blocks,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Parse a JSON string into ACP content blocks.
|
|
///
|
|
/// Accepts an array (`[...]`) or typed wrapper (`{"type":"acp","content":[...]}`).
|
|
fn parse_prompt_json(json_str: &str) -> anyhow::Result<Vec<acp::ContentBlock>> {
|
|
let value: serde_json::Value =
|
|
serde_json::from_str(json_str).map_err(|e| anyhow::anyhow!("Invalid JSON: {e}"))?;
|
|
|
|
let blocks: Vec<acp::ContentBlock> = match value {
|
|
serde_json::Value::Array(_) => serde_json::from_value(value)
|
|
.map_err(|e| anyhow::anyhow!("Invalid ACP content blocks: {e}"))?,
|
|
|
|
serde_json::Value::Object(ref map) => {
|
|
let format_type = map.get("type").and_then(|v| v.as_str()).ok_or_else(|| {
|
|
anyhow::anyhow!(
|
|
"JSON object must have a \"type\" field \
|
|
(e.g., {{\"type\": \"acp\", \"content\": [...]}})"
|
|
)
|
|
})?;
|
|
let content = map
|
|
.get("content")
|
|
.ok_or_else(|| anyhow::anyhow!("JSON object must have a \"content\" field"))?;
|
|
|
|
match format_type {
|
|
"acp" => serde_json::from_value(content.clone()).map_err(|e| {
|
|
anyhow::anyhow!("Invalid ACP content blocks in \"content\": {e}")
|
|
})?,
|
|
other => anyhow::bail!(
|
|
"Unsupported prompt format type: \"{other}\". Supported types: \"acp\""
|
|
),
|
|
}
|
|
}
|
|
|
|
_ => {
|
|
anyhow::bail!("Expected JSON array or {{\"type\": \"...\", \"content\": [...]}} object")
|
|
}
|
|
};
|
|
|
|
if blocks.is_empty() {
|
|
anyhow::bail!("content blocks array is empty");
|
|
}
|
|
Ok(blocks)
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct HeadlessOptions {
|
|
pub session_id: Option<String>,
|
|
pub resume: Option<String>,
|
|
pub cwd: Option<PathBuf>,
|
|
pub yolo: bool,
|
|
pub trust: bool,
|
|
pub output_format: OutputFormat,
|
|
pub json_schema: Option<serde_json::Value>,
|
|
pub model: Option<String>,
|
|
pub rules: Option<String>,
|
|
pub system_prompt_override: Option<String>,
|
|
pub continue_last_session: bool,
|
|
/// Fork on resume/continue (`--fork-session`).
|
|
pub fork_session: bool,
|
|
pub worktree: Option<String>,
|
|
pub restore_code: bool,
|
|
pub agent: Option<String>,
|
|
pub agents_json: Option<String>,
|
|
pub cli_tools: Option<String>,
|
|
pub cli_disallowed_tools: Option<String>,
|
|
pub disable_web_search: bool,
|
|
pub allow_rules: Vec<String>,
|
|
pub deny_rules: Vec<String>,
|
|
pub max_turns: Option<u32>,
|
|
pub permission_mode_flag: Option<String>,
|
|
/// Effort token (`--reasoning-effort` / `--effort`); resolved like `/effort` after models load.
|
|
pub reasoning_effort: Option<String>,
|
|
/// Append a self-verification loop after the prompt completes.
|
|
pub self_verify: bool,
|
|
/// Run the task N ways in parallel and pick the best.
|
|
pub best_of_n: Option<u32>,
|
|
/// Wait for background tasks (bash, subagent, monitor) to report
|
|
/// `task_completed` before exiting. Default: true. Does not wait for
|
|
/// server-side auto-wake (that runs inside the shell). Use
|
|
/// `--no-wait-for-background` for fast smoke tests; long-lived monitors
|
|
/// are capped by `background_wait_timeout`.
|
|
pub wait_for_background: bool,
|
|
/// Max time to wait for background quiescence after the first turn ends.
|
|
pub background_wait_timeout: Duration,
|
|
}
|
|
|
|
// ── CLI flag helpers ─────────────────────────────────────────────────────
|
|
|
|
/// Parse a comma-separated list into a vec, or None if empty.
|
|
fn parse_comma_list(s: Option<&str>) -> Option<Vec<String>> {
|
|
s.and_then(|s| {
|
|
let v: Vec<String> = s
|
|
.split(',')
|
|
.map(|t| t.trim().to_string())
|
|
.filter(|t| !t.is_empty())
|
|
.collect();
|
|
if v.is_empty() { None } else { Some(v) }
|
|
})
|
|
}
|
|
|
|
pub fn parse_permission_rules_strict(
|
|
allow: &[String],
|
|
deny: &[String],
|
|
) -> anyhow::Result<Vec<kigi_workspace::permission::types::PermissionRule>> {
|
|
let (rules, errors) = parse_permission_rules_inner(allow, deny);
|
|
if !errors.is_empty() {
|
|
let msgs: Vec<String> = errors
|
|
.into_iter()
|
|
.map(|(flag, rule, err)| format!("{flag} \"{rule}\": {err}"))
|
|
.collect();
|
|
anyhow::bail!("{}", msgs.join("; "));
|
|
}
|
|
Ok(rules)
|
|
}
|
|
|
|
pub fn parse_permission_rules_lenient(
|
|
allow: &[String],
|
|
deny: &[String],
|
|
) -> Vec<kigi_workspace::permission::types::PermissionRule> {
|
|
let (rules, errors) = parse_permission_rules_inner(allow, deny);
|
|
for (flag, rule, err) in errors {
|
|
eprintln!("warning: {flag} \"{rule}\": {err}, skipping");
|
|
}
|
|
rules
|
|
}
|
|
|
|
// Deny rules are processed before allow rules so that after prepending
|
|
// to the config's rule list the order is [cli_deny, cli_allow, config_rules...].
|
|
// The policy evaluator is order-independent (deny > ask > allow), so this
|
|
// ordering is cosmetic for logging/provenance, not functional.
|
|
pub(crate) fn parse_permission_rules_inner(
|
|
allow: &[String],
|
|
deny: &[String],
|
|
) -> (
|
|
Vec<kigi_workspace::permission::types::PermissionRule>,
|
|
Vec<(&'static str, String, String)>,
|
|
) {
|
|
use kigi_workspace::permission::rules::parse_permission_rule;
|
|
use kigi_workspace::permission::types::RuleAction;
|
|
|
|
let mut rules = Vec::new();
|
|
let mut errors = Vec::new();
|
|
for rule_str in deny {
|
|
match parse_permission_rule(rule_str, RuleAction::Deny) {
|
|
Ok(rule) => rules.push(rule),
|
|
Err(e) => errors.push(("--deny", rule_str.clone(), e.to_string())),
|
|
}
|
|
}
|
|
for rule_str in allow {
|
|
match parse_permission_rule(rule_str, RuleAction::Allow) {
|
|
Ok(rule) => rules.push(rule),
|
|
Err(e) => errors.push(("--allow", rule_str.clone(), e.to_string())),
|
|
}
|
|
}
|
|
(rules, errors)
|
|
}
|
|
|
|
pub(crate) enum ResolvedAgent {
|
|
FilePath(PathBuf),
|
|
Name(String),
|
|
}
|
|
|
|
pub(crate) fn resolve_agent_arg(agent: &str) -> ResolvedAgent {
|
|
let path = std::path::Path::new(agent);
|
|
if path.exists() && path.is_file() {
|
|
ResolvedAgent::FilePath(dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()))
|
|
} else {
|
|
ResolvedAgent::Name(agent.to_string())
|
|
}
|
|
}
|
|
|
|
fn parse_cli_agents(json: &str) -> anyhow::Result<Vec<kigi_shell::agent::config::AgentDefinition>> {
|
|
let map: std::collections::HashMap<String, serde_json::Value> =
|
|
serde_json::from_str(json).map_err(|e| anyhow::anyhow!("--agents: invalid JSON: {e}"))?;
|
|
let mut agents = Vec::with_capacity(map.len());
|
|
for (name, mut value) in map {
|
|
if let serde_json::Value::Object(ref mut obj) = value {
|
|
// Accept "prompt" as an alias for "promptBody".
|
|
if !obj.contains_key("promptBody")
|
|
&& let Some(prompt) = obj.remove("prompt")
|
|
{
|
|
obj.insert("promptBody".to_string(), prompt);
|
|
}
|
|
obj.entry("name".to_string())
|
|
.or_insert_with(|| serde_json::Value::String(name.clone()));
|
|
obj.entry("description".to_string())
|
|
.or_insert_with(|| serde_json::Value::String(name.clone()));
|
|
}
|
|
let mut def = kigi_shell::agent::config::AgentDefinition::from_json(&value)
|
|
.map_err(|e| anyhow::anyhow!("--agents: failed to parse '{name}': {e}"))?;
|
|
def.name = name;
|
|
agents.push(def);
|
|
}
|
|
Ok(agents)
|
|
}
|
|
|
|
fn apply_agent_flag(agent: &Option<String>, config: &mut kigi_shell::agent::config::Config) {
|
|
if let Some(agent) = agent {
|
|
match resolve_agent_arg(agent) {
|
|
ResolvedAgent::FilePath(path) => config.agent_profile_path = Some(path),
|
|
ResolvedAgent::Name(name) => config.agent.name = Some(name),
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Emitter ──────────────────────────────────────────────────────────────
|
|
|
|
struct HeadlessEmitter {
|
|
format: OutputFormat,
|
|
parse_structured_output: bool,
|
|
text_buffer: String,
|
|
thought_buffer: String,
|
|
/// Agent's schema-validated output (both backends), read from the
|
|
/// prompt-response `_meta`.
|
|
structured_output: Option<Result<serde_json::Value, String>>,
|
|
/// From `_meta.usage`, projected onto the final result when present.
|
|
usage: Option<serde_json::Value>,
|
|
}
|
|
|
|
impl HeadlessEmitter {
|
|
fn new(format: OutputFormat, parse_structured_output: bool) -> Self {
|
|
Self {
|
|
format,
|
|
parse_structured_output,
|
|
text_buffer: String::new(),
|
|
thought_buffer: String::new(),
|
|
structured_output: None,
|
|
usage: None,
|
|
}
|
|
}
|
|
|
|
/// Read structured output from the prompt-response `_meta` — the same
|
|
/// object headless awaits for `sessionId`/`requestId`, so delivery is
|
|
/// deterministic (no side-channel race). `structuredOutput` carries the
|
|
/// value, `structuredOutputError` the failure; absence leaves `None`.
|
|
fn set_structured_output_from_meta(&mut self, meta: Option<&acp::Meta>) {
|
|
if !self.parse_structured_output {
|
|
return;
|
|
}
|
|
let Some(meta) = meta else { return };
|
|
if let Some(err) = meta.get("structuredOutputError").and_then(|v| v.as_str()) {
|
|
self.structured_output = Some(Err(err.to_string()));
|
|
} else if let Some(value) = meta.get("structuredOutput") {
|
|
self.structured_output = Some(Ok(value.clone()));
|
|
}
|
|
}
|
|
|
|
fn set_usage_from_meta(&mut self, meta: Option<&acp::Meta>) {
|
|
let Some(meta) = meta else { return };
|
|
self.usage = meta.get("usage").cloned();
|
|
}
|
|
|
|
fn on_text_chunk(&mut self, text: &str) {
|
|
match self.format {
|
|
OutputFormat::Plain => {
|
|
use std::io::Write as _;
|
|
print!("{text}");
|
|
let _ = std::io::stdout().flush();
|
|
}
|
|
OutputFormat::StreamingJson => {
|
|
println!("{}", serde_json::json!({"type":"text","data": text}));
|
|
if self.parse_structured_output {
|
|
self.text_buffer.push_str(text);
|
|
}
|
|
}
|
|
OutputFormat::Json => {
|
|
self.text_buffer.push_str(text);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn on_thought_chunk(&mut self, text: &str) {
|
|
match self.format {
|
|
OutputFormat::Plain => { /* no-op */ }
|
|
OutputFormat::StreamingJson => {
|
|
println!("{}", serde_json::json!({"type":"thought","data": text}));
|
|
}
|
|
OutputFormat::Json => {
|
|
self.thought_buffer.push_str(text);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn attach_structured_output(&self, target: &mut serde_json::Value) {
|
|
if !self.parse_structured_output {
|
|
return;
|
|
}
|
|
// The agent is the only source of validated output; never parse the raw
|
|
// text buffer (that would bypass validation). Absent `_meta` output
|
|
// (max-turns/cancel) → a clean error, never unvalidated JSON.
|
|
let result = self
|
|
.structured_output
|
|
.clone()
|
|
.unwrap_or_else(|| Err("model did not produce structured output".to_string()));
|
|
match result {
|
|
Ok(value) => {
|
|
target["structuredOutput"] = value;
|
|
}
|
|
Err(e) => {
|
|
target["structuredOutput"] = serde_json::Value::Null;
|
|
target["structuredOutputError"] = e.into();
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Final object for `--output-format json`, including spend fields when present.
|
|
fn build_json_result(
|
|
&self,
|
|
stop_reason: &str,
|
|
session_id: &str,
|
|
request_id: &str,
|
|
) -> serde_json::Value {
|
|
let mut result = serde_json::json!({
|
|
"text": self.text_buffer,
|
|
"stopReason": stop_reason,
|
|
"sessionId": session_id,
|
|
"requestId": request_id
|
|
});
|
|
if !self.thought_buffer.is_empty() {
|
|
result["thought"] = serde_json::Value::String(self.thought_buffer.clone());
|
|
}
|
|
if let Some(usage) = &self.usage {
|
|
attach_result_usage(&mut result, usage);
|
|
}
|
|
self.attach_structured_output(&mut result);
|
|
result
|
|
}
|
|
|
|
fn on_end(&mut self, stop_reason: &str, session_id: &str, request_id: &str) {
|
|
match self.format {
|
|
OutputFormat::Plain => {
|
|
println!();
|
|
}
|
|
OutputFormat::StreamingJson => {
|
|
let mut end = serde_json::json!({
|
|
"type": "end",
|
|
"stopReason": stop_reason,
|
|
"sessionId": session_id,
|
|
"requestId": request_id
|
|
});
|
|
if let Some(usage) = &self.usage {
|
|
attach_result_usage(&mut end, usage);
|
|
}
|
|
self.attach_structured_output(&mut end);
|
|
println!("{end}");
|
|
}
|
|
OutputFormat::Json => {
|
|
let result = self.build_json_result(stop_reason, session_id, request_id);
|
|
println!(
|
|
"{}",
|
|
serde_json::to_string_pretty(&result).unwrap_or_else(|_| result.to_string())
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn on_error(&self, message: &str) {
|
|
match self.format {
|
|
OutputFormat::Plain => eprintln!("{message}"),
|
|
OutputFormat::StreamingJson | OutputFormat::Json => {
|
|
let mut err = serde_json::json!({"type":"error","message": message});
|
|
if let Some(usage) = &self.usage {
|
|
attach_result_usage(&mut err, usage);
|
|
}
|
|
println!("{err}");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn attach_result_usage(result: &mut serde_json::Value, usage: &serde_json::Value) {
|
|
kigi_shell::extensions::notification::attach_result_usage_fail_closed(result, usage);
|
|
}
|
|
|
|
// ── Helpers ──────────────────────────────────────────────────────────────
|
|
|
|
fn auto_respond_to_permissions(
|
|
args: &acp::RequestPermissionRequest,
|
|
option_kinds: &[acp::PermissionOptionKind],
|
|
) -> Option<acp::RequestPermissionResponse> {
|
|
for &option_kind in option_kinds {
|
|
for option in &args.options {
|
|
if option.kind == option_kind {
|
|
return Some(acp::RequestPermissionResponse::new(
|
|
acp::RequestPermissionOutcome::Selected(acp::SelectedPermissionOutcome::new(
|
|
option.option_id.clone(),
|
|
)),
|
|
));
|
|
}
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
/// "Not signed in" error message, tailored to the session type.
|
|
fn auth_required_message(interactive: bool) -> String {
|
|
if interactive {
|
|
"Not signed in. Run `kigi login` to authenticate with Kimi Code.".to_string()
|
|
} else {
|
|
"Not signed in. Run `kigi login` to authenticate with Kimi Code \
|
|
(the device flow prints a URL you can open on any machine).\n\
|
|
Alternatively, set a Moonshot open-platform API key via \
|
|
KIGI_MOONSHOT_API_KEY."
|
|
.to_string()
|
|
}
|
|
}
|
|
|
|
/// Authenticate using the agent's `defaultAuthMethodId` (source of truth for
|
|
/// `[auth] preferred_method`). Fail closed when no method is available — do not
|
|
/// invent api_key vs session ordering client-side.
|
|
///
|
|
/// Returns whether the selected method is API-key auth (for rate-limit copy).
|
|
async fn authenticate(
|
|
acp_tx: &AcpAgentTx,
|
|
auths: &[acp::AuthMethod],
|
|
default_auth_method_id: Option<&acp::AuthMethodId>,
|
|
) -> anyhow::Result<bool> {
|
|
let method_id = crate::acp::select_eager_auth_method(auths, default_auth_method_id)
|
|
.ok_or_else(|| {
|
|
use std::io::IsTerminal;
|
|
let interactive =
|
|
std::io::stdin().is_terminal() && !kigi_shell::util::clipboard::is_remote_session();
|
|
anyhow::anyhow!("{}", auth_required_message(interactive))
|
|
})?;
|
|
let kind = AuthMethodKind::from_id(&method_id);
|
|
// Prefer non-interactive methods only; interactive login is not usable headless.
|
|
if kind.needs_interactive_login() {
|
|
use std::io::IsTerminal;
|
|
let interactive =
|
|
std::io::stdin().is_terminal() && !kigi_shell::util::clipboard::is_remote_session();
|
|
anyhow::bail!("{}", auth_required_message(interactive));
|
|
}
|
|
let is_api_key_auth = kind.is_api_key();
|
|
let _resp: acp::AuthenticateResponse = acp_send(
|
|
acp::AuthenticateRequest::new(method_id)
|
|
.meta(serde_json::json!({"headless": true}).as_object().cloned()),
|
|
acp_tx,
|
|
)
|
|
.await?;
|
|
Ok(is_api_key_auth)
|
|
}
|
|
|
|
fn build_headless_init_request(
|
|
rules: Option<&str>,
|
|
system_prompt_override: Option<&str>,
|
|
) -> acp::InitializeRequest {
|
|
let mut meta = serde_json::json!({
|
|
"clientType": HEADLESS_CLIENT_TYPE,
|
|
"clientVersion": PAGER_CLIENT_VERSION,
|
|
});
|
|
if let Some(rules) = rules {
|
|
meta["rules"] = serde_json::json!(rules);
|
|
}
|
|
if let Some(system_prompt_override) = system_prompt_override {
|
|
meta["systemPromptOverride"] = serde_json::json!(system_prompt_override);
|
|
}
|
|
meta["startupHints"] = serde_json::json!({
|
|
"nonInteractive": true,
|
|
"skipGitStatus": true,
|
|
"skipProjectLayout": true,
|
|
});
|
|
|
|
acp::InitializeRequest::new(acp::ProtocolVersion::V1)
|
|
.client_capabilities(
|
|
acp::ClientCapabilities::new()
|
|
.fs(acp::FileSystemCapabilities::new())
|
|
.terminal(false),
|
|
)
|
|
.meta(meta.as_object().cloned())
|
|
}
|
|
|
|
/// Extract the body of a compiled-in SKILL.md (strip YAML frontmatter).
|
|
fn skill_body(raw: &str) -> &str {
|
|
let trimmed = raw.trim_start();
|
|
if !trimmed.starts_with("---") {
|
|
return trimmed;
|
|
}
|
|
if let Some(rest) = trimmed.get(3..)
|
|
&& let Some(end) = rest.find("\n---")
|
|
{
|
|
return rest[end + 4..].trim_start();
|
|
}
|
|
trimmed
|
|
}
|
|
|
|
struct OpenedSession {
|
|
session_id: acp::SessionId,
|
|
models: ModelState,
|
|
}
|
|
|
|
async fn open_session(
|
|
acp_tx: &AcpAgentTx,
|
|
cwd: &Path,
|
|
session_id_flag: Option<&str>,
|
|
restore_code: Option<bool>,
|
|
) -> anyhow::Result<OpenedSession> {
|
|
// Pager opens sessions before the agent resolves per-vendor compat;
|
|
// default (all-on) preserves existing behavior — the agent applies
|
|
// the resolved config once the session is live.
|
|
let mcp_servers =
|
|
cli_config::load_mcp_servers(cwd, &kigi_tools::types::compat::CompatConfig::default());
|
|
|
|
if let Some(sid) = session_id_flag {
|
|
let try_load: Result<acp::LoadSessionResponse, _> = acp_send(
|
|
acp::LoadSessionRequest::new(acp::SessionId::new(sid.to_string()), cwd.to_path_buf())
|
|
.mcp_servers(mcp_servers.clone())
|
|
.meta({
|
|
let mut m = acp::Meta::new();
|
|
m.insert("noReplay".into(), serde_json::Value::Bool(true));
|
|
if let Some(true) = restore_code {
|
|
m.insert("kigi/restore_code".into(), serde_json::Value::Bool(true));
|
|
}
|
|
Some(m)
|
|
}),
|
|
acp_tx,
|
|
)
|
|
.await;
|
|
if let Ok(resp) = try_load {
|
|
return Ok(OpenedSession {
|
|
session_id: acp::SessionId::new(sid.to_string()),
|
|
models: ModelState::from(resp.models),
|
|
});
|
|
}
|
|
anyhow::bail!("Session does not exist");
|
|
}
|
|
|
|
let new_resp: acp::NewSessionResponse = acp_send(
|
|
acp::NewSessionRequest::new(cwd.to_path_buf()).mcp_servers(mcp_servers),
|
|
acp_tx,
|
|
)
|
|
.await?;
|
|
Ok(OpenedSession {
|
|
session_id: new_resp.session_id,
|
|
models: ModelState::from(new_resp.models),
|
|
})
|
|
}
|
|
|
|
async fn open_session_with_id(
|
|
acp_tx: &AcpAgentTx,
|
|
cwd: &Path,
|
|
session_id: &str,
|
|
) -> anyhow::Result<OpenedSession> {
|
|
let cwd_str = cwd.to_string_lossy();
|
|
crate::app::session_startup::ensure_session_id_available(session_id, &cwd_str)?;
|
|
let mcp_servers =
|
|
cli_config::load_mcp_servers(cwd, &kigi_tools::types::compat::CompatConfig::default());
|
|
let new_resp: acp::NewSessionResponse = acp_send(
|
|
acp::NewSessionRequest::new(cwd.to_path_buf())
|
|
.mcp_servers(mcp_servers)
|
|
.meta(
|
|
serde_json::json!({ "sessionId": session_id })
|
|
.as_object()
|
|
.cloned(),
|
|
),
|
|
acp_tx,
|
|
)
|
|
.await?;
|
|
Ok(OpenedSession {
|
|
session_id: new_resp.session_id,
|
|
models: ModelState::from(new_resp.models),
|
|
})
|
|
}
|
|
|
|
async fn fork_then_open(
|
|
acp_tx: &AcpAgentTx,
|
|
launch_cwd: &Path,
|
|
parent_id: &str,
|
|
parent_cwd: Option<&Path>,
|
|
new_id: Option<&str>,
|
|
restore_code: Option<bool>,
|
|
) -> anyhow::Result<OpenedSession> {
|
|
use crate::app::session_startup::{
|
|
effective_fork_new_cwd, ensure_session_id_available, fork_response_error,
|
|
fork_response_new_session_id, fork_session_params, parent_session_is_worktree,
|
|
};
|
|
let launch_cwd_str = launch_cwd.to_string_lossy().into_owned();
|
|
// Align with interactive: child lands under parent session cwd when the
|
|
// parent was resolved from another directory (`newCwd` = parent_cwd).
|
|
let new_cwd_str = effective_fork_new_cwd(&launch_cwd_str, parent_cwd);
|
|
let write_cwd = PathBuf::from(&new_cwd_str);
|
|
if let Some(nid) = new_id {
|
|
ensure_session_id_available(nid, &new_cwd_str)?;
|
|
}
|
|
let parent_is_worktree = parent_session_is_worktree(parent_id, &write_cwd);
|
|
let payload = fork_session_params(parent_id, &write_cwd, new_id, parent_is_worktree);
|
|
let req = acp::ExtRequest::new(
|
|
"kigi/session/fork",
|
|
serde_json::value::to_raw_value(&payload)
|
|
.expect("serialize fork params")
|
|
.into(),
|
|
);
|
|
let resp = acp_send(req, acp_tx).await?;
|
|
if let Some(err) = fork_response_error(resp.0.get()) {
|
|
anyhow::bail!("fork failed: {err}");
|
|
}
|
|
let child = fork_response_new_session_id(resp.0.get())
|
|
.ok_or_else(|| anyhow::anyhow!("fork response missing newSessionId"))?;
|
|
match open_session(acp_tx, &write_cwd, Some(&child), restore_code).await {
|
|
Ok(opened) => Ok(opened),
|
|
Err(e) => Err(anyhow::anyhow!(
|
|
"fork succeeded as {child} but load failed: {e}"
|
|
)),
|
|
}
|
|
}
|
|
|
|
/// Apply `-m` / effort after session open (via `resolve_effort_for_model`, then
|
|
/// SetSessionModel).
|
|
///
|
|
/// Headless maps the classified [`EffortTokenError`] differently from the TUI: a
|
|
/// one-shot run soft-ignores effort on a non-supporting model (still applying
|
|
/// `-m`) but hard-fails on a genuinely unknown token. The TUI instead keeps the
|
|
/// `-m` switch and only toasts — intentional, since headless has no scrollback
|
|
/// to carry a non-fatal warning.
|
|
async fn apply_headless_model_and_effort(
|
|
acp_tx: &AcpAgentTx,
|
|
session_id: &acp::SessionId,
|
|
models: &ModelState,
|
|
model_name: Option<&str>,
|
|
effort_token: Option<&str>,
|
|
) -> anyhow::Result<()> {
|
|
if model_name.is_none() && effort_token.is_none() {
|
|
return Ok(());
|
|
}
|
|
|
|
let model_id = if let Some(name) = model_name {
|
|
models
|
|
.resolve_by_name_or_id(name)
|
|
.unwrap_or_else(|| acp::ModelId::new(name))
|
|
} else {
|
|
models.current.clone().ok_or_else(|| {
|
|
anyhow::anyhow!("--effort/--reasoning-effort: no active model to apply effort to")
|
|
})?
|
|
};
|
|
|
|
let effort = match effort_token {
|
|
None => None,
|
|
// Pre-catalog: the canonical token was already stamped into the agent
|
|
// config; a remapped menu id can't resolve without a loaded catalog.
|
|
Some(token) if models.available.is_empty() => {
|
|
if parse_canonical_effort_token(token).is_none() {
|
|
// Do not hardcode a level list here: without a catalog we cannot
|
|
// know what the model offers, and advertising none/minimal/… has
|
|
// led users to try values that then 400 on the API.
|
|
anyhow::bail!(
|
|
"--effort/--reasoning-effort: unknown effort level '{token}' \
|
|
(model catalog unavailable; remapped menu ids require a loaded catalog)"
|
|
);
|
|
}
|
|
None
|
|
}
|
|
Some(token) => match models.resolve_effort_for_model(&model_id, token) {
|
|
Ok(effort) => Some(effort),
|
|
// Soft-ignore effort on a non-supporting model; still apply `-m`.
|
|
Err(EffortTokenError::Unsupported) => {
|
|
tracing::warn!(
|
|
model = %model_id.0,
|
|
token,
|
|
"--effort/--reasoning-effort: model does not support reasoning effort; ignoring"
|
|
);
|
|
None
|
|
}
|
|
Err(err) => anyhow::bail!("--effort/--reasoning-effort: {}", err.message()),
|
|
},
|
|
};
|
|
|
|
// Nothing to apply (effort pre-stamped or ignored, and no model override):
|
|
// skip the no-op SetSessionModel.
|
|
if model_name.is_none() && effort.is_none() {
|
|
return Ok(());
|
|
}
|
|
|
|
let meta = effort.map(|eff| {
|
|
let mut m = acp::Meta::new();
|
|
m.insert(
|
|
REASONING_EFFORT_META_KEY.to_string(),
|
|
reasoning_effort_meta_value(eff),
|
|
);
|
|
m
|
|
});
|
|
|
|
acp_send(
|
|
acp::SetSessionModelRequest::new(session_id.clone(), model_id.clone()).meta(meta),
|
|
acp_tx,
|
|
)
|
|
.await
|
|
.map_err(|e| {
|
|
if let Some(name) = model_name {
|
|
anyhow::anyhow!(
|
|
"Couldn't set model '{}': {}. Run 'kigi models' to see available models.",
|
|
name,
|
|
e
|
|
)
|
|
} else {
|
|
anyhow::anyhow!("Couldn't apply reasoning effort: {e}")
|
|
}
|
|
})?;
|
|
tracing::debug!(
|
|
model_id = %model_id.0,
|
|
effort = ?effort,
|
|
"headless: model/effort set"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
// ── Main entry point ─────────────────────────────────────────────────────
|
|
|
|
/// Startup-materialization context for headless (`-p`) runs. Never chat:
|
|
/// `HeadlessOptions` carries no chat flag, so headless resume targets are
|
|
/// always disk/GCS Build sessions.
|
|
fn headless_materialize_ctx(has_worktree: bool) -> crate::app::session_startup::MaterializeCtx {
|
|
crate::app::session_startup::MaterializeCtx {
|
|
has_worktree,
|
|
allow_remote_restore: false,
|
|
chat_mode: false,
|
|
}
|
|
}
|
|
|
|
/// Run a headless single-turn prompt.
|
|
///
|
|
/// Spawns the agent in-process, runs the full ACP lifecycle (init → auth →
|
|
/// session → prompt), streams output to stdout, and returns cleanly.
|
|
pub async fn run_single_turn(
|
|
prompt: HeadlessPrompt,
|
|
verbatim: bool,
|
|
options: HeadlessOptions,
|
|
) -> Result<()> {
|
|
// Stamp proxy requests as headless before the agent spawns and issues
|
|
// its first request (auth enrichment, model list, etc.).
|
|
|
|
let cwd = match options.cwd {
|
|
None => std::env::current_dir()?,
|
|
Some(ref p) => dunce::canonicalize(p)?,
|
|
};
|
|
|
|
let mut emitter = HeadlessEmitter::new(options.output_format, options.json_schema.is_some());
|
|
|
|
// Load config and spawn agent
|
|
let t_spawn = Instant::now();
|
|
let raw_config = kigi_shell::config::load_effective_config()
|
|
.map_err(|e| anyhow::anyhow!("Failed to load config: {e}"))?;
|
|
let mut agent_config = AgentConfig::new_from_toml_cfg(&raw_config)
|
|
.map_err(|e| anyhow::anyhow!("Failed to create agent config: {e}"))?;
|
|
|
|
// Canonical-only early stamp; remaps need the post-session catalog resolve below.
|
|
if let Some(ref token) = options.reasoning_effort
|
|
&& let Some(effort) = parse_canonical_effort_token(token)
|
|
{
|
|
agent_config.reasoning_effort_override = Some(effort);
|
|
}
|
|
// So initial system prompt / `system_prompt_label` use `-m`, not a later SetSessionModel.
|
|
if let Some(ref model) = options.model {
|
|
agent_config.default_model_override = Some(model.clone());
|
|
}
|
|
|
|
agent_config.resolve_runtime_fields(&kigi_shell::agent::config::RuntimeResolutionContext {
|
|
raw_config: &raw_config,
|
|
remote_settings: None,
|
|
cwd: Some(&cwd),
|
|
is_headless: true,
|
|
cli_subagents: None,
|
|
cli_session_summary_model: None,
|
|
cli_experimental_memory: false,
|
|
cli_no_memory: false,
|
|
disable_web_search: options.disable_web_search,
|
|
todo_gate: false,
|
|
laziness_debug_log: None,
|
|
storage_mode: None,
|
|
});
|
|
|
|
agent_config.mode = kigi_shell::agent::config::AgentMode::Headless;
|
|
agent_config.default_yolo_mode = options.yolo;
|
|
// Remote arg is None: the remote settings permission_mode soft-default is
|
|
// TUI-only; headless runs must not change permission behavior on a
|
|
// remote flag flip.
|
|
agent_config.default_auto_mode = kigi_shell::util::config::effective_auto_for_launch(
|
|
options.yolo,
|
|
options.permission_mode_flag.as_deref(),
|
|
None,
|
|
);
|
|
|
|
apply_agent_flag(&options.agent, &mut agent_config);
|
|
|
|
if let Some(ref json) = options.agents_json {
|
|
agent_config.cli_agents = parse_cli_agents(json)?;
|
|
}
|
|
|
|
agent_config.cli_agent_overrides = kigi_shell::agent::config::CliAgentOverrides {
|
|
tools: parse_comma_list(options.cli_tools.as_deref()),
|
|
disallowed_tools: parse_comma_list(options.cli_disallowed_tools.as_deref()),
|
|
permission_rules: parse_permission_rules_strict(&options.allow_rules, &options.deny_rules)?,
|
|
max_turns: options.max_turns,
|
|
permission_mode: options
|
|
.permission_mode_flag
|
|
.as_deref()
|
|
.map(|s| {
|
|
serde_json::from_value(serde_json::Value::String(s.to_string()))
|
|
.map_err(|e| anyhow::anyhow!("--permission-mode: invalid value: {e}"))
|
|
})
|
|
.transpose()?,
|
|
};
|
|
|
|
// Persist an explicit --trust grant before the agent starts.
|
|
if options.trust {
|
|
kigi_shell::agent::folder_trust::grant_folder_trust(&cwd);
|
|
}
|
|
|
|
let cancel = CancellationToken::new();
|
|
let memory_config = agent_config.memory_config.clone();
|
|
let spawned = match spawn_kigi_shell(agent_config, &cancel, memory_config).await {
|
|
Ok(s) => s,
|
|
Err(e) => {
|
|
let msg = format!("Couldn't start session: {e}");
|
|
emitter.on_error(&msg);
|
|
anyhow::bail!("{msg}");
|
|
}
|
|
};
|
|
let (acp_tx, mut acp_rx) = (spawned.channel.tx, spawned.channel.rx);
|
|
crate::unified_log::init(acp_tx.clone());
|
|
crate::unified_log::info(
|
|
"pager started",
|
|
None,
|
|
Some(serde_json::json!({"mode": "headless"})),
|
|
);
|
|
crate::unified_log::flush();
|
|
|
|
// Initialize with headless hints
|
|
let init_req = build_headless_init_request(
|
|
options.rules.as_deref(),
|
|
options.system_prompt_override.as_deref(),
|
|
);
|
|
let init_resp: acp::InitializeResponse = match acp_send(init_req, &acp_tx).await {
|
|
Ok(r) => r,
|
|
Err(e) => {
|
|
let msg = format!("Couldn't initialize: {e}");
|
|
emitter.on_error(&msg);
|
|
cancel.cancel();
|
|
anyhow::bail!("{msg}");
|
|
}
|
|
};
|
|
tracing::debug!(
|
|
elapsed_ms = t_spawn.elapsed().as_millis() as u64,
|
|
"headless: spawn + initialize complete"
|
|
);
|
|
|
|
// Authenticate using agent defaultAuthMethodId (preferred_method pin).
|
|
let t_auth = Instant::now();
|
|
let default_auth_method_id = crate::acp::parse_default_auth_method_id(init_resp.meta.as_ref());
|
|
let is_api_key_auth = match authenticate(
|
|
&acp_tx,
|
|
&init_resp.auth_methods,
|
|
default_auth_method_id.as_ref(),
|
|
)
|
|
.await
|
|
{
|
|
Ok(is_api_key) => is_api_key,
|
|
Err(e) => {
|
|
emitter.on_error(&e.to_string());
|
|
cancel.cancel();
|
|
return Err(e);
|
|
}
|
|
};
|
|
tracing::debug!(
|
|
elapsed_ms = t_auth.elapsed().as_millis() as u64,
|
|
"headless: authenticate complete"
|
|
);
|
|
|
|
// Same intent + materialize path as interactive (shared SSOT).
|
|
use crate::app::session_startup::{self, MaterializedStartup, SessionStartupFlags};
|
|
let has_resume_id = options.resume.as_deref().filter(|s| !s.is_empty());
|
|
let resume_most_recent = options.resume.as_deref() == Some("");
|
|
let intent = session_startup::session_startup_intent_from_flags(SessionStartupFlags {
|
|
session_id: options.session_id.as_deref(),
|
|
resume_session_id: has_resume_id,
|
|
resume_most_recent,
|
|
continue_last_session: options.continue_last_session,
|
|
fork_session: options.fork_session,
|
|
has_worktree: options.worktree.is_some(),
|
|
})
|
|
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
|
|
|
let cwd_str = cwd.to_string_lossy().to_string();
|
|
let materialized = session_startup::materialize_startup_for_cwd(
|
|
headless_materialize_ctx(options.worktree.is_some()),
|
|
intent,
|
|
&cwd_str,
|
|
)
|
|
.await?;
|
|
|
|
// Open session
|
|
let restore_code = options.restore_code.then_some(true);
|
|
let t_session = Instant::now();
|
|
let opened = match materialized {
|
|
MaterializedStartup::NewAuto => open_session(&acp_tx, &cwd, None, None).await,
|
|
MaterializedStartup::NewWithId { session_id } => {
|
|
open_session_with_id(&acp_tx, &cwd, &session_id).await
|
|
}
|
|
MaterializedStartup::Resume {
|
|
session_id,
|
|
original_cwd,
|
|
..
|
|
} => {
|
|
let load_cwd = original_cwd.as_deref().unwrap_or(cwd.as_path());
|
|
open_session(&acp_tx, load_cwd, Some(session_id.as_str()), restore_code).await
|
|
}
|
|
MaterializedStartup::Fork {
|
|
parent_session_id,
|
|
parent_cwd,
|
|
new_session_id,
|
|
..
|
|
} => {
|
|
fork_then_open(
|
|
&acp_tx,
|
|
&cwd,
|
|
&parent_session_id,
|
|
parent_cwd.as_deref(),
|
|
new_session_id.as_deref(),
|
|
restore_code,
|
|
)
|
|
.await
|
|
}
|
|
};
|
|
let OpenedSession {
|
|
session_id,
|
|
models: session_models,
|
|
} = match opened {
|
|
Ok(v) => v,
|
|
Err(e) => {
|
|
let msg = format!("Couldn't create session: {e}");
|
|
emitter.on_error(&msg);
|
|
cancel.cancel();
|
|
anyhow::bail!("{msg}");
|
|
}
|
|
};
|
|
tracing::debug!(
|
|
elapsed_ms = t_session.elapsed().as_millis() as u64,
|
|
session_id = %session_id.0,
|
|
"headless: open_session complete"
|
|
);
|
|
|
|
// Debug: track headless sessions in active_sessions.json when env var is set.
|
|
let track_active = std::env::var("KIGI_TRACK_HEADLESS").is_ok();
|
|
if track_active {
|
|
let _ = kigi_shell::active_sessions::register(kigi_shell::active_sessions::ActiveSession {
|
|
session_id: session_id.clone(),
|
|
pid: std::process::id(),
|
|
cwd: cwd.display().to_string(),
|
|
opened_at: chrono::Utc::now(),
|
|
});
|
|
}
|
|
|
|
if let Err(e) = apply_headless_model_and_effort(
|
|
&acp_tx,
|
|
&session_id,
|
|
&session_models,
|
|
options.model.as_deref(),
|
|
options.reasoning_effort.as_deref(),
|
|
)
|
|
.await
|
|
{
|
|
let msg = e.to_string();
|
|
emitter.on_error(&msg);
|
|
cancel.cancel();
|
|
anyhow::bail!("{msg}");
|
|
}
|
|
|
|
// Send prompt and stream response
|
|
let mut prompt_blocks = prompt.into_content_blocks();
|
|
|
|
// --check / --self-verify: append the check skill AFTER the user prompt
|
|
// so the model completes the task first, then runs verification.
|
|
if options.self_verify {
|
|
prompt_blocks.push(acp::ContentBlock::Text(acp::TextContent::new(
|
|
skill_body(kigi_shell::builtin::CHECK_SKILL_MD).to_string(),
|
|
)));
|
|
}
|
|
|
|
// --best-of-n N: prefix the user prompt with the compiled-in best-of-n
|
|
// skill content and the candidate count.
|
|
if let Some(n) = options.best_of_n {
|
|
let n = n.clamp(2, 10);
|
|
{
|
|
prompt_blocks.insert(
|
|
0,
|
|
acp::ContentBlock::Text(acp::TextContent::new(format!(
|
|
"{}\n\n## Number of candidates: {n}",
|
|
skill_body(kigi_shell::builtin::BEST_OF_N_SKILL_MD)
|
|
))),
|
|
);
|
|
}
|
|
}
|
|
|
|
let prompt_meta = {
|
|
let mut meta = serde_json::Map::new();
|
|
if verbatim {
|
|
meta.insert("verbatim".to_string(), serde_json::Value::Bool(true));
|
|
}
|
|
if let Some(ref schema) = options.json_schema {
|
|
meta.insert("outputSchema".to_string(), schema.clone());
|
|
}
|
|
// Screen-mode telemetry (`prompt_submitted.screen_mode`): headless is
|
|
// its own mode, distinct from the TUI's fullscreen/inline/minimal.
|
|
meta.insert(
|
|
"screenMode".to_string(),
|
|
serde_json::Value::String("headless".to_string()),
|
|
);
|
|
Some(meta)
|
|
};
|
|
|
|
let request = acp::PromptRequest::new(session_id.clone(), prompt_blocks).meta(prompt_meta);
|
|
let t_prompt = Instant::now();
|
|
let mut ttf_logged = false;
|
|
let mut prompt_fut = Box::pin(acp_send(request, &acp_tx));
|
|
let mut prompt_result = None;
|
|
// Pending background work: bash/monitor via kigi/task_backgrounded +
|
|
// task_completed; background subagents via SubagentSpawned + SubagentFinished
|
|
// on kigi/session_notification (prefixed `subagent:{id}` in pending_bg).
|
|
// Tracked regardless of wait_for_background so the exit reaper always
|
|
// sees still-running work; the flag only gates waiting.
|
|
// No idle/quiet polling and no wait for server-side auto-wake text — exit
|
|
// when lifecycle sets are empty. Auto-wake may still be in flight at exit.
|
|
let mut pending_bg: HashSet<String> = HashSet::new();
|
|
// task_completed can arrive before task_backgrounded; remember those IDs
|
|
// so a late backgrounded does not re-arm waiting.
|
|
let mut completed_before_bg: HashSet<String> = HashSet::new();
|
|
let mut prompt_done_at: Option<Instant> = None;
|
|
|
|
loop {
|
|
// First turn done and no tracked bg/monitor tasks still running.
|
|
// Drain buffered ACP first: PromptResponse can complete while
|
|
// task_backgrounded is still queued on acp_rx (never reached select!).
|
|
if options.wait_for_background && prompt_result.is_some() && pending_bg.is_empty() {
|
|
while let Ok(msg) = acp_rx.try_recv() {
|
|
handle_headless_acp_message(
|
|
msg.boxed(),
|
|
&mut emitter,
|
|
t_prompt,
|
|
&mut ttf_logged,
|
|
options.yolo,
|
|
options.output_format,
|
|
&mut pending_bg,
|
|
&mut completed_before_bg,
|
|
);
|
|
}
|
|
if pending_bg.is_empty() {
|
|
tracing::debug!("headless: no pending background tasks, exiting");
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Safety valve so evals don't hang on long-lived monitors or stuck tasks.
|
|
if options.wait_for_background
|
|
&& let Some(done_at) = prompt_done_at
|
|
&& done_at.elapsed() >= options.background_wait_timeout
|
|
{
|
|
tracing::warn!(
|
|
pending_bg = pending_bg.len(),
|
|
timeout_secs = options.background_wait_timeout.as_secs(),
|
|
"headless: background wait timed out, exiting"
|
|
);
|
|
break;
|
|
}
|
|
|
|
// Only needed while waiting on tasks (timeout enforcement); otherwise
|
|
// the loop blocks on ACP until task_completed or PromptResponse.
|
|
let timeout_deadline = if options.wait_for_background
|
|
&& prompt_result.is_some()
|
|
&& !pending_bg.is_empty()
|
|
&& let Some(done_at) = prompt_done_at
|
|
{
|
|
let remaining = options
|
|
.background_wait_timeout
|
|
.saturating_sub(done_at.elapsed());
|
|
if remaining.is_zero() {
|
|
Duration::from_millis(50)
|
|
} else {
|
|
remaining
|
|
}
|
|
} else {
|
|
Duration::from_secs(3600)
|
|
};
|
|
|
|
tokio::select! {
|
|
biased;
|
|
msg = acp_rx.recv() => {
|
|
let Some(msg) = msg else {
|
|
emitter.on_error("Connection closed unexpectedly");
|
|
cancel.cancel();
|
|
anyhow::bail!("Connection closed unexpectedly");
|
|
};
|
|
handle_headless_acp_message(
|
|
msg.boxed(),
|
|
&mut emitter,
|
|
t_prompt,
|
|
&mut ttf_logged,
|
|
options.yolo,
|
|
options.output_format,
|
|
&mut pending_bg,
|
|
&mut completed_before_bg,
|
|
);
|
|
}
|
|
res = &mut prompt_fut, if prompt_result.is_none() => {
|
|
prompt_result = Some(res);
|
|
prompt_done_at = Some(Instant::now());
|
|
if !options.wait_for_background {
|
|
// Drain notifications already queued ahead of the response.
|
|
while let Ok(msg) = acp_rx.try_recv() {
|
|
handle_headless_acp_message(
|
|
msg.boxed(),
|
|
&mut emitter,
|
|
t_prompt,
|
|
&mut ttf_logged,
|
|
options.yolo,
|
|
options.output_format,
|
|
&mut pending_bg,
|
|
&mut completed_before_bg,
|
|
);
|
|
}
|
|
break;
|
|
}
|
|
// With wait_for_background: keep draining ACP for task_completed.
|
|
}
|
|
_ = tokio::time::sleep(timeout_deadline), if options.wait_for_background
|
|
&& prompt_result.is_some()
|
|
&& !pending_bg.is_empty() =>
|
|
{
|
|
// Wake to re-check background_wait_timeout at the top of the loop.
|
|
}
|
|
}
|
|
}
|
|
|
|
// Track lifecycle notifications still queued at loop exit so the reaper
|
|
// sees them (the timeout path breaks without draining).
|
|
while let Ok(msg) = acp_rx.try_recv() {
|
|
handle_headless_acp_message(
|
|
msg.boxed(),
|
|
&mut emitter,
|
|
t_prompt,
|
|
&mut ttf_logged,
|
|
options.yolo,
|
|
options.output_format,
|
|
&mut pending_bg,
|
|
&mut completed_before_bg,
|
|
);
|
|
}
|
|
|
|
// Kill background tasks/subagents still pending at exit (background-wait
|
|
// timeout or --no-wait-for-background) so they don't outlive the process.
|
|
if !pending_bg.is_empty() {
|
|
tracing::warn!(
|
|
pending_bg = pending_bg.len(),
|
|
"headless: killing background work still pending at exit"
|
|
);
|
|
reap_pending_background_tasks(&pending_bg, &session_id, &acp_tx).await;
|
|
}
|
|
|
|
// Flush buffered unified log entries before exit.
|
|
crate::unified_log::flush_blocking().await;
|
|
|
|
// Handle result
|
|
if track_active {
|
|
// Non-blocking flock so a slow/network ~/.kigi can't hang exit.
|
|
let _ = kigi_shell::active_sessions::try_unregister(&session_id);
|
|
}
|
|
cancel.cancel();
|
|
match prompt_result {
|
|
Some(Ok(resp)) => {
|
|
let stop_reason = format!("{:?}", resp.stop_reason);
|
|
emitter.set_structured_output_from_meta(resp.meta.as_ref());
|
|
emitter.set_usage_from_meta(resp.meta.as_ref());
|
|
let sid = resp
|
|
.meta
|
|
.as_ref()
|
|
.and_then(|m| m.get("sessionId"))
|
|
.and_then(|v| v.as_str())
|
|
.unwrap_or_default();
|
|
let rid = resp
|
|
.meta
|
|
.as_ref()
|
|
.and_then(|m| m.get("requestId"))
|
|
.and_then(|v| v.as_str())
|
|
.unwrap_or_default();
|
|
let is_max_turns = resp
|
|
.meta
|
|
.as_ref()
|
|
.and_then(|m| m.get("cancellationCategory"))
|
|
.and_then(|v| v.as_str())
|
|
== Some("max_turns_reached");
|
|
if is_max_turns {
|
|
match emitter.format {
|
|
OutputFormat::Plain => eprintln!("Max turns reached"),
|
|
OutputFormat::StreamingJson => {
|
|
println!("{}", serde_json::json!({"type": "max_turns_reached"}))
|
|
}
|
|
OutputFormat::Json => {} // conveyed by stopReason in the final JSON
|
|
}
|
|
emitter.on_end(&stop_reason, sid, rid);
|
|
anyhow::bail!("max turns reached");
|
|
}
|
|
emitter.on_end(&stop_reason, sid, rid);
|
|
Ok(())
|
|
}
|
|
Some(Err(err)) => {
|
|
let msg = if i32::from(err.code) == RATE_LIMITED_ERROR_CODE {
|
|
rate_limited_user_message(is_api_key_auth).to_string()
|
|
} else {
|
|
err.to_string()
|
|
};
|
|
if let Some(usage) = kigi_shell::sampling::error::prompt_usage_from_error(&err)
|
|
&& let Ok(v) = serde_json::to_value(&usage)
|
|
{
|
|
emitter.usage = Some(v);
|
|
}
|
|
emitter.on_error(&msg);
|
|
anyhow::bail!("{msg}")
|
|
}
|
|
None => Ok(()),
|
|
}
|
|
}
|
|
|
|
/// Ext request that kills pending background work `key` (a `pending_bg`
|
|
/// entry): `subagent:{id}` cancels the subagent, anything else kills the
|
|
/// bash/monitor task with that id.
|
|
fn reap_request_for_key(
|
|
key: &str,
|
|
session_id: &acp::SessionId,
|
|
) -> serde_json::Result<acp::ExtRequest> {
|
|
let (method, params) = match key.strip_prefix("subagent:") {
|
|
Some(id) => (
|
|
"kigi/subagent/cancel",
|
|
serde_json::value::to_raw_value(&CancelSubagentRequest {
|
|
subagent_id: id.to_string(),
|
|
})?,
|
|
),
|
|
None => (
|
|
"kigi/task/kill",
|
|
serde_json::value::to_raw_value(&KillTaskRequest {
|
|
session_id: session_id.0.to_string(),
|
|
task_id: key.to_string(),
|
|
})?,
|
|
),
|
|
};
|
|
Ok(acp::ExtRequest::new(method, params.into()))
|
|
}
|
|
|
|
/// Best-effort kill of background work still pending when headless exits
|
|
/// (background-wait timeout or `--no-wait-for-background`) so model-spawned
|
|
/// processes never outlive the process. Failures are logged, never fatal.
|
|
async fn reap_pending_background_tasks(
|
|
pending_bg: &HashSet<String>,
|
|
session_id: &acp::SessionId,
|
|
acp_tx: &AcpAgentTx,
|
|
) {
|
|
for key in pending_bg {
|
|
let request = match reap_request_for_key(key, session_id) {
|
|
Ok(request) => request,
|
|
Err(e) => {
|
|
tracing::warn!(key = %key, error = %e, "headless: failed to build reap request");
|
|
continue;
|
|
}
|
|
};
|
|
let method = request.method.clone();
|
|
match tokio::time::timeout(Duration::from_secs(10), acp_send(request, acp_tx)).await {
|
|
Ok(Ok(_)) => {
|
|
tracing::debug!(key = %key, %method, "headless: reaped pending background work")
|
|
}
|
|
Ok(Err(e)) => {
|
|
tracing::warn!(key = %key, %method, error = %e, "headless: failed to reap background work")
|
|
}
|
|
Err(_) => {
|
|
tracing::warn!(key = %key, %method, "headless: timed out reaping background work")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Track a background lifecycle event in the pending set.
|
|
///
|
|
/// Tracking is unconditional — independent of `--no-wait-for-background` — so
|
|
/// the exit reaper sees everything still running. `wait_for_background` only
|
|
/// gates whether the loop waits for this set to drain.
|
|
fn track_background_lifecycle(
|
|
event: ExtEvent,
|
|
pending_bg: &mut HashSet<String>,
|
|
completed_before_bg: &mut HashSet<String>,
|
|
) {
|
|
match event {
|
|
ExtEvent::TaskBackgrounded {
|
|
task_id,
|
|
is_monitor,
|
|
} => {
|
|
if !completed_before_bg.remove(&task_id) {
|
|
pending_bg.insert(task_id);
|
|
tracing::debug!(
|
|
pending = pending_bg.len(),
|
|
is_monitor,
|
|
"headless: tracking background task"
|
|
);
|
|
}
|
|
}
|
|
ExtEvent::TaskCompleted { task_id } => {
|
|
if pending_bg.remove(&task_id) {
|
|
tracing::debug!(
|
|
pending = pending_bg.len(),
|
|
"headless: background task completed"
|
|
);
|
|
} else {
|
|
completed_before_bg.insert(task_id);
|
|
}
|
|
}
|
|
ExtEvent::SubagentSpawned { subagent_id } => {
|
|
let key = format!("subagent:{subagent_id}");
|
|
if !completed_before_bg.remove(&key) {
|
|
pending_bg.insert(key);
|
|
tracing::debug!(
|
|
pending = pending_bg.len(),
|
|
"headless: tracking background subagent"
|
|
);
|
|
}
|
|
}
|
|
ExtEvent::SubagentFinished { subagent_id } => {
|
|
let key = format!("subagent:{subagent_id}");
|
|
if pending_bg.remove(&key) {
|
|
tracing::debug!(
|
|
pending = pending_bg.len(),
|
|
"headless: background subagent finished"
|
|
);
|
|
} else {
|
|
completed_before_bg.insert(key);
|
|
}
|
|
}
|
|
ExtEvent::MonitorEvent | ExtEvent::None => {}
|
|
}
|
|
}
|
|
|
|
// ── ACP client message handling (select arm + pre-exit drain) ────────────
|
|
|
|
/// Process one inbound ACP client message. Used by both `acp_rx.recv()` and
|
|
/// `try_recv()` so buffered `task_backgrounded` is not dropped when
|
|
/// `PromptResponse` completes first.
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn handle_headless_acp_message(
|
|
msg: AcpClientMessageBox,
|
|
emitter: &mut HeadlessEmitter,
|
|
t_prompt: Instant,
|
|
ttf_logged: &mut bool,
|
|
yolo: bool,
|
|
output_format: OutputFormat,
|
|
pending_bg: &mut HashSet<String>,
|
|
completed_before_bg: &mut HashSet<String>,
|
|
) {
|
|
match msg {
|
|
AcpClientMessageBox::SessionNotification(boxed) => {
|
|
match &boxed.request.update {
|
|
acp::SessionUpdate::AgentMessageChunk(chunk) => {
|
|
if let acp::ContentBlock::Text(text) = &chunk.content
|
|
&& !text.text.is_empty()
|
|
{
|
|
if !*ttf_logged {
|
|
*ttf_logged = true;
|
|
tracing::debug!(
|
|
elapsed_ms = t_prompt.elapsed().as_millis() as u64,
|
|
"headless: time-to-first-chunk"
|
|
);
|
|
}
|
|
emitter.on_text_chunk(&text.text);
|
|
}
|
|
}
|
|
acp::SessionUpdate::AgentThoughtChunk(chunk) => {
|
|
if let acp::ContentBlock::Text(text) = &chunk.content {
|
|
if !*ttf_logged {
|
|
*ttf_logged = true;
|
|
tracing::debug!(
|
|
elapsed_ms = t_prompt.elapsed().as_millis() as u64,
|
|
"headless: time-to-first-thought"
|
|
);
|
|
}
|
|
emitter.on_thought_chunk(&text.text);
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
let _ = boxed.response_tx.send(Ok(()));
|
|
}
|
|
AcpClientMessageBox::RequestPermission(req) => {
|
|
if yolo {
|
|
if let Some(resp) = auto_respond_to_permissions(
|
|
&req.request,
|
|
&[
|
|
acp::PermissionOptionKind::AllowOnce,
|
|
acp::PermissionOptionKind::AllowAlways,
|
|
],
|
|
) {
|
|
let _ = req.response_tx.send(Ok(resp));
|
|
} else {
|
|
let _ = req.response_tx.send(Ok(acp::RequestPermissionResponse::new(
|
|
acp::RequestPermissionOutcome::Cancelled,
|
|
)));
|
|
}
|
|
} else {
|
|
let _ = req.response_tx.send(Ok(acp::RequestPermissionResponse::new(
|
|
acp::RequestPermissionOutcome::Cancelled,
|
|
)));
|
|
}
|
|
}
|
|
AcpClientMessageBox::ExtNotification(notif) => {
|
|
let event = handle_ext_notification(¬if, output_format);
|
|
let _ = notif.response_tx.send(Ok(()));
|
|
track_background_lifecycle(event, pending_bg, completed_before_bg);
|
|
}
|
|
AcpClientMessageBox::WaitForTerminalExit(args) => {
|
|
args.response_tx
|
|
.send(Err(crate::acp::wait_for_exit_not_supported(
|
|
"headless mode",
|
|
)))
|
|
.ok();
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
// ── Extension notification handling ──────────────────────────────────────
|
|
|
|
enum ExtEvent {
|
|
None,
|
|
TaskBackgrounded {
|
|
task_id: String,
|
|
is_monitor: bool,
|
|
},
|
|
TaskCompleted {
|
|
task_id: String,
|
|
},
|
|
SubagentSpawned {
|
|
subagent_id: String,
|
|
},
|
|
SubagentFinished {
|
|
subagent_id: String,
|
|
},
|
|
/// Monitor emitted a line (or ended streaming). Does not complete the task;
|
|
/// completion still arrives via `TaskCompleted`.
|
|
MonitorEvent,
|
|
}
|
|
|
|
fn handle_ext_notification(
|
|
notif: &kigi_acp_lib::AcpArgsBox<acp::ExtNotification>,
|
|
format: OutputFormat,
|
|
) -> ExtEvent {
|
|
let method = notif.request.method.as_ref();
|
|
|
|
// Background task lifecycle uses dedicated methods (not session_notification).
|
|
if method == "kigi/task_backgrounded" {
|
|
#[derive(serde::Deserialize)]
|
|
struct TaskBgEnvelope {
|
|
update: TaskBgUpdate,
|
|
}
|
|
#[derive(serde::Deserialize)]
|
|
#[serde(rename_all = "snake_case", tag = "sessionUpdate")]
|
|
enum TaskBgUpdate {
|
|
TaskBackgrounded {
|
|
task_id: String,
|
|
#[serde(default)]
|
|
monitor_description: Option<String>,
|
|
},
|
|
#[serde(other)]
|
|
Other,
|
|
}
|
|
if let Ok(env) = serde_json::from_str::<TaskBgEnvelope>(notif.request.params.get())
|
|
&& let TaskBgUpdate::TaskBackgrounded {
|
|
task_id,
|
|
monitor_description,
|
|
} = env.update
|
|
{
|
|
return ExtEvent::TaskBackgrounded {
|
|
task_id,
|
|
is_monitor: monitor_description.is_some(),
|
|
};
|
|
}
|
|
return ExtEvent::None;
|
|
}
|
|
|
|
if method == "kigi/task_completed" {
|
|
#[derive(serde::Deserialize)]
|
|
struct TaskDoneEnvelope {
|
|
update: TaskDoneUpdate,
|
|
}
|
|
#[derive(serde::Deserialize)]
|
|
#[serde(rename_all = "snake_case", tag = "sessionUpdate")]
|
|
enum TaskDoneUpdate {
|
|
TaskCompleted {
|
|
task_snapshot: TaskSnapshotLite,
|
|
},
|
|
#[serde(other)]
|
|
Other,
|
|
}
|
|
#[derive(serde::Deserialize)]
|
|
struct TaskSnapshotLite {
|
|
task_id: String,
|
|
}
|
|
if let Ok(env) = serde_json::from_str::<TaskDoneEnvelope>(notif.request.params.get())
|
|
&& let TaskDoneUpdate::TaskCompleted { task_snapshot } = env.update
|
|
{
|
|
return ExtEvent::TaskCompleted {
|
|
task_id: task_snapshot.task_id,
|
|
};
|
|
}
|
|
return ExtEvent::None;
|
|
}
|
|
|
|
if method == "kigi/monitor_event" {
|
|
return ExtEvent::MonitorEvent;
|
|
}
|
|
|
|
match method {
|
|
"kigi/session_notification" | "kigi/session/update" => {}
|
|
_ => return ExtEvent::None,
|
|
}
|
|
|
|
#[derive(serde::Deserialize)]
|
|
#[serde(rename_all = "snake_case", tag = "sessionUpdate")]
|
|
enum XaiUpdate {
|
|
AutoCompactStarted {
|
|
percentage: u8,
|
|
},
|
|
AutoCompactCompleted {},
|
|
AutoCompactFailed {
|
|
error: String,
|
|
},
|
|
AutoCompactCancelled {},
|
|
AutoContinueCompleted {
|
|
total_tokens: u64,
|
|
},
|
|
ImageCompressed {
|
|
message: String,
|
|
},
|
|
SubagentSpawned {
|
|
subagent_id: String,
|
|
},
|
|
SubagentFinished {
|
|
subagent_id: String,
|
|
},
|
|
#[serde(other)]
|
|
Other,
|
|
}
|
|
#[derive(serde::Deserialize)]
|
|
struct XaiNotif {
|
|
update: XaiUpdate,
|
|
}
|
|
|
|
let Ok(xai_notif) = serde_json::from_str::<XaiNotif>(notif.request.params.get()) else {
|
|
return ExtEvent::None;
|
|
};
|
|
|
|
match xai_notif.update {
|
|
XaiUpdate::AutoCompactStarted { percentage } => match format {
|
|
OutputFormat::StreamingJson => {
|
|
println!(
|
|
"{}",
|
|
serde_json::json!({"type": "auto_compact_started", "percentage": percentage})
|
|
);
|
|
}
|
|
OutputFormat::Plain => {
|
|
eprintln!("Auto-compacting conversation ({percentage}% full)...");
|
|
}
|
|
OutputFormat::Json => {}
|
|
},
|
|
XaiUpdate::AutoCompactCompleted {} => match format {
|
|
OutputFormat::StreamingJson => {
|
|
println!("{}", serde_json::json!({"type": "auto_compact_completed"}));
|
|
}
|
|
OutputFormat::Plain => eprintln!("Conversation compacted."),
|
|
OutputFormat::Json => {}
|
|
},
|
|
XaiUpdate::AutoCompactFailed { error } => match format {
|
|
OutputFormat::StreamingJson => {
|
|
println!(
|
|
"{}",
|
|
serde_json::json!({"type": "auto_compact_failed", "error": error})
|
|
);
|
|
}
|
|
OutputFormat::Plain => {
|
|
if error.trim().is_empty() {
|
|
eprintln!("Auto-compact failed.");
|
|
} else {
|
|
eprintln!("Auto-compact failed: {error}");
|
|
}
|
|
}
|
|
OutputFormat::Json => {}
|
|
},
|
|
XaiUpdate::AutoCompactCancelled {} => match format {
|
|
OutputFormat::StreamingJson => {
|
|
println!("{}", serde_json::json!({"type": "auto_compact_cancelled"}));
|
|
}
|
|
OutputFormat::Plain => eprintln!("Auto-compact cancelled."),
|
|
OutputFormat::Json => {}
|
|
},
|
|
XaiUpdate::AutoContinueCompleted { total_tokens } => match format {
|
|
OutputFormat::StreamingJson => {
|
|
println!(
|
|
"{}",
|
|
serde_json::json!({"type": "auto_continue_completed", "total_tokens": total_tokens})
|
|
);
|
|
}
|
|
OutputFormat::Plain => eprintln!("Resumed after compaction."),
|
|
OutputFormat::Json => {}
|
|
},
|
|
XaiUpdate::ImageCompressed { message } => match format {
|
|
OutputFormat::StreamingJson => {
|
|
println!(
|
|
"{}",
|
|
serde_json::json!({"type": "image_compressed", "message": message})
|
|
);
|
|
}
|
|
OutputFormat::Plain => eprintln!("{message}"),
|
|
OutputFormat::Json => {}
|
|
},
|
|
XaiUpdate::SubagentSpawned { subagent_id } => {
|
|
return ExtEvent::SubagentSpawned { subagent_id };
|
|
}
|
|
XaiUpdate::SubagentFinished { subagent_id, .. } => {
|
|
return ExtEvent::SubagentFinished { subagent_id };
|
|
}
|
|
XaiUpdate::Other => {}
|
|
}
|
|
ExtEvent::None
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
#[test]
|
|
fn lifecycle_tracking_is_independent_of_wait_flag() {
|
|
let mut pending = std::collections::HashSet::new();
|
|
let mut completed = std::collections::HashSet::new();
|
|
super::track_background_lifecycle(
|
|
super::ExtEvent::TaskBackgrounded {
|
|
task_id: "t1".into(),
|
|
is_monitor: false,
|
|
},
|
|
&mut pending,
|
|
&mut completed,
|
|
);
|
|
super::track_background_lifecycle(
|
|
super::ExtEvent::SubagentSpawned {
|
|
subagent_id: "s1".into(),
|
|
},
|
|
&mut pending,
|
|
&mut completed,
|
|
);
|
|
assert!(pending.contains("t1"));
|
|
assert!(pending.contains("subagent:s1"));
|
|
|
|
super::track_background_lifecycle(
|
|
super::ExtEvent::TaskCompleted {
|
|
task_id: "t1".into(),
|
|
},
|
|
&mut pending,
|
|
&mut completed,
|
|
);
|
|
super::track_background_lifecycle(
|
|
super::ExtEvent::SubagentFinished {
|
|
subagent_id: "s1".into(),
|
|
},
|
|
&mut pending,
|
|
&mut completed,
|
|
);
|
|
assert!(pending.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn completion_before_backgrounded_never_rearms_pending() {
|
|
let mut pending = std::collections::HashSet::new();
|
|
let mut completed = std::collections::HashSet::new();
|
|
super::track_background_lifecycle(
|
|
super::ExtEvent::TaskCompleted {
|
|
task_id: "t1".into(),
|
|
},
|
|
&mut pending,
|
|
&mut completed,
|
|
);
|
|
super::track_background_lifecycle(
|
|
super::ExtEvent::TaskBackgrounded {
|
|
task_id: "t1".into(),
|
|
is_monitor: false,
|
|
},
|
|
&mut pending,
|
|
&mut completed,
|
|
);
|
|
assert!(pending.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn reap_request_for_task_kills_with_session_scope() {
|
|
let session_id = acp::SessionId::new("sess-1");
|
|
let request = super::reap_request_for_key("task-42", &session_id).unwrap();
|
|
assert_eq!(request.method.as_ref(), "kigi/task/kill");
|
|
let params: serde_json::Value = serde_json::from_str(request.params.get()).unwrap();
|
|
assert_eq!(params["sessionId"], "sess-1");
|
|
assert_eq!(params["taskId"], "task-42");
|
|
}
|
|
|
|
#[test]
|
|
fn reap_request_for_subagent_cancels_with_stripped_id() {
|
|
let session_id = acp::SessionId::new("sess-1");
|
|
let request = super::reap_request_for_key("subagent:sub-7", &session_id).unwrap();
|
|
assert_eq!(request.method.as_ref(), "kigi/subagent/cancel");
|
|
let params: serde_json::Value = serde_json::from_str(request.params.get()).unwrap();
|
|
assert_eq!(params["subagentId"], "sub-7");
|
|
}
|
|
|
|
use super::*;
|
|
use kigi_workspace::permission::types::{RuleAction, ToolFilter};
|
|
|
|
fn s(v: &str) -> String {
|
|
v.to_owned()
|
|
}
|
|
|
|
/// Headless materialization is never chat, regardless of worktree flag —
|
|
/// resume targets stay disk/GCS Build sessions.
|
|
#[test]
|
|
fn headless_materialize_ctx_stays_non_chat() {
|
|
for has_worktree in [false, true] {
|
|
let ctx = headless_materialize_ctx(has_worktree);
|
|
assert!(!ctx.chat_mode);
|
|
assert!(!ctx.allow_remote_restore);
|
|
assert_eq!(ctx.has_worktree, has_worktree);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn strict_valid_rules_parse_deny_before_allow() {
|
|
let allow = vec![s("Bash(npm*)")];
|
|
let deny = vec![s("Bash(rm*)"), s("Edit(/etc/**)")];
|
|
let rules = parse_permission_rules_strict(&allow, &deny).unwrap();
|
|
assert_eq!(rules.len(), 3);
|
|
assert_eq!(rules[0].action, RuleAction::Deny);
|
|
assert!(matches!(rules[0].tool, ToolFilter::Bash));
|
|
assert_eq!(rules[1].action, RuleAction::Deny);
|
|
assert!(matches!(rules[1].tool, ToolFilter::Edit));
|
|
assert_eq!(rules[2].action, RuleAction::Allow);
|
|
assert!(matches!(rules[2].tool, ToolFilter::Bash));
|
|
}
|
|
|
|
#[test]
|
|
fn strict_invalid_rule_errors() {
|
|
let result = parse_permission_rules_strict(&[], &[s("EnterWorktree(foo)")]);
|
|
assert!(result.is_err());
|
|
let msg = result.unwrap_err().to_string();
|
|
assert!(msg.contains("--deny"));
|
|
assert!(msg.contains("EnterWorktree"));
|
|
}
|
|
|
|
#[test]
|
|
fn strict_reports_all_invalid_rules() {
|
|
let result = parse_permission_rules_strict(
|
|
&[s("BadTool(x)")],
|
|
&[s("EnterWorktree(foo)"), s("Bash(rm*)")],
|
|
);
|
|
assert!(result.is_err());
|
|
let msg = result.unwrap_err().to_string();
|
|
assert!(
|
|
msg.contains("EnterWorktree"),
|
|
"should mention first bad deny"
|
|
);
|
|
assert!(msg.contains("BadTool"), "should mention bad allow");
|
|
}
|
|
|
|
#[test]
|
|
fn lenient_skips_invalid_keeps_valid() {
|
|
let allow = vec![s("Bash(npm*)")];
|
|
let deny = vec![s("EnterWorktree(foo)"), s("Bash(rm*)")];
|
|
let rules = parse_permission_rules_lenient(&allow, &deny);
|
|
assert_eq!(rules.len(), 2);
|
|
assert_eq!(rules[0].action, RuleAction::Deny);
|
|
assert_eq!(rules[0].pattern.as_deref(), Some("rm*"));
|
|
assert_eq!(rules[1].action, RuleAction::Allow);
|
|
assert_eq!(rules[1].pattern.as_deref(), Some("npm*"));
|
|
}
|
|
|
|
#[test]
|
|
fn empty_inputs_produce_empty_rules() {
|
|
let rules = parse_permission_rules_strict(&[], &[]).unwrap();
|
|
assert!(rules.is_empty());
|
|
let rules = parse_permission_rules_lenient(&[], &[]);
|
|
assert!(rules.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn domain_mode_web_fetch() {
|
|
let rules = parse_permission_rules_strict(&[], &[s("WebFetch(domain:evil.com)")]).unwrap();
|
|
assert_eq!(rules.len(), 1);
|
|
assert!(matches!(rules[0].tool, ToolFilter::WebFetch));
|
|
assert_eq!(
|
|
rules[0].pattern_mode,
|
|
kigi_workspace::permission::types::PatternMode::Domain
|
|
);
|
|
assert_eq!(rules[0].pattern.as_deref(), Some("evil.com"));
|
|
}
|
|
|
|
#[test]
|
|
fn bash_colon_wildcard_deny_translates_to_prefix() {
|
|
let rules = parse_permission_rules_strict(&[], &[s("Bash(sed:*)")]).unwrap();
|
|
assert_eq!(rules.len(), 1);
|
|
assert!(matches!(rules[0].tool, ToolFilter::Bash));
|
|
assert_eq!(rules[0].pattern.as_deref(), Some("sed"));
|
|
}
|
|
|
|
#[test]
|
|
fn structured_output_without_meta_errors_never_parses_text() {
|
|
// No `_meta` structured output (e.g. max-turns/cancel): emit a clean
|
|
// error, never an unvalidated parse of the raw text buffer.
|
|
let mut emitter = HeadlessEmitter::new(OutputFormat::Json, true);
|
|
emitter.text_buffer = r#"{"name":"alice","age":30}"#.into();
|
|
emitter.set_structured_output_from_meta(serde_json::json!({}).as_object());
|
|
let result = emitter.build_json_result("EndTurn", "sess-1", "req-1");
|
|
assert!(result["structuredOutput"].is_null());
|
|
assert_eq!(
|
|
result["structuredOutputError"],
|
|
"model did not produce structured output"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn structured_output_from_meta_wins_over_text_buffer() {
|
|
// The agent's validated output (from `_meta`) must override accumulated
|
|
// prose (the multi-round corruption bug).
|
|
let mut emitter = HeadlessEmitter::new(OutputFormat::Json, true);
|
|
emitter.text_buffer = "thinking out loud...".into();
|
|
emitter.set_structured_output_from_meta(
|
|
serde_json::json!({"structuredOutput": {"name": "carol"}}).as_object(),
|
|
);
|
|
let result = emitter.build_json_result("EndTurn", "sess-1", "req-1");
|
|
assert_eq!(result["structuredOutput"]["name"], "carol");
|
|
assert!(result.get("structuredOutputError").is_none());
|
|
|
|
let mut emitter = HeadlessEmitter::new(OutputFormat::Json, true);
|
|
emitter.set_structured_output_from_meta(
|
|
serde_json::json!({
|
|
"structuredOutputError": "output does not match the required schema"
|
|
})
|
|
.as_object(),
|
|
);
|
|
let result = emitter.build_json_result("EndTurn", "sess-1", "req-1");
|
|
assert!(result["structuredOutput"].is_null());
|
|
assert_eq!(
|
|
result["structuredOutputError"],
|
|
"output does not match the required schema"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn streaming_json_structured_output_emits_from_meta() {
|
|
let mut emitter = HeadlessEmitter::new(OutputFormat::StreamingJson, true);
|
|
emitter.on_text_chunk(r#"{"name":"#);
|
|
emitter.on_text_chunk(r#""bob"}"#);
|
|
assert_eq!(emitter.text_buffer, r#"{"name":"bob"}"#);
|
|
|
|
// structuredOutput comes from the prompt-response `_meta`, not the buffer.
|
|
emitter.set_structured_output_from_meta(
|
|
serde_json::json!({"structuredOutput": {"name": "bob"}}).as_object(),
|
|
);
|
|
let mut target = serde_json::json!({});
|
|
emitter.attach_structured_output(&mut target);
|
|
assert_eq!(target["structuredOutput"]["name"], "bob");
|
|
assert!(target.get("structuredOutputError").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn parse_json_schema_rejects_non_objects_and_invalid_json() {
|
|
assert!(super::parse_json_schema(r#"{"type":"object"}"#).is_ok());
|
|
assert!(
|
|
super::parse_json_schema(r#"[1,2,3]"#)
|
|
.unwrap_err()
|
|
.to_string()
|
|
.contains("must be a JSON object")
|
|
);
|
|
assert!(
|
|
super::parse_json_schema(r#"{not json"#)
|
|
.unwrap_err()
|
|
.to_string()
|
|
.contains("invalid JSON")
|
|
);
|
|
}
|
|
|
|
fn make_ext_notif(
|
|
method: &str,
|
|
update: serde_json::Value,
|
|
) -> kigi_acp_lib::AcpArgsBox<acp::ExtNotification> {
|
|
let payload = serde_json::json!({
|
|
"sessionId": "sess-1",
|
|
"update": update,
|
|
});
|
|
let raw = serde_json::value::to_raw_value(&payload).unwrap();
|
|
let (tx, _rx) = tokio::sync::oneshot::channel();
|
|
kigi_acp_lib::AcpArgs {
|
|
request: acp::ExtNotification::new(method, raw.into()),
|
|
response_tx: tx,
|
|
}
|
|
.boxed()
|
|
}
|
|
|
|
#[test]
|
|
fn headless_task_backgrounded_parses_task_id() {
|
|
// `make_ext_notif` wraps the arg under `update`, so pass
|
|
// the inner update object (matching the real `kigi/task_backgrounded`
|
|
// wire shape: `{ "update": { "sessionUpdate": ..., "task_id": ... } }`).
|
|
let notif = make_ext_notif(
|
|
"kigi/task_backgrounded",
|
|
serde_json::json!({
|
|
"sessionUpdate": "task_backgrounded",
|
|
"task_id": "task-abc",
|
|
}),
|
|
);
|
|
assert!(matches!(
|
|
handle_ext_notification(¬if, OutputFormat::Plain),
|
|
ExtEvent::TaskBackgrounded { task_id, is_monitor: false } if task_id == "task-abc"
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn headless_task_backgrounded_with_monitor_description_is_monitor() {
|
|
let notif = make_ext_notif(
|
|
"kigi/task_backgrounded",
|
|
serde_json::json!({
|
|
"sessionUpdate": "task_backgrounded",
|
|
"task_id": "mon-1",
|
|
"monitor_description": "watching logs",
|
|
}),
|
|
);
|
|
assert!(matches!(
|
|
handle_ext_notification(¬if, OutputFormat::Plain),
|
|
ExtEvent::TaskBackgrounded { task_id, is_monitor: true } if task_id == "mon-1"
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn headless_task_completed_parses_task_id() {
|
|
// `task_completed` nests the id under `task_snapshot`. The
|
|
// internally-tagged `rename_all = "snake_case"` renames only the
|
|
// `sessionUpdate` tag, so `task_id` / `task_snapshot` stay snake_case;
|
|
// this test guards against a future `rename_all = "camelCase"` on
|
|
// `TaskSnapshot` silently turning waiting into a no-op.
|
|
let notif = make_ext_notif(
|
|
"kigi/task_completed",
|
|
serde_json::json!({
|
|
"sessionUpdate": "task_completed",
|
|
"task_snapshot": { "task_id": "task-abc" }
|
|
}),
|
|
);
|
|
assert!(matches!(
|
|
handle_ext_notification(¬if, OutputFormat::Plain),
|
|
ExtEvent::TaskCompleted { task_id } if task_id == "task-abc"
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn headless_subagent_spawned_and_finished_parse() {
|
|
let spawned = make_ext_notif(
|
|
"kigi/session_notification",
|
|
serde_json::json!({
|
|
"sessionUpdate": "subagent_spawned",
|
|
"subagent_id": "sub-1",
|
|
"parent_session_id": "p",
|
|
"child_session_id": "c",
|
|
"subagent_type": "explore",
|
|
"description": "test"
|
|
}),
|
|
);
|
|
assert!(matches!(
|
|
handle_ext_notification(&spawned, OutputFormat::Plain),
|
|
ExtEvent::SubagentSpawned { subagent_id } if subagent_id == "sub-1"
|
|
));
|
|
let finished = make_ext_notif(
|
|
"kigi/session_notification",
|
|
serde_json::json!({
|
|
"sessionUpdate": "subagent_finished",
|
|
"subagent_id": "sub-1",
|
|
"child_session_id": "c",
|
|
"status": "completed",
|
|
"tool_calls": 0,
|
|
"turns": 1,
|
|
"duration_ms": 5
|
|
}),
|
|
);
|
|
assert!(matches!(
|
|
handle_ext_notification(&finished, OutputFormat::Plain),
|
|
ExtEvent::SubagentFinished { subagent_id } if subagent_id == "sub-1"
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn headless_session_update_unknown_method_is_none() {
|
|
let payload = serde_json::json!({
|
|
"sessionId": "sess-1",
|
|
"update": {
|
|
"sessionUpdate": "subagent_spawned",
|
|
"subagent_id": "sub-1"
|
|
}
|
|
});
|
|
let raw = serde_json::value::to_raw_value(&payload).unwrap();
|
|
let (tx, _rx) = tokio::sync::oneshot::channel();
|
|
let notif = kigi_acp_lib::AcpArgs {
|
|
request: acp::ExtNotification::new("kigi/other", raw.into()),
|
|
response_tx: tx,
|
|
}
|
|
.boxed();
|
|
assert!(matches!(
|
|
handle_ext_notification(¬if, OutputFormat::Plain),
|
|
ExtEvent::None
|
|
));
|
|
}
|
|
}
|