docs(comments): rewrite comments across all crates to the guidelines

Sweep every first-party crate source (1956 .rs files) to the project comment
guidelines: delete redundant restatements, decorative banners, change
narration, and end-of-line comments; keep and tighten the crucial ones
(invariants, bug rationale, SAFETY blocks, ported-source attribution).

No functional code changed. Every edit is proven comment-only against the
prior tree by a comment-stripping lexer (string/char/raw-string aware) plus a
separate doctest-fence check. Where removing a comment made rustfmt or clippy
want to re-lay-out adjacent code, the minimal triggering comment is restored so
code tokens stay byte-identical.

Gates green: cargo fmt --all --check (0 diffs), cargo check and cargo clippy
--workspace --all-targets (0 warnings).

Adds scripts/check_codegen_comment_guidelines.py — the enforcement gate for
these guidelines (flags banners, end-of-line comments, change narration, and
commented-out code).
This commit is contained in:
ZacharyZhang-NY committed 2026-07-23 16:55:39 -04:00
1 parent ff0fb56c67
commit a02b555e66
1458 files changed
+10729 -21750

No files matched your search

@@ -34,8 +34,7 @@ fn resolve_idle_notification_delay(raw: Option<String>) -> Duration {
struct IdlePromptExtension {
notification_event_sink: Rc<dyn NotificationEventSink>,
timer: TaskSlot<()>,
/// Only a completed turn earns a ping; aborted and errored turns do not (matching the old
/// end_turn-only arming).
/// Only a completed turn earns a ping; aborted and errored turns do not.
last_turn_completed: std::cell::Cell<bool>,
}
@@ -386,13 +386,9 @@ impl SessionActor {
// OLDEST entry (the new entry reflects the model's current
// intent) and emit `FailClosed { PendingQueueFull }`.
if matches!(purpose, DrainPurpose::MidTurn) {
// Cap the queue at `GOAL_CLASSIFIER_PENDING_QUEUE_CAP`;
// on overflow drop the OLDEST entry (the new entry
// reflects the model's current intent). The dropped
// entry's ack was ALREADY resolved at its own MidTurn
// defer time, so we just emit the eviction telemetry
// and let the dropped input fall out of scope —
// there's no parked ack to update.
// The evicted entry's ack was ALREADY resolved at its own
// MidTurn defer time; just emit the eviction telemetry and
// let the dropped input fall out of scope — no parked ack.
let pending_depth = {
let mut q = self.pending_classifier_completions.lock();
if q.len() >= GOAL_CLASSIFIER_PENDING_QUEUE_CAP {
@@ -1396,11 +1392,8 @@ impl SessionActor {
else {
return fail_open(Reason::ModelUnknown);
};
// 2. Entitlement: the session's `allowed_models` permits the model. A
// restricted session marks out-of-list catalog entries
// `user_selectable == false`; an unrestricted session marks all
// `true`. This is the session-local entitlement boundary (no disk
// read, no `api_key` probe that would false-fail session-token auth).
// 2. Entitlement: the session's `allowed_models` permits the model
// (`ModelInfo.user_selectable`).
if !entry.info.user_selectable {
return fail_open(Reason::ModelUnauthorized);
}
@@ -1643,8 +1636,6 @@ impl SessionActor {
| GoalStatus::InfraPaused
| GoalStatus::Blocked,
) => {
// Capture-before-clear: read status and pause_message
// first, then resume() drops them.
let previous_status = tracker.status().unwrap();
let previous_pause_message =
tracker.snapshot().and_then(|o| o.pause_message.clone());
@@ -1805,39 +1796,6 @@ impl SessionActor {
}
}
/// If a goal is active, inject a continuation nudge so the model keeps
/// working on it after the current turn completes.
///
/// After the completion drain and the goal-active gate, the
/// turn-final assistant text is matched against the
/// [`goal_stop_detector`](super::super::goal_stop_detector) panel. On a
/// hit (with pending todos) the nudge renders the bail-specific
/// flavor and `Event::GoalPrematureStopDetected` is emitted on the
/// queue path; otherwise the generic flavor renders. A
/// verified-complete goal returns at the gate before detection, so
/// it never receives a bail nudge.
///
/// Idempotent on the push side: if `state.pending_inputs` already
/// contains an `InputItem` whose origin is
/// [`PromptOrigin::GoalSummary`](crate::session::PromptOrigin::GoalSummary),
/// the function skips the push and returns without enqueuing a
/// duplicate. The token-update and `GoalUpdated` notification still
/// run on every call that doesn't early-return on an inactive goal
/// — they reflect current accounting, not reminder identity, so
/// running them ahead of the dedup gate is safe. Two call sites
/// rely on this: the pre-`emit_turn_ended` queue in
/// `handle_completion`'s `TurnOutcome::Completed` arm (which
/// guarantees the reminder is part of pending state by the time the
/// user-visible turn-ended event fires) and the post-completion
/// safety net inside [`Self::handle_turn_end`].
///
/// A pending `GoalClassifierNudge` also satisfies the idempotency
/// gate: when a completion claim is classifier-rejected during the
/// drain (goal stays Active, a classifier nudge queued), that nudge
/// pre-empts the bail nudge — the early-return fires before the
/// `GoalPrematureStopDetected` emit, so no bail event is recorded on
/// that turn. The classifier nudge already forces continuation with
/// the gap inlined, so this precedence is intentional.
/// Remove every prior goal-continuation directive from the persisted
/// conversation, keeping only the copy about to be pushed. The
/// directive re-embeds the full objective each turn, so without this
@@ -6,7 +6,6 @@ use super::*;
/// Number of consecutive non-completing goal-mode turns before the goal
/// auto-pauses with `GoalPauseReason::BackOff`. See `handle_turn_end`.
/// Compile-time constant for v1; remote tunability is a deferred follow-up.
pub(super) const GOAL_CONTINUATION_BACKOFF_THRESHOLD: u32 = 3;
impl DrainSource {
@@ -188,7 +187,6 @@ pub(super) fn render_goal_plan_block(plan_path: &std::path::Path, names: &GoalTo
empty path renders a dangling `Plan:` line that the model \
cannot follow",
);
// Column-0 single-line `Plan: <abs>` contract — see fn docs.
GOAL_PLAN_BLOCK_TEMPLATE
.replace("{PLAN_PATH}", &plan_path.display().to_string())
.replace("{TODO_TOOL}", &names.todo)
@@ -235,13 +233,11 @@ pub(super) const GOAL_CONTINUATION_BAIL_PREFACE: &str = "You appear to be stoppi
/// Render the shared goal-rules template with tool names and
/// site-specific blocks substituted in.
///
/// The template is the slim current form: verification is owned by
/// the harness (the adversarial skeptic panel in `goal_classifier.rs`),
/// so this body only carries TRACKING / WORKING / VERIFY / TEST
/// guidance and the `{GOAL_TOOL}` completion contract. No per-goal
/// verdict-file path is substituted — `{VERIFIER_ID}` no longer
/// appears in the template; `verifier_id` continues to anchor
/// harness-owned skeptic verdict files inside `goal_classifier.rs`.
/// Verification is owned by the harness (the adversarial skeptic panel
/// in `goal_classifier.rs`), so this body only carries TRACKING /
/// WORKING / VERIFY / TEST guidance and the `{GOAL_TOOL}` completion
/// contract. No per-goal verdict-file path is substituted; `verifier_id`
/// anchors harness-owned skeptic verdict files inside `goal_classifier.rs`.
///
/// `block_recap` and `goal_state` are inserted verbatim — pass an
/// empty string to omit either section. The trailing newline of the
@@ -251,10 +247,6 @@ pub(super) const GOAL_CONTINUATION_BAIL_PREFACE: &str = "You appear to be stoppi
///
/// `plan_path` `Some` folds the plan-aware preamble into the same block as
/// the discipline; `None` renders the no-plan block byte-for-byte unchanged.
///
/// Legacy artifacts (the deleted COMPLETION AUDIT / canonical
/// verifier blocks / `{VERIFIER_ID}` placeholder) are pinned absent
/// by `goal_rules_template_drops_all_legacy_verifier_artifacts`.
pub(super) fn render_goal_rules(
objective: &str,
names: &GoalToolNames,
@@ -701,8 +693,10 @@ mod fold_tokens_by_model_tests {
fn mixed_models_sum_marginals_sorted_desc() {
let records = vec![
rec(Some("g1"), 0, 100, Some("kigi-3")),
rec(Some("g1"), 100, 500, Some("kigi-4")), // marginal 400
rec(Some("g1"), 0, 50, Some("kigi-3")), // kigi-3 total 150
// marginal 400
rec(Some("g1"), 100, 500, Some("kigi-4")),
// kigi-3 total 150
rec(Some("g1"), 0, 50, Some("kigi-3")),
];
let out = fold_tokens_by_model(&records, "g1", "cur");
assert_eq!(
@@ -735,7 +729,8 @@ mod fold_tokens_by_model_tests {
fn single_distinct_model_collapses_to_one_entry() {
let records = vec![
rec(Some("g1"), 0, 100, Some("kigi-4")),
rec(Some("g1"), 0, 200, None), // folds under current = kigi-4
// folds under current = kigi-4
rec(Some("g1"), 0, 200, None),
];
let out = fold_tokens_by_model(&records, "g1", "kigi-4");
assert_eq!(out, vec![("kigi-4".to_owned(), 300)]);
@@ -28,8 +28,6 @@ use super::SessionActor;
const WORKER_PROMPT_TEMPLATE: &str = include_str!("../templates/graph_node_worker_prompt.md");
const VERIFIER_PROMPT_TEMPLATE: &str = include_str!("../templates/graph_node_verifier_prompt.md");
// Terminal-contract parsing
/// The worker's parsed claim, from the trailing `NODE_RESULT:` line.
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum WorkerClaim {
@@ -136,9 +134,7 @@ pub(crate) fn parse_node_verdict(output: &str) -> NodeVerdict {
}
}
/// Line-anchored `DISCOVERED:` items outside fenced blocks. The
/// placeholder filter (`<`) drops template echoes ("<one-line
/// description …>") a child may parrot back.
/// Line-anchored `DISCOVERED:` items outside fenced blocks.
pub(crate) fn parse_discovered_lines(output: &str) -> Vec<String> {
strip_fenced_blocks(output)
.lines()
@@ -156,8 +152,6 @@ pub(crate) fn parse_discovered_lines(output: &str) -> Vec<String> {
.collect()
}
// Spawner seam (mockable in tests)
pub(crate) struct WorkerSpawnSpec {
pub prompt: String,
pub description: String,
@@ -265,8 +259,6 @@ impl GraphWorkerSpawner for GraphWorkerChannelSpawner {
}
}
// Per-node bounded closed loop
#[derive(Debug)]
pub(crate) struct NodeRunReport {
pub node_id: String,
@@ -454,7 +446,6 @@ pub(crate) async fn run_node_to_verdict(
let verify_spec = WorkerSpawnSpec {
prompt: verifier_prompt(node_objective, &summary),
description: format!("graph node verifier ({node_id})"),
// The verifier inspects the worker's worktree.
cwd: worktree_path.clone(),
isolation_worktree: false,
resume_from: None,
@@ -535,8 +526,6 @@ async fn git_head(dir: &std::path::Path) -> Option<String> {
.then(|| String::from_utf8_lossy(&output.stdout).trim().to_owned())
}
// SessionActor integration
impl SessionActor {
/// Production worker spawner wired to this session's coordinator.
fn graph_worker_spawner(&self) -> Option<Arc<dyn GraphWorkerSpawner>> {
@@ -883,9 +872,12 @@ mod tests {
let mut bg = outcome("");
bg.backgrounded = true;
let replies = std::collections::VecDeque::from(vec![
bg, // round 1: budget overrun
outcome("NODE_RESULT: done\nfinished"), // round 2: worker done
outcome("NODE_VERDICT: achieved"), // round 2: verifier
// round 1: budget overrun
bg,
// round 2: worker done
outcome("NODE_RESULT: done\nfinished"),
// round 2: verifier
outcome("NODE_VERDICT: achieved"),
]);
// Keep a concrete handle for assertions; hand the trait object in.
let mock = Arc::new(MockSpawner {
@@ -934,9 +926,12 @@ mod tests {
worktree_path: None,
};
let replies = std::collections::VecDeque::from(vec![
failed, // round 1: in-band failure
outcome("NODE_RESULT: done\nfinished"), // round 2: worker (fresh, isolated)
outcome("NODE_VERDICT: achieved"), // round 2: verifier
// round 1: in-band failure
failed,
// round 2: worker (fresh, isolated)
outcome("NODE_RESULT: done\nfinished"),
// round 2: verifier
outcome("NODE_VERDICT: achieved"),
]);
let mock = Arc::new(MockSpawner {
replies: std::sync::Mutex::new(replies),
@@ -3,7 +3,6 @@
use super::*;
/// Map a turn result to the hub protocol's `TurnHookOutcome`.
pub(super) fn turn_result_to_hook_outcome(
result: &Result<TurnOutcome, acp::Error>,
) -> kigi_tool_protocol::turn_hook::TurnHookOutcome {
@@ -176,7 +175,6 @@ impl SessionActor {
.await;
}
/// Returns the resolved workspace root for hook envelopes.
pub(super) fn hook_workspace_root(&self) -> String {
self.hook_resolved_workspace_root.clone()
}
@@ -1,10 +1,8 @@
use super::*;
impl SessionActor {
// ── Shared hook/plugin operation functions ────────────────────────
/// Trust the current project via the unified folder-trust store. Now an
/// alias of `--trust`: also allows repo-local MCP/LSP for this folder.
/// Trust the current project via the unified folder-trust store, which also
/// allows repo-local MCP/LSP for this folder (equivalent to `--trust`).
pub(super) fn do_hooks_trust_project(cwd: &str) -> Result<std::path::PathBuf, String> {
let root = kigi_workspace::session::git::find_git_root_from_path(std::path::Path::new(cwd))
.map_err(|_| {
@@ -69,7 +67,6 @@ impl SessionActor {
}
}
/// Resolve a potentially relative path against the session cwd.
fn resolve_path(cwd: &str, path: &str) -> std::path::PathBuf {
let p = std::path::Path::new(path);
if p.is_relative() {
@@ -79,9 +76,6 @@ impl SessionActor {
}
}
// ── Hooks/plugins action handlers (pager modal) ──────────────────
/// Handle a hooks management action from the pager modal.
pub(super) async fn handle_hooks_action(
self: &Arc<Self>,
action: kigi_hooks_plugins_types::HooksAction,
@@ -134,7 +128,7 @@ impl SessionActor {
},
Ok((root, true)) => {
let reload_msg = self.reload_hooks_impl().await;
// Revoked trust must immediately drop a previously seeded
// Revoked trust must immediately drop an already-seeded
// repo-level MCP output cap (the resolver is trust-gated,
// so this clears it) — not linger until the next config
// reload.
@@ -264,7 +258,6 @@ impl SessionActor {
}
}
/// Handle a plugins management action from the pager modal.
pub(super) async fn handle_plugins_action(
self: &Arc<Self>,
action: kigi_hooks_plugins_types::PluginsAction,
@@ -370,7 +363,6 @@ impl SessionActor {
let plugin_names: Vec<String> = repo.plugins.keys().cloned().collect();
let count = plugin_names.len();
// Check multi-plugin repo — return ConfirmationRequired.
if count > 1 && !confirmed {
return ActionOutcome {
status: OutcomeStatus::ConfirmationRequired,
@@ -383,7 +375,6 @@ impl SessionActor {
};
}
// Proceed with removal.
if let Err(e) =
kigi_agent::plugins::git_install::remove_repo_path(&repo_path)
{
@@ -513,7 +504,6 @@ impl SessionActor {
}
}
PluginsAction::Enable { plugin_id } => {
// Add to enabled list (for project plugins) and remove from disabled list.
let r1 = crate::config::add_enabled_plugin(&plugin_id);
let r2 = crate::config::remove_disabled_plugin(&plugin_id);
match r1.and(r2) {
@@ -544,7 +534,6 @@ impl SessionActor {
}
}
PluginsAction::Disable { plugin_id } => {
// Add to disabled list and remove from enabled list.
let r1 = crate::config::add_disabled_plugin(&plugin_id);
let r2 = crate::config::remove_enabled_plugin(&plugin_id);
match r1.and(r2) {
@@ -636,7 +625,6 @@ impl SessionActor {
tracing::warn!("hook reload error: {err}");
}
*self.hook_load_errors.borrow_mut() = errors.iter().map(|e| e.to_string()).collect();
// Re-append plugin hooks from current plugin registry.
// Clone the Arc out of the RefCell so the borrow is dropped immediately.
let plugin_registry_snapshot = self.plugin_registry.borrow().clone();
if let Some(ref pr) = plugin_registry_snapshot {
@@ -679,7 +667,6 @@ impl SessionActor {
}
tracing::info!(hook_count, "hooks reloaded mid-session");
// Notify pager about hooks change.
// Extract all RefCell borrows into locals before the .await so
// no Ref guard is alive across the suspension point.
{
@@ -830,16 +817,13 @@ impl SessionActor {
let sid = self.session_info.id.0.as_ref();
let session_cwd = std::path::Path::new(&self.session_info.cwd);
// Update session's plugin registry snapshot
*self.plugin_registry.borrow_mut() = new_registry_snapshot.clone();
// Reload hooks in the current session
let t_hooks = std::time::Instant::now();
let mut hooks_reloaded = 0usize;
if let Some(ref new_registry) = new_registry_snapshot {
let mut new_specs = Vec::new();
for plugin in new_registry.active_plugins() {
// File-based hooks
if let Some(ref hooks_path) = plugin.hooks_path {
let (specs, warnings) = kigi_agent::plugins::hooks_adapter::parse_plugin_hooks(
hooks_path,
@@ -852,7 +836,6 @@ impl SessionActor {
}
new_specs.extend(specs);
}
// Inline hooks
if let Some(ref inline_value) = plugin.inline_hooks {
let (specs, warnings) =
kigi_agent::plugins::hooks_adapter::parse_plugin_hooks_from_value(
@@ -953,7 +936,6 @@ impl SessionActor {
})),
);
// Refresh skills: re-scan from disk using the (already-updated) plugin registry.
let t_skills = std::time::Instant::now();
let skill_count = self.reload_skills_from_disk().await;
kigi_log::unified_log::info(
@@ -965,7 +947,6 @@ impl SessionActor {
})),
);
// Notify pager about registry changes so the modal auto-refreshes.
// Extract all RefCell borrows into locals before the .await so
// no Ref guard is alive across the suspension point (prevents
// BorrowMutError panics when send_xai_notification dispatches
@@ -146,11 +146,11 @@ impl SessionActor {
}
/// Broadcast a mid-turn interjection to every attached client.
///
/// Fan it out (sessionId-routed, fire-and-forget) so every pane viewing the
/// session renders the interjection block — not just the originator. The
/// originating pager rendered an optimistic block locally and dedups this
/// echo by `id`; other panes (which never minted the id) render it. `id` is
/// to chat state. Shared by `add_followup_message_as_user_turn` (which
/// `None` only for older clients, in which case every pane renders.
pub(super) fn broadcast_interjection(&self, text: &str, id: Option<&str>) {
let mut payload = serde_json::json!({
@@ -170,10 +170,12 @@ impl SessionActor {
}
}
/// Inject a synthetic user message: persist, optionally notify pager, push
/// notifies) and `drain_pending_interjections` (which skips notification
/// `<skill_information>` envelope (loaded + substituted SKILL.md bodies).
/// because the pager already has a local user prompt block).
/// Inject a synthetic user message: persist to `updates.jsonl`, optionally
/// notify the pager, and push it into the conversation context.
///
/// Shared by `add_followup_message_as_user_turn` (which notifies) and
/// `drain_pending_interjections` (which skips notification because the
/// pager already has a local user prompt block).
pub(super) async fn inject_synthetic_user_message(
&self,
text: &str,
@@ -220,17 +222,16 @@ impl SessionActor {
.await;
}
// Add to conversation context
self.chat_state_handle.push_user_message(item);
}
/// Expand skill slash references in interjection text into the
/// `<skill_information>` envelope (loaded + substituted SKILL.md bodies).
///
/// Interjections bypass turn-start slash resolution
/// (`slash_commands::resolve`), so without this a queued `/skill` row
/// force-sent mid-turn — or a typed `/skill` interjection — reaches the
/// model as a bare, unexpanded slash command. Returns `None` when the
/// conversation as a standalone synthetic user message
/// text references no known skill.
async fn interjection_skill_information(&self, text: &str) -> Option<String> {
// Mirror turn-start gating (`parse_slash_prefix`): only a leading
@@ -260,13 +261,13 @@ impl SessionActor {
}
/// Drain all pending interjections, wrap them, and inject each into the
/// conversation as a standalone synthetic user message
/// ([`ConversationItem::interjection`], tagged
/// `SyntheticReason::Interjection`) — never appended to tool results, so
/// compaction, replay, and analytics see the user's steering text as its
/// own user turn.
///
/// Returns `true` if any interjections were drained (caller may want to
/// Returns `true` if any interjections were drained (caller may want to
/// `continue` the turn loop so the model sees them on the next iteration).
pub(super) async fn drain_pending_interjections(&self) -> bool {
// Manual drain (not `drain_formatted`): skill parsing needs the raw
@@ -56,11 +56,9 @@ pub(crate) enum LazinessSuppressReason {
NotGoalMode,
}
/// Pure helper — build a `LazinessDebugLogLine` from the captured
/// per-fire metadata and a classifier outcome. Extracted so the
/// JSON-line shape stays unit-testable without a `SessionActor`.
/// Consumes `meta` so its `String` + `Vec` fields move into the
/// output line (no clones on the per-fire hot path).
/// Pure so the JSON-line shape stays unit-testable without a
/// `SessionActor`. Consumes `meta` so its `String` + `Vec` fields
/// move into the output line rather than clone on the per-fire path.
pub(crate) fn build_laziness_debug_line(
meta: LazinessFireMeta,
model_id: &str,
@@ -132,10 +130,9 @@ pub(crate) fn build_laziness_debug_line(
}
}
/// Pure helper — given a parsed classifier output and the production
/// min-confidence threshold, classify what production WOULD have done.
/// Extracted so the JSON-line shape can be unit-tested without a
/// SessionActor.
/// Classify what production WOULD have done for this parsed output at
/// the given min-confidence threshold. Pure so it can be unit-tested
/// without a `SessionActor`.
pub(crate) fn classify_debug_decision(
parsed: &ClassifierOutput,
min_confidence: f32,
@@ -673,7 +670,6 @@ impl SessionActor {
Ok(p) => p,
Err(parse_err) => {
let parse_error_detail = parse_err.to_string();
// Truncate raw to 200 chars for offline analysis.
let snippet: String = raw_text.chars().take(200).collect();
tracing::debug!(
error = %parse_error_detail,
@@ -4,13 +4,6 @@
use super::*;
// ── Layer 3: LazinessDetector pure helpers ──────────────────────────
//
// Idle-triggered classifier that asks the active session model whether
// the conversation looks stalled. Decision logic lives here as a pure
// function so it can be unit-tested without the actor; the integration
// glue lives in `maybe_fire_laziness_check`.
/// Harness-wide default `idle_threshold_ms` when the per-model
/// `LazinessDetectorPerModelConfig::idle_threshold_ms` is `None`. Chosen
/// to catch stalls within ~10s without
@@ -25,7 +18,7 @@ pub(crate) const LAZINESS_DEFAULT_MIN_CONFIDENCE: f32 = 0.7;
/// Baseline chat-history window — the classifier sees AT LEAST the
/// last N items (tool calls + tool results included). The window can
/// extend further back if the per-kind minimums below haven't been
/// satisfied yet. Uses a 30-message baseline window.
/// satisfied yet.
pub(crate) const LAZINESS_CONTEXT_ITEM_LIMIT: usize = 30;
/// Minimum number of real user prompts (i.e., `User` items with
@@ -94,6 +87,21 @@ impl LazinessAbortReason {
}
}
/// Prefix on `x_kigi_req_id` for laziness-classifier sampler calls.
/// Centralised here so the production producer
/// (`maybe_fire_laziness_check`) AND the offline replay harness
/// (`crate::trace_classifier::build_classifier_request`) share a
/// single source of truth — a drift would otherwise pass the F17
/// fidelity test by re-typing the literal in both sites.
pub(crate) const LAZINESS_REQ_ID_PREFIX: &str = "xai-laziness-";
/// Preamble on the User-item text of the classifier request. The
/// User content is
/// `format!("{LAZINESS_USER_PREAMBLE}=== BEGIN TRANSCRIPT ===\n{runtime_state}{transcript}=== END TRANSCRIPT ===\n")`.
/// See [`LAZINESS_REQ_ID_PREFIX`] for the same shared-truth rationale.
pub(crate) const LAZINESS_USER_PREAMBLE: &str =
"Classify the following transcript. Output JSON only.\n\n";
/// Classifier system prompt. The JSON category strings are
/// byte-identical to the `LAZINESS_*` consts in
/// [`crate::session::events`]; the producer-consistency test in that
@@ -110,21 +118,6 @@ impl LazinessAbortReason {
/// Prompt-structure mitigations against motivated reasoning:
/// "Do not roleplay", JSON-only, no chain-of-thought,
/// no role context, transcript framed as third-party data.
/// Prefix on `x_kigi_req_id` for laziness-classifier sampler calls.
/// Centralised here so the production producer
/// (`maybe_fire_laziness_check`) AND the offline replay harness
/// (`crate::trace_classifier::build_classifier_request`) share a
/// single source of truth — a drift would otherwise pass the F17
/// fidelity test by re-typing the literal in both sites.
pub(crate) const LAZINESS_REQ_ID_PREFIX: &str = "xai-laziness-";
/// Preamble on the User-item text of the classifier request. The
/// User content is
/// `format!("{LAZINESS_USER_PREAMBLE}=== BEGIN TRANSCRIPT ===\n{runtime_state}{transcript}=== END TRANSCRIPT ===\n")`.
/// See [`LAZINESS_REQ_ID_PREFIX`] for the same shared-truth rationale.
pub(crate) const LAZINESS_USER_PREAMBLE: &str =
"Classify the following transcript. Output JSON only.\n\n";
pub(crate) const LAZINESS_CLASSIFIER_PROMPT: &str = "You are a strict JSON-emitting classifier. \
You are NOT the agent in the transcript below. You are NOT continuing \
the conversation. You are reading the transcript as third-party data \
@@ -242,10 +235,10 @@ pub(crate) const LAZINESS_INCLUDE_REASONING: bool = true;
/// jump backward) — production drops the field rather than emit a
/// meaningless value.
///
/// Extracted as a pure helper so the negative-delta and missing-
/// timestamp branches are directly unit-testable without standing
/// up a full `SessionActor`. The production call site in
/// `maybe_fire_laziness_check` is a one-liner over this.
/// Pure so the negative-delta and missing-timestamp branches are
/// directly unit-testable without standing up a full `SessionActor`.
/// The production call site in `maybe_fire_laziness_check` is a
/// one-liner over this.
pub(crate) fn turn_elapsed_seconds_from_start_ms(
turn_start_ms: Option<i64>,
now_ms: i64,
@@ -470,7 +463,7 @@ pub(crate) const CLASSIFIER_REFRESH_TURNS: usize = 16;
/// Per-turn text cap (bytes) for the classifier transcript so one giant pasted
/// user message or huge tool args can't blow up the per-call classifier request
/// (token/latency, or context overflow → error → silent heuristic fallback).
/// Mirrors the laziness classifier's 400-char field cap; truncation appends the
/// Mirrors the laziness classifier's 400-char field cap.
const CLASSIFIER_TURN_MAX_LEN: usize = kigi_workspace::permission::CLASSIFIER_TURN_MAX_LEN;
/// Build the auto-mode classifier transcript from the most recent `max_items`
@@ -1,7 +1,6 @@
use super::*;
impl SessionActor {
/// Wait for MCP tools to be initialized.
/// If initialization is in progress by another task, this will poll until complete.
/// If initialization is in progress by another task, polls until complete.
pub(super) async fn wait_for_mcp_initialized(&self) {
loop {
{
@@ -584,7 +583,7 @@ impl SessionActor {
/// matching the post-handshake state produced by
/// [`Self::ensure_mcp_tools_initialized`] for a fresh server.
///
/// Tools that were previously registered against this server
/// Tools already registered against this server
/// remain in `ToolBridge` and resolve transparently through
/// `McpTool::mcp_state` — there's no per-tool re-registration
/// step. `tools/list_changed` notifications from the respawned
@@ -1265,7 +1264,7 @@ impl SessionActor {
} else {
e.error_category()
};
// Stable snake_case error labels (match the old wire enum).
// Stable snake_case error labels kept for wire compatibility.
let error_type_label = match error_cat {
kigi_file_utils::events::McpErrorCategory::AuthRequired => "auth",
kigi_file_utils::events::McpErrorCategory::Timeout => "timeout",
@@ -235,21 +235,13 @@ impl SessionActor {
);
}
/// Re-register MCP tools onto a freshly-built `ToolBridge` after a
/// zero-turn harness rebuild.
/// Re-register MCP tools from existing clients onto a freshly-built
/// `ToolBridge` after a zero-turn harness rebuild.
///
/// Snapshots the live MCP `Client` connections from `mcp_state` and
/// (eventually) re-walks each client's `list_tools` to mirror its
/// tool registrations onto the new bridge. Best-effort: per-server
/// failures are logged but do not abort the rebuild.
///
/// Re-register MCP tools from existing clients onto the rebuilt bridge.
///
/// Iterates over all connected MCP clients, calls `list_tools` on each
/// to obtain tool registrations, and registers them on the new bridge.
/// Errors on individual servers are logged but don't abort the process.
/// After re-registration, refreshes the tool metadata snapshot so
/// `search_tool` returns accurate results.
/// Snapshots the live MCP clients from `mcp_state` and re-walks each
/// client's `list_tools` to mirror its registrations onto the new
/// bridge. Best-effort: per-server failures are logged but do not
/// abort the rebuild.
pub(super) async fn re_register_mcp_tools_on_rebuilt_bridge(&self) {
// Snapshot server names + client Arcs to avoid holding the lock
// across async list_tools calls.
@@ -73,10 +73,6 @@ impl SessionActor {
) {
}
/// Reindex a single file and embed any new chunks.
///
/// Used after flush writes and session-end writes to keep the index
/// and embeddings up to date immediately.
pub(super) async fn reindex_and_embed(&self, path: &std::path::Path, source: &str) {
self.memory.reindex_and_embed(path, source).await;
}
@@ -102,8 +98,6 @@ impl SessionActor {
/// Run dream consolidation if gates pass.
///
/// Called at session end after the session summary is written.
/// Uses the same sampling client infrastructure as flush but sends
/// the dream prompt instead. The model call has a 60s timeout.
pub(super) async fn maybe_run_dream(&self) {
if self.startup_hints.is_subagent {
tracing::debug!(
@@ -189,7 +183,7 @@ impl SessionActor {
.await;
}
/// Shared dream execution: build message, call model, execute, record result.
/// Shared dream execution path for `maybe_run_dream` and `run_dream_slash_command`.
async fn run_dream_inner(
&self,
storage: &crate::session::memory::MemoryStorage,
@@ -298,7 +292,6 @@ impl SessionActor {
);
}
/// Make the dream model call using the session's sampling client.
async fn run_dream_model_call(&self, user_message: &str) -> Result<String, acp::Error> {
let sampling_client = self.prepare_chat_completion(false).await?;
let model = self
@@ -610,16 +603,14 @@ impl SessionActor {
/// Rewrite a raw memory note into well-structured markdown via a one-shot
/// LLM call using the `kigi` model.
///
/// Follows the same streaming pattern as [`handle_ai_suggest`]: prepares
/// a sampling client, builds a system+user prompt, streams the response,
/// and returns the collected text.
/// Follows the same streaming pattern as [`handle_ai_suggest`].
pub(super) async fn handle_rewrite_memory_note(
&self,
raw_text: &str,
context_summary: &str,
) -> Result<String, String> {
// Upper-bound check to prevent unbounded LLM input.
const MAX_INPUT_BYTES: usize = 32 * 1024; // 32 KB
const MAX_INPUT_BYTES: usize = 32 * 1024;
let combined_len = raw_text.len() + context_summary.len();
if combined_len > MAX_INPUT_BYTES {
return Err(format!(
@@ -283,7 +283,7 @@ impl SessionActor {
/// atomically inside the `ChatStateActor` (see
/// `ChatStateCommand::ReplaceSystemHead` for the serialization guarantees).
/// `system_prompt.txt` (not owned by the persistence actor) is saved
/// directly, even on a head no-op, so a previously-diverged secondary
/// directly, even on a head no-op, so a diverged secondary
/// artifact self-heals. Skipped entirely on a verbatim mirror-fork
/// (`preserve_inherited_system`).
pub(super) async fn handle_replace_system_prompt(&self, system_prompt: String) {
@@ -52,9 +52,6 @@ impl SessionActor {
return;
}
// Note: Auto-compact is now handled inline during process_conversation_turn,
// so we no longer need to check for queued auto-compact here.
// Start the next pending user prompt. Pull all needed fields from the
// queue head in one `front_mut` scope so we can mutate `state` again
// (e.g. `rewindable`) without overlapping borrows.
@@ -175,12 +172,10 @@ impl SessionActor {
// turn teardown). Normally a no-op.
self.sweep_monitor_buffer_into_pending(&mut state, "monitor-idle-drain");
// Nothing to drain
if state.pending_notifications.is_empty() {
return;
}
// Take all notifications and build merged blocks inside the lock
let notifications = std::mem::take(&mut state.pending_notifications);
drained_task_ids = notifications
@@ -334,7 +329,8 @@ impl SessionActor {
});
if monitor_section_idx.is_none() {
monitor_section_idx = Some(sections.len());
sections.push(Vec::new()); // placeholder, filled below
// Placeholder; the batch is built and slotted in below.
sections.push(Vec::new());
}
}
NotificationSource::BashTaskCompleted { .. } => {
@@ -3,16 +3,6 @@
//! payload preparation.
#![allow(clippy::items_after_test_module)]
use super::*;
/// Partition the AGENTS.md / Claude.md / `.kigi/rules/*.md` files returned
/// by `read_agents_config_with_paths` into "workspace" (cwd / repo root /
/// extra workspace user dir) and "user" (`~/.kigi/`, `~/.claude/`,
/// `~/.kigi/bundled/`) buckets, mirroring the split between
/// `<always_applied_workspace_rules>` and `<user_rules>`.
/// Normalize a free-form name (e.g. an MCP server identifier) into a
/// single safe filesystem segment.
///
/// Replaces anything outside `[A-Za-z0-9._-]` with `_` so the result is a
/// portable directory name on macOS/Linux.
/// Whether `url` is an `http://` or `https://` URL — i.e. a remote URL the
/// upstream API can fetch directly. `file://` and other local schemes are
/// rejected by the API and must be inlined as a `data:` URL instead.
@@ -25,10 +15,6 @@ pub(super) fn is_remote_image_url(url: &str) -> bool {
/// `file://` and other local schemes return 400. Inline bytes win when
/// present (the canonical payload); `uri` is forwarded directly only
/// when it is a remote URL with no inline bytes available.
///
/// Extracted so production and the regression tests assert against the
/// same selector — a future change to the production rule cannot drift
/// past the tests.
pub(super) fn pick_user_image_url(image: &agent_client_protocol::ImageContent) -> String {
if let Some(uri) = image.uri.as_deref()
&& image.data.is_empty()
@@ -39,6 +25,11 @@ pub(super) fn pick_user_image_url(image: &agent_client_protocol::ImageContent) -
format!("data:{};base64,{}", image.mime_type, image.data)
}
}
/// Partition the AGENTS.md / Claude.md / `.kigi/rules/*.md` files returned
/// by `read_agents_config_with_paths` into "workspace" (cwd / repo root /
/// extra workspace user dir) and "user" (`~/.kigi/`, `~/.claude/`,
/// `~/.kigi/bundled/`) buckets, mirroring the split between
/// `<always_applied_workspace_rules>` and `<user_rules>`.
fn partition_rules_by_scope(
files: Vec<kigi_agent::prompt::agents_md::AgentConfigFile>,
) -> (
@@ -190,10 +181,10 @@ const LARGE_QUERY_BUDGET_PERCENT: usize = 80;
const BOUNDED_TAIL_BUDGET: usize = 4_000;
/// Bytes reserved for skill instructions (own budget, not crowded out by the query).
pub(super) const SKILL_INLINE_BUDGET: usize = 4_000;
/// Marker between the head and tail of an elided block. Single source of truth.
/// Marker between the head and tail of an elided block.
pub(super) const ELISION_MARKER: &str =
"\n\n…[middle truncated — full text in the offloaded file]…\n\n";
/// Stable marker opening the offload notice. Single source of truth (for a future strip-on-re-read).
/// Stable marker opening the offload notice.
pub(super) const OFFLOAD_NOTICE_MARKER: &str = "[Full request offloaded to file]";
/// In-band notice that REPLACES the offload notice when the full request could
/// not be persisted to the session file (write error or task-join failure).
@@ -428,9 +419,9 @@ impl SessionActor {
};
ctx.render(&bridge).await
}
/// Gather VCS root + status with the same 2s timeout used by the legacy
/// `construct_user_message` path. Returns `(root, status)` -- either may
/// be `None` if VCS is absent or the lookup timed out.
/// Gather VCS root + status, bounded by a 5s timeout. Returns
/// `(root, status)` -- either may be `None` if VCS is absent or the
/// lookup timed out.
async fn gather_vcs_for_prefix(
&self,
cwd: &std::path::Path,
@@ -459,7 +450,8 @@ impl SessionActor {
};
(root, status)
}
/// `None` twin: descriptor materialization is unavailable in this build.
/// Stub: per-server descriptor materialization is unavailable in this
/// build, so there is no descriptors root.
fn workspace_mcps_root(_cwd: &std::path::Path) -> Option<std::path::PathBuf> {
None
}
@@ -530,7 +522,6 @@ impl SessionActor {
/// when it fits, else bounded head+tail (trailing question survives).
///
/// Returns `(assembled_message, Some(local_path))` when truncated, or `(assembled, None)`.
/// Includes skill information in the assembled prompt.
pub(super) async fn maybe_truncate_large_prompt_with_skills(
&self,
context: String,
@@ -582,7 +573,6 @@ impl SessionActor {
}
}
/// Add a followup message from the permission panel as a user turn in the conversation.
/// This sends the message to the scrollback and adds it to the conversation context.
pub(super) async fn add_followup_message_as_user_turn(&self, message: &str) {
self.inject_synthetic_user_message(
message,
@@ -32,7 +32,6 @@ impl SessionActor {
// Log prompt to per-CWD fast history file immediately when queued
// (not in handle_prompt, because prompt might be cancelled before processing)
// Extract raw text from prompt_blocks (without <user_query> tags)
let raw_prompt_text: String = prompt_blocks
.iter()
.filter_map(|block| {
@@ -80,8 +79,8 @@ impl SessionActor {
// User prompts have priority over queued synthetic auto-wake prompts;
// the guarded sweep exempts the running turn's own slot (see
// `State::sweep_pending_inputs`). Gate deliberately keyed on
// completion-id-bearing synthetics only (pre-existing shape): a queue
// holding only drain/goal-summary synthetics is never preempted.
// completion-id-bearing synthetics only: a queue holding only
// drain/goal-summary synthetics is never preempted.
if !origin.is_synthetic() {
let preempt_armed = state.pending_inputs.iter().any(|i| {
i.origin.completion_id().is_some()
@@ -340,11 +339,6 @@ impl SessionActor {
state.running_prompt_id() == Some(prompt_id)
}
/// Remove a queued prompt by id. Versioned + idempotent:
/// a missing id (already drained) or a stale `expected_version` is a
/// benign no-op — the actor still re-broadcasts so the client reconciles.
/// The in-flight turn is never removed. `owner` (when `Some`) scopes the
/// edit to the requesting client's own items.
/// Resolve a removed/cleared queued prompt's in-flight `session/prompt` RPC
/// before its [`InputItem`] is dropped.
///
@@ -376,6 +370,11 @@ impl SessionActor {
}));
}
/// Remove a queued prompt by id. Versioned + idempotent:
/// a missing id (already drained) or a stale `expected_version` is a
/// benign no-op — the actor still re-broadcasts so the client reconciles.
/// The in-flight turn is never removed. `owner` (when `Some`) scopes the
/// edit to the requesting client's own items.
pub(super) async fn handle_remove_queued_prompt(
&self,
id: &str,
@@ -409,31 +408,25 @@ impl SessionActor {
self.broadcast_queue_changed(&state);
}
/// Atomically interject a queued (not-yet-running) prompt into the running
/// turn. In a single state-lock hold the actor removes the
/// prompt from `pending_inputs` and pushes its text into
/// `pending_interjections`, so the in-flight turn merges it at the next safe
/// point (`drain_pending_interjections`) and the prompt can never both
/// interject AND later run as its own turn — the race the client-side
/// "interject + queue/remove" pair could not avoid.
/// Promote a queued (not-yet-running) prompt to run as the next turn
/// ("send now"). In a single state-lock hold, remove it from its current
/// position and re-insert it right behind the running front, behind any
/// earlier send-now prompts (FIFO among sends). Returns `true` when the
/// caller must cancel the running turn — a turn is running and no goal loop
/// is active.
///
/// Mirrors [`handle_remove_queued_prompt`]'s versioned/owner gate and
/// [`SessionCommand::Interject`]'s broadcast-then-buffer. Benign **no-op**
/// (the prompt stays queued and runs normally) when:
/// - no turn is running (the `Send now` race where the turn just ended —
/// buffering into nothing would strand the text), or
/// - `id` names the running turn, is already drained/removed, carries a
/// stale `expected_version`, or is owned by another client, or
/// - the row is not a plain prompt (it would reach the model as prompt text).
/// Mirrors [`handle_remove_queued_prompt`]'s versioned/owner gate. Benign
/// no-op (the prompt stays where it is and runs normally) when `id` names
/// the running turn, is already drained/removed, carries a stale
/// `expected_version`, or is owned by another client.
///
/// Always re-broadcasts `kigi/queue/changed` so every client reconciles
/// (the row vanishes on success, is unchanged on a no-op).
/// `new_text` (when `Some`) replaces the stored queue text in the
/// interjection — the client edited the row before interjecting. It rides
/// the same version check, so a stale version no-ops the edit too.
/// Exception: when the interject no-ops but the row is still queued, a
/// version-matching `new_text` is saved to the row as an LWW edit so the
/// edit isn't silently lost when the row later drains as its own turn.
/// `new_text` (when `Some` and non-blank) applies an LWW edit to the row
/// before promoting; it rides the same version check, so a stale version
/// no-ops the edit too. When the promotion no-ops but the row is still
/// queued, a version-matching `new_text` is saved to the row as an LWW edit
/// so it isn't silently lost when the row later drains as its own turn.
///
/// Always re-broadcasts `kigi/queue/changed` so every client reconciles.
pub(super) async fn handle_interject_queued_prompt(
&self,
id: &str,
@@ -604,9 +597,8 @@ impl SessionActor {
/// Semantics — last write wins via the actor's serialized mailbox.
/// Concretely, for an entry whose `queue_meta.id == id`:
/// 1. Rebuild the underlying `prompt_blocks` as a single
/// [`acp::TextContent`] block carrying `new_text` (any non-text blocks
/// such as pasted images on the original prompt are not preserved — the
/// user has explicitly typed replacement text).
/// [`acp::TextContent`] block carrying `new_text`, retaining any image
/// blocks from the original prompt (other non-text blocks are dropped).
/// 2. Update `queue_meta.text`, bump `queue_meta.version`, and record
/// `last_editor` (the original `owner` attribution is preserved).
/// 3. Re-broadcast `kigi/queue/changed` so every subscriber renders the
@@ -55,7 +55,6 @@ impl SessionActor {
}
}
// Wrap the question in a <system-reminder> user message.
let tag = self.reminder_wrapper_tag();
let wrapped_question = format!(
"<{tag}>This is a side question from the user. \
@@ -144,7 +143,6 @@ impl SessionActor {
/// for display only. It never mutates the conversation.
///
/// Best-effort: a failed or empty generation is logged and dropped — a
///
/// missing recap must never disrupt the session.
pub(super) async fn handle_recap(&self, auto: bool) {
use crate::session::helpers::session_recap;
@@ -201,7 +199,6 @@ impl SessionActor {
Err(e) => {
tracing::warn!(error = %e, "recap: failed to prepare sampling client");
clear_in_flight();
// A manual `/recap` shows a loading spinner; clear it on failure.
if !auto {
self.emit_recap_unavailable().await;
}
@@ -273,7 +270,6 @@ impl SessionActor {
Some(&e.to_string()),
);
clear_in_flight();
// A manual `/recap` shows a loading spinner; clear it on failure.
if !auto {
self.emit_recap_unavailable().await;
}
@@ -299,7 +295,6 @@ impl SessionActor {
Some("empty summary after clean_recap_text"),
);
clear_in_flight();
// A manual `/recap` shows a loading spinner; clear it when empty.
if !auto {
self.emit_recap_unavailable().await;
}
@@ -423,7 +418,6 @@ impl SessionActor {
/// summary and raw assistant text (or error). Rides on the post-turn
/// session archive to cloud storage like compaction request artifacts.
/// Best-effort: send-failures are logged at `warn` and never surfaced —
/// clear the loading spinner it is showing instead of animating forever.
/// a missing artifact must never disrupt recap display.
#[allow(clippy::too_many_arguments)]
fn persist_recap_request_artifact(
@@ -473,6 +467,7 @@ impl SessionActor {
}
/// Tell the live client that a manual `/recap` produced no recap, so it can
/// clear the loading spinner it is showing instead of animating forever.
///
/// Only the manual path shows a spinner, so callers gate this on `!auto`.
async fn emit_recap_unavailable(&self) {
@@ -484,7 +479,6 @@ impl SessionActor {
/// Handle an AI-powered shell command suggestion request.
/// Builds a minimal prompt from the prefix and CWD, calls the sampler
///
/// with low temperature and small max_tokens, and returns the suggestion.
pub(super) async fn handle_ai_suggest(
&self,
@@ -568,7 +562,6 @@ impl SessionActor {
/// [`prompt_suggest::effective_suggest_model`]: env
/// (`KIGI_PROMPT_SUGGESTIONS_MODEL`) > `[models] prompt_suggestion`
/// (config.toml) > remote `prompt_suggestion_model` (remote settings) >
/// (config.toml) > remote `prompt_suggestion_model` (remote settings) >
/// [`prompt_suggest::default_suggest_model`]. Every
/// tier except env is catalog-guarded against this shell's own model
/// catalog — when the effective model is not sampleable here (e.g.
@@ -581,7 +574,6 @@ impl SessionActor {
/// a reasoning model's response, and some models (e.g. `kigi`)
/// reject an explicit `reasoningEffort` with a 400. Output is filtered
/// through [`prompt_suggest::sanitize_suggestion`]; any failure returns
/// through [`prompt_suggest::sanitize_suggestion`]; any failure returns
/// `None`.
pub(super) async fn handle_suggest_prompt(
&self,
@@ -31,20 +31,17 @@ impl SessionActor {
// just to render the picker.
let file_metas = self.file_state_tracker.get_rewind_point_metas().await;
// Query prompt state from the chat state actor.
let snapshot = self.chat_state_handle.snapshot().await;
let (prompts, current_prompt_index) = match snapshot {
Some(ref s) => (s.prompt_texts.clone(), s.prompt_index),
None => (vec![], 0),
};
// Build a lookup of which prompt indices have file snapshots.
let file_meta_map: std::collections::HashMap<
usize,
&kigi_workspace::session::file_state::RewindPointMeta,
> = file_metas.iter().map(|m| (m.prompt_index, m)).collect();
// Generate a rewind point for every prompt 0..current_prompt_index.
let rewind_points = (0..current_prompt_index)
.map(|idx| {
let prompt_preview = prompts.get(idx).and_then(|text| {
@@ -164,7 +161,6 @@ impl SessionActor {
&self,
request: RewindRequest,
) -> anyhow::Result<RewindResponse> {
// Track revert for feedback signals
self.signals_handle().mark_reverted();
let target_index = request.target_prompt_index;
@@ -194,7 +190,7 @@ impl SessionActor {
});
}
// ── Build file revert preview (for All and FilesOnly modes) ─────
// Build file revert preview (for All and FilesOnly modes).
let mut clean_files = Vec::new();
let mut conflicts = Vec::new();
@@ -221,7 +217,6 @@ impl SessionActor {
}
}
// Build conflict/clean lists for the preview
for path in files_to_revert.keys() {
let current_content = self
.tool_context
@@ -258,7 +253,7 @@ impl SessionActor {
}
}
// ── Preview mode (force=false): pure dry run, no mutations ────
// Preview mode (force=false): pure dry run, no mutations
// Return what WOULD happen so the TUI can show a confirmation
// modal. Nothing is written, deleted, or truncated.
if !request.force {
@@ -279,9 +274,8 @@ impl SessionActor {
});
}
// ── Commit mode (force=true): execute the rewind ─────────────
// Commit mode (force=true): execute the rewind
// Execute file revert
let mut reverted_files = Vec::new();
if wants_file_revert {
for (rel_path, content) in files_to_revert {
@@ -314,7 +308,6 @@ impl SessionActor {
}
}
// Execute conversation rewind
let mut prompt_text: Option<String> = None;
if wants_conversation_rewind {
let session_dir = crate::session::persistence::session_dir(&self.session_info);
@@ -329,10 +322,8 @@ impl SessionActor {
*pending = prompt_text.clone();
}
// Check cross-compaction before rewinding.
let needs_replay = self.needs_compaction_replay().await;
// Get conversation from the chat state actor for truncation logic.
let mut conversation = self.chat_state_handle.get_conversation().await;
// Cross-compaction replay recomputes whether a compaction summary
@@ -340,7 +331,6 @@ impl SessionActor {
let mut replay_compaction_marker: Option<Option<usize>> = None;
if needs_replay {
// Cross-compaction rewind: reconstruct conversation from updates.jsonl.
// Run on the blocking pool since replay does synchronous file I/O
// (reading checkpoint files + scanning updates.jsonl).
let replay_updates = updates_path.clone();
@@ -386,10 +376,12 @@ impl SessionActor {
// index 1 with the original from the checkpoint if
// available, otherwise keep the current one.
if let Some(ui0) = replay_result.original_user_info {
conversation.truncate(1); // keep System only
// keep System only
conversation.truncate(1);
conversation.push(ConversationItem::user(ui0));
} else {
conversation.truncate(2); // keep System + current user_info
// keep System + current user_info
conversation.truncate(2);
}
conversation.extend(replay_result.conversation);
}
@@ -474,7 +466,6 @@ impl SessionActor {
});
}
// Update the file state tracker to reflect the rewind.
if wants_file_revert {
// All/FilesOnly: files were reverted, snapshots are stale — truncate.
self.file_state_tracker.truncate_from(target_index).await;
@@ -16,9 +16,6 @@ pub(super) fn yolo_toggle_report(was: bool, actual: bool) -> Option<bool> {
#[cfg(test)]
mod yolo_toggle_report_tests {
use super::yolo_toggle_report;
/// A pin-clamped enable (requested ON but actual stays OFF) reports no
/// change, so no spurious "turned on" event/telemetry is emitted. Real
/// flips report the actual new state.
#[test]
fn reports_actual_state_change_only() {
assert_eq!(yolo_toggle_report(false, false), None);
@@ -98,20 +98,17 @@ impl SessionTokenAuthGate {
/// session-token gate is active; everything else gets `None`, so the aux model's
/// own key survives to the wire.
///
/// M3 — the FIRST-PARTY case honours the gate, which is what the old "copy
/// `active_session_config.bearer_resolver`" shape did implicitly: that field is
/// `None` whenever the gate is inactive. Without it, a BYOK / api-key session
/// with a `[model.*]` aux entry carrying its OWN `env_key` on the session's own
/// coding endpoint has that key REPLACED on the wire by the primary bearer on
/// every image-describe / auto-mode-classifier / summary request. A
/// subscription-OAuth aux model is deliberately NOT gated this way: its pooled
/// token IS its credential, and withholding the resolver only costs it
/// mid-session refresh (L13).
/// The FIRST-PARTY case honours the gate — it yields no resolver whenever the
/// gate is inactive. Without that, a BYOK / api-key session with a `[model.*]`
/// aux entry carrying its OWN `env_key` on the session's own coding endpoint has
/// that key REPLACED on the wire by the primary bearer on every image-describe /
/// auto-mode-classifier / summary request. A subscription-OAuth aux model is
/// deliberately NOT gated this way: its pooled token IS its credential, and
/// withholding the resolver only costs it mid-session refresh (L13).
///
/// Shared by [`SessionActor::aux_bearer_resolver`] and
/// `MvpAgent::summary_bearer_resolver`: the summary client is built by the
/// AGENT, not the session actor, and its own private copy of this rule is
/// exactly how it stayed ungated after M3 closed the session-actor side.
/// AGENT, not the session actor, so it holds its own private copy of this rule.
pub(crate) fn aux_bearer_resolver_for(
authority: &crate::auth::credential_authority::CredentialAuthority,
auth_method_id: Option<&acp::AuthMethodId>,
@@ -190,7 +187,6 @@ impl kigi_sampler::BearerResolver for AuthManagerBearerResolver {
self.0.current_or_expired().map(|a| a.key)
}
}
/// Wrap `am` as a shared sampler bearer resolver.
pub(crate) fn auth_manager_bearer_resolver(
am: std::sync::Arc<crate::auth::AuthManager>,
) -> kigi_sampler::SharedBearerResolver {
@@ -255,16 +251,16 @@ impl SessionActor {
/// AUX / summary / image-describe slug.
///
/// Identical resolution, but it NEVER WRITES the slot. The memo is a SINGLE
/// slot: when the aux path shared it, one classifier or image-describe call
/// evicted the session model's entry, and (a) the next
/// [`Self::reconstruct_full_config`] paid another `load_effective_config()`
/// + `resolve_model_list()` — the per-turn disk read M7/M9 removed — while
/// (b) a transient `Unknown` for the SESSION model then had no same-`model_id`
/// definite value to fall back to, so it degraded to `endpoint_is_first_party`
/// — `false` for every subscription-OAuth host, costing the session its
/// `bearer_resolver` and 401ing unrecoverably ~1h in (the failure L13
/// prevents). Reading a matching entry is still allowed: it can only hit when
/// the slot already names this same slug.
/// slot: were the aux path to share it, one classifier or image-describe call
/// would evict the session model's entry, and (a) the next
/// [`Self::reconstruct_full_config`] would pay another `load_effective_config()`
/// + `resolve_model_list()` disk read while (b) a transient `Unknown` for the
/// SESSION model would then have no same-`model_id` definite value to fall
/// back to, so it would degrade to `endpoint_is_first_party` — `false` for
/// every subscription-OAuth host, costing the session its `bearer_resolver`
/// and 401ing unrecoverably ~1h in (the failure L13 prevents). Reading a
/// matching entry is still allowed: it can only hit when the slot already
/// names this same slug.
fn aux_model_auth_facts(&self, model_id: &str) -> crate::agent::config::ModelAuthFacts {
self.resolve_auth_facts(model_id, false)
}
@@ -338,11 +334,7 @@ impl SessionActor {
/// Callers pass the LIVE sampling config's `base_url` so the manager, the
/// gate and the wire can never be resolved against three different endpoints
/// (an `OverrideModelName` session keeps its original `base_url` under a
/// routing name absent from the catalog). A `model`-only sibling that
/// re-derived the endpoint from the CATALOG instead used to exist beside
/// this; it had zero callers and was deleted rather than left as a second,
/// unexercised way to answer the same question (`lib.rs`'s
/// `#![allow(dead_code)]` means such a helper raises no warning).
/// routing name absent from the catalog).
pub(super) fn auth_manager_for_endpoint(
&self,
model: &str,
@@ -383,8 +375,8 @@ impl SessionActor {
/// (same entry, new routing name), otherwise CLEAR it.
///
/// H-c: `OverrideModelName` is the one command that rewrites
/// `SamplingConfig::model` without going through `SetSessionModel`, so it
/// used to leave the field naming a model the session is no longer on.
/// `SamplingConfig::model` without going through `SetSessionModel`, so it can
/// leave the field naming a model the session is no longer on.
/// Clearing rather than re-resolving is deliberate: re-resolving would put
/// `resolve_catalog_key`'s `.rev()` guess INTO the field the whole rule
/// treats as the session's deliberate selection, and a cleared field
@@ -475,13 +467,13 @@ impl SessionActor {
}
/// Emit a unified-log breadcrumb whenever the session-token refresh gate is
/// evaluated with an **`Unknown`** per-model BYOK status on a session-based
/// method — the condition that (pre-fix) silently demoted live sessions to
/// stale-token 401s. The uploaded per-turn unified log then shows whether
/// the first-party-endpoint fallback kept refresh active or withheld it, so
/// we can confirm the fix works (or catch a residual demotion) per session
/// even when server-side metrics only show the aggregate 401. No-op for a
/// definite `Byok`/`NotByok`, so steady-state turns stay quiet — a burst of
/// these is itself the signal that `Unknown` is being hit in the field.
/// method — the condition that would otherwise silently demote live sessions
/// to stale-token 401s. The uploaded per-turn unified log then shows whether
/// the first-party-endpoint fallback kept refresh active or withheld it, so a
/// residual demotion can be caught per session even when server-side metrics
/// only show the aggregate 401. No-op for a definite `Byok`/`NotByok`, so
/// steady-state turns stay quiet — a burst of these is itself the signal that
/// `Unknown` is being hit in the field.
fn log_auth_gate_unknown(&self, site: &str, gate: SessionTokenAuthGate, base_url: &str) {
use crate::agent::auth_method::ModelByok;
if gate.model_byok != ModelByok::Unknown || !gate.is_session_based {
@@ -762,11 +754,10 @@ impl SessionActor {
// Kimi session token (which `resolve_credentials` would otherwise stamp
// onto an api.x.ai / api.deepseek.com request). The first-party
// subscription channel still gets the primary (byte-identical).
// M6: ONE lookup. The platform AND the base URL the rule is applied to
// both come from `credential_for_slug`'s single resolution of `slug`
// against this catalog, so the platform and the endpoint can no longer
// disagree (they were previously resolved by two different lookups).
// Aux slugs are not the session's selection, so no `current_key`.
// The platform AND the base URL the rule is applied to both come from
// `credential_for_slug`'s single resolution of `slug` against this
// catalog, so the platform and the endpoint cannot disagree. Aux slugs
// are not the session's selection, so no `current_key`.
let session_key = self
.credential_authority()
.credential_for_slug(&models, None, slug);
@@ -1167,7 +1158,6 @@ impl SessionActor {
}
}
}
/// Proactively refresh the auth token if near expiry.
pub(super) async fn refresh_token_if_expired(&self) {
let (model_id, base_url) = self
.chat_state_handle
@@ -1210,12 +1200,12 @@ impl SessionActor {
.map(|c| c.model)
.unwrap_or_default();
let Some(ref key) = current_key else { return };
// M7/M9: a registry-platform model's key normally comes from that
// platform's credential resolved into its catalog entry, so with the
// session gate now inactive for every API-key platform those turns all
// fell through to here and paid a `load_effective_config()` disk read
// PER TURN, then logged a permanently false "Model not found in
// config.toml [model.*]" warning.
// A registry-platform model's key normally comes from that platform's
// credential resolved into its catalog entry; the session gate is
// inactive for every API-key platform, so those turns reach here.
// Falling through would pay a `load_effective_config()` disk read PER
// TURN and log a permanently false "Model not found in config.toml
// [model.*]" warning.
//
// But a `[model."deepseek/deepseek-chat"]` override DOES keep the base
// entry's `info.id` (`ConfigModelOverride::apply`), so "has a platform"
@@ -3,7 +3,6 @@
//! refresh.
use super::*;
impl SessionActor {
/// `true` for session-based ACP auth methods.
fn is_session_based_auth(&self) -> bool {
self.auth_method_id
.load()
@@ -194,8 +193,6 @@ impl SessionActor {
}
skill_count
}
/// Send `AvailableCommandsUpdate` to the client.
///
/// Reads the current slash-command skill list from the tools layer
/// (`SkillManager`), NOT from `PromptContext`.
pub(super) async fn send_available_commands_update(&self) {
@@ -238,9 +235,6 @@ impl SessionActor {
/// `Discovery` reminders (skills found mid-session via tool
/// navigation into directories the baseline hadn't seen) are kept
/// for both harnesses; the preamble cannot list those.
///
/// Tag selection for skill reminders. Centralized here so new call sites
/// cannot accidentally drift the gating or tag selection.
pub(super) fn wrap_skill_reminder(
&self,
effects: &kigi_tools::types::skill_discovery_tracker::SkillUpdateEffects,
@@ -266,10 +260,6 @@ impl SessionActor {
///
/// Slash command data is read from `bridge.slash_skills()`.
/// `PromptContext` is not involved. The system prompt is not mutated.
///
/// Apply skill update effects: inject a system-reminder and refresh
/// slash commands. Both default and compat agents receive mid-session
/// discovery reminders.
pub(super) async fn apply_skill_update_effects(
&self,
effects: kigi_tools::types::skill_discovery_tracker::SkillUpdateEffects,
@@ -39,8 +39,6 @@ mod cli_catchall_drop_tests {
fn allow(rule: &str) -> PermissionRule {
parse_permission_rule(rule, RuleAction::Allow).expect("rule parses")
}
/// Under the pin, CLI catch-all `--allow` rules (`*`, `**`) are dropped while
/// a scoped rule (`Bash(touch *)`) survives.
#[test]
fn pin_drops_cli_catchalls_keeps_scoped() {
let rules = vec![allow("*"), allow("Bash(touch *)"), allow("**")];
@@ -49,7 +47,6 @@ mod cli_catchall_drop_tests {
assert_eq!(kept[0].tool, ToolFilter::Bash);
assert_eq!(dropped.len(), 2, "both catch-alls are dropped");
}
/// Without the pin nothing is dropped, even catch-alls.
#[test]
fn no_pin_keeps_everything() {
let rules = vec![allow("*"), allow("Bash(touch *)"), allow("**")];
@@ -57,9 +54,9 @@ mod cli_catchall_drop_tests {
assert_eq!(kept.len(), 3);
assert!(dropped.is_empty());
}
/// FIX 2: a bare `--allow Bash` and a `?*` Bash pattern are `--yolo`
/// substitutes on the freeform-execution dimension, so the pin drops them
/// while a scoped `Bash(git *)` survives.
/// A bare `--allow Bash` and a `?*` Bash pattern are `--yolo` substitutes on
/// the freeform-execution dimension, so the pin drops them while a scoped
/// `Bash(git *)` survives.
#[test]
fn pin_drops_cli_bare_and_prefix_bash_keeps_scoped() {
let rules = vec![allow("Bash"), allow("Bash(?*)"), allow("Bash(git *)")];
@@ -73,10 +70,8 @@ mod cli_catchall_drop_tests {
assert!(dropped.is_empty());
}
}
/// Spawns a session actor and returns the session handle plus a receiver for permission events.
///
/// The permission events receiver should be used to collect telemetry about permission
/// decisions (YOLO mode, user accept/reject, etc.) for upload to GCS.
/// Spawns the session actor and returns its handle, the system prompt, and a
/// oneshot that fires when the session's run loop exits.
#[allow(clippy::too_many_arguments)]
#[tracing::instrument(
name = "session.spawn",
@@ -985,9 +980,9 @@ pub(crate) async fn spawn_session_actor(
session_info: session_info.clone(),
auth_method_id,
model_auth_facts: std::cell::RefCell::new(None),
// H4: seed the session's OWN selected catalog key from the model it was
// spawned with, resolved through the picker's lookup. Never the
// process-global `current_model_id()`. H-c: the rule lives in
// Seed the session's OWN selected catalog key from the model it was
// spawned with, resolved through the picker's lookup — never the
// process-global `current_model_id()`. The rule lives in
// `selected_catalog_key_for_spawn` so it is covered by a test.
selected_catalog_key: std::cell::RefCell::new(
crate::agent::models::selected_catalog_key_for_spawn(
@@ -1485,11 +1480,9 @@ pub struct SessionThread {
join_handle: std::thread::JoinHandle<()>,
}
impl SessionThread {
/// Check if the session thread has exited (panicked or finished).
pub fn is_finished(&self) -> bool {
self.join_handle.is_finished()
}
/// Construct from a raw `JoinHandle`. Used in tests.
#[cfg(test)]
pub fn from_handle(handle: std::thread::JoinHandle<()>) -> Self {
Self {
@@ -1497,7 +1490,6 @@ impl SessionThread {
}
}
}
/// Return type from the session thread's initialization, sent via oneshot.
struct SessionInitResult {
handle: SessionHandle,
system_prompt: String,
@@ -1780,16 +1772,7 @@ pub(crate) async fn spawn_session_on_thread(
///
/// Captured by the dispatcher task at session startup when
/// `mcp.auto_restart=true`. Holds an `Arc<SessionActor>` plus the
/// dispatcher's `SharedShutdownState` so:
///
/// - `is_stdio_server_configured` resolves against
/// [`SessionActor::is_stdio_server_configured`] (which reads
/// `McpState::configs`).
/// - `is_in_shutting_down` peeks at the dispatcher's set.
/// - `respawn_stdio` delegates to
/// [`SessionActor::respawn_stdio`] (re-runs `start_mcp_server`,
/// handshake, liveness arm, owned_clients swap).
/// - `push_status` forwards directly via the session's gateway.
/// dispatcher's `SharedShutdownState`.
pub(crate) struct SessionRestartActions {
session: Arc<SessionActor>,
shutdown: crate::session::mcp_dispatcher::SharedShutdownState,
@@ -115,7 +115,6 @@ impl<T> TaskSlot<T> {
}
}
/// Abort any pending task and store the new one.
pub(crate) fn arm(&self, handle: tokio::task::JoinHandle<T>) {
if let Some(old) = self.handle.take() {
old.abort();
@@ -123,12 +122,10 @@ impl<T> TaskSlot<T> {
self.handle.set(Some(handle));
}
/// Take the pending task handle (e.g. to await its result).
pub(crate) fn take(&self) -> Option<tokio::task::JoinHandle<T>> {
self.handle.take()
}
/// Abort and drop any pending task (e.g. because a new turn started).
pub(crate) fn cancel(&self) {
if let Some(old) = self.handle.take() {
old.abort();
@@ -349,10 +346,7 @@ impl SessionActor {
// user's next queued prompt) and rebroadcasts `kigi/queue/changed`.
// The cancelling client does not pull any prompt back into its
// input — the server queue is the single source of truth for what
// runs next. Previously every cancel did `std::mem::take`,
// discarding the whole queue server-side; because no broadcast
// followed, clients kept a stale mirror and the queue only visibly
// vanished on the next prompt's (now-empty) broadcast.
// runs next.
//
// The in-flight turn is always `pending_inputs.front()`
// (`maybe_start_running_task` promotes the front WITHOUT popping it;
@@ -580,11 +574,10 @@ impl SessionActor {
total_tokens,
turn_snapshot: None,
completion_kind: PromptCompletionKind::Cancelled {
// Previously hard-coded `None`, which dropped the
// category on the abort path so `streaming_partial.json`
// recorded a bare `"cancelled"`. Carry `MidTurnAbort`
// so the partial's `reason` and any downstream consumer
// match what `emit_turn_ended` wrote to `events.jsonl`.
// Carry `MidTurnAbort` so the partial's `reason` and any
// downstream consumer match what `emit_turn_ended` wrote
// to `events.jsonl`; `None` here would drop the category
// and record a bare `"cancelled"`.
category: Some(crate::session::events::CancellationCategory::MidTurnAbort),
// Thread the trigger on the running turn only (idx 0);
// MvpAgent stamps it on the `PromptResponse` `_meta`.
@@ -7,8 +7,6 @@
//! the parent module's private helpers.
use super::*;
use futures::StreamExt;
/// Whether a tool name is an MCP `create_pull_request` (qualified
/// `server__create_pull_request` or bare).
fn is_mcp_create_pull_request(tool_name: &str) -> bool {
match crate::session::mcp_servers::parse_mcp_tool_name(tool_name) {
Some((_, tool)) => tool == "create_pull_request",
@@ -102,7 +100,6 @@ pub(super) enum PlanFileRead {
Absent,
Unreadable,
}
/// Classify a plan-file read result into present / absent / unreadable.
pub(super) fn classify_plan_file_read(result: Result<String, std::io::Error>) -> PlanFileRead {
match result {
Ok(text) if !text.trim().is_empty() => PlanFileRead::Present(text),
@@ -147,7 +144,7 @@ pub(super) enum PlanEditGate {
///
/// - **Compat-toolset `Write`/`StrReplace`**: any markdown
/// file is editable in plan mode (plan docs are written with these
/// same tools); everything else is rejected. Pre-existing behavior.
/// same tools); everything else is rejected.
/// - **Compat-toolset `Delete`** is **not** on the markdown carve-out: it maps to
/// `AccessKind::Edit` and is plan-file-only (same as kigi edits). Deleting
/// an arbitrary `.md` in plan mode must not pass.
@@ -233,9 +230,8 @@ fn revise_plan_message(feedback: &str) -> String {
format!("The user wants to revise the plan. The user said:\n{feedback}")
}
}
/// What the resume re-park does with the user's decision. Extracted
/// from `resume_plan_approval` so the branch logic is unit-testable without
/// driving a real turn.
/// What the resume re-park does with the user's decision — a standalone value
/// so the resume branch logic is unit-testable without driving a real turn.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) enum ResumeAction {
/// Approved: leave plan mode and start an implement turn (Agent mode).
@@ -983,7 +979,7 @@ impl SessionActor {
perm_start,
);
let wait_ms = perm_start.elapsed().as_millis() as u64;
// Stable snake_case decision labels (match the old wire enum).
// Stable snake_case decision labels.
let decision_outcome = match &decision {
Decision::Allow | Decision::Ask => "allow",
Decision::Reject(_) | Decision::PolicyDeny(_) => "deny",
@@ -2041,9 +2037,8 @@ impl SessionActor {
/// Handle a hard tool execution error (dispatch/validation failure).
///
/// Emits the failed tool_result to the client and records failure signals.
/// Tool failures are not fed to the doom-loop detector (error-count streaks
/// were removed), so this never warns/terminates and returns no deferred
/// follow-ups today.
/// Tool failures are not fed to the doom-loop detector, so this never
/// warns/terminates and returns no deferred follow-ups today.
pub(super) async fn handle_tool_error(
&self,
tool_call_id: &acp::ToolCallId,
@@ -4,10 +4,9 @@
use super::*;
/// Number of output lines to show in final bash mode output summary
const BASH_MODE_FINAL_OUTPUT_LINES: usize = 10;
/// Phase 2: dispatch a tool call through [`WorkspaceOps::call_tool`].
/// Dispatch a tool call through [`WorkspaceOps::call_tool`].
///
/// Agent sessions always use local workspace ops (in-process toolset).
pub(super) async fn dispatch_tool(
@@ -134,7 +133,6 @@ pub(crate) const HTTP_STATUS_DETAILS_KEY: &str = "status";
impl SessionActor {
/// Extract bash command from prompt blocks if present in meta.
/// Returns Some(command) if the prompt is a direct bash command, None otherwise.
pub(super) fn extract_bash_command(prompt_blocks: &[acp::ContentBlock]) -> Option<String> {
use crate::extensions::prompt_meta::PromptBlockMeta;
for block in prompt_blocks {
@@ -148,7 +146,6 @@ impl SessionActor {
None
}
/// Handle a direct bash command from bash mode.
/// Runs the command with streaming output and sends updates to the TUI.
pub(super) async fn handle_direct_bash_command(
&self,
@@ -158,7 +155,6 @@ impl SessionActor {
) -> PromptTurnResult {
tracing::info!("Handling direct bash command");
// Send user message chunks to scrollback (so user sees their command)
let model_id = self.current_model_id().await;
let user_chunk_meta = serde_json::json!({ "modelId": model_id })
.as_object()
@@ -177,7 +173,6 @@ impl SessionActor {
))));
}
// Persist the user message for session history
let _ = self
.notifications
.persistence_tx
@@ -185,11 +180,8 @@ impl SessionActor {
prompt_blocks.to_vec(),
)));
// Run the bash command with streaming enabled
let tool_call_id = acp::ToolCallId::from(format!("bash-mode-{}", uuid::Uuid::new_v4()));
// Send initial ToolCall to register with TUI
use kigi_tools::types::ToolInput;
// Use the stripped command as description so pager chrome shows the
// real command (not a generic label) while still satisfying the required field.
@@ -235,14 +227,14 @@ impl SessionActor {
cwd: self.tool_context.cwd.clone(),
env: self.tool_context.session_env.as_ref().clone(),
timeout: DEFAULT_TIMEOUT,
output_byte_limit: 1_048_576, // 1 MiB
stream: true, // Enable streaming for bash mode
output_file: None, // No file logging for interactive bash mode
// 1 MiB
output_byte_limit: 1_048_576,
stream: true,
output_file: None,
};
let result = self.tool_context.terminal.run(request).await;
// Format the output
let (output, exit_code, timed_out, signal) = match result {
Ok(res) => (
res.combined_output,
@@ -253,8 +245,6 @@ impl SessionActor {
Err(e) => (format!("Error running command: {}", e), -1, false, None),
};
// Create final summary with last N lines
// Format: "... (X lines)\nlast\nfew\nlines"
let lines: Vec<&str> = output.lines().collect();
let total_lines = lines.len();
let displayed_output = if total_lines > BASH_MODE_FINAL_OUTPUT_LINES {
@@ -267,7 +257,6 @@ impl SessionActor {
let is_backgrounded = signal.as_deref() == Some("backgrounded");
// Build the final response text with output summary and exit code
let mut response_text = displayed_output.clone();
if is_backgrounded {
response_text.push_str("\n\n[command running in background]");
@@ -279,7 +268,6 @@ impl SessionActor {
response_text.push_str(&format!("\n\n[exit code: {}]", exit_code));
}
// Send final tool call update
// For backgrounded commands, don't mark as completed/failed - let the background task do that
if !is_backgrounded {
let final_status = if exit_code == 0 && signal.is_none() {
@@ -314,19 +302,11 @@ impl SessionActor {
.await;
}
// NOTE: The redundant AgentMessageChunk summary that was previously
// sent here has been removed. The execute block already contains the
// full command output — sending it again as an agent message created
// a noisy duplicate scrollback entry. Old sessions that have it will
// still replay fine; new sessions are cleaner.
// Build a single user message for chat history that includes command, output, and exit code
let user_message = format!(
"I executed a terminal command: `{}`\n\nOutput:\n```\n{}\n```\n\n[exit code: {}]",
command, displayed_output, exit_code
);
// Add to chat history as a user message only
self.chat_state_handle
.push_user_message(ConversationItem::user(&user_message));
@@ -339,11 +319,6 @@ impl SessionActor {
}
}
// ── Tool argument error formatting ─────────────────────────────────────
// Re-use the UTF-8-safe truncation helper from kigi-sampling-types rather
// than duplicating it here (R3).
/// Maximum bytes of `raw_arguments` included in a parse-error tool_result.
///
/// The model already holds the arguments in its recent context window, so
@@ -385,8 +360,7 @@ pub(super) fn build_tool_parse_error_message(
return msg;
}
// Append the original arguments (capped) so the model knows what it sent.
// Use truncate_bytes to avoid panicking on a multi-byte UTF-8 boundary.
// truncate_bytes avoids panicking on a multi-byte UTF-8 boundary.
msg.push_str("\n\nYour original arguments:\n");
let prefix = truncate_bytes(raw_arguments, MAX_ARGS_IN_ERROR);
msg.push_str(prefix);
@@ -394,8 +368,6 @@ pub(super) fn build_tool_parse_error_message(
msg.push_str("\n... (truncated)");
}
// If the arguments string is not valid JSON, surface the exact position
// of the syntax error so the model can fix it directly.
// Use `IgnoredAny` — we only need the error, not a DOM.
if let Err(json_err) = serde_json::from_str::<serde::de::IgnoredAny>(raw_arguments) {
msg.push_str(&format!(
@@ -15,16 +15,10 @@ impl SessionActor {
/// are unchanged. It only emits a transient (non-persisted) `Plan`
/// notification where `in_progress` entries are mapped to `completed`
/// for display.
///
/// Uses the canonical `plan_entry_from_todo_item` helper to preserve
/// cancelled metadata, priorities, and other semantics.
///
/// No-op if no `in_progress` items exist.
pub(super) async fn emit_turn_end_plan_cleanup(&self) {
use crate::tools::todo::{TodoState, TodoStatus, plan_entry_from_todo_item};
use kigi_tools::types::resources::State;
// Read the current TodoState (no mutation).
let (entries, stale_count) = {
let res = self
.agent
@@ -33,7 +27,7 @@ impl SessionActor {
.read_resource::<State<TodoState>>()
.await;
let Some(state) = res else {
return; // No todo state at all.
return;
};
let stale_count = state
@@ -45,9 +39,6 @@ impl SessionActor {
return;
}
// Build plan entries with in_progress → completed for display.
// Uses the canonical `plan_entry_from_todo_item` helper to
// preserve cancelled metadata, priority, and other semantics.
let entries: Vec<_> = state
.0
.todo_items()
@@ -231,8 +222,6 @@ impl SessionActor {
if broadcast_queue {
self.broadcast_queue_changed(&state);
}
// Note: Auto-compact is now handled inline during process_conversation_turn,
// so we no longer need to queue it here after turn completion.
// If the user toggled plan mode off while this turn was in-flight
// (state == ExitPending), complete the deferred exit now that the