Files
Kigi-CLI/crates/codegen/kigi-shell/src/session/helpers/prompt_suggest.rs
T
ZacharyZhang-NY 6f31415ed6 §9 acceptance: grep-zero sweep — every internal x.ai/grok identifier renamed
The PRD's first acceptance gate now holds: grep -RinE '\bx\.ai\b|grok'
crates/ --include='*.rs' → 0 matches (exempt: NOTICE and third-party
license archives, README provenance, and the required 'Based on Grok
Build Open Source' attribution, now sourced from version_attribution.txt).

Wire-visible renames (both sides in this repo, changed in lockstep):
- Auth method id 'grok.com' → 'kimi-code' (AuthMethodKind::KimiCode).
- Every x.ai/* and _x.ai/* ACP ext method and meta key → kigi/* /
  _kigi/* (~200 names; grokShell → kigiShell). Session-file replay keeps
  a read-side alias for the legacy '_x.ai/session/update' method so
  existing updates.jsonl histories load; writes emit only the new name
  (both directions test-pinned).
- Agent types grok-build* → kigi* with a documented legacy-prefix alias
  at resolution time so persisted sessions keep resolving.
- ToolNamespace/BuiltinAgentName GrokBuild* → Kigi* (wire snake_case
  kigi/kigi_concise/kigi_hashline; schema regenerated); grok_build
  implementation dirs renamed to kigi*.
- x-grok-* headers → x-kigi-*, __GROK_* sentinels → __KIGI_*, themes
  grokday/groknight → kigiday/kiginight (old persisted values fall back
  to the default theme), web_fetch allowlist xAI hosts → kimi.com +
  moonshot platforms, changelog CDN → this repo, grok-build changelog
  archives deleted.
- BYOK default endpoint removed: [endpoints] api_base_url is now truly
  optional with NO default — consumers fail fast with the flag name when
  unset (no silent x.ai egress). Mock harnesses inject it explicitly.
- System-prompt identity fixed: 'released by xAI' → 'an unofficial
  community CLI for Kimi' (template + regenerated encrypted form).

Also repaired pre-existing grok-era test debt found by the sweep: the
stale trace_classify default-model pin, the grok-pager UA label test,
pty-harness stale-binary reuse and non-hermetic moonshot routing (a PTY
test could previously reach the real api.moonshot.cn), and the outdated
oauth fixture scope key.

Gates: §9 grep 0; fmt clean; workspace check/clippy 0/0 (-D warnings);
FULL cargo test --workspace: 234 suites, 21,961 passed, 0 failed;
deny advisories ok.
2026-07-18 02:48:46 -04:00

586 lines
22 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Next-prompt prediction helpers (tab autocomplete ghost text).
//!
//! After a turn completes, the client asks the session to predict what the
//! user is likely to type next. The prediction renders as dim ghost text in
//! the empty prompt input; Tab accepts it. Modelled on common coding-agent
//! prompt suggestion features, but instead of replaying the full conversation prefix
//! it sends a *compact text-only transcript* — the call always routes to a
//! dedicated model (configurable, [`default_suggest_model`] by
//! default, never the session model — see [`effective_suggest_model`]),
//! where the parent session's prompt cache would not apply anyway, so a
//! small request wins on both cost and latency.
//!
//! The pure helpers here build the request items and filter the model output;
//! the actual model call lives on the `SessionActor`
//! (`handle_suggest_prompt`).
use crate::config::PromptSuggestModelPin;
use crate::sampling::ConversationItem;
use crate::session::helpers::chat::floor_char_boundary;
/// Model used for suggestion calls when nothing pins one (no env /
/// `[models] prompt_suggestion` / remote setting / client hint — see
/// [`effective_suggest_model`]). The Kimi catalog has no dedicated small
/// suggestion model, so this is the bundled default coding model; the
/// catalog guard still controls whether the request fires at all.
pub(crate) fn default_suggest_model() -> &'static str {
crate::models::default_model()
}
/// Resolve the model for one suggestion request, or `None` to skip the
/// request entirely (controlled disable).
///
/// Precedence: env pin > config.toml/remote pin > client hint (the request's
/// `model` param) > [`default_suggest_model`]. Every tier except the env pin
/// is catalog-guarded via `in_catalog`: firing an unavailable pin or default
/// would send a doomed per-turn request
/// that can never render ghost text. Skipping keeps the per-turn cost at
/// zero; deliberately NOT a session-model fallback — a per-turn background
/// call must stay on a small cheap model. The env pin bypasses the guard so
/// `KIGI_PROMPT_SUGGESTIONS_MODEL` keeps working for models the catalog does
/// not list (mirrors the pager, which forwards the env value unchecked).
pub(crate) fn effective_suggest_model(
pin: &PromptSuggestModelPin,
client_hint: Option<&str>,
in_catalog: impl Fn(&str) -> bool,
) -> Option<String> {
let client_hint = client_hint.map(str::trim).filter(|s| !s.is_empty());
let (model, catalog_guarded) = match pin {
PromptSuggestModelPin::Env(m) => (m.as_str(), false),
PromptSuggestModelPin::Pinned(m) => (m.as_str(), true),
PromptSuggestModelPin::Unpinned => (client_hint.unwrap_or(default_suggest_model()), true),
};
if catalog_guarded && !in_catalog(model) {
return None;
}
Some(model.to_owned())
}
/// Total character budget for the compact transcript (~6k tokens at the
/// bytes/4 estimate). Keeps the per-turn cost of the feature trivial even on
/// long sessions.
const TRANSCRIPT_BUDGET_CHARS: usize = 24_000;
/// Per-message character cap inside the transcript. Long messages (pasted
/// logs, big diffs) carry little signal for next-prompt prediction.
const MESSAGE_CAP_CHARS: usize = 1_500;
/// Reject suggestions longer than this — a prompt suggestion should be a
/// short, obvious next step, not an essay.
const SUGGESTION_MAX_CHARS: usize = 120;
/// Reject suggestions with more words than this (mirrors common
/// "2-12 words" guidance with a little slack).
const SUGGESTION_MAX_WORDS: usize = 16;
/// Short replies that are useful suggestions despite being a single word.
const ONE_WORD_ALLOWLIST: &[&str] = &[
"yes", "yeah", "yep", "no", "ok", "okay", "continue", "proceed", "push", "commit", "deploy",
"stop", "check", "retry", "undo", "merge",
];
/// System prompt for the suggestion call. The model sees a compact transcript
/// and must reply with ONLY the predicted next user message (or nothing).
pub(crate) const SUGGEST_PROMPT_SYSTEM: &str = "You predict what the USER will type next into their coding agent CLI.\n\
You are shown a transcript of the conversation so far. The agent's latest reply ends the transcript.\n\n\
FIRST: look at the user's recent messages and original request.\n\
Your job is to predict what THEY would type next — not what you think they should do.\n\
THE TEST: would they think \"I was just about to type that\"?\n\n\
EXAMPLES:\n\
- User asked \"fix the bug and run tests\", bug is fixed -> \"run the tests\"\n\
- After code was written -> \"try it out\"\n\
- Agent offers options -> the option the user would likely pick, based on the conversation\n\
- Agent ends by asking a yes/no question (continue? delete it? print it?) -> the user's likely answer: \"yes\" or \"no\"\n\
- Task complete with an obvious follow-up -> \"commit this\" or \"push it\"\n\
- After an error or a misunderstanding -> NONE (let them assess)\n\n\
Be specific: \"run the tests\" beats \"continue\".\n\
When the agent's reply ends with a question, a suggestion almost always exists — predict the answer.\n\n\
NEVER SUGGEST:\n\
- A message the user already sent, or a rephrasing of one — the transcript is history, not a menu. \
Once a request was handled, predict the step AFTER it, never the request again \
(short confirmations like \"yes\" or \"continue\" are the only acceptable repeats)\n\
- Evaluative filler (\"looks good\", \"thanks\")\n\
- Questions back to the agent (\"what about...?\")\n\
- Agent-voice phrasing (\"Let me...\", \"I'll...\", \"Here's...\")\n\
- New ideas the user never asked about\n\
- Multiple sentences\n\n\
Stay silent if the next step is not obvious from what the user said: reply with the single word NONE.\n\n\
Format: 2-12 words, matching the user's own style and casing.\n\
Reply with ONLY the suggestion text (or NONE) — no quotes, no markdown, no explanation.";
/// One transcript line: role label + flattened text content.
fn transcript_line(role: &str, text: &str) -> Option<String> {
let text = text.trim();
if text.is_empty() {
return None;
}
let mut text = text;
if text.len() > MESSAGE_CAP_CHARS {
let cut = floor_char_boundary(text, MESSAGE_CAP_CHARS);
text = &text[..cut];
}
Some(format!("{role}: {text}"))
}
/// Build the compact transcript from a conversation snapshot.
///
/// Keeps genuine `User` messages (skipping runtime-synthesized ones) and
/// `Assistant` text, newest-last, walking backwards until the character
/// budget is exhausted. Tool calls/results, reasoning, and the system prompt
/// are dropped — the user/assistant dialogue carries the signal for "what
/// will the user type next", and dropping the rest keeps the request cheap.
///
/// Returns `None` when the conversation has no assistant reply yet (nothing
/// to predict from).
pub(crate) fn build_transcript(conversation: &[ConversationItem]) -> Option<String> {
let mut lines: Vec<String> = Vec::new();
let mut used = 0usize;
let mut saw_assistant = false;
for item in conversation.iter().rev() {
let line = match item {
ConversationItem::User(u) => {
if u.synthetic_reason.is_some() {
continue;
}
transcript_line("User", &item.text_content())
}
ConversationItem::Assistant(_) => {
let line = transcript_line("Agent", &item.text_content());
if line.is_some() {
saw_assistant = true;
}
line
}
_ => continue,
};
let Some(line) = line else { continue };
if used + line.len() > TRANSCRIPT_BUDGET_CHARS && !lines.is_empty() {
break;
}
used += line.len();
lines.push(line);
}
if !saw_assistant || lines.is_empty() {
return None;
}
lines.reverse();
Some(lines.join("\n\n"))
}
/// Build the user message for the suggestion request.
pub(crate) fn suggest_prompt_user_message(transcript: &str, cwd: &str) -> String {
format!(
"CWD: {cwd}\n\nTranscript:\n\n{transcript}\n\n\
Predict the user's next message. Reply with ONLY the suggestion text."
)
}
/// Minimum word count for the deterministic repeat filter. Short
/// command-like replies ("yes", "run tests", "try again") legitimately
/// recur across a session; a repeated multi-word task prompt is the
/// "it suggested my old prompt back to me" failure mode.
const REPEAT_MIN_WORDS: usize = 4;
/// Case- and whitespace-insensitive form used for repeat comparison, with
/// trailing sentence punctuation dropped.
fn normalize_for_repeat(text: &str) -> String {
text.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.trim_end_matches(['.', '!', '?'])
.to_ascii_lowercase()
}
/// Whether a sanitized suggestion merely repeats a message the user already
/// sent. Deterministic backstop behind the system prompt's anti-repeat rule:
/// prompt guidance reduces repeats, this guarantees an exact (normalized)
/// re-suggestion of a past multi-word prompt never renders as ghost text.
/// Short suggestions (< [`REPEAT_MIN_WORDS`] words) are exempt — repeating
/// "yes" or "run tests" is often exactly what the user is about to type.
pub(crate) fn is_repeat_of_user_message(
suggestion: &str,
conversation: &[ConversationItem],
) -> bool {
if suggestion.split_whitespace().count() < REPEAT_MIN_WORDS {
return false;
}
let needle = normalize_for_repeat(suggestion);
conversation.iter().any(|item| match item {
ConversationItem::User(u) if u.synthetic_reason.is_none() => {
normalize_for_repeat(&item.text_content()) == needle
}
_ => false,
})
}
/// Filter/normalize the raw model output into a usable suggestion.
///
/// Returns `None` for anything that should not be shown as ghost text:
/// meta/no-op replies, agent-voice phrasing, multi-sentence or multi-line
/// output, markdown, or over-long text. Mirrors typical coding-agent suggestion
/// filters, adapted to the compact-transcript prompt above.
pub(crate) fn sanitize_suggestion(raw: &str) -> Option<String> {
// First line only; the prompt asks for a single line but models drift.
let line = raw.trim().lines().next()?.trim();
// Strip common wrappers the prompt forbids but models still emit.
let line = line
.trim_start_matches(['"', '\'', '`', '“', ''])
.trim_end_matches(['"', '\'', '`', '”', ''])
.trim();
if line.is_empty() || line.len() >= SUGGESTION_MAX_CHARS {
return None;
}
// Meta / "no suggestion" replies.
let lowered = line.to_ascii_lowercase();
let meta = [
"none",
"n/a",
"no suggestion",
"nothing",
"(silence)",
"silence",
"null",
];
if meta
.iter()
.any(|m| lowered == *m || lowered.starts_with(&format!("{m}.")))
{
return None;
}
// Markdown / formatting — ghost text renders on a single styled line.
if line.contains('*') || line.contains("```") || line.starts_with('#') || line.starts_with('-')
{
return None;
}
// Agent-voice phrasing — the suggestion must be in the USER's voice.
let agent_voice = [
"i'll ",
"i will ",
"let me ",
"here's ",
"here is ",
"i'm going to ",
];
if agent_voice.iter().any(|p| lowered.starts_with(p)) {
return None;
}
// Parenthetical/bracketed meta replies like "(no suggestion)".
if (line.starts_with('(') && line.ends_with(')'))
|| (line.starts_with('[') && line.ends_with(']'))
{
return None;
}
// Label prefixes like "Suggestion: ..." / "User: ...".
if let Some((head, _)) = line.split_once(':')
&& !head.contains(' ')
&& head.chars().all(|c| c.is_ascii_alphabetic())
{
return None;
}
// Multiple sentences read as agent prose, not a prompt.
let multi_sentence = line
.as_bytes()
.windows(3)
.any(|w| matches!(w[0], b'.' | b'!' | b'?') && w[1] == b' ' && w[2].is_ascii_uppercase());
if multi_sentence {
return None;
}
// Word-count bounds: 1 word only from the allowlist, and never a wall of text.
let words = line.split_whitespace().count();
if words > SUGGESTION_MAX_WORDS {
return None;
}
if words == 1 {
let bare = lowered.trim_end_matches(['.', '!']);
if !ONE_WORD_ALLOWLIST.contains(&bare) && !bare.starts_with('/') {
return None;
}
}
Some(line.to_owned())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::PromptSuggestModelPin as Pin;
// -- effective_suggest_model ---------------------------------------------
#[test]
fn effective_model_default_requires_catalog() {
// No pin, no hint: the built-in default fires only when this shell's
// catalog can sample it.
assert_eq!(
effective_suggest_model(&Pin::Unpinned, None, |m| m == default_suggest_model())
.as_deref(),
Some(default_suggest_model())
);
// OAuth catalogs exclude kigi-0.1 → skip the request entirely,
// never a doomed call (and never the session model).
assert_eq!(
effective_suggest_model(&Pin::Unpinned, None, |_| false),
None
);
}
#[test]
fn effective_model_client_hint_beats_default_and_is_guarded() {
assert_eq!(
effective_suggest_model(&Pin::Unpinned, Some("hinted"), |m| m == "hinted").as_deref(),
Some("hinted")
);
// A hint the shell can't sample skips — no silent fall-through.
assert_eq!(
effective_suggest_model(&Pin::Unpinned, Some("hinted"), |_| false),
None
);
// Blank hints are ignored: the default tier applies.
assert_eq!(
effective_suggest_model(&Pin::Unpinned, Some(" "), |m| m == default_suggest_model())
.as_deref(),
Some(default_suggest_model())
);
}
#[test]
fn effective_model_pin_beats_client_hint_and_is_guarded() {
assert_eq!(
effective_suggest_model(&Pin::Pinned("pinned".into()), Some("hinted"), |m| m
== "pinned"
|| m == "hinted")
.as_deref(),
Some("pinned")
);
// A pinned-but-unavailable model skips — the pin is an explicit
// choice, not a preference list; no fall-through to hint or default.
assert_eq!(
effective_suggest_model(&Pin::Pinned("pinned".into()), Some("hinted"), |m| m
== "hinted"),
None
);
}
#[test]
fn effective_model_env_pin_bypasses_catalog_guard() {
// KIGI_PROMPT_SUGGESTIONS_MODEL is the explicit escape hatch: used
// verbatim even when the catalog does not list the model (mirrors
// the pager, which forwards the env value unchecked).
assert_eq!(
effective_suggest_model(&Pin::Env("custom-model".into()), Some("hinted"), |_| false)
.as_deref(),
Some("custom-model")
);
}
// -- sanitize_suggestion ------------------------------------------------
#[test]
fn sanitize_accepts_short_imperative() {
assert_eq!(
sanitize_suggestion("run the tests").as_deref(),
Some("run the tests")
);
}
#[test]
fn sanitize_strips_quotes_and_backticks() {
assert_eq!(
sanitize_suggestion("\"commit this\"").as_deref(),
Some("commit this")
);
assert_eq!(sanitize_suggestion("`push it`").as_deref(), Some("push it"));
}
#[test]
fn sanitize_takes_first_line_only() {
assert_eq!(
sanitize_suggestion("run the tests\nthen commit").as_deref(),
Some("run the tests")
);
}
#[test]
fn sanitize_rejects_none_and_meta() {
for s in ["NONE", "none", "n/a", "no suggestion", "(silence)", ""] {
assert_eq!(sanitize_suggestion(s), None, "should reject {s:?}");
}
}
#[test]
fn sanitize_rejects_agent_voice() {
for s in [
"I'll run the tests",
"Let me check the output",
"Here's what to do next",
] {
assert_eq!(sanitize_suggestion(s), None, "should reject {s:?}");
}
}
#[test]
fn sanitize_rejects_markdown_and_labels() {
for s in [
"**run tests**",
"- run tests",
"# next",
"Suggestion: run tests",
"```run```",
] {
assert_eq!(sanitize_suggestion(s), None, "should reject {s:?}");
}
}
#[test]
fn sanitize_rejects_multi_sentence_and_overlong() {
assert_eq!(
sanitize_suggestion("Run the tests. Then commit the changes."),
None
);
let long = "word ".repeat(20);
assert_eq!(sanitize_suggestion(&long), None);
let chars = "x".repeat(200);
assert_eq!(sanitize_suggestion(&chars), None);
}
#[test]
fn sanitize_one_word_allowlist() {
assert_eq!(sanitize_suggestion("yes").as_deref(), Some("yes"));
assert_eq!(sanitize_suggestion("commit").as_deref(), Some("commit"));
// Bare one-word verbs outside the allowlist are too ambiguous.
assert_eq!(sanitize_suggestion("refactor"), None);
// Slash commands are fine.
assert_eq!(sanitize_suggestion("/review").as_deref(), Some("/review"));
}
#[test]
fn sanitize_allows_colon_after_multiword_head() {
// Only single-word alphabetic label heads are rejected.
assert_eq!(
sanitize_suggestion("fix the parse error: line 42").as_deref(),
Some("fix the parse error: line 42")
);
}
// -- build_transcript ---------------------------------------------------
fn user(text: &str) -> ConversationItem {
ConversationItem::user(text.to_owned())
}
fn assistant(text: &str) -> ConversationItem {
ConversationItem::assistant(text.to_owned())
}
// -- is_repeat_of_user_message -------------------------------------------
#[test]
fn repeat_filter_rejects_verbatim_past_prompt() {
let conv = vec![user("fix the flaky auth test"), assistant("Fixed it")];
assert!(is_repeat_of_user_message("fix the flaky auth test", &conv));
}
#[test]
fn repeat_filter_is_case_whitespace_and_punctuation_insensitive() {
let conv = vec![user("Fix the flaky\nauth test."), assistant("Fixed it")];
assert!(is_repeat_of_user_message("fix the flaky auth test!", &conv));
}
#[test]
fn repeat_filter_exempts_short_suggestions() {
let conv = vec![user("run the tests"), assistant("3 failures")];
// 3 words — legitimately recurs after new changes.
assert!(!is_repeat_of_user_message("run the tests", &conv));
assert!(!is_repeat_of_user_message("yes", &conv));
}
#[test]
fn repeat_filter_allows_novel_suggestions() {
let conv = vec![user("fix the flaky auth test"), assistant("Fixed it")];
assert!(!is_repeat_of_user_message("commit and push the fix", &conv));
}
#[test]
fn repeat_filter_ignores_synthetic_user_messages() {
let mut synthetic = user("please review the changes now");
if let ConversationItem::User(u) = &mut synthetic {
u.synthetic_reason = Some(crate::sampling::SyntheticReason::SystemReminder);
}
let conv = vec![synthetic, assistant("done")];
assert!(!is_repeat_of_user_message(
"please review the changes now",
&conv
));
}
#[test]
fn transcript_keeps_user_and_assistant_in_order() {
let conv = vec![
ConversationItem::system("sys".to_owned()),
user("fix the bug"),
assistant("Fixed it in foo.rs"),
];
let t = build_transcript(&conv).unwrap();
assert_eq!(t, "User: fix the bug\n\nAgent: Fixed it in foo.rs");
}
#[test]
fn transcript_requires_an_assistant_reply() {
let conv = vec![ConversationItem::system("sys".to_owned()), user("hello")];
assert!(build_transcript(&conv).is_none());
assert!(build_transcript(&[]).is_none());
}
#[test]
fn transcript_skips_synthetic_user_messages() {
let mut synthetic = ConversationItem::user("synthetic reminder".to_owned());
if let ConversationItem::User(u) = &mut synthetic {
u.synthetic_reason = Some(crate::sampling::SyntheticReason::SystemReminder);
}
let conv = vec![user("real question"), synthetic, assistant("answer")];
let t = build_transcript(&conv).unwrap();
assert!(!t.contains("synthetic reminder"));
assert!(t.contains("User: real question"));
}
#[test]
fn transcript_caps_long_messages() {
let long = "a".repeat(10_000);
let conv = vec![user(&long), assistant("ok")];
let t = build_transcript(&conv).unwrap();
assert!(
t.len() < 2_000,
"long message must be truncated: {}",
t.len()
);
}
#[test]
fn transcript_budget_keeps_newest_messages() {
let filler = "b".repeat(MESSAGE_CAP_CHARS);
let mut conv = Vec::new();
for _ in 0..40 {
conv.push(user(&filler));
conv.push(assistant(&filler));
}
conv.push(user("newest question"));
conv.push(assistant("newest answer"));
let t = build_transcript(&conv).unwrap();
assert!(t.len() <= TRANSCRIPT_BUDGET_CHARS + MESSAGE_CAP_CHARS + 64);
assert!(t.contains("newest question"));
assert!(t.ends_with("Agent: newest answer"));
}
}