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:
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
|
||||
|
||||
Reference in new issue
Block a user