diff --git a/crates/jp_cli/src/cmd.rs b/crates/jp_cli/src/cmd.rs index 586abab6..178218d6 100644 --- a/crates/jp_cli/src/cmd.rs +++ b/crates/jp_cli/src/cmd.rs @@ -1,4 +1,5 @@ mod attachment; +pub(crate) mod compact_flag; mod config; mod conversation; pub(crate) mod conversation_id; @@ -124,15 +125,22 @@ impl IntoPartialAppConfig for Commands { workspace: Option<&Workspace>, partial: PartialAppConfig, merged_config: Option<&PartialAppConfig>, + handles: &[jp_workspace::ConversationHandle], ) -> Result> { match self { - Commands::Query(args) => args.apply_cli_config(workspace, partial, merged_config), - Commands::Attachment(args) => args.apply_cli_config(workspace, partial, merged_config), + Commands::Query(args) => { + args.apply_cli_config(workspace, partial, merged_config, handles) + } + Commands::Attachment(args) => { + args.apply_cli_config(workspace, partial, merged_config, handles) + } Commands::AttachmentAdd(args) => { - args.apply_cli_config(workspace, partial, merged_config) + args.apply_cli_config(workspace, partial, merged_config, handles) + } + Commands::Conversation(args) => { + args.apply_cli_config(workspace, partial, merged_config, handles) } Commands::Config(_) - | Commands::Conversation(_) | Commands::Init(_) | Commands::Plugin(_) | Commands::External(_) => Ok(partial), @@ -391,6 +399,7 @@ impl From for Error { disable_persistence: false, }; } + Compaction(error) => [("message", "Compaction error".into()), ("error", error)].into(), CliConfig(error) => { [("message", "CLI Config error".to_owned()), ("error", error)].into() } diff --git a/crates/jp_cli/src/cmd/attachment.rs b/crates/jp_cli/src/cmd/attachment.rs index 73cc3847..7db4dd30 100644 --- a/crates/jp_cli/src/cmd/attachment.rs +++ b/crates/jp_cli/src/cmd/attachment.rs @@ -47,10 +47,15 @@ impl IntoPartialAppConfig for Attachment { workspace: Option<&Workspace>, partial: PartialAppConfig, merged_config: Option<&PartialAppConfig>, + handles: &[jp_workspace::ConversationHandle], ) -> std::result::Result> { match &self.command { - Commands::Add(args) => args.apply_cli_config(workspace, partial, merged_config), - Commands::Remove(args) => args.apply_cli_config(workspace, partial, merged_config), + Commands::Add(args) => { + args.apply_cli_config(workspace, partial, merged_config, handles) + } + Commands::Remove(args) => { + args.apply_cli_config(workspace, partial, merged_config, handles) + } Commands::List(_) | Commands::Print(_) => Ok(partial), } } diff --git a/crates/jp_cli/src/cmd/attachment/add.rs b/crates/jp_cli/src/cmd/attachment/add.rs index 8acc8d30..21c62deb 100644 --- a/crates/jp_cli/src/cmd/attachment/add.rs +++ b/crates/jp_cli/src/cmd/attachment/add.rs @@ -31,6 +31,7 @@ impl IntoPartialAppConfig for Add { workspace: Option<&Workspace>, mut partial: PartialAppConfig, _: Option<&PartialAppConfig>, + _handles: &[jp_workspace::ConversationHandle], ) -> std::result::Result> { for uri in &self.attachments { let uri = uri.parse(workspace.map(Workspace::root))?; diff --git a/crates/jp_cli/src/cmd/attachment/rm.rs b/crates/jp_cli/src/cmd/attachment/rm.rs index ba69254f..64ea7b3f 100644 --- a/crates/jp_cli/src/cmd/attachment/rm.rs +++ b/crates/jp_cli/src/cmd/attachment/rm.rs @@ -23,6 +23,7 @@ impl IntoPartialAppConfig for Rm { workspace: Option<&Workspace>, mut partial: PartialAppConfig, _: Option<&PartialAppConfig>, + _handles: &[jp_workspace::ConversationHandle], ) -> std::result::Result> { let mut attachments = vec![]; diff --git a/crates/jp_cli/src/cmd/compact_flag.rs b/crates/jp_cli/src/cmd/compact_flag.rs new file mode 100644 index 00000000..7285bfc3 --- /dev/null +++ b/crates/jp_cli/src/cmd/compact_flag.rs @@ -0,0 +1,256 @@ +//! Shared `--compact` / `-k` flag for compaction across commands. +//! +//! Used by `query`, `fork`, and `compact`. Supports bare `--compact` (apply +//! config rules) and `--compact=SPEC` (inline DSL rules). + +use std::str::FromStr; + +use clap::{Arg, ArgAction, ArgMatches, Command}; +use jp_config::{ + PartialAppConfig, + conversation::compaction::{ + PartialCompactionRuleConfig, PartialSummaryConfig, ReasoningMode, RuleBound, ToolCallsMode, + }, + types::vec::MergeableVec, +}; + +/// Shared compaction flag that can be embedded in any command. +/// +/// Supports two forms: +/// - `--compact` (bare): apply compaction rules from the resolved config. +/// - `--compact=SPEC` (with value): apply an inline DSL rule. +/// +/// Both compose: bare `--compact` includes config rules, each `--compact=SPEC` +/// adds a DSL rule. When only specs are present (no bare `--compact`), config +/// rules are not included. +#[derive(Debug, Default)] +pub(crate) struct CompactFlag { + /// True if bare `--compact` (no value) was specified. + pub use_config_rules: bool, + /// DSL specs from `--compact=SPEC` values. + pub specs: Vec, +} + +impl CompactFlag { + /// Whether compaction should be applied at all. + pub fn should_compact(&self) -> bool { + self.use_config_rules || !self.specs.is_empty() + } + + /// Apply DSL specs to the config partial. + /// + /// - If only specs (no bare `--compact`): replace the rules array. + /// - If bare `--compact` + specs: append DSL rules to existing config rules. + /// - If bare `--compact` only: leave config unchanged (rules apply as-is). + pub fn apply_to_config(&self, partial: &mut PartialAppConfig) { + if self.specs.is_empty() { + return; + } + + let rules: Vec = self + .specs + .iter() + .map(CompactSpec::to_partial_rule) + .collect(); + + if self.use_config_rules { + partial.conversation.compaction.rules.extend(rules); + } else { + partial.conversation.compaction.rules = MergeableVec::Vec(rules); + } + } +} + +impl clap::Args for CompactFlag { + fn augment_args(cmd: Command) -> Command { + cmd.arg( + Arg::new("compact") + .short('k') + .long("compact") + .help("Compact the conversation before proceeding") + .long_help( + "Compact the conversation.\n\nWithout a value, applies the compaction rules \ + from the resolved configuration.\n\nWith a DSL value (e.g. \ + `--compact=s:..-3`), applies an inline compaction rule. Multiple \ + `--compact=SPEC` flags add multiple rules.\n\nBoth forms compose: bare \ + `--compact` includes config rules, each `--compact=SPEC` adds a DSL \ + rule.\n\nDSL format: POLICIES[:RANGE]\nPolicies: r (reasoning), t (tools), s \ + (summarize), joined with +\nRange: FROM..TO, single number, or .. for \ + all\nExamples: s:..-3, r+t, s:5..-3, r:-20", + ) + .action(ArgAction::Append) + .num_args(0..=1) + .default_missing_value(""), + ) + } + + fn augment_args_for_update(cmd: Command) -> Command { + Self::augment_args(cmd) + } +} + +impl clap::FromArgMatches for CompactFlag { + fn from_arg_matches(matches: &ArgMatches) -> Result { + let values: Vec = matches + .get_many("compact") + .map(|v| v.cloned().collect()) + .unwrap_or_default(); + + let mut flag = CompactFlag::default(); + for val in values { + if val.is_empty() { + flag.use_config_rules = true; + } else { + let spec = val.parse::().map_err(|e| { + clap::Error::raw( + clap::error::ErrorKind::InvalidValue, + format!("invalid compact spec '{val}': {e}\n"), + ) + })?; + flag.specs.push(spec); + } + } + + Ok(flag) + } + + fn update_from_arg_matches(&mut self, matches: &ArgMatches) -> Result<(), clap::Error> { + *self = Self::from_arg_matches(matches)?; + Ok(()) + } +} + +// ── DSL types ─────────────────────────────────────────────────────────────── + +/// A parsed compaction DSL spec: `POLICIES[:RANGE]`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct CompactSpec { + pub reasoning: bool, + pub tools: bool, + pub summarize: bool, + /// `None` = use config defaults for range. + pub range: Option, +} + +/// A parsed DSL range. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct DslRange { + /// Left bound: turns to preserve at the start. `None` = 0. + pub keep_first: Option, + /// Right bound: turns to preserve at the end. `None` = 0. + pub keep_last: Option, +} + +impl CompactSpec { + fn to_partial_rule(&self) -> PartialCompactionRuleConfig { + let mut rule = PartialCompactionRuleConfig::default(); + + if self.reasoning { + rule.reasoning = Some(ReasoningMode::Strip); + } + if self.tools { + rule.tool_calls = Some(ToolCallsMode::Strip); + } + if self.summarize { + rule.summary = Some(PartialSummaryConfig::default()); + } + + if let Some(range) = &self.range { + rule.keep_first = Some(RuleBound::Turns(range.keep_first.unwrap_or(0))); + rule.keep_last = Some(RuleBound::Turns(range.keep_last.unwrap_or(0))); + } + + rule + } +} + +impl FromStr for CompactSpec { + type Err = String; + + fn from_str(s: &str) -> Result { + let (policies_str, range_str) = match s.split_once(':') { + Some((p, r)) => (p, Some(r)), + None => (s, None), + }; + + let mut reasoning = false; + let mut tools = false; + let mut summarize = false; + + for policy in policies_str.split('+') { + match policy.trim() { + "r" | "reasoning" => reasoning = true, + "t" | "tools" => tools = true, + "s" | "summarize" => summarize = true, + "" => return Err("empty policy".into()), + other => return Err(format!("unknown policy '{other}'")), + } + } + + if !reasoning && !tools && !summarize { + return Err("at least one policy required (r, t, s)".into()); + } + + let range = range_str.map(parse_dsl_range).transpose()?; + + Ok(CompactSpec { + reasoning, + tools, + summarize, + range, + }) + } +} + +fn parse_dsl_range(s: &str) -> Result { + // Full range: FROM..TO + if let Some((left, right)) = s.split_once("..") { + let keep_first = if left.is_empty() { + None + } else { + let n: usize = left + .parse() + .map_err(|_| format!("invalid left bound '{left}'"))?; + Some(n) + }; + + let keep_last = if right.is_empty() { + None + } else if let Some(rest) = right.strip_prefix('-') { + let n: usize = rest + .parse() + .map_err(|_| format!("invalid right bound '-{rest}'"))?; + Some(n) + } else { + return Err(format!( + "right bound must be negative (from end), got '{right}'" + )); + }; + + return Ok(DslRange { + keep_first, + keep_last, + }); + } + + // Single number shorthand + if let Some(rest) = s.strip_prefix('-') { + let n: usize = rest + .parse() + .map_err(|_| format!("invalid range '-{rest}'"))?; + Ok(DslRange { + keep_first: None, + keep_last: Some(n), + }) + } else { + let n: usize = s.parse().map_err(|_| format!("invalid range '{s}'"))?; + Ok(DslRange { + keep_first: Some(n), + keep_last: None, + }) + } +} + +#[cfg(test)] +#[path = "compact_flag_tests.rs"] +mod tests; diff --git a/crates/jp_cli/src/cmd/compact_flag_tests.rs b/crates/jp_cli/src/cmd/compact_flag_tests.rs new file mode 100644 index 00000000..4ea99bf4 --- /dev/null +++ b/crates/jp_cli/src/cmd/compact_flag_tests.rs @@ -0,0 +1,147 @@ +use super::*; + +#[test] +fn parse_policy_only() { + assert_eq!("s".parse::().unwrap(), CompactSpec { + reasoning: false, + tools: false, + summarize: true, + range: None, + }); + assert_eq!("r+t".parse::().unwrap(), CompactSpec { + reasoning: true, + tools: true, + summarize: false, + range: None, + }); + assert_eq!( + "reasoning+tools+summarize".parse::().unwrap(), + CompactSpec { + reasoning: true, + tools: true, + summarize: true, + range: None, + } + ); +} + +#[test] +fn parse_with_range() { + assert_eq!("s:..-3".parse::().unwrap(), CompactSpec { + reasoning: false, + tools: false, + summarize: true, + range: Some(DslRange { + keep_first: None, + keep_last: Some(3), + }), + }); + assert_eq!("r+t:5..-3".parse::().unwrap(), CompactSpec { + reasoning: true, + tools: true, + summarize: false, + range: Some(DslRange { + keep_first: Some(5), + keep_last: Some(3), + }), + }); + assert_eq!("s:..".parse::().unwrap(), CompactSpec { + reasoning: false, + tools: false, + summarize: true, + range: Some(DslRange { + keep_first: None, + keep_last: None, + }), + }); + assert_eq!("r:5..".parse::().unwrap(), CompactSpec { + reasoning: true, + tools: false, + summarize: false, + range: Some(DslRange { + keep_first: Some(5), + keep_last: None, + }), + }); +} + +#[test] +fn parse_single_number_shorthand() { + // Negative: keep last N + assert_eq!("s:-3".parse::().unwrap(), CompactSpec { + reasoning: false, + tools: false, + summarize: true, + range: Some(DslRange { + keep_first: None, + keep_last: Some(3), + }), + }); + // Positive: keep first N + assert_eq!("r:5".parse::().unwrap(), CompactSpec { + reasoning: true, + tools: false, + summarize: false, + range: Some(DslRange { + keep_first: Some(5), + keep_last: None, + }), + }); +} + +#[test] +fn parse_errors() { + assert!("".parse::().is_err()); + assert!("x".parse::().is_err()); + assert!("s:abc".parse::().is_err()); + // Positive right bound not supported + assert!("s:5..10".parse::().is_err()); +} + +#[test] +fn to_partial_rule_with_range() { + let spec = "r+t:..-3".parse::().unwrap(); + let rule = spec.to_partial_rule(); + assert_eq!(rule.reasoning, Some(ReasoningMode::Strip)); + assert_eq!(rule.tool_calls, Some(ToolCallsMode::Strip)); + assert!(rule.summary.is_none()); + assert_eq!(rule.keep_first, Some(RuleBound::Turns(0))); + assert_eq!(rule.keep_last, Some(RuleBound::Turns(3))); +} + +#[test] +fn to_partial_rule_no_range() { + let spec = "s".parse::().unwrap(); + let rule = spec.to_partial_rule(); + assert!(rule.reasoning.is_none()); + assert!(rule.tool_calls.is_none()); + assert!(rule.summary.is_some()); + // No range → None → use config defaults + assert!(rule.keep_first.is_none()); + assert!(rule.keep_last.is_none()); +} + +#[test] +fn apply_specs_only_replaces_rules() { + let flag = CompactFlag { + use_config_rules: false, + specs: vec!["s:..-3".parse().unwrap()], + }; + let mut partial = PartialAppConfig::default(); + flag.apply_to_config(&mut partial); + + let rules: &[_] = &partial.conversation.compaction.rules; + assert_eq!(rules.len(), 1); +} + +#[test] +fn apply_bare_compact_leaves_config_unchanged() { + let flag = CompactFlag { + use_config_rules: true, + specs: vec![], + }; + let mut partial = PartialAppConfig::default(); + let before = partial.conversation.compaction.rules.len(); + flag.apply_to_config(&mut partial); + assert_eq!(partial.conversation.compaction.rules.len(), before); +} diff --git a/crates/jp_cli/src/cmd/conversation.rs b/crates/jp_cli/src/cmd/conversation.rs index 863dc326..a409163a 100644 --- a/crates/jp_cli/src/cmd/conversation.rs +++ b/crates/jp_cli/src/cmd/conversation.rs @@ -1,9 +1,11 @@ -use jp_workspace::ConversationHandle; +use jp_config::PartialAppConfig; +use jp_workspace::{ConversationHandle, Workspace}; use super::{ConversationLoadRequest, Output}; -use crate::ctx::Ctx; +use crate::ctx::{Ctx, IntoPartialAppConfig}; mod archive; +pub(crate) mod compact; mod edit; pub(crate) mod fork; mod grep; @@ -12,6 +14,7 @@ mod path; mod print; mod rm; mod show; +pub(crate) mod summarize; mod unarchive; mod use_; @@ -27,7 +30,8 @@ impl Conversation { Commands::Show(args) => args.run(ctx, handles), Commands::Remove(args) => args.run(ctx, handles).await, Commands::Edit(args) => args.run(ctx, handles).await, - Commands::Fork(args) => args.run(ctx, &handles), + Commands::Fork(args) => args.run(ctx, &handles).await, + Commands::Compact(args) => args.run(ctx, handles).await, Commands::Grep(args) => args.run(ctx, handles), Commands::Print(args) => args.run(ctx, &handles), Commands::Path(args) => args.run(ctx, handles), @@ -44,6 +48,7 @@ impl Conversation { Commands::Remove(args) => args.conversation_load_request(), Commands::Edit(args) => args.conversation_load_request(), Commands::Fork(args) => args.conversation_load_request(), + Commands::Compact(args) => args.conversation_load_request(), Commands::Grep(args) => args.conversation_load_request(), Commands::Print(args) => args.conversation_load_request(), Commands::Path(args) => args.conversation_load_request(), @@ -81,6 +86,14 @@ enum Commands { #[command(name = "fork", visible_alias = "f")] Fork(fork::Fork), + /// Compact a conversation to reduce context size. + /// + /// Appends a compaction overlay that instructs the LLM projection layer + /// to strip reasoning blocks and/or tool call content from the specified + /// range. The original events are preserved. + #[command(name = "compact")] + Compact(compact::Compact), + /// Search through conversation history. #[command(name = "grep", alias = "rg", visible_alias = "g")] Grep(grep::Grep), @@ -101,3 +114,23 @@ enum Commands { #[command(name = "unarchive", visible_alias = "ua")] Unarchive(unarchive::Unarchive), } + +impl IntoPartialAppConfig for Conversation { + fn apply_cli_config( + &self, + workspace: Option<&Workspace>, + partial: PartialAppConfig, + merged_config: Option<&PartialAppConfig>, + handles: &[jp_workspace::ConversationHandle], + ) -> Result> { + match &self.command { + Commands::Compact(args) => { + args.apply_cli_config(workspace, partial, merged_config, handles) + } + Commands::Fork(args) => { + args.apply_cli_config(workspace, partial, merged_config, handles) + } + _ => Ok(partial), + } + } +} diff --git a/crates/jp_cli/src/cmd/conversation/compact.rs b/crates/jp_cli/src/cmd/conversation/compact.rs new file mode 100644 index 00000000..89dc1a87 --- /dev/null +++ b/crates/jp_cli/src/cmd/conversation/compact.rs @@ -0,0 +1,414 @@ +use std::{str::FromStr as _, time::Duration}; + +use chrono::{DateTime, Utc}; +use jp_config::{ + PartialAppConfig, + conversation::compaction::{ + CompactionRuleConfig, PartialCompactionRuleConfig, PartialSummaryConfig, ReasoningMode, + RuleBound, ToolCallsMode, + }, + types::vec::MergeableVec, +}; +use jp_conversation::{ + Compaction, ConversationStream, RangeBound, ReasoningPolicy, SummaryPolicy, ToolCallPolicy, + compaction::{extend_summary_range, resolve_range}, +}; +use jp_workspace::ConversationHandle; + +use crate::{ + cmd::{ + ConversationLoadRequest, Output, + conversation_id::PositionalIds, + lock::{LockOutcome, LockRequest, acquire_lock}, + }, + ctx::{Ctx, IntoPartialAppConfig}, +}; + +#[derive(Debug, clap::Args)] +pub(crate) struct Compact { + #[command(flatten)] + target: PositionalIds, + + /// Preserve the first N turns (or turns within a duration). + /// + /// Accepts a turn count (e.g. `2`) or a duration (e.g. `5h`). + #[arg(long)] + keep_first: Option, + + /// Preserve the last N turns (or turns within a duration). + /// + /// Accepts a turn count (e.g. `3`) or a duration (e.g. `2h`). + #[arg(long)] + keep_last: Option, + + /// Start compacting from a specific turn or time. + /// + /// Accepts an absolute turn index, a duration (e.g. `5h`), or `last` + /// to start after the most recent compaction. Overrides `--keep-first`. + #[arg(long, value_parser = parse_bound, conflicts_with = "keep_first")] + from: Option, + + /// Stop compacting at a specific turn or time. + /// + /// Accepts an absolute turn index or a duration. Overrides `--keep-last`. + #[arg(long, value_parser = parse_bound, conflicts_with = "keep_last")] + to: Option, + + /// Strip reasoning (thinking) blocks from the compacted range. + #[arg(long)] + reasoning: Option, + + /// Strip tool call arguments and responses in the compacted range. + #[arg(long)] + tools: Option, + + /// Generate an LLM summary for the compacted range. + /// + /// When enabled, the compacted turns are replaced with a single + /// LLM-generated summary. + #[arg(long)] + summarize: Option>, + + /// Preview what would change without applying. + #[arg(long)] + dry_run: bool, + + /// Remove all compaction events from the stream. + /// + /// Restores the raw event history so the LLM sees all original events. + #[arg(long)] + reset: bool, + + /// Compact using an inline DSL rule. + /// + /// Can be used alongside the dedicated flags above, or on its own. + /// See `jp query --help` for DSL syntax. + #[command(flatten)] + compact_flag: crate::cmd::compact_flag::CompactFlag, +} + +impl Compact { + /// Returns `true` if any flag that overrides compaction rule config is set. + /// + /// When true, the rules array is replaced with a single ad-hoc rule built + /// from the CLI flags via [`IntoPartialAppConfig`]. + fn has_rule_overrides(&self) -> bool { + self.keep_first.is_some() + || self.keep_last.is_some() + || self.reasoning.is_some() + || self.tools.is_some() + || self.summarize.is_some() + } +} + +impl IntoPartialAppConfig for Compact { + fn apply_cli_config( + &self, + _workspace: Option<&jp_workspace::Workspace>, + mut partial: PartialAppConfig, + _merged_config: Option<&PartialAppConfig>, + _handles: &[jp_workspace::ConversationHandle], + ) -> Result> { + // Dedicated flags build a single ad-hoc rule. + if self.has_rule_overrides() { + let mut rule = PartialCompactionRuleConfig::default(); + + if let Some(bound) = &self.keep_first { + rule.keep_first = Some(bound.clone()); + } + if let Some(bound) = &self.keep_last { + rule.keep_last = Some(bound.clone()); + } + if let Some(true) = self.reasoning { + rule.reasoning = Some(ReasoningMode::Strip); + } + if let Some(true) = self.tools { + rule.tool_calls = Some(ToolCallsMode::Strip); + } + if let Some(summarize) = self.summarize + && summarize.unwrap_or(true) + { + rule.summary = Some(PartialSummaryConfig::default()); + } + + partial.conversation.compaction.rules = MergeableVec::Vec(vec![rule]); + } + + // DSL specs (from --compact=SPEC / -k) compose on top. + self.compact_flag.apply_to_config(&mut partial); + + Ok(partial) + } +} + +/// A CLI range bound before time-based resolution. +#[derive(Debug, Clone)] +enum CliRangeBound { + /// Already resolved to a `RangeBound`. + Resolved(RangeBound), + /// Duration ago — needs the stream to find the turn. + Duration(DateTime), +} + +fn parse_bound(s: &str) -> Result { + if s.eq_ignore_ascii_case("last") { + return Ok(CliRangeBound::Resolved(RangeBound::AfterLastCompaction)); + } + + // Negative integer → FromEnd. + if let Some(rest) = s.strip_prefix('-') + && let Ok(n) = rest.parse::() + { + return Ok(CliRangeBound::Resolved(RangeBound::FromEnd(n))); + } + + // Positive integer → Absolute. + if let Ok(n) = s.parse::() { + return Ok(CliRangeBound::Resolved(RangeBound::Absolute(n))); + } + + // Duration string → resolve to DateTime. + humantime::Duration::from_str(s) + .map(|d| CliRangeBound::Duration(Utc::now() - Duration::from(d))) + .map_err(|e| format!("invalid range bound `{s}`: {e}")) +} + +/// Build a [`Compaction`] event from a resolved config rule. +/// +/// `from_override` and `to_override` are runtime-resolved range bounds +/// (`--from`/`--to`) that take precedence over the rule's `keep_first`/ +/// `keep_last`. +/// +/// Returns `None` if the resolved range is empty (nothing to compact). +pub(crate) async fn build_compaction_event( + events: &ConversationStream, + cfg: &jp_config::AppConfig, + rule: &CompactionRuleConfig, + from_override: Option, + to_override: Option, + printer: &jp_printer::Printer, +) -> crate::Result> { + let from = from_override.or_else(|| keep_first_to_bound(&rule.keep_first, events)); + let to = to_override.or_else(|| keep_last_to_bound(&rule.keep_last, events)); + + let Some(range) = resolve_range(events, from, to) else { + return Ok(None); + }; + + let should_summarize = rule.summary.is_some(); + + // Auto-extend range if summary would partially overlap existing summaries. + let range = if should_summarize { + extend_summary_range(events, range) + } else { + range + }; + + let summary_text = if should_summarize { + printer.println("Generating summary..."); + let text = super::summarize::generate_summary( + events, + range.from_turn, + range.to_turn, + rule.summary.as_ref(), + cfg, + ) + .await?; + Some(text) + } else { + None + }; + + let mut compaction = build_mechanical_compaction(range.from_turn, range.to_turn, rule); + + if let Some(text) = summary_text { + compaction = compaction.with_summary(SummaryPolicy { summary: text }); + } + + Ok(Some(compaction)) +} + +/// Build compaction events from all config rules. +/// +/// Each rule produces one `Compaction` event. Runtime range overrides +/// (`--from`/`--to`) apply to every rule. +pub(crate) async fn build_compaction_events_from_config( + events: &ConversationStream, + cfg: &jp_config::AppConfig, + from_override: Option, + to_override: Option, + printer: &jp_printer::Printer, +) -> crate::Result> { + let mut compactions = Vec::new(); + for rule in &cfg.conversation.compaction.rules { + if let Some(c) = build_compaction_event( + events, + cfg, + rule, + from_override.clone(), + to_override.clone(), + printer, + ) + .await? + { + compactions.push(c); + } + } + + Ok(compactions) +} + +/// Convert a `keep_first` rule bound to a `from` `RangeBound`. +fn keep_first_to_bound(bound: &RuleBound, events: &ConversationStream) -> Option { + match bound { + RuleBound::Turns(n) => Some(RangeBound::Absolute(*n)), + RuleBound::Duration(d) => { + let dt = chrono::Utc::now() - *d; + Some(RangeBound::Absolute(events.turn_at_time(dt)?.index())) + } + RuleBound::AfterLastCompaction => Some(RangeBound::AfterLastCompaction), + } +} + +/// Convert a `keep_last` rule bound to a `to` `RangeBound`. +fn keep_last_to_bound(bound: &RuleBound, events: &ConversationStream) -> Option { + match bound { + RuleBound::Turns(n) => Some(RangeBound::FromEnd(*n)), + RuleBound::Duration(d) => { + let dt = chrono::Utc::now() - *d; + Some(RangeBound::Absolute(events.turn_at_time(dt)?.index())) + } + RuleBound::AfterLastCompaction => None, + } +} + +/// Build a `Compaction` event from mechanical policies (no summary). +fn build_mechanical_compaction( + from_turn: usize, + to_turn: usize, + rule: &CompactionRuleConfig, +) -> Compaction { + let mut compaction = Compaction::new(from_turn, to_turn); + + if rule.reasoning.is_some() { + compaction = compaction.with_reasoning(ReasoningPolicy::Strip); + } + + if let Some(mode) = rule.tool_calls { + compaction = compaction.with_tool_calls(match mode { + ToolCallsMode::Strip => ToolCallPolicy::Strip { + request: true, + response: true, + }, + ToolCallsMode::StripResponses => ToolCallPolicy::Strip { + request: false, + response: true, + }, + ToolCallsMode::StripRequests => ToolCallPolicy::Strip { + request: true, + response: false, + }, + ToolCallsMode::Omit => ToolCallPolicy::Omit, + }); + } + + compaction +} + +impl Compact { + pub(crate) fn conversation_load_request(&self) -> ConversationLoadRequest { + ConversationLoadRequest::explicit_or_session(&self.target) + } + + pub(crate) async fn run(self, ctx: &mut Ctx, handles: Vec) -> Output { + for handle in handles { + self.compact_one(ctx, handle).await?; + } + Ok(()) + } + + async fn compact_one(&self, ctx: &mut Ctx, handle: ConversationHandle) -> Output { + let lock = match acquire_lock(LockRequest::from_ctx(handle, ctx)).await? { + LockOutcome::Acquired(lock) => lock, + LockOutcome::NewConversation | LockOutcome::ForkConversation(_) => { + unreachable!("compact does not allow new/fork on contention") + } + }; + + let cfg = ctx.config(); + let conv = lock.into_mut(); + let events_snapshot = conv.events().clone(); + + if self.reset { + let removed = conv.update_events(ConversationStream::remove_compactions); + if removed > 0 { + ctx.printer + .println(format!("Removed {removed} compaction event(s).")); + } else { + ctx.printer.println("No compaction events to remove."); + } + return Ok(()); + } + + // --from/--to are runtime-resolved range overrides (they need the + // stream for duration and "last" resolution). They apply to all rules. + let from_override = self.resolve_from(&events_snapshot); + let to_override = self.resolve_to(&events_snapshot); + + if self.dry_run { + let range = resolve_range(&events_snapshot, from_override.clone(), to_override.clone()); + if let Some(range) = range { + ctx.printer.println(format!( + "Would compact turns {}..={}", + range.from_turn, range.to_turn, + )); + } else { + ctx.printer.println("Nothing to compact."); + } + return Ok(()); + } + + let compactions = build_compaction_events_from_config( + &events_snapshot, + &cfg, + from_override, + to_override, + &ctx.printer, + ) + .await?; + + if compactions.is_empty() { + ctx.printer.println("Nothing to compact."); + return Ok(()); + } + + for compaction in compactions { + let from = compaction.from_turn; + let to = compaction.to_turn; + conv.update_events(|stream| stream.add_compaction(compaction)); + ctx.printer + .println(format!("Compacted turns {from}..={to}.")); + } + + Ok(()) + } + + /// Resolve `--from` to a `RangeBound`, if present. + fn resolve_from(&self, events: &ConversationStream) -> Option { + resolve_cli_bound(self.from.as_ref()?, events) + } + + /// Resolve `--to` to a `RangeBound`, if present. + fn resolve_to(&self, events: &ConversationStream) -> Option { + resolve_cli_bound(self.to.as_ref()?, events) + } +} + +fn resolve_cli_bound(bound: &CliRangeBound, events: &ConversationStream) -> Option { + match bound { + CliRangeBound::Resolved(b) => Some(b.clone()), + CliRangeBound::Duration(dt) => { + Some(RangeBound::Absolute(events.turn_at_time(*dt)?.index())) + } + } +} diff --git a/crates/jp_cli/src/cmd/conversation/fork.rs b/crates/jp_cli/src/cmd/conversation/fork.rs index 5e8cdf6c..267f0f48 100644 --- a/crates/jp_cli/src/cmd/conversation/fork.rs +++ b/crates/jp_cli/src/cmd/conversation/fork.rs @@ -4,7 +4,7 @@ use tracing::debug; use crate::{ cmd::{ConversationLoadRequest, Output, conversation_id::PositionalIds, time::TimeThreshold}, - ctx::Ctx, + ctx::{Ctx, IntoPartialAppConfig}, }; #[derive(Debug, clap::Args)] @@ -33,6 +33,10 @@ pub(crate) struct Fork { #[arg(long, short = 'l')] last: Option>, + /// Compact the forked conversation. + #[command(flatten)] + compact: crate::cmd::compact_flag::CompactFlag, + /// Set a custom title for the forked conversation. #[arg(long, short)] title: Option, @@ -43,7 +47,7 @@ impl Fork { ConversationLoadRequest::explicit_or_session(&self.target) } - pub(crate) fn run(self, ctx: &mut Ctx, handles: &[ConversationHandle]) -> Output { + pub(crate) async fn run(self, ctx: &mut Ctx, handles: &[ConversationHandle]) -> Output { for source in handles { let lock = fork_conversation(ctx, source, |events| { events.retain(|event| { @@ -56,6 +60,23 @@ impl Fork { } })?; + if self.compact.should_compact() { + let cfg = ctx.config(); + let events_snapshot = lock.events().clone(); + let compactions = super::compact::build_compaction_events_from_config( + &events_snapshot, + &cfg, + None, + None, + &ctx.printer, + ) + .await?; + for compaction in compactions { + lock.as_mut() + .update_events(|events| events.add_compaction(compaction)); + } + } + if let Some(title) = &self.title { lock.as_mut().update_metadata(|m| { m.title = Some(title.clone()); @@ -76,6 +97,19 @@ impl Fork { } } +impl IntoPartialAppConfig for Fork { + fn apply_cli_config( + &self, + _workspace: Option<&jp_workspace::Workspace>, + mut partial: jp_config::PartialAppConfig, + _merged_config: Option<&jp_config::PartialAppConfig>, + _handles: &[jp_workspace::ConversationHandle], + ) -> Result> { + self.compact.apply_to_config(&mut partial); + Ok(partial) + } +} + /// Fork a conversation and return the new conversation's lock. pub(crate) fn fork_conversation( ctx: &mut Ctx, diff --git a/crates/jp_cli/src/cmd/conversation/fork_tests.rs b/crates/jp_cli/src/cmd/conversation/fork_tests.rs index 8241e8f0..91979c75 100644 --- a/crates/jp_cli/src/cmd/conversation/fork_tests.rs +++ b/crates/jp_cli/src/cmd/conversation/fork_tests.rs @@ -17,7 +17,10 @@ use jp_workspace::Workspace; use tokio::runtime::Runtime; use super::*; -use crate::{Globals, cmd::conversation_id::PositionalIds}; +use crate::{ + Globals, + cmd::{compact_flag::CompactFlag, conversation_id::PositionalIds}, +}; #[test] #[expect(clippy::too_many_lines)] @@ -37,6 +40,7 @@ fn test_conversation_fork() { until: None, last: None, title: None, + compact: CompactFlag::default(), }, setup: |ctx| { let id = ConversationId::try_from(ctx.now()).unwrap(); @@ -79,6 +83,7 @@ fn test_conversation_fork() { until: None, last: None, title: None, + compact: CompactFlag::default(), }, setup: |ctx| { let id = ConversationId::try_from(ctx.now()).unwrap(); @@ -131,6 +136,7 @@ fn test_conversation_fork() { until: None, last: None, title: None, + compact: CompactFlag::default(), }, setup: |ctx| { let id = ConversationId::try_from(ctx.now()).unwrap(); @@ -185,6 +191,7 @@ fn test_conversation_fork() { until: None, last: None, title: None, + compact: CompactFlag::default(), }, setup: |ctx| { let id = ConversationId::try_from(ctx.now()).unwrap(); @@ -238,6 +245,7 @@ fn test_conversation_fork() { until: Some(Utc.with_ymd_and_hms(2020, 1, 1, 0, 1, 0).unwrap().into()), last: None, title: None, + compact: CompactFlag::default(), }, setup: |ctx| { let id = ConversationId::try_from(ctx.now()).unwrap(); @@ -291,6 +299,7 @@ fn test_conversation_fork() { until: None, last: Some(None), title: None, + compact: CompactFlag::default(), }, setup: |ctx| { let id = ConversationId::try_from(ctx.now()).unwrap(); @@ -368,6 +377,7 @@ fn test_conversation_fork() { until: None, last: Some(Some(2)), title: None, + compact: CompactFlag::default(), }, setup: |ctx| { let id = ConversationId::try_from(ctx.now()).unwrap(); @@ -444,6 +454,7 @@ fn test_conversation_fork() { until: None, last: Some(Some(10)), title: None, + compact: CompactFlag::default(), }, setup: |ctx| { let id = ConversationId::try_from(ctx.now()).unwrap(); @@ -488,6 +499,7 @@ fn test_conversation_fork() { until: None, last: Some(Some(1)), title: None, + compact: CompactFlag::default(), }, setup: |ctx| { let id = ConversationId::try_from(ctx.now()).unwrap(); @@ -528,6 +540,7 @@ fn test_conversation_fork() { from: None, until: None, last: None, + compact: CompactFlag::default(), title: Some("my custom title".to_owned()), }, setup: |ctx| { @@ -559,6 +572,7 @@ fn test_conversation_fork() { until: Some(Utc.with_ymd_and_hms(2020, 1, 1, 0, 2, 0).unwrap().into()), last: None, title: None, + compact: CompactFlag::default(), }, setup: |ctx| { let id = ConversationId::try_from(ctx.now()).unwrap(); @@ -651,7 +665,10 @@ fn test_conversation_fork() { ctx.set_now(DateTime::::UNIX_EPOCH + Duration::from_secs(1)); let source_handle = ctx.workspace.acquire_conversation(&source_id).unwrap(); - case.args.run(&mut ctx, &[source_handle]).unwrap(); + tokio::runtime::Runtime::new() + .unwrap() + .block_on(case.args.run(&mut ctx, &[source_handle])) + .unwrap(); ctx.printer.flush(); assert_eq!(*out.lock(), "Conversation forked.\n"); @@ -765,10 +782,14 @@ fn fork_targets_correct_source() { from: None, until: None, last: None, + compact: CompactFlag::default(), title: Some("forked-from-b".to_owned()), }; let handle_b = ctx.workspace.acquire_conversation(&id_b).unwrap(); - fork.run(&mut ctx, &[handle_b]).unwrap(); + tokio::runtime::Runtime::new() + .unwrap() + .block_on(fork.run(&mut ctx, &[handle_b])) + .unwrap(); // Should now have 3 conversations: A, B, and the fork. let all: Vec<_> = ctx diff --git a/crates/jp_cli/src/cmd/conversation/print.rs b/crates/jp_cli/src/cmd/conversation/print.rs index a80167bc..23ca07a7 100644 --- a/crates/jp_cli/src/cmd/conversation/print.rs +++ b/crates/jp_cli/src/cmd/conversation/print.rs @@ -65,6 +65,11 @@ pub(crate) struct Print { /// untruncated tool results. #[arg(long, short = 's', value_enum)] style: Option, + + /// Print the compacted view (what the LLM sees) instead of the full + /// history. + #[arg(long)] + compacted: bool, } /// Output style presets for `jp conversation print`. @@ -95,7 +100,14 @@ impl Print { }; for handle in handles { - Self::print_conversation(ctx, handle, &selection, self.current_config, self.style)?; + Self::print_conversation( + ctx, + handle, + &selection, + self.current_config, + self.style, + self.compacted, + )?; } ctx.printer.println(""); ctx.printer.flush(); @@ -108,8 +120,13 @@ impl Print { selection: &TurnSelection, current_config: bool, print_style: Option, + compacted: bool, ) -> Output { - let events = ctx.workspace.events(handle)?.clone(); + let mut events = ctx.workspace.events(handle)?.clone(); + + if compacted { + events.apply_projection(); + } let cfg = ctx.config(); let root = ctx diff --git a/crates/jp_cli/src/cmd/conversation/print_tests.rs b/crates/jp_cli/src/cmd/conversation/print_tests.rs index dd9ec247..90b4b1a6 100644 --- a/crates/jp_cli/src/cmd/conversation/print_tests.rs +++ b/crates/jp_cli/src/cmd/conversation/print_tests.rs @@ -88,6 +88,7 @@ fn prints_user_message() { turn: None, current_config: false, style: None, + compacted: false, }; let h = ctx.workspace.acquire_conversation(&id).unwrap(); let result = print.run(&mut ctx, &[h]); @@ -111,6 +112,7 @@ fn prints_assistant_message() { turn: None, current_config: false, style: None, + compacted: false, }; let h = ctx.workspace.acquire_conversation(&id).unwrap(); let result = print.run(&mut ctx, &[h]); @@ -140,6 +142,7 @@ fn prints_reasoning_full() { turn: None, current_config: false, style: None, + compacted: false, }; let h = ctx.workspace.acquire_conversation(&id).unwrap(); let result = print.run(&mut ctx, &[h]); @@ -170,6 +173,7 @@ fn hides_reasoning_when_hidden() { turn: None, current_config: false, style: None, + compacted: false, }; let h = ctx.workspace.acquire_conversation(&id).unwrap(); let result = print.run(&mut ctx, &[h]); @@ -202,6 +206,7 @@ fn truncates_reasoning() { turn: None, current_config: false, style: None, + compacted: false, }; let h = ctx.workspace.acquire_conversation(&id).unwrap(); let result = print.run(&mut ctx, &[h]); @@ -243,6 +248,7 @@ fn prints_tool_call_and_result() { turn: None, current_config: false, style: None, + compacted: false, }; let h = ctx.workspace.acquire_conversation(&id).unwrap(); let result = print.run(&mut ctx, &[h]); @@ -271,6 +277,7 @@ fn prints_structured_data() { turn: None, current_config: false, style: None, + compacted: false, }; let h = ctx.workspace.acquire_conversation(&id).unwrap(); let result = print.run(&mut ctx, &[h]); @@ -299,6 +306,7 @@ fn turn_separators_between_turns() { turn: None, current_config: false, style: None, + compacted: false, }; let h = ctx.workspace.acquire_conversation(&id).unwrap(); let result = print.run(&mut ctx, &[h]); @@ -323,6 +331,7 @@ fn prints_conversation_by_id() { turn: None, current_config: false, style: None, + compacted: false, }; let h = ctx.workspace.acquire_conversation(&id).unwrap(); let result = print.run(&mut ctx, &[h]); @@ -346,6 +355,7 @@ fn empty_conversation_produces_no_content() { turn: None, current_config: false, style: None, + compacted: false, }; let h = ctx.workspace.acquire_conversation(&id).unwrap(); let result = print.run(&mut ctx, &[h]); @@ -398,6 +408,7 @@ fn full_conversation_round_trip() { turn: None, current_config: false, style: None, + compacted: false, }; let h = ctx.workspace.acquire_conversation(&id).unwrap(); let result = print.run(&mut ctx, &[h]); @@ -438,6 +449,7 @@ fn last_prints_only_last_turn() { turn: None, current_config: false, style: None, + compacted: false, }; let h = ctx.workspace.acquire_conversation(&id).unwrap(); let result = print.run(&mut ctx, &[h]); @@ -479,6 +491,7 @@ fn last_two_with_three_turns() { turn: None, current_config: false, style: None, + compacted: false, }; let h = ctx.workspace.acquire_conversation(&id).unwrap(); let result = print.run(&mut ctx, &[h]); @@ -508,6 +521,7 @@ fn last_exceeding_turn_count_prints_all() { turn: None, current_config: false, style: None, + compacted: false, }; let h = ctx.workspace.acquire_conversation(&id).unwrap(); let result = print.run(&mut ctx, &[h]); @@ -553,6 +567,7 @@ fn blank_line_between_tool_calls_and_message() { turn: None, current_config: false, style: None, + compacted: false, }; let h = ctx.workspace.acquire_conversation(&id).unwrap(); let result = print.run(&mut ctx, &[h]); @@ -606,6 +621,7 @@ fn blank_line_between_message_and_tool_calls() { turn: None, current_config: false, style: None, + compacted: false, }; let h = ctx.workspace.acquire_conversation(&id).unwrap(); let result = print.run(&mut ctx, &[h]); @@ -675,6 +691,7 @@ fn no_extra_blank_line_between_consecutive_tool_calls() { turn: None, current_config: false, style: None, + compacted: false, }; let h = ctx.workspace.acquire_conversation(&id).unwrap(); let result = print.run(&mut ctx, &[h]); @@ -712,6 +729,7 @@ fn last_zero_prints_nothing() { turn: None, current_config: false, style: None, + compacted: false, }; let h = ctx.workspace.acquire_conversation(&id).unwrap(); let result = print.run(&mut ctx, &[h]); @@ -747,6 +765,7 @@ fn turn_prints_specific_turn() { turn: Some(2), current_config: false, style: None, + compacted: false, }; let h = ctx.workspace.acquire_conversation(&id).unwrap(); let result = print.run(&mut ctx, &[h]); @@ -785,6 +804,7 @@ fn turn_out_of_range_errors() { turn: Some(5), current_config: false, style: None, + compacted: false, }; let h = ctx.workspace.acquire_conversation(&id).unwrap(); let result = print.run(&mut ctx, &[h]); @@ -804,6 +824,7 @@ fn turn_zero_errors() { turn: Some(0), current_config: false, style: None, + compacted: false, }; let h = ctx.workspace.acquire_conversation(&id).unwrap(); let result = print.run(&mut ctx, &[h]); @@ -849,6 +870,7 @@ fn style_brief_hides_reasoning_and_tool_details() { turn: None, current_config: false, style: Some(PrintStyle::Brief), + compacted: false, }; let h = ctx.workspace.acquire_conversation(&id).unwrap(); let result = print.run(&mut ctx, &[h]); @@ -926,6 +948,7 @@ fn style_chat_hides_reasoning_and_tool_calls() { turn: None, current_config: false, style: Some(PrintStyle::Chat), + compacted: false, }; let h = ctx.workspace.acquire_conversation(&id).unwrap(); let result = print.run(&mut ctx, &[h]); @@ -997,6 +1020,7 @@ fn style_chat_separates_messages_across_hidden_reasoning() { turn: None, current_config: false, style: Some(PrintStyle::Chat), + compacted: false, }; let h = ctx.workspace.acquire_conversation(&id).unwrap(); print.run(&mut ctx, &[h]).unwrap(); @@ -1048,6 +1072,7 @@ fn style_chat_separates_messages_across_hidden_tool_call() { turn: None, current_config: false, style: Some(PrintStyle::Chat), + compacted: false, }; let h = ctx.workspace.acquire_conversation(&id).unwrap(); print.run(&mut ctx, &[h]).unwrap(); @@ -1109,6 +1134,7 @@ fn style_full_shows_reasoning_and_untruncated_results() { turn: None, current_config: false, style: Some(PrintStyle::Full), + compacted: false, }; let h = ctx.workspace.acquire_conversation(&id).unwrap(); let result = print.run(&mut ctx, &[h]); diff --git a/crates/jp_cli/src/cmd/conversation/summarize.rs b/crates/jp_cli/src/cmd/conversation/summarize.rs new file mode 100644 index 00000000..4af2c115 --- /dev/null +++ b/crates/jp_cli/src/cmd/conversation/summarize.rs @@ -0,0 +1,148 @@ +//! LLM-assisted conversation summarization for compaction. + +use jp_config::{ + AppConfig, PartialAppConfig, ToPartial as _, conversation::compaction::SummaryConfig, +}; +use jp_conversation::{ + ConversationEvent, ConversationStream, + event::{ChatRequest, ChatResponse}, + thread::ThreadBuilder, +}; +use jp_llm::{ + event::Event, + event_builder::EventBuilder, + provider, + retry::{RetryConfig, collect_with_retry}, +}; + +use crate::error::Result; + +const DEFAULT_INSTRUCTIONS: &str = "\ +Summarize the preceding conversation for continuity. The summary will replace the original \ + messages, so it must be self-contained. + +Preserve: +- File paths and code structures discussed +- Key decisions and their rationale +- Errors encountered and how they were resolved +- Current task state and next steps +- Any constraints or requirements established + +Be concise but thorough. The reader should be able to continue the conversation without having \ + seen the original messages."; + +/// Generate a summary of the given conversation events using an LLM. +/// +/// The summary is a plain text string suitable for storing in a +/// `SummaryPolicy`. The summarizer reads the raw (non-compacted) events. +pub async fn generate_summary( + events: &ConversationStream, + range_from: usize, + range_to: usize, + summary_cfg: Option<&SummaryConfig>, + app_cfg: &AppConfig, +) -> Result { + let model = summary_cfg + .and_then(|c| c.model.clone()) + .unwrap_or_else(|| app_cfg.assistant.model.clone()); + + let model_id = model.id.resolved(); + + let range_events = collect_range_events(events, range_from, range_to); + + // Rebuild a clean stream with just the range events. + let mut stream = ConversationStream::new(events.base_config()); + stream.extend(range_events); + + // Override the model in the stream config so the provider picks up the + // summary model. + let mut partial = PartialAppConfig::empty(); + partial.assistant.model.id = + jp_config::model::id::PartialModelIdOrAliasConfig::Id(model_id.to_partial()); + stream.add_config_delta(partial); + + let instructions = summary_cfg + .and_then(|c| c.instructions.as_deref()) + .unwrap_or(DEFAULT_INSTRUCTIONS); + + let thread = ThreadBuilder::default() + .with_events(stream.clone()) + .with_system_prompt(instructions.to_owned()) + .build()?; + + let mut thread_events = thread.events.clone(); + thread_events.start_turn(ChatRequest::from("Summarize the conversation above.")); + + let query = jp_llm::query::ChatQuery { + thread: jp_conversation::thread::Thread { + events: thread_events, + ..thread + }, + tools: vec![], + tool_choice: jp_config::assistant::tool_choice::ToolChoice::default(), + }; + + let provider = provider::get_provider(model_id.provider, &app_cfg.providers.llm)?; + let model_details = provider.model_details(&model_id.name).await?; + + let retry_config = RetryConfig::default(); + let llm_events = + collect_with_retry(provider.as_ref(), &model_details, query, &retry_config).await?; + + // Collect the response text. + let mut builder = EventBuilder::new(); + let mut flushed = Vec::new(); + for event in llm_events { + match event { + Event::Part { + index, + part, + metadata, + } => { + builder.handle_part(index, part, metadata); + } + Event::Flush { index, metadata } => { + flushed.extend(builder.handle_flush(index, metadata)); + } + Event::Finished(_) => flushed.extend(builder.drain()), + Event::Patch(_) => {} + } + } + + let summary = flushed + .into_iter() + .filter_map(ConversationEvent::into_chat_response) + .filter_map(|r| match r { + ChatResponse::Message { message } => Some(message), + _ => None, + }) + .collect::(); + + if summary.is_empty() { + return Err(crate::error::Error::Compaction( + "Summarizer returned an empty response".into(), + )); + } + + Ok(summary) +} + +/// Collect all events in the inclusive turn range `[range_from, range_to]`. +/// +/// Each covered turn contributes its full event sequence, including the +/// leading `TurnStart`. Out-of-range and missing turns contribute nothing. +fn collect_range_events( + events: &ConversationStream, + range_from: usize, + range_to: usize, +) -> Vec { + events + .iter_turns() + .filter(|turn| turn.index() >= range_from && turn.index() <= range_to) + .flat_map(|turn| turn.into_iter().map(|e| e.event.clone())) + .collect() +} + +#[cfg(test)] +#[path = "summarize_tests.rs"] +mod tests; diff --git a/crates/jp_cli/src/cmd/conversation/summarize_tests.rs b/crates/jp_cli/src/cmd/conversation/summarize_tests.rs new file mode 100644 index 00000000..343dc5f2 --- /dev/null +++ b/crates/jp_cli/src/cmd/conversation/summarize_tests.rs @@ -0,0 +1,87 @@ +use jp_conversation::ConversationStream; + +use super::collect_range_events; + +fn build_stream_with_turns(count: usize) -> ConversationStream { + let mut stream = ConversationStream::new_test(); + for i in 0..count { + stream.start_turn(format!("turn {i}")); + } + stream +} + +fn chat_request_texts(events: &[jp_conversation::ConversationEvent]) -> Vec { + events + .iter() + .filter_map(|e| e.as_chat_request()) + .map(|r| r.content.clone()) + .collect() +} + +#[test] +fn collects_full_range() { + let stream = build_stream_with_turns(4); + let events = collect_range_events(&stream, 0, 3); + + assert_eq!(chat_request_texts(&events), vec![ + "turn 0", "turn 1", "turn 2", "turn 3" + ],); +} + +#[test] +fn collects_middle_range_when_range_from_is_nonzero() { + // Regression: the previous implementation never advanced its turn + // counter when range_from > 0, so this returned an empty result for + // any range that didn't start at turn 0 — including the default + // compaction range (keep_first = 1). + let stream = build_stream_with_turns(4); + let events = collect_range_events(&stream, 1, 2); + + assert_eq!(chat_request_texts(&events), vec!["turn 1", "turn 2"]); +} + +#[test] +fn collects_default_compaction_range() { + // Mirrors the default config: keep_first=1, keep_last=3. + // For a 5-turn stream this resolves to the single middle turn (1..=1). + let stream = build_stream_with_turns(5); + let events = collect_range_events(&stream, 1, 1); + + assert_eq!(chat_request_texts(&events), vec!["turn 1"]); +} + +#[test] +fn collects_single_turn_at_end() { + let stream = build_stream_with_turns(4); + let events = collect_range_events(&stream, 3, 3); + + assert_eq!(chat_request_texts(&events), vec!["turn 3"]); +} + +#[test] +fn each_collected_turn_includes_its_turn_start() { + let stream = build_stream_with_turns(4); + let events = collect_range_events(&stream, 1, 1); + + // start_turn pushes (TurnStart, ChatRequest), so a single covered + // turn contributes two events in that order. + assert_eq!(events.len(), 2); + assert!(events[0].is_turn_start()); + assert!(events[1].is_chat_request()); +} + +#[test] +fn empty_for_out_of_bounds_range() { + let stream = build_stream_with_turns(4); + let events = collect_range_events(&stream, 10, 20); + + assert!(events.is_empty()); +} + +#[test] +fn empty_for_empty_stream() { + let stream = ConversationStream::new_test(); + let events = collect_range_events(&stream, 0, 5); + + assert!(events.is_empty()); +} diff --git a/crates/jp_cli/src/cmd/init.rs b/crates/jp_cli/src/cmd/init.rs index 89c397d2..3bf1d513 100644 --- a/crates/jp_cli/src/cmd/init.rs +++ b/crates/jp_cli/src/cmd/init.rs @@ -272,6 +272,7 @@ impl IntoPartialAppConfig for Init { _workspace: Option<&Workspace>, partial: PartialAppConfig, _: Option<&PartialAppConfig>, + _handles: &[jp_workspace::ConversationHandle], ) -> std::result::Result> { Ok(partial) } diff --git a/crates/jp_cli/src/cmd/query.rs b/crates/jp_cli/src/cmd/query.rs index 4dd2d669..3de98675 100644 --- a/crates/jp_cli/src/cmd/query.rs +++ b/crates/jp_cli/src/cmd/query.rs @@ -273,6 +273,10 @@ pub(crate) struct Query { /// Disable tool use by the assistant. #[arg(short = 'U', long = "no-tool-use")] no_tool_use: bool, + + /// Compact the conversation before querying. + #[command(flatten)] + compact: crate::cmd::compact_flag::CompactFlag, } impl Query { @@ -305,6 +309,11 @@ impl Query { .update_events(|events| events.add_config_delta(delta)); } + // Compact the conversation before querying, if requested. + if self.compact.should_compact() { + self.apply_pre_query_compaction(&lock, &cfg, ctx).await?; + } + let mut mcp_servers_handle = ctx.configure_active_mcp_servers().await?; let (conv_title, is_local) = { @@ -745,6 +754,41 @@ impl Query { .or_else(|| Some(Duration::new(0, 0))) } + /// Apply compaction before the query turn starts. + /// + /// Applies all compaction rules from the resolved config and appends + /// the compaction events to the conversation. + async fn apply_pre_query_compaction( + &self, + lock: &ConversationLock, + cfg: &AppConfig, + ctx: &Ctx, + ) -> Result<()> { + let events = lock.events().clone(); + + let compactions = super::conversation::compact::build_compaction_events_from_config( + &events, + cfg, + None, + None, + &ctx.printer, + ) + .await?; + + for compaction in compactions { + let from = compaction.from_turn; + let to = compaction.to_turn; + + lock.as_mut() + .update_events(|stream| stream.add_compaction(compaction)); + + ctx.printer + .println(format!("Compacted turns {from}..={to}.")); + } + + Ok(()) + } + async fn acquire_lock( &self, ctx: &mut Ctx, @@ -946,6 +990,7 @@ impl IntoPartialAppConfig for Query { workspace: Option<&Workspace>, mut partial: PartialAppConfig, merged_config: Option<&PartialAppConfig>, + _handles: &[jp_workspace::ConversationHandle], ) -> std::result::Result> { let Self { model, @@ -970,6 +1015,7 @@ impl IntoPartialAppConfig for Query { expires_in: _, target: _, fork: _, + compact, } = &self; apply_model(&mut partial, model.as_deref(), merged_config); @@ -1006,6 +1052,8 @@ impl IntoPartialAppConfig for Query { partial.style.tool_call.show = Some(false); } + compact.apply_to_config(&mut partial); + Ok(partial) } diff --git a/crates/jp_cli/src/cmd/query_tests.rs b/crates/jp_cli/src/cmd/query_tests.rs index 83a883aa..a8bd2271 100644 --- a/crates/jp_cli/src/cmd/query_tests.rs +++ b/crates/jp_cli/src/cmd/query_tests.rs @@ -46,6 +46,7 @@ fn test_query_tools_and_no_tools() { None, partial, None, + &[], ) .unwrap(); @@ -77,6 +78,7 @@ fn test_query_tools_and_no_tools() { None, partial, None, + &[], ) .unwrap(); @@ -108,6 +110,7 @@ fn test_query_tools_and_no_tools() { None, partial, None, + &[], ) .unwrap(); @@ -137,6 +140,7 @@ fn test_query_tools_and_no_tools() { None, partial, None, + &[], ) .unwrap(); @@ -167,6 +171,7 @@ fn test_query_tools_and_no_tools() { None, partial, None, + &[], ) .unwrap(); @@ -199,6 +204,7 @@ fn test_query_tools_and_no_tools() { None, partial, None, + &[], ) .unwrap(); @@ -233,6 +239,7 @@ fn test_explicit_tool_enabled_by_name() { None, partial, None, + &[], ) .unwrap(); @@ -260,6 +267,7 @@ fn test_enable_all_and_explicit_by_name() { None, partial, None, + &[], ) .unwrap(); @@ -291,6 +299,7 @@ fn test_enable_all_skips_unnamed_explicit() { None, partial, None, + &[], ) .unwrap(); @@ -324,6 +333,7 @@ fn test_interleaved_disable_all_then_enable_named() { None, partial, None, + &[], ) .unwrap(); @@ -363,6 +373,7 @@ fn test_interleaved_enable_all_then_disable_named() { None, partial, None, + &[], ) .unwrap(); @@ -397,6 +408,7 @@ fn test_interleaved_disable_all_then_enable_all() { None, partial, None, + &[], ) .unwrap(); @@ -437,6 +449,7 @@ fn test_interleaved_three_step_composition() { None, partial, None, + &[], ) .unwrap(); diff --git a/crates/jp_cli/src/ctx.rs b/crates/jp_cli/src/ctx.rs index 09e85ee5..342e0412 100644 --- a/crates/jp_cli/src/ctx.rs +++ b/crates/jp_cli/src/ctx.rs @@ -189,18 +189,18 @@ impl Ctx { /// A trait for converting any type into a partial [`AppConfig`]. pub(crate) trait IntoPartialAppConfig { + /// Apply CLI flag overrides to the partial config. + /// + /// `merged_config` may contain the full configuration for validation + /// when `partial` is incomplete. `handles` are the resolved conversation + /// targets for this invocation (may be empty for commands that don't + /// target conversations). fn apply_cli_config( &self, workspace: Option<&Workspace>, partial: PartialAppConfig, - - // Whenever called the `partial` argument might be empty, or contain - // any subset of the full configuration. This might prevent validating - // certain fields before applying them. In these situations, the - // `merged_config` argument can be used to provide the full - // configuration, and the partial configuration can be validated against - // it. merged_config: Option<&PartialAppConfig>, + handles: &[jp_workspace::ConversationHandle], ) -> std::result::Result>; #[expect(unused_variables)] diff --git a/crates/jp_cli/src/error.rs b/crates/jp_cli/src/error.rs index 5ab7fc3d..dd36eed3 100644 --- a/crates/jp_cli/src/error.rs +++ b/crates/jp_cli/src/error.rs @@ -117,4 +117,7 @@ pub(crate) enum Error { /// The user requested conversation target help. #[error("target help")] TargetHelp { session: bool, multi: bool }, + + #[error("Compaction error: {0}")] + Compaction(String), } diff --git a/crates/jp_cli/src/lib.rs b/crates/jp_cli/src/lib.rs index 3d20b49c..8c1a1958 100644 --- a/crates/jp_cli/src/lib.rs +++ b/crates/jp_cli/src/lib.rs @@ -38,7 +38,7 @@ use crossterm::style::Stylize as _; use ctx::{Ctx, IntoPartialAppConfig}; use error::{Error, Result}; use jp_config::{ - PartialAppConfig, + AppConfig, PartialAppConfig, assignment::KvAssignment, fs::user_global_config_dir, util::{ @@ -418,57 +418,16 @@ fn run_inner(cli: Cli, format: OutputFormat) -> Result<()> { // individual conversations, this is done lazily as needed. workspace.load_conversation_index(); - // Config Loading Phase 1: Load static sources (files + env + --cfg) once. let base = load_base_partial(fs_backend.as_deref())?; - let pipeline = ConfigPipeline::new( + let (config, handles) = resolve_config( + &cli.command, base, &cli.globals.config, - Some(&workspace), + &mut workspace, + session.as_ref(), fs_backend.as_deref(), )?; - - // Extract default_id for conversation resolution. This builds a temporary - // partial (base + --cfg + command-CLI) without the per-conversation layer. - let default_id = { - let mut cfg = pipeline.partial_without_conversation()?; - cfg = cli - .command - .apply_cli_config(Some(&workspace), cfg, None) - .map_err(|error| Error::CliConfig(error.to_string()))?; - - cfg.conversation.default_id.take().unwrap_or_default() - }; - - let request = cli.command.conversation_load_request(); - let handles = resolve_request(&request, &workspace, session.as_ref(), default_id)?; - - // Config Loading Phase 2: Build final config with per-conversation layer. - let config_handle = request.config_conversation.and_then(|idx| handles.get(idx)); - if let Some(handle) = config_handle - && let Err(error) = workspace.eager_load_conversation(handle) - { - tracing::warn!(error = ?error, "Failed to eager-load conversation."); - } - - let conversation_partial = config_handle - .map(|handle| { - cli.command - .apply_conversation_config(&workspace, PartialAppConfig::default(), None, handle) - .map_err(|error| Error::CliConfig(error.to_string())) - }) - .transpose()?; - - let mut partial = match conversation_partial { - Some(conversation_config) => pipeline.partial_with_conversation(conversation_config)?, - None => pipeline.partial_without_conversation()?, - }; - - partial = cli - .command - .apply_cli_config(Some(&workspace), partial, None) - .map_err(|error| Error::CliConfig(error.to_string()))?; - - let config = Arc::new(build(partial)?); + let config = Arc::new(config); let runtime = build_runtime(cli.root.threads, "jp-worker")?; let mut ctx = Ctx::new( workspace, @@ -611,6 +570,70 @@ fn parse_error(error: cmd::Error, format: OutputFormat) -> (u8, String) { (code.into(), error) } +/// Resolve the final [`AppConfig`] and conversation handles. +/// +/// Takes a pre-loaded base partial (from config files + env) and runs the +/// full config pipeline: +/// 1. Extract `default_id` for conversation resolution (loading-time only). +/// 2. Resolve conversation handles from the command's load request. +/// 3. Merge per-conversation config layer. +/// 4. Apply CLI flag overrides via [`IntoPartialAppConfig`]. +/// 5. Consume `default_id` so it doesn't leak into the runtime config. +/// 6. Build the final [`AppConfig`]. +pub(crate) fn resolve_config( + command: &Commands, + base: PartialAppConfig, + cfg_overrides: &[KeyValueOrPath], + workspace: &mut Workspace, + session: Option<&jp_workspace::session::Session>, + fs: Option<&FsStorageBackend>, +) -> Result<(AppConfig, Vec)> { + let pipeline = ConfigPipeline::new(base, cfg_overrides, Some(workspace), fs)?; + + // Extract default_id — a loading-time concern consumed here, not + // propagated to the runtime config. + let default_id = pipeline + .partial_without_conversation()? + .conversation + .default_id + .unwrap_or_default(); + + let request = command.conversation_load_request(); + let handles = resolve_request(&request, workspace, session, default_id)?; + + // Phase 2: per-conversation layer. + let config_handle = request.config_conversation.and_then(|idx| handles.get(idx)); + if let Some(handle) = config_handle + && let Err(error) = workspace.eager_load_conversation(handle) + { + tracing::warn!(error = ?error, "Failed to eager-load conversation."); + } + + let conversation_partial = config_handle + .map(|handle| { + command + .apply_conversation_config(workspace, PartialAppConfig::default(), None, handle) + .map_err(|error| Error::CliConfig(error.to_string())) + }) + .transpose()?; + + let mut partial = match conversation_partial { + Some(conversation_config) => pipeline.partial_with_conversation(conversation_config)?, + None => pipeline.partial_without_conversation()?, + }; + + // Phase 3: CLI flag overrides. + partial = command + .apply_cli_config(Some(workspace), partial, None, &handles) + .map_err(|error| Error::CliConfig(error.to_string()))?; + + // Consume default_id so it doesn't appear in the runtime config. + partial.conversation.default_id.take(); + + let config = build(partial)?; + Ok((config, handles)) +} + /// Load the base partial config from files and environment variables. /// /// This produces the `files + inheritance + env` layer that serves as input to diff --git a/crates/jp_cli/src/lib_tests.rs b/crates/jp_cli/src/lib_tests.rs index b081f7aa..8e8e2e93 100644 --- a/crates/jp_cli/src/lib_tests.rs +++ b/crates/jp_cli/src/lib_tests.rs @@ -303,3 +303,37 @@ fn test_load_cli_cfg_args_global_only_when_workspace_has_no_match() { unsafe { std::env::remove_var("JP_GLOBAL_CONFIG_FILE") }; } + +/// Verify that `resolve_config` consumes `default_id` so it doesn't leak +/// into the runtime `AppConfig`. +#[test] +fn resolve_config_consumes_default_id() { + use jp_config::conversation::DefaultConversationId; + + let tmp = tempdir().unwrap(); + let root = tmp.path(); + + let mut workspace = Workspace::new(root); + workspace.load_conversation_index(); + + // Inject default_id into the base partial — no filesystem needed. + let mut base = PartialAppConfig::new_test(); + base.conversation.default_id = Some(DefaultConversationId::LastActivated); + + let cli = Cli::try_parse_from(["jp", "conversation", "ls"]).unwrap(); + let (config, _handles) = resolve_config( + &cli.command, + base, + &cli.globals.config, + &mut workspace, + None, + None, + ) + .unwrap(); + + assert!( + config.conversation.default_id.is_none(), + "default_id should be consumed by resolve_config, got: {:?}", + config.conversation.default_id, + ); +} diff --git a/crates/jp_config/src/conversation.rs b/crates/jp_config/src/conversation.rs index 3bc7dabd..76ab954b 100644 --- a/crates/jp_config/src/conversation.rs +++ b/crates/jp_config/src/conversation.rs @@ -1,6 +1,7 @@ //! Conversation-specific configuration for Jean-Pierre. pub mod attachment; +pub mod compaction; pub mod title; pub mod tool; @@ -19,6 +20,7 @@ use crate::{ }, conversation::{ attachment::{AttachmentConfig, PartialAttachmentConfig}, + compaction::{CompactionConfig, PartialCompactionConfig}, title::{PartialTitleConfig, TitleConfig}, tool::{PartialToolsConfig, ToolsConfig}, }, @@ -46,6 +48,13 @@ pub struct ConversationConfig { #[setting(nested)] pub tools: ToolsConfig, + /// Compaction configuration. + /// + /// Controls how conversation compaction works, including rules for + /// stripping reasoning, tool calls, and summarization. + #[setting(nested)] + pub compaction: CompactionConfig, + /// Attachment configuration. /// /// This section defines attachments (files, resources) that are added to @@ -86,6 +95,7 @@ impl AssignKeyValue for PartialConversationConfig { "" => kv.try_merge_object(self)?, _ if kv.p("title") => self.title.assign(kv)?, _ if kv.p("tools") => self.tools.assign(kv)?, + _ if kv.p("compaction") => self.compaction.assign(kv)?, _ if kv.p("attachments") => kv.try_vec_of_nested(self.attachments.as_mut())?, _ if kv.p("inquiry") => self.inquiry.assign(kv)?, _ if kv.p("start_local") => self.start_local = kv.try_some_bool()?, @@ -102,6 +112,7 @@ impl PartialConfigDelta for PartialConversationConfig { Self { title: self.title.delta(next.title), tools: self.tools.delta(next.tools), + compaction: self.compaction.delta(next.compaction), attachments: { next.attachments .into_iter() @@ -121,6 +132,7 @@ impl FillDefaults for PartialConversationConfig { Self { title: self.title.fill_from(defaults.title), tools: self.tools.fill_from(defaults.tools), + compaction: self.compaction.fill_from(defaults.compaction), attachments: self.attachments.fill_from(defaults.attachments), inquiry: self.inquiry.fill_from(defaults.inquiry), start_local: self.start_local.or(defaults.start_local), @@ -136,6 +148,7 @@ impl ToPartial for ConversationConfig { Self::Partial { title: self.title.to_partial(), tools: self.tools.to_partial(), + compaction: self.compaction.to_partial(), attachments: vec_to_mergeable_partial(&self.attachments), inquiry: self.inquiry.to_partial(), start_local: partial_opt(&self.start_local, defaults.start_local), diff --git a/crates/jp_config/src/conversation/compaction.rs b/crates/jp_config/src/conversation/compaction.rs new file mode 100644 index 00000000..e2675037 --- /dev/null +++ b/crates/jp_config/src/conversation/compaction.rs @@ -0,0 +1,418 @@ +//! Compaction configuration for conversations. + +use std::{fmt, str::FromStr}; + +use schematic::{Config, ConfigEnum}; +use serde::{Deserialize, Serialize}; + +use crate::{ + BoxedError, + assignment::{AssignKeyValue, AssignResult, KvAssignment, missing_key}, + delta::{PartialConfigDelta, delta_opt, delta_opt_partial}, + fill::{self, FillDefaults}, + internal::merge::vec_with_strategy, + model::{ModelConfig, PartialModelConfig}, + partial::{ToPartial, partial_opt_config, partial_opts}, + types::vec::{MergeableVec, MergedVec, vec_to_mergeable_partial}, +}; + +/// Compaction configuration. +/// +/// The `rules` array defines the compaction operations applied when the user +/// runs `jp conversation compact` or uses `--compact`. Each rule produces one +/// compaction event in the conversation stream. +#[derive(Debug, Clone, PartialEq, Config)] +#[config(rename_all = "snake_case")] +pub struct CompactionConfig { + /// Compaction rules applied in order. Each rule produces one compaction + /// event. + /// + /// The built-in default (strip reasoning + tools, keep last 3) is used + /// when no rules are configured. It is discarded as soon as any + /// user-defined rule is present. + #[setting( + nested, + partial_via = MergeableVec::, + default = default_rules, + merge = vec_with_strategy, + )] + pub rules: Vec, +} + +/// Built-in default rules: strip reasoning + tool calls, keep first 1, last 3. +/// +/// Uses `discard_when_merged: true` so these defaults are dropped the moment +/// any user-defined rule appears. +#[expect(clippy::trivially_copy_pass_by_ref, clippy::unnecessary_wraps)] +fn default_rules(_: &()) -> schematic::TransformResult> { + Ok(MergeableVec::Merged(MergedVec { + value: vec![PartialCompactionRuleConfig { + reasoning: Some(ReasoningMode::Strip), + tool_calls: Some(ToolCallsMode::Strip), + ..Default::default() + }], + strategy: None, + dedup: None, + discard_when_merged: true, + })) +} + +impl AssignKeyValue for PartialCompactionConfig { + fn assign(&mut self, mut kv: KvAssignment) -> AssignResult { + match kv.key_string().as_str() { + "" => kv.try_merge_object(self)?, + _ if kv.p("rules") => kv.try_vec_of_nested(self.rules.as_mut())?, + _ => return missing_key(&kv), + } + + Ok(()) + } +} + +impl PartialConfigDelta for PartialCompactionConfig { + fn delta(&self, next: Self) -> Self { + Self { + rules: { + next.rules + .into_iter() + .filter(|v| !self.rules.contains(v)) + .collect::>() + .into() + }, + } + } +} + +impl FillDefaults for PartialCompactionConfig { + fn fill_from(self, defaults: Self) -> Self { + Self { + rules: self.rules.fill_from(defaults.rules), + } + } +} + +impl ToPartial for CompactionConfig { + fn to_partial(&self) -> Self::Partial { + Self::Partial { + rules: vec_to_mergeable_partial(&self.rules), + } + } +} + +/// A compaction rule defining which policies to apply over a turn range. +/// +/// Each rule produces one [`Compaction`] event when applied. +/// +/// [`Compaction`]: https://docs.rs/jp_conversation/latest/jp_conversation/struct.Compaction.html +#[derive(Debug, Clone, PartialEq, Config)] +#[config(rename_all = "snake_case")] +pub struct CompactionRuleConfig { + /// Number of turns to preserve at the start of the conversation. + /// + /// Accepts a positive integer (turn count) or a duration string + /// (e.g. `"5h"` — preserve turns from the last 5 hours). + /// + /// Defaults to 1 (preserve the initial request). + #[setting(default = default_keep_first)] + pub keep_first: RuleBound, + + /// Number of turns to preserve at the end of the conversation. + /// + /// Accepts a positive integer (turn count) or a duration string + /// (e.g. `"3h"` — preserve turns from the last 3 hours). + /// + /// Defaults to 3 (keep last 3 turns). + #[setting(default = default_keep_last)] + pub keep_last: RuleBound, + + /// Policy for reasoning (thinking) blocks. + pub reasoning: Option, + + /// Policy for tool call arguments and responses. + pub tool_calls: Option, + + /// Summarization configuration. + /// + /// When set, all events in the compacted range are replaced by a single + /// LLM-generated summary. This takes precedence over `reasoning` and + /// `tool_calls`. + #[setting(nested)] + pub summary: Option, +} + +/// Default `keep_first`: preserve the genesis turn. +#[expect(clippy::trivially_copy_pass_by_ref, clippy::unnecessary_wraps)] +const fn default_keep_first(_: &()) -> schematic::TransformResult> { + Ok(Some(RuleBound::Turns(1))) +} + +/// Default `keep_last`: preserve the last 3 turns. +#[expect(clippy::trivially_copy_pass_by_ref, clippy::unnecessary_wraps)] +const fn default_keep_last(_: &()) -> schematic::TransformResult> { + Ok(Some(RuleBound::Turns(3))) +} + +impl FromStr for PartialCompactionRuleConfig { + type Err = BoxedError; + + fn from_str(s: &str) -> Result { + serde_json::from_str(s).map_err(|e| format!("invalid compaction rule: {e}").into()) + } +} + +impl AssignKeyValue for PartialCompactionRuleConfig { + fn assign(&mut self, mut kv: KvAssignment) -> AssignResult { + match kv.key_string().as_str() { + "" => kv.try_merge_object(self)?, + "keep_first" => self.keep_first = kv.try_some_from_str()?, + "keep_last" => self.keep_last = kv.try_some_from_str()?, + "reasoning" => self.reasoning = kv.try_some_from_str()?, + "tool_calls" => self.tool_calls = kv.try_some_from_str()?, + _ if kv.p("summary") => self.summary.assign(kv)?, + _ => return missing_key(&kv), + } + + Ok(()) + } +} + +impl PartialConfigDelta for PartialCompactionRuleConfig { + fn delta(&self, next: Self) -> Self { + Self { + keep_first: delta_opt(self.keep_first.as_ref(), next.keep_first), + keep_last: delta_opt(self.keep_last.as_ref(), next.keep_last), + reasoning: delta_opt(self.reasoning.as_ref(), next.reasoning), + tool_calls: delta_opt(self.tool_calls.as_ref(), next.tool_calls), + summary: delta_opt_partial(self.summary.as_ref(), next.summary), + } + } +} + +impl FillDefaults for PartialCompactionRuleConfig { + fn fill_from(self, defaults: Self) -> Self { + Self { + keep_first: self.keep_first.or(defaults.keep_first), + keep_last: self.keep_last.or(defaults.keep_last), + reasoning: self.reasoning.or(defaults.reasoning), + tool_calls: self.tool_calls.or(defaults.tool_calls), + summary: fill::fill_opt(self.summary, defaults.summary), + } + } +} + +impl ToPartial for CompactionRuleConfig { + fn to_partial(&self) -> Self::Partial { + Self::Partial { + keep_first: partial_opts(Some(&self.keep_first), None), + keep_last: partial_opts(Some(&self.keep_last), None), + reasoning: partial_opts(self.reasoning.as_ref(), None), + tool_calls: partial_opts(self.tool_calls.as_ref(), None), + summary: self.summary.as_ref().map(ToPartial::to_partial), + } + } +} + +/// Summarization configuration for a compaction rule. +#[derive(Debug, Clone, PartialEq, Config)] +#[config(rename_all = "snake_case")] +pub struct SummaryConfig { + /// Model to use for summarization. + /// + /// If not set, the main assistant model is used. + #[setting(nested)] + pub model: Option, + + /// Custom instructions for the summarizer. + /// + /// If not set, a default prompt is used that preserves key decisions, + /// file paths, error resolutions, and current task state. + pub instructions: Option, +} + +impl AssignKeyValue for PartialSummaryConfig { + fn assign(&mut self, mut kv: KvAssignment) -> AssignResult { + match kv.key_string().as_str() { + "" => kv.try_merge_object(self)?, + _ if kv.p("model") => self.model.assign(kv)?, + "instructions" => self.instructions = kv.try_some_string()?, + _ => return missing_key(&kv), + } + + Ok(()) + } +} + +impl PartialConfigDelta for PartialSummaryConfig { + fn delta(&self, next: Self) -> Self { + Self { + model: delta_opt_partial(self.model.as_ref(), next.model), + instructions: delta_opt(self.instructions.as_ref(), next.instructions), + } + } +} + +impl FillDefaults for PartialSummaryConfig { + fn fill_from(self, defaults: Self) -> Self { + Self { + model: fill::fill_opt(self.model, defaults.model), + instructions: self.instructions.or(defaults.instructions), + } + } +} + +impl ToPartial for SummaryConfig { + fn to_partial(&self) -> Self::Partial { + Self::Partial { + model: partial_opt_config(self.model.as_ref(), None), + instructions: partial_opts(self.instructions.as_ref(), None), + } + } +} + +/// A range bound for compaction rules. +/// +/// Rules only accept relative bounds (stable across invocations). +/// CLI flags extend this with absolute turn indices and dates. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RuleBound { + /// A number of turns to preserve. + Turns(usize), + /// Preserve turns within this duration, e.g. `"5h"`, `"2days"`. + Duration(std::time::Duration), + /// Start after the most recent compaction's `to_turn`. + /// Only meaningful for `keep_first` (used via `from = "last"`). + AfterLastCompaction, +} + +impl FromStr for RuleBound { + type Err = BoxedError; + + fn from_str(s: &str) -> Result { + if s.eq_ignore_ascii_case("last") { + return Ok(Self::AfterLastCompaction); + } + + if let Ok(n) = s.parse::() { + return Ok(Self::Turns(n)); + } + + humantime::parse_duration(s) + .map(Self::Duration) + .map_err(|e| format!("invalid range bound `{s}`: {e}").into()) + } +} + +impl fmt::Display for RuleBound { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Turns(n) => write!(f, "{n}"), + Self::Duration(d) => write!(f, "{}", humantime::format_duration(*d)), + Self::AfterLastCompaction => write!(f, "last"), + } + } +} + +impl Serialize for RuleBound { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(&self.to_string()) + } +} + +impl<'de> Deserialize<'de> for RuleBound { + fn deserialize>(deserializer: D) -> Result { + let s = String::deserialize(deserializer)?; + s.parse().map_err(serde::de::Error::custom) + } +} + +impl RuleBound { + /// A bound of zero turns. + pub const ZERO: Self = Self::Turns(0); +} + +impl Default for RuleBound { + fn default() -> Self { + Self::ZERO + } +} + +impl schematic::Schematic for RuleBound { + fn build_schema(mut schema: schematic::SchemaBuilder) -> schematic::Schema { + schema.string_default() + } +} + +/// How to handle reasoning blocks during compaction. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ConfigEnum)] +#[serde(rename_all = "snake_case")] +pub enum ReasoningMode { + /// Strip all reasoning blocks from the projected view. + Strip, +} + +/// How to handle tool calls during compaction. +/// +/// Parses from strings for config ergonomics: +/// - `"strip"` → strip both request arguments and response content +/// - `"strip-responses"` → strip response content only +/// - `"strip-requests"` → strip request arguments only +/// - `"omit"` → remove tool call pairs entirely +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ToolCallsMode { + /// Strip both request arguments and response content. + Strip, + /// Strip response content only, keep request arguments. + StripResponses, + /// Strip request arguments only, keep response content. + StripRequests, + /// Remove tool call pairs entirely. + Omit, +} + +impl FromStr for ToolCallsMode { + type Err = BoxedError; + + fn from_str(s: &str) -> Result { + match s { + "strip" => Ok(Self::Strip), + "strip-responses" | "strip_responses" => Ok(Self::StripResponses), + "strip-requests" | "strip_requests" => Ok(Self::StripRequests), + "omit" => Ok(Self::Omit), + _ => Err(format!("unknown tool_calls mode: `{s}`").into()), + } + } +} + +impl fmt::Display for ToolCallsMode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Strip => write!(f, "strip"), + Self::StripResponses => write!(f, "strip-responses"), + Self::StripRequests => write!(f, "strip-requests"), + Self::Omit => write!(f, "omit"), + } + } +} + +impl serde::Serialize for ToolCallsMode { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(&self.to_string()) + } +} + +impl<'de> serde::Deserialize<'de> for ToolCallsMode { + fn deserialize>(deserializer: D) -> Result { + let s = String::deserialize(deserializer)?; + s.parse().map_err(serde::de::Error::custom) + } +} + +impl schematic::Schematic for ToolCallsMode { + fn build_schema(mut schema: schematic::SchemaBuilder) -> schematic::Schema { + schema.string_default() + } +} + +#[cfg(test)] +#[path = "compaction_tests.rs"] +mod tests; diff --git a/crates/jp_config/src/conversation/compaction_tests.rs b/crates/jp_config/src/conversation/compaction_tests.rs new file mode 100644 index 00000000..f6271cb4 --- /dev/null +++ b/crates/jp_config/src/conversation/compaction_tests.rs @@ -0,0 +1,76 @@ +use super::*; + +#[test] +fn tool_calls_mode_parse() { + assert_eq!( + "strip".parse::().unwrap(), + ToolCallsMode::Strip + ); + assert_eq!( + "strip-responses".parse::().unwrap(), + ToolCallsMode::StripResponses + ); + assert_eq!( + "strip_responses".parse::().unwrap(), + ToolCallsMode::StripResponses + ); + assert_eq!( + "strip-requests".parse::().unwrap(), + ToolCallsMode::StripRequests + ); + assert_eq!( + "omit".parse::().unwrap(), + ToolCallsMode::Omit + ); + assert!("invalid".parse::().is_err()); +} + +#[test] +fn tool_calls_mode_roundtrip() { + for mode in [ + ToolCallsMode::Strip, + ToolCallsMode::StripResponses, + ToolCallsMode::StripRequests, + ToolCallsMode::Omit, + ] { + let s = mode.to_string(); + assert_eq!(s.parse::().unwrap(), mode); + } +} + +#[test] +fn reasoning_mode_parse() { + assert_eq!( + "strip".parse::().unwrap(), + ReasoningMode::Strip + ); +} + +#[test] +fn rule_partial_roundtrip_json() { + let rule = PartialCompactionRuleConfig { + keep_first: None, + keep_last: Some(RuleBound::Turns(3)), + reasoning: Some(ReasoningMode::Strip), + tool_calls: Some(ToolCallsMode::Strip), + summary: None, + }; + let json = serde_json::to_value(&rule).unwrap(); + let deserialized: PartialCompactionRuleConfig = serde_json::from_value(json).unwrap(); + assert_eq!(rule, deserialized); +} + +#[test] +fn rule_partial_none_fields_omitted() { + let rule = PartialCompactionRuleConfig { + keep_first: None, + keep_last: None, + reasoning: Some(ReasoningMode::Strip), + tool_calls: None, + summary: None, + }; + let json = serde_json::to_value(&rule).unwrap(); + let obj = json.as_object().unwrap(); + assert!(obj.contains_key("reasoning")); + assert!(!obj.contains_key("tool_calls")); +} diff --git a/crates/jp_config/src/lib.rs b/crates/jp_config/src/lib.rs index e02e02ac..724ebda8 100644 --- a/crates/jp_config/src/lib.rs +++ b/crates/jp_config/src/lib.rs @@ -297,22 +297,7 @@ impl AppConfig { #[doc(hidden)] #[must_use] pub fn new_test() -> Self { - use crate::{ - conversation::tool::RunMode, - model::id::{Name, PartialModelIdConfig, ProviderId}, - }; - - let mut partial = PartialAppConfig::empty(); - - partial.conversation.title.generate.auto = Some(false); - partial.conversation.tools.defaults.run = Some(RunMode::Ask); - partial.assistant.model.id = PartialModelIdConfig { - provider: Some(ProviderId::Anthropic), - name: Some(Name("test".to_owned())), - } - .into(); - - Self::from_partial_with_defaults(partial).expect("valid config") + Self::from_partial_with_defaults(PartialAppConfig::new_test()).expect("valid config") } /// Build the schema for the configuration. @@ -506,25 +491,25 @@ impl PartialAppConfig { } } - /// Create a new partial configuration with stub values for testing - /// purposes. - /// - /// # Panics + /// Return a partial configuration with required fields populated for + /// testing purposes. /// - /// This function cannot panic. + /// This CANNOT be used in release mode. + #[cfg(debug_assertions)] #[doc(hidden)] #[must_use] - pub fn stub() -> Self { + pub fn new_test() -> Self { use crate::{ conversation::tool::RunMode, - model::id::{PartialModelIdConfig, ProviderId}, + model::id::{Name, PartialModelIdConfig, ProviderId}, }; let mut partial = Self::empty(); - partial.conversation.tools.defaults.run = Some(RunMode::Unattended); + partial.conversation.title.generate.auto = Some(false); + partial.conversation.tools.defaults.run = Some(RunMode::Ask); partial.assistant.model.id = PartialModelIdConfig { - provider: Some(ProviderId::Ollama), - name: Some("world".try_into().expect("valid name")), + provider: Some(ProviderId::Anthropic), + name: Some(Name("test".to_owned())), } .into(); partial diff --git a/crates/jp_config/src/snapshots/jp_config__tests__app_config_fields.snap b/crates/jp_config/src/snapshots/jp_config__tests__app_config_fields.snap index 7daa5a38..bd679aef 100644 --- a/crates/jp_config/src/snapshots/jp_config__tests__app_config_fields.snap +++ b/crates/jp_config/src/snapshots/jp_config__tests__app_config_fields.snap @@ -79,6 +79,7 @@ expression: "AppConfig::fields()" "conversation.inquiry.assistant.system_prompt", "conversation.inquiry.assistant.system_prompt_sections", "conversation.inquiry.assistant.tool_choice", + "conversation.compaction.rules", "assistant.instructions", "assistant.name", "assistant.system_prompt", diff --git a/crates/jp_config/src/snapshots/jp_config__tests__partial_app_config_default.snap b/crates/jp_config/src/snapshots/jp_config__tests__partial_app_config_default.snap index 83b326d7..565431dc 100644 --- a/crates/jp_config/src/snapshots/jp_config__tests__partial_app_config_default.snap +++ b/crates/jp_config/src/snapshots/jp_config__tests__partial_app_config_default.snap @@ -61,6 +61,11 @@ PartialAppConfig { }, tools: {}, }, + compaction: PartialCompactionConfig { + rules: Vec( + [], + ), + }, attachments: Vec( [], ), diff --git a/crates/jp_config/src/snapshots/jp_config__tests__partial_app_config_default_values.snap b/crates/jp_config/src/snapshots/jp_config__tests__partial_app_config_default_values.snap index 85aa907d..bcc686a5 100644 --- a/crates/jp_config/src/snapshots/jp_config__tests__partial_app_config_default_values.snap +++ b/crates/jp_config/src/snapshots/jp_config__tests__partial_app_config_default_values.snap @@ -115,6 +115,28 @@ Ok( }, tools: {}, }, + compaction: PartialCompactionConfig { + rules: Merged( + MergedVec { + value: [ + PartialCompactionRuleConfig { + keep_first: None, + keep_last: None, + reasoning: Some( + Strip, + ), + tool_calls: Some( + Strip, + ), + summary: None, + }, + ], + strategy: None, + dedup: None, + discard_when_merged: true, + }, + ), + }, attachments: Merged( MergedVec { value: [], diff --git a/crates/jp_config/src/snapshots/jp_config__tests__partial_app_config_empty_serialize.snap b/crates/jp_config/src/snapshots/jp_config__tests__partial_app_config_empty_serialize.snap index 82f1ab12..f919a92e 100644 --- a/crates/jp_config/src/snapshots/jp_config__tests__partial_app_config_empty_serialize.snap +++ b/crates/jp_config/src/snapshots/jp_config__tests__partial_app_config_empty_serialize.snap @@ -61,6 +61,11 @@ PartialAppConfig { }, tools: {}, }, + compaction: PartialCompactionConfig { + rules: Vec( + [], + ), + }, attachments: Vec( [], ), diff --git a/crates/jp_conversation/src/compaction.rs b/crates/jp_conversation/src/compaction.rs new file mode 100644 index 00000000..80a8848d --- /dev/null +++ b/crates/jp_conversation/src/compaction.rs @@ -0,0 +1,256 @@ +//! Conversation compaction types. +//! +//! Compaction is a non-destructive, additive operation that appends overlay +//! events to the conversation stream. These overlays instruct the projection +//! layer to present a reduced view when building the LLM request. The original +//! events are always preserved. +//! +//! See [RFD 064]. +//! +//! [RFD 064]: https://github.com/dcdpr/jp/blob/main/docs/rfd/064-non-destructive-conversation-compaction.md + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +/// A compaction overlay stored in the event stream. +/// +/// Defines how events within `[from_turn, to_turn]` should be projected +/// when building the LLM request. The original events are unmodified. +/// +/// Policies are optional: `None` means "this compaction has no opinion on this +/// content type" — the original events pass through, or an earlier compaction's +/// policy applies. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Compaction { + /// The timestamp when this compaction was created. + #[serde( + serialize_with = "crate::serialize_dt", + deserialize_with = "crate::deserialize_dt" + )] + pub timestamp: DateTime, + + /// First turn in the compacted range (inclusive, 0-based). + pub from_turn: usize, + + /// Last turn in the compacted range (inclusive, 0-based). + pub to_turn: usize, + + /// When set, replaces ALL provider-visible events in the range with a + /// pre-computed summary. Takes precedence over `reasoning` and + /// `tool_calls`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub summary: Option, + + /// Policy for `ChatResponse::Reasoning` events. + /// Ignored when `summary` is set. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reasoning: Option, + + /// Policy for `ToolCallRequest` and `ToolCallResponse` pairs. + /// Ignored when `summary` is set. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tool_calls: Option, +} + +impl Compaction { + /// Create a new compaction event for the given turn range. + /// + /// Timestamp is set to the current time. All policies default to `None` + /// (pass-through). + #[must_use] + pub fn new(from_turn: usize, to_turn: usize) -> Self { + Self { + timestamp: Utc::now(), + from_turn, + to_turn, + summary: None, + reasoning: None, + tool_calls: None, + } + } + + /// Set the reasoning policy. + #[must_use] + pub const fn with_reasoning(mut self, policy: ReasoningPolicy) -> Self { + self.reasoning = Some(policy); + self + } + + /// Set the tool call policy. + #[must_use] + pub const fn with_tool_calls(mut self, policy: ToolCallPolicy) -> Self { + self.tool_calls = Some(policy); + self + } + + /// Set the summary policy. + #[must_use] + pub fn with_summary(mut self, policy: SummaryPolicy) -> Self { + self.summary = Some(policy); + self + } +} + +/// Policy for handling reasoning events during compaction. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ReasoningPolicy { + /// Omit all reasoning events from the projected view. + Strip, +} + +/// Replaces all provider-visible events in the compacted range with a +/// pre-computed summary. +/// +/// Messages, reasoning, and tool calls are all replaced by a single synthetic +/// `ChatRequest`/`ChatResponse` pair containing the summary text. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SummaryPolicy { + /// The summary text, generated at compaction-creation time. + pub summary: String, +} + +/// Policy for handling tool call request/response pairs during compaction. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "policy", rename_all = "snake_case")] +pub enum ToolCallPolicy { + /// Replace request arguments and/or response content with compact + /// summaries. Preserves tool name, call ID, and success/error status. + Strip { + /// Replace arguments with a compact summary. + request: bool, + /// Replace response content with a status line. + response: bool, + }, + + /// Remove all tool call pairs entirely. + Omit, +} + +/// A user-specified compaction range bound. +/// +/// Bounds are resolved against a [`ConversationStream`] to produce absolute +/// turn indices. See [`self::resolve_range`]. +/// +/// [`ConversationStream`]: crate::ConversationStream +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RangeBound { + /// Absolute 0-based turn index. + Absolute(usize), + /// Offset from the end. `FromEnd(3)` means 3 turns before the last. + FromEnd(usize), + /// The turn after the most recent compaction's `to_turn`, or 0 if none. + /// Used by `--from last` for incremental compaction. + AfterLastCompaction, +} + +/// A resolved compaction range with inclusive bounds. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CompactionRange { + /// First turn (inclusive, 0-based). + pub from_turn: usize, + /// Last turn (inclusive, 0-based). + pub to_turn: usize, +} + +/// Extend a summary compaction range to fully subsume any partially +/// overlapping existing summary compactions in the stream. +/// +/// When two summary ranges partially overlap (each covers turns the other +/// doesn't), the projected view produces two synthetic pairs instead of one +/// coherent summary. This function prevents that by expanding the proposed +/// range to cover any such partial overlaps. +/// +/// The extension repeats until no partial overlaps remain, handling transitive +/// chains (A overlaps B, B overlaps C → extend to cover all three). +/// +/// Only considers existing compactions that have `summary: Some(...)`. Returns +/// the input range unchanged if there are no overlapping summaries. +/// +/// Call this before generating the summary text so the summarizer reads +/// events for the full extended range. +#[must_use] +pub fn extend_summary_range( + stream: &crate::ConversationStream, + range: CompactionRange, +) -> CompactionRange { + let mut from = range.from_turn; + let mut to = range.to_turn; + + // Repeat until stable — extension may expose new overlaps. + loop { + let mut changed = false; + + for c in stream.compactions() { + if c.summary.is_none() { + continue; + } + + let intersects = from <= c.to_turn && to >= c.from_turn; + let new_contains_old = from <= c.from_turn && to >= c.to_turn; + let old_contains_new = c.from_turn <= from && c.to_turn >= to; + + // Only extend on partial overlap: ranges intersect but neither + // fully contains the other. + if intersects && !new_contains_old && !old_contains_new { + from = from.min(c.from_turn); + to = to.max(c.to_turn); + changed = true; + } + } + + if !changed { + break; + } + } + + CompactionRange { + from_turn: from, + to_turn: to, + } +} + +/// Resolve user-specified range bounds against a conversation stream. +/// +/// Returns `None` if the stream has no turns, or if the resolved range is +/// empty (`from > to`). +/// +/// Defaults: `from` = turn 0, `to` = last turn. +#[must_use] +pub fn resolve_range( + stream: &crate::ConversationStream, + from: Option, + to: Option, +) -> Option { + let count = stream.turn_count(); + if count == 0 { + return None; + } + let last = count - 1; + + let resolve = |bound: RangeBound| -> usize { + match bound { + RangeBound::Absolute(n) => n.min(last), + RangeBound::FromEnd(n) => last.saturating_sub(n), + RangeBound::AfterLastCompaction => stream + .compactions() + .map(|c| c.to_turn + 1) + .max() + .unwrap_or(0) + .min(last), + } + }; + + let from_turn = from.map_or(0, resolve); + let to_turn = to.map_or(last, resolve); + + if from_turn > to_turn { + return None; + } + + Some(CompactionRange { from_turn, to_turn }) +} + +#[cfg(test)] +#[path = "compaction_tests.rs"] +mod tests; diff --git a/crates/jp_conversation/src/compaction_tests.rs b/crates/jp_conversation/src/compaction_tests.rs new file mode 100644 index 00000000..41d3ba1c --- /dev/null +++ b/crates/jp_conversation/src/compaction_tests.rs @@ -0,0 +1,311 @@ +use chrono::{TimeZone as _, Utc}; + +use super::*; +use crate::ConversationStream; + +// --------------------------------------------------------------------------- +// Builder methods +// --------------------------------------------------------------------------- + +#[test] +fn builder_with_reasoning() { + let c = Compaction::new(0, 5).with_reasoning(ReasoningPolicy::Strip); + assert_eq!(c.reasoning, Some(ReasoningPolicy::Strip)); + assert!(c.tool_calls.is_none()); + assert!(c.summary.is_none()); +} + +#[test] +fn builder_with_tool_calls() { + let c = Compaction::new(0, 5).with_tool_calls(ToolCallPolicy::Omit); + assert_eq!(c.tool_calls, Some(ToolCallPolicy::Omit)); +} + +#[test] +fn builder_chained() { + let c = Compaction::new(0, 5) + .with_reasoning(ReasoningPolicy::Strip) + .with_tool_calls(ToolCallPolicy::Strip { + request: true, + response: true, + }); + assert!(c.reasoning.is_some()); + assert!(c.tool_calls.is_some()); + assert!(c.summary.is_none()); +} + +// --------------------------------------------------------------------------- +// Serialization +// --------------------------------------------------------------------------- + +fn sample_compaction() -> Compaction { + Compaction { + timestamp: Utc.with_ymd_and_hms(2025, 1, 15, 12, 0, 0).unwrap(), + from_turn: 0, + to_turn: 5, + summary: None, + reasoning: Some(ReasoningPolicy::Strip), + tool_calls: Some(ToolCallPolicy::Strip { + request: true, + response: true, + }), + } +} + +#[test] +fn roundtrip_mechanical_compaction() { + let original = sample_compaction(); + let json = serde_json::to_value(&original).unwrap(); + let deserialized: Compaction = serde_json::from_value(json).unwrap(); + assert_eq!(original, deserialized); +} + +#[test] +fn roundtrip_summary_compaction() { + let compaction = Compaction { + timestamp: Utc.with_ymd_and_hms(2025, 1, 15, 12, 0, 0).unwrap(), + from_turn: 0, + to_turn: 10, + summary: Some(SummaryPolicy { + summary: "Set up a Rust project with error handling.".into(), + }), + reasoning: None, + tool_calls: None, + }; + + let json = serde_json::to_value(&compaction).unwrap(); + let deserialized: Compaction = serde_json::from_value(json).unwrap(); + assert_eq!(compaction, deserialized); +} + +#[test] +fn none_policies_omitted_from_json() { + let compaction = Compaction { + timestamp: Utc.with_ymd_and_hms(2025, 1, 15, 12, 0, 0).unwrap(), + from_turn: 0, + to_turn: 3, + summary: None, + reasoning: Some(ReasoningPolicy::Strip), + tool_calls: None, + }; + + let json = serde_json::to_value(&compaction).unwrap(); + let obj = json.as_object().unwrap(); + + assert!(!obj.contains_key("summary")); + assert!(obj.contains_key("reasoning")); + assert!(!obj.contains_key("tool_calls")); +} + +#[test] +fn tool_call_policy_strip_roundtrip() { + let policy = ToolCallPolicy::Strip { + request: false, + response: true, + }; + let json = serde_json::to_value(&policy).unwrap(); + assert_eq!(json["policy"], "strip"); + assert_eq!(json["request"], false); + assert_eq!(json["response"], true); + + let deserialized: ToolCallPolicy = serde_json::from_value(json).unwrap(); + assert_eq!(policy, deserialized); +} + +#[test] +fn tool_call_policy_omit_roundtrip() { + let policy = ToolCallPolicy::Omit; + let json = serde_json::to_value(&policy).unwrap(); + assert_eq!(json["policy"], "omit"); + + let deserialized: ToolCallPolicy = serde_json::from_value(json).unwrap(); + assert_eq!(policy, deserialized); +} + +#[test] +fn reasoning_policy_roundtrip() { + let policy = ReasoningPolicy::Strip; + let json = serde_json::to_value(&policy).unwrap(); + assert_eq!(json, serde_json::json!("strip")); + + let deserialized: ReasoningPolicy = serde_json::from_value(json).unwrap(); + assert_eq!(policy, deserialized); +} + +#[test] +fn summary_policy_roundtrip() { + let policy = SummaryPolicy { + summary: "This is a summary of the conversation.".into(), + }; + let json = serde_json::to_value(&policy).unwrap(); + assert_eq!(json["summary"], "This is a summary of the conversation."); + + let deserialized: SummaryPolicy = serde_json::from_value(json).unwrap(); + assert_eq!(policy, deserialized); +} + +// --------------------------------------------------------------------------- +// Summary range auto-extension +// --------------------------------------------------------------------------- + +fn summary_compaction(from: usize, to: usize, hour: u32) -> Compaction { + Compaction { + timestamp: Utc.with_ymd_and_hms(2025, 1, 1, hour, 0, 0).unwrap(), + from_turn: from, + to_turn: to, + summary: Some(SummaryPolicy { + summary: format!("summary {from}-{to}"), + }), + reasoning: None, + tool_calls: None, + } +} + +/// Build a stream with `n` turns. +#[expect(clippy::cast_possible_truncation)] +fn stream_with_turns(n: usize) -> ConversationStream { + let mut stream = ConversationStream::new_test(); + for i in 0..n { + stream.extend(vec![ + crate::ConversationEvent::new( + crate::event::TurnStart, + Utc.with_ymd_and_hms(2025, 1, 1, i as u32, 0, 0).unwrap(), + ), + crate::ConversationEvent::new( + crate::event::ChatRequest::from(format!("turn {i}")), + Utc.with_ymd_and_hms(2025, 1, 1, i as u32, 0, 1).unwrap(), + ), + ]); + } + stream +} + +#[test] +fn extend_no_existing_summaries() { + let stream = stream_with_turns(10); + let range = CompactionRange { + from_turn: 3, + to_turn: 7, + }; + let result = extend_summary_range(&stream, range); + assert_eq!(result, range, "No existing summaries → unchanged"); +} + +#[test] +fn extend_no_overlap() { + let mut stream = stream_with_turns(10); + stream.add_compaction(summary_compaction(0, 2, 10)); + + let range = CompactionRange { + from_turn: 5, + to_turn: 8, + }; + let result = extend_summary_range(&stream, range); + assert_eq!(result, range, "Disjoint ranges → unchanged"); +} + +#[test] +fn extend_partial_overlap_right() { + let mut stream = stream_with_turns(10); + // Existing summary: turns 5–10. + stream.add_compaction(summary_compaction(5, 9, 10)); + + // New range 3–7 partially overlaps: extends to 3–9. + let range = CompactionRange { + from_turn: 3, + to_turn: 7, + }; + let result = extend_summary_range(&stream, range); + assert_eq!(result, CompactionRange { + from_turn: 3, + to_turn: 9 + }); +} + +#[test] +fn extend_partial_overlap_left() { + let mut stream = stream_with_turns(10); + // Existing summary: turns 0–4. + stream.add_compaction(summary_compaction(0, 4, 10)); + + // New range 3–8 partially overlaps: extends to 0–8. + let range = CompactionRange { + from_turn: 3, + to_turn: 8, + }; + let result = extend_summary_range(&stream, range); + assert_eq!(result, CompactionRange { + from_turn: 0, + to_turn: 8 + }); +} + +#[test] +fn extend_new_fully_contains_old() { + let mut stream = stream_with_turns(10); + stream.add_compaction(summary_compaction(3, 5, 10)); + + // New [0, 8] fully contains old [3, 5] → no extension needed. + let range = CompactionRange { + from_turn: 0, + to_turn: 8, + }; + let result = extend_summary_range(&stream, range); + assert_eq!(result, range); +} + +#[test] +fn extend_old_fully_contains_new() { + let mut stream = stream_with_turns(10); + stream.add_compaction(summary_compaction(0, 9, 10)); + + // New [3, 5] fully contained by old [0, 9] → no extension. + let range = CompactionRange { + from_turn: 3, + to_turn: 5, + }; + let result = extend_summary_range(&stream, range); + assert_eq!(result, range); +} + +#[test] +fn extend_transitive_chain() { + let mut stream = stream_with_turns(20); + // A: 0–5, B: 4–10, C: 9–15 + stream.add_compaction(summary_compaction(0, 5, 10)); + stream.add_compaction(summary_compaction(4, 10, 11)); + stream.add_compaction(summary_compaction(9, 15, 12)); + + // New range 3–7 overlaps A and B directly. + // After extending to 0–10, that overlaps C → extends to 0–15. + let range = CompactionRange { + from_turn: 3, + to_turn: 7, + }; + let result = extend_summary_range(&stream, range); + assert_eq!(result, CompactionRange { + from_turn: 0, + to_turn: 15 + }); +} + +#[test] +fn extend_ignores_mechanical_compactions() { + let mut stream = stream_with_turns(10); + // Mechanical compaction (no summary) covering 0–9. + stream.add_compaction(Compaction { + timestamp: Utc.with_ymd_and_hms(2025, 1, 1, 10, 0, 0).unwrap(), + from_turn: 0, + to_turn: 9, + summary: None, + reasoning: Some(ReasoningPolicy::Strip), + tool_calls: None, + }); + + let range = CompactionRange { + from_turn: 3, + to_turn: 7, + }; + let result = extend_summary_range(&stream, range); + assert_eq!(result, range, "Mechanical compactions should be ignored"); +} diff --git a/crates/jp_conversation/src/lib.rs b/crates/jp_conversation/src/lib.rs index dbf2ef27..423b9d9d 100644 --- a/crates/jp_conversation/src/lib.rs +++ b/crates/jp_conversation/src/lib.rs @@ -27,6 +27,7 @@ reason = "we don't host the docs, and use them mainly for LSP integration" )] +pub mod compaction; mod compat; pub mod conversation; pub mod error; @@ -35,6 +36,10 @@ pub(crate) mod storage; pub mod stream; pub mod thread; +pub use compaction::{ + Compaction, CompactionRange, RangeBound, ReasoningPolicy, SummaryPolicy, ToolCallPolicy, + resolve_range, +}; pub use conversation::{Conversation, ConversationId}; pub use error::Error; pub use event::{ConversationEvent, EventKind}; diff --git a/crates/jp_conversation/src/stream.rs b/crates/jp_conversation/src/stream.rs index 8f6237ca..9edbad16 100644 --- a/crates/jp_conversation/src/stream.rs +++ b/crates/jp_conversation/src/stream.rs @@ -8,12 +8,14 @@ use serde::{Deserialize, Serialize, Serializer}; use serde_json::{Map, Value}; use tracing::error; +mod projection; pub mod turn_iter; pub mod turn_mut; pub use turn_iter::{IterTurns, Turn}; pub use turn_mut::TurnMut; use crate::{ + Compaction, compat::deserialize_partial_config, event::{ChatRequest, ConversationEvent, EventKind, InquiryId, ToolCallResponse, TurnStart}, storage::{decode_event_value, encode_event}, @@ -40,6 +42,10 @@ enum InternalEvent { ConfigDelta(ConfigDelta), /// An event in the conversation stream. Event(Box), + /// A compaction overlay that modifies how preceding events are projected + /// when building the LLM request. Does not modify or delete any existing + /// events. + Compaction(Compaction), } impl Serialize for InternalEvent { @@ -68,6 +74,21 @@ impl Serialize for InternalEvent { encode_event(&mut value, &event.kind); value.serialize(serializer) } + Self::Compaction(compaction) => { + #[derive(Serialize)] + struct Tagged<'a> { + #[serde(rename = "type")] + tag: &'static str, + #[serde(flatten)] + inner: &'a Compaction, + } + + Tagged { + tag: "compaction", + inner: compaction, + } + .serialize(serializer) + } } } } @@ -79,7 +100,7 @@ impl InternalEvent { fn into_event(self) -> Option { match self { Self::Event(event) => Some(*event), - Self::ConfigDelta(_) => None, + Self::ConfigDelta(_) | Self::Compaction(_) => None, } } @@ -88,7 +109,7 @@ impl InternalEvent { fn as_event(&self) -> Option<&ConversationEvent> { match self { Self::Event(event) => Some(event), - Self::ConfigDelta(_) => None, + Self::ConfigDelta(_) | Self::Compaction(_) => None, } } } @@ -270,7 +291,7 @@ impl ConversationStream { let mut partial = self.base_config.to_partial(); let iter = self.events.iter().filter_map(|event| match event { InternalEvent::ConfigDelta(delta) => Some(delta.clone()), - InternalEvent::Event(_) => None, + InternalEvent::Event(_) | InternalEvent::Compaction(_) => None, }); for delta in iter { @@ -326,6 +347,45 @@ impl ConversationStream { self } + /// Add a compaction overlay to the stream. + pub fn add_compaction(&mut self, compaction: Compaction) { + self.events.push(InternalEvent::Compaction(compaction)); + } + + /// Remove all compaction events from the stream. + /// + /// Returns the number of compaction events removed. + pub fn remove_compactions(&mut self) -> usize { + let before = self.events.len(); + self.events + .retain(|e| !matches!(e, InternalEvent::Compaction(_))); + before - self.events.len() + } + + /// Returns an iterator over the [`Compaction`] events in the stream. + pub fn compactions(&self) -> impl Iterator { + self.events.iter().filter_map(|e| match e { + InternalEvent::Compaction(c) => Some(c), + _ => None, + }) + } + + /// Apply compaction projection to the stream. + /// + /// Reads all compaction overlays and transforms the event list so that + /// the projected view reflects the compaction policies. After this call, + /// the stream's conversation events represent what the LLM should see. + /// + /// This is a no-op when no compaction events are present. + /// + /// This method is called by [`Thread::into_parts()`] before provider + /// visibility filtering. + /// + /// [`Thread::into_parts()`]: crate::thread::Thread::into_parts + pub fn apply_projection(&mut self) { + projection::apply(&mut self.events); + } + /// Start a new turn with the given chat request. /// /// Atomically adds a [`TurnStart`] and the [`ChatRequest`] to the stream. @@ -492,7 +552,7 @@ impl ConversationStream { .rev() .find_map(|event| match event { InternalEvent::Event(event) => Some(f(event)), - InternalEvent::ConfigDelta(_) => None, + InternalEvent::ConfigDelta(_) | InternalEvent::Compaction(_) => None, }) .unwrap_or(false) { @@ -504,10 +564,10 @@ impl ConversationStream { /// Retains only the [`ConversationEvent`]s that pass the predicate. /// - /// This does NOT remove the [`ConfigDelta`]s. + /// This does NOT remove [`ConfigDelta`]s or [`Compaction`] events. pub fn retain(&mut self, mut f: impl FnMut(&ConversationEvent) -> bool) { self.events.retain(|event| match event { - InternalEvent::ConfigDelta(_) => true, + InternalEvent::ConfigDelta(_) | InternalEvent::Compaction(_) => true, InternalEvent::Event(event) => f(event), }); } @@ -574,7 +634,7 @@ impl ConversationStream { return true; } match event { - InternalEvent::ConfigDelta(_) => true, + InternalEvent::ConfigDelta(_) | InternalEvent::Compaction(_) => true, InternalEvent::Event(e) => e.is_turn_start(), } }); @@ -801,6 +861,38 @@ impl ConversationStream { IterTurns::new(self.iter()) } + /// Returns the number of turns in the stream. + /// + /// A turn is delimited by [`TurnStart`] events. A stream with no events + /// has 0 turns. A stream with events but no `TurnStart` has 1 implicit + /// turn. + /// + /// [`TurnStart`]: crate::event::TurnStart + #[must_use] + pub fn turn_count(&self) -> usize { + self.iter_turns().len() + } + + /// Returns the turn that was active at the given time. + /// + /// Finds the last turn whose starting timestamp is ≤ `dt`. Returns `None` + /// if the stream has no turns, or if `dt` is before the first turn. + /// + /// Use [`Turn::index()`] on the result to get the 0-based turn index. + #[must_use] + pub fn turn_at_time(&self, dt: DateTime) -> Option> { + let mut result = None; + for turn in self.iter_turns() { + let start = turn.iter().next()?.event.timestamp; + if start <= dt { + result = Some(turn); + } else { + break; + } + } + result + } + /// Retain only the last `n` turns, dropping earlier ones. /// /// A turn is delimited by a [`TurnStart`] event. If there are `n` or @@ -952,6 +1044,7 @@ impl Iterator for IntoIter { config: self.current_config.clone(), }); } + InternalEvent::Compaction(_) => {} } } } @@ -963,10 +1056,10 @@ impl DoubleEndedIterator for IntoIter { let event = self.inner_iter.next_back()?; match event { - InternalEvent::ConfigDelta(_) => { - // A delta at the very end of the list affects nothing that - // follows it (because nothing follows it), and it doesn't - // affect previous items. We simply discard it. + InternalEvent::ConfigDelta(_) | InternalEvent::Compaction(_) => { + // A delta/compaction at the very end of the list affects + // nothing that follows it, and it doesn't affect previous + // items. We simply discard it. } InternalEvent::Event(event) => { // Start with the state currently at the front of the line @@ -1028,6 +1121,7 @@ impl<'a> Iterator for Iter<'a> { config: self.front_config.clone(), }); } + InternalEvent::Compaction(_) => {} } } @@ -1087,6 +1181,7 @@ impl<'a> Iterator for IterMut<'a> { config: self.front_config.clone(), }); } + InternalEvent::Compaction(_) => {} } } @@ -1401,6 +1496,12 @@ impl<'de> Deserialize<'de> for InternalEvent { return Ok(Self::ConfigDelta(deserialize_config_delta(&value))); } + if tag == "compaction" { + return serde_json::from_value(value) + .map(Self::Compaction) + .map_err(serde::de::Error::custom); + } + // Decode base64-encoded storage fields before deserializing. decode_event_value(&mut value); diff --git a/crates/jp_conversation/src/stream/projection.rs b/crates/jp_conversation/src/stream/projection.rs new file mode 100644 index 00000000..dc1229d7 --- /dev/null +++ b/crates/jp_conversation/src/stream/projection.rs @@ -0,0 +1,263 @@ +//! Compaction projection logic. +//! +//! Transforms a conversation event stream by applying compaction overlays. +//! The original events are consumed and a new projected event list is produced. +//! +//! See [`apply`] for the entry point. + +use std::collections::{HashMap, HashSet}; + +use chrono::{DateTime, Utc}; +use serde_json::{Map, Value}; + +use super::InternalEvent; +use crate::{ + ReasoningPolicy, ToolCallPolicy, + event::{ChatRequest, ChatResponse, ConversationEvent}, +}; + +/// Resolved compaction policies for a single turn. +struct TurnPolicy { + /// Summary covering this turn. Takes precedence over per-type policies. + summary: Option, + /// Reasoning policy. Ignored when `summary` is set. + reasoning: Option, + /// Tool call policy. Ignored when `summary` is set. + tool_calls: Option, +} + +/// A summary that won the latest-timestamp contest for a set of turns. +struct ResolvedSummary { + /// The summary text to inject. + text: String, + /// The `from_turn` of the originating compaction, used to determine + /// where the synthetic pair is injected. + from_turn: usize, +} + +/// Apply compaction projection to the event list in place. +/// +/// Reads all [`Compaction`] events, resolves per-turn policies using +/// latest-timestamp-wins semantics, then walks the events to apply: +/// +/// - **Summary**: replaces all events in the covered range with a single +/// synthetic `ChatRequest`/`ChatResponse::Message` pair. +/// - **Reasoning strip**: removes `ChatResponse::Reasoning` events. +/// - **Tool call strip**: replaces arguments and/or response content with +/// compact summaries. +/// - **Tool call omit**: removes tool call request/response pairs. +/// +/// [`Compaction`]: crate::Compaction +pub(super) fn apply(events: &mut Vec) { + let compactions: Vec<_> = events + .iter() + .filter_map(|e| match e { + InternalEvent::Compaction(c) => Some(c.clone()), + _ => None, + }) + .collect(); + + if compactions.is_empty() { + return; + } + + let turn_indices = assign_turn_indices(events); + let max_turn = turn_indices.iter().copied().max().unwrap_or(0); + let policies = resolve_policies(max_turn, &compactions); + let tool_names = build_tool_name_map(events); + + let mut projected = Vec::with_capacity(events.len()); + let mut summaries_injected: HashSet = HashSet::new(); + + for (i, event) in std::mem::take(events).into_iter().enumerate() { + let turn = turn_indices[i]; + + match event { + InternalEvent::ConfigDelta(_) => { + projected.push(event); + } + // Compaction events are consumed by projection — they've been + // applied and should not survive into the projected stream. + InternalEvent::Compaction(_) => {} + InternalEvent::Event(conv_event) => { + let Some(policy) = policies.get(turn) else { + projected.push(InternalEvent::Event(conv_event)); + continue; + }; + + // Summary takes precedence over all per-type policies. + if let Some(summary) = &policy.summary { + if summary.from_turn == turn && summaries_injected.insert(turn) { + inject_summary(&mut projected, &summary.text, conv_event.timestamp); + } + // Drop the original event — it's covered by the summary. + continue; + } + + let mut event = *conv_event; + + // Reasoning policy. + if matches!(policy.reasoning, Some(ReasoningPolicy::Strip)) + && event + .as_chat_response() + .is_some_and(ChatResponse::is_reasoning) + { + continue; + } + + // Tool call policy. + if let Some(tc_policy) = &policy.tool_calls { + match tc_policy { + ToolCallPolicy::Omit => { + if event.is_tool_call_request() || event.is_tool_call_response() { + continue; + } + } + ToolCallPolicy::Strip { request, response } => { + if *request { + strip_tool_request(&mut event); + } + if *response { + strip_tool_response(&mut event, &tool_names); + } + } + } + } + + projected.push(InternalEvent::Event(Box::new(event))); + } + } + } + + *events = projected; +} + +/// Assign a 0-based turn index to each event position. +/// +/// Turn boundaries are marked by [`TurnStart`] events. Everything before the +/// first `TurnStart` (or from the first `TurnStart` onward until the next) +/// belongs to turn 0. Non-event entries (`ConfigDelta`, `Compaction`) inherit +/// the current turn index. +/// +/// [`TurnStart`]: crate::event::TurnStart +pub(super) fn assign_turn_indices(events: &[InternalEvent]) -> Vec { + let mut indices = Vec::with_capacity(events.len()); + let mut turn: usize = 0; + let mut first_turn_seen = false; + + for event in events { + let is_turn_start = matches!(event, InternalEvent::Event(ev) if ev.is_turn_start()); + + if is_turn_start && first_turn_seen { + turn += 1; + } + if is_turn_start { + first_turn_seen = true; + } + + indices.push(turn); + } + + indices +} + +/// Resolve the winning compaction policy for each turn. +/// +/// For each turn, the compaction with the latest timestamp wins per policy +/// type. Summary, reasoning, and `tool_calls` are resolved independently. +fn resolve_policies(max_turn: usize, compactions: &[crate::Compaction]) -> Vec { + let count = max_turn + 1; + + let mut policies: Vec = (0..count) + .map(|_| TurnPolicy { + summary: None, + reasoning: None, + tool_calls: None, + }) + .collect(); + + // Track winning timestamps separately to keep TurnPolicy simple. + let mut summary_ts: Vec>> = vec![None; count]; + let mut reasoning_ts: Vec>> = vec![None; count]; + let mut tool_calls_ts: Vec>> = vec![None; count]; + + for c in compactions { + let to = c.to_turn.min(max_turn); + + for turn in c.from_turn..=to { + if c.summary.is_some() && summary_ts[turn].is_none_or(|ts| c.timestamp > ts) { + summary_ts[turn] = Some(c.timestamp); + policies[turn].summary = c.summary.as_ref().map(|s| ResolvedSummary { + text: s.summary.clone(), + from_turn: c.from_turn, + }); + } + + if c.reasoning.is_some() && reasoning_ts[turn].is_none_or(|ts| c.timestamp > ts) { + reasoning_ts[turn] = Some(c.timestamp); + policies[turn].reasoning.clone_from(&c.reasoning); + } + + if c.tool_calls.is_some() && tool_calls_ts[turn].is_none_or(|ts| c.timestamp > ts) { + tool_calls_ts[turn] = Some(c.timestamp); + policies[turn].tool_calls.clone_from(&c.tool_calls); + } + } + } + + policies +} + +/// Inject a synthetic `ChatRequest`/`ChatResponse` pair for a summary. +fn inject_summary(events: &mut Vec, summary: &str, timestamp: DateTime) { + events.push(InternalEvent::Event(Box::new(ConversationEvent::new( + ChatRequest::from("[Summary of previous conversation]"), + timestamp, + )))); + events.push(InternalEvent::Event(Box::new(ConversationEvent::new( + ChatResponse::message(summary), + timestamp, + )))); +} + +/// Replace a tool call request's arguments with a compacted marker. +fn strip_tool_request(event: &mut ConversationEvent) { + if let Some(req) = event.as_tool_call_request_mut() { + req.arguments = Map::from_iter([("[compacted]".to_owned(), Value::Bool(true))]); + } +} + +/// Replace a tool call response's content with a compact status line. +fn strip_tool_response(event: &mut ConversationEvent, tool_names: &HashMap) { + if let Some(resp) = event.as_tool_call_response_mut() { + let name = tool_names.get(&resp.id).map_or("unknown", String::as_str); + let status = if resp.result.is_ok() { + "success" + } else { + "error" + }; + let line = format!("[compacted] {name}: {status}"); + resp.result = if resp.result.is_ok() { + Ok(line) + } else { + Err(line) + }; + } +} + +/// Build a map from tool call ID → tool name for response stripping. +fn build_tool_name_map(events: &[InternalEvent]) -> HashMap { + let mut map = HashMap::new(); + for event in events { + if let InternalEvent::Event(ev) = event + && let Some(req) = ev.as_tool_call_request() + { + map.insert(req.id.clone(), req.name.clone()); + } + } + map +} + +#[cfg(test)] +#[path = "projection_tests.rs"] +mod tests; diff --git a/crates/jp_conversation/src/stream/projection_tests.rs b/crates/jp_conversation/src/stream/projection_tests.rs new file mode 100644 index 00000000..cb1b0683 --- /dev/null +++ b/crates/jp_conversation/src/stream/projection_tests.rs @@ -0,0 +1,785 @@ +use chrono::{TimeZone as _, Utc}; +use serde_json::Map; + +use crate::{ + Compaction, ConversationEvent, ConversationStream, EventKind, ReasoningPolicy, SummaryPolicy, + ToolCallPolicy, + event::{ChatRequest, ChatResponse, ToolCallRequest, ToolCallResponse, TurnStart}, +}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn ts(hour: u32) -> chrono::DateTime { + Utc.with_ymd_and_hms(2025, 7, 1, hour, 0, 0).unwrap() +} + +/// Build a stream with two turns and some tool calls + reasoning. +fn two_turn_stream() -> ConversationStream { + let mut stream = ConversationStream::new_test(); + + // Turn 0 + stream.push(ConversationEvent::new(TurnStart, ts(0))); + stream.push(ConversationEvent::new( + ChatRequest::from("set up the project"), + ts(0), + )); + stream.push(ConversationEvent::new( + ChatResponse::reasoning("thinking about setup..."), + ts(0), + )); + stream.push(ConversationEvent::new( + ToolCallRequest { + id: "tc1".into(), + name: "fs_create_file".into(), + arguments: Map::from_iter([("path".into(), "src/main.rs".into())]), + }, + ts(0), + )); + stream.push(ConversationEvent::new( + ToolCallResponse { + id: "tc1".into(), + result: Ok("file created".into()), + }, + ts(0), + )); + stream.push(ConversationEvent::new( + ChatResponse::message("Created the project."), + ts(0), + )); + + // Turn 1 + stream.push(ConversationEvent::new(TurnStart, ts(1))); + stream.push(ConversationEvent::new( + ChatRequest::from("add error handling"), + ts(1), + )); + stream.push(ConversationEvent::new( + ChatResponse::reasoning("considering error types..."), + ts(1), + )); + stream.push(ConversationEvent::new( + ToolCallRequest { + id: "tc2".into(), + name: "fs_modify_file".into(), + arguments: Map::from_iter([ + ("path".into(), "src/main.rs".into()), + ("old".into(), "fn main()".into()), + ("new".into(), "fn main() -> Result<()>".into()), + ]), + }, + ts(1), + )); + stream.push(ConversationEvent::new( + ToolCallResponse { + id: "tc2".into(), + result: Ok("file modified with 5 changes".into()), + }, + ts(1), + )); + stream.push(ConversationEvent::new( + ChatResponse::message("Added error handling."), + ts(1), + )); + + stream +} + +/// Collect only provider-visible events from the stream (what providers see). +fn visible_events(stream: &ConversationStream) -> Vec<&EventKind> { + stream + .iter() + .filter(|e| e.event.kind.is_provider_visible()) + .map(|e| &e.event.kind) + .collect() +} + +// --------------------------------------------------------------------------- +// No compaction → no-op +// --------------------------------------------------------------------------- + +#[test] +fn no_compaction_is_noop() { + let mut stream = two_turn_stream(); + let len_before = stream.len(); + + stream.apply_projection(); + + assert_eq!(stream.len(), len_before); +} + +// --------------------------------------------------------------------------- +// Reasoning strip +// --------------------------------------------------------------------------- + +#[test] +fn strip_reasoning_removes_reasoning_events() { + let mut stream = two_turn_stream(); + stream.add_compaction(Compaction { + timestamp: ts(2), + from_turn: 0, + to_turn: 1, + summary: None, + reasoning: Some(ReasoningPolicy::Strip), + tool_calls: None, + }); + + stream.apply_projection(); + + let events = visible_events(&stream); + assert!( + !events + .iter() + .any(|k| matches!(k, EventKind::ChatResponse(ChatResponse::Reasoning { .. }))), + "Reasoning events should be stripped" + ); + // Messages and tool calls remain. + assert!( + events + .iter() + .any(|k| matches!(k, EventKind::ChatResponse(ChatResponse::Message { .. }))) + ); + assert!( + events + .iter() + .any(|k| matches!(k, EventKind::ToolCallRequest(_))) + ); +} + +// --------------------------------------------------------------------------- +// Tool call strip +// --------------------------------------------------------------------------- + +#[test] +fn strip_tool_calls_replaces_content() { + let mut stream = two_turn_stream(); + stream.add_compaction(Compaction { + timestamp: ts(2), + from_turn: 0, + to_turn: 1, + summary: None, + reasoning: None, + tool_calls: Some(ToolCallPolicy::Strip { + request: true, + response: true, + }), + }); + + stream.apply_projection(); + + let events = visible_events(&stream); + + // Tool call requests should have compacted arguments. + for kind in &events { + if let EventKind::ToolCallRequest(req) = kind { + assert!( + req.arguments.contains_key("[compacted]"), + "Request arguments should be replaced: {:?}", + req.arguments + ); + assert!( + !req.arguments.contains_key("path"), + "Original arguments should be gone" + ); + } + } + + // Tool call responses should have compacted content. + for kind in &events { + if let EventKind::ToolCallResponse(resp) = kind { + assert!( + resp.content().starts_with("[compacted]"), + "Response should be compacted: {}", + resp.content() + ); + } + } +} + +#[test] +fn strip_tool_response_only() { + let mut stream = two_turn_stream(); + stream.add_compaction(Compaction { + timestamp: ts(2), + from_turn: 0, + to_turn: 1, + summary: None, + reasoning: None, + tool_calls: Some(ToolCallPolicy::Strip { + request: false, + response: true, + }), + }); + + stream.apply_projection(); + + // Requests keep original arguments. + let req = stream + .iter() + .find_map(|e| e.event.as_tool_call_request().cloned()) + .unwrap(); + assert!( + req.arguments.contains_key("path"), + "Request arguments should be preserved" + ); + + // Responses are compacted. + let resp = stream.find_tool_call_response("tc1").unwrap(); + assert!(resp.content().starts_with("[compacted]")); +} + +#[test] +fn strip_tool_response_preserves_error_status() { + let mut stream = ConversationStream::new_test(); + stream.push(ConversationEvent::new(TurnStart, ts(0))); + stream.push(ConversationEvent::new( + ChatRequest::from("do something"), + ts(0), + )); + stream.push(ConversationEvent::new( + ToolCallRequest { + id: "tc1".into(), + name: "cargo_test".into(), + arguments: Map::new(), + }, + ts(0), + )); + stream.push(ConversationEvent::new( + ToolCallResponse { + id: "tc1".into(), + result: Err("test failed: assertion error".into()), + }, + ts(0), + )); + + stream.add_compaction(Compaction { + timestamp: ts(1), + from_turn: 0, + to_turn: 0, + summary: None, + reasoning: None, + tool_calls: Some(ToolCallPolicy::Strip { + request: false, + response: true, + }), + }); + + stream.apply_projection(); + + let resp = stream.find_tool_call_response("tc1").unwrap(); + assert!(resp.result.is_err(), "Error status should be preserved"); + assert_eq!(resp.content(), "[compacted] cargo_test: error"); +} + +// --------------------------------------------------------------------------- +// Tool call omit +// --------------------------------------------------------------------------- + +#[test] +fn omit_tool_calls_removes_them() { + let mut stream = two_turn_stream(); + stream.add_compaction(Compaction { + timestamp: ts(2), + from_turn: 0, + to_turn: 1, + summary: None, + reasoning: None, + tool_calls: Some(ToolCallPolicy::Omit), + }); + + stream.apply_projection(); + + let events = visible_events(&stream); + assert!( + !events.iter().any(|k| matches!( + k, + EventKind::ToolCallRequest(_) | EventKind::ToolCallResponse(_) + )), + "All tool call events should be removed" + ); + // Messages and reasoning still present. + assert!( + events + .iter() + .any(|k| matches!(k, EventKind::ChatResponse(ChatResponse::Message { .. }))) + ); +} + +// --------------------------------------------------------------------------- +// Summary +// --------------------------------------------------------------------------- + +#[test] +fn summary_replaces_all_events_in_range() { + let mut stream = two_turn_stream(); + stream.add_compaction(Compaction { + timestamp: ts(2), + from_turn: 0, + to_turn: 1, + summary: Some(SummaryPolicy { + summary: "Set up a Rust project with error handling.".into(), + }), + reasoning: None, + tool_calls: None, + }); + + stream.apply_projection(); + + let events = visible_events(&stream); + + // Should be exactly: synthetic ChatRequest + synthetic ChatResponse. + assert_eq!(events.len(), 2, "Summary should produce exactly 2 events"); + + assert!( + matches!(events[0], EventKind::ChatRequest(r) if r.content.contains("Summary")), + "First event should be the synthetic request" + ); + assert!( + matches!(events[1], EventKind::ChatResponse(ChatResponse::Message { message }) if message.contains("error handling")), + "Second event should be the summary response" + ); +} + +#[test] +fn summary_ignores_per_type_policies() { + let mut stream = two_turn_stream(); + // Both summary and mechanical policies — summary should win. + stream.add_compaction(Compaction { + timestamp: ts(2), + from_turn: 0, + to_turn: 1, + summary: Some(SummaryPolicy { + summary: "Everything summarized.".into(), + }), + reasoning: Some(ReasoningPolicy::Strip), + tool_calls: Some(ToolCallPolicy::Strip { + request: true, + response: true, + }), + }); + + stream.apply_projection(); + + let events = visible_events(&stream); + assert_eq!( + events.len(), + 2, + "Summary should replace everything regardless of other policies" + ); +} + +#[test] +fn summary_partial_range() { + let mut stream = two_turn_stream(); + // Only compact turn 0, leave turn 1 intact. + stream.add_compaction(Compaction { + timestamp: ts(2), + from_turn: 0, + to_turn: 0, + summary: Some(SummaryPolicy { + summary: "Project was set up.".into(), + }), + reasoning: None, + tool_calls: None, + }); + + stream.apply_projection(); + + let events = visible_events(&stream); + + // Turn 0: synthetic request + response = 2 + // Turn 1: request + reasoning + tool_req + tool_resp + message = 5 + assert_eq!(events.len(), 7); + + assert!(matches!( + events[0], + EventKind::ChatRequest(r) if r.content.contains("Summary") + )); + assert!(matches!( + events[1], + EventKind::ChatResponse(ChatResponse::Message { message }) + if message.contains("set up") + )); + // Turn 1 starts at index 2 with the original ChatRequest. + assert!(matches!(events[2], EventKind::ChatRequest(r) if r.content == "add error handling")); +} + +// --------------------------------------------------------------------------- +// Stacking: latest timestamp wins +// --------------------------------------------------------------------------- + +#[test] +fn later_compaction_wins_for_same_turn() { + let mut stream = two_turn_stream(); + + // Earlier: strip reasoning only. + stream.add_compaction(Compaction { + timestamp: ts(2), + from_turn: 0, + to_turn: 1, + summary: None, + reasoning: Some(ReasoningPolicy::Strip), + tool_calls: None, + }); + + // Later: also strip tool calls. + stream.add_compaction(Compaction { + timestamp: ts(3), + from_turn: 0, + to_turn: 1, + summary: None, + reasoning: None, + tool_calls: Some(ToolCallPolicy::Strip { + request: true, + response: true, + }), + }); + + stream.apply_projection(); + + let events = visible_events(&stream); + + // Reasoning should be stripped (from earlier compaction — no later one overrides it). + assert!( + !events + .iter() + .any(|k| matches!(k, EventKind::ChatResponse(ChatResponse::Reasoning { .. }))), + ); + + // Tool calls should be compacted (from later compaction). + for kind in &events { + if let EventKind::ToolCallResponse(resp) = kind { + assert!(resp.content().starts_with("[compacted]")); + } + } +} + +#[test] +fn later_compaction_overrides_earlier_for_same_type() { + let mut stream = two_turn_stream(); + + // Earlier: omit tool calls. + stream.add_compaction(Compaction { + timestamp: ts(2), + from_turn: 0, + to_turn: 1, + summary: None, + reasoning: None, + tool_calls: Some(ToolCallPolicy::Omit), + }); + + // Later: strip tool calls instead (less aggressive). + stream.add_compaction(Compaction { + timestamp: ts(3), + from_turn: 0, + to_turn: 1, + summary: None, + reasoning: None, + tool_calls: Some(ToolCallPolicy::Strip { + request: true, + response: true, + }), + }); + + stream.apply_projection(); + + // Tool calls should be stripped (not omitted), because the later compaction wins. + let events = visible_events(&stream); + assert!( + events + .iter() + .any(|k| matches!(k, EventKind::ToolCallRequest(_))), + "Tool calls should be present (stripped, not omitted)" + ); +} + +#[test] +fn summary_wins_over_mechanical_for_same_turns() { + let mut stream = two_turn_stream(); + + // Earlier mechanical compaction. + stream.add_compaction(Compaction { + timestamp: ts(2), + from_turn: 0, + to_turn: 1, + summary: None, + reasoning: Some(ReasoningPolicy::Strip), + tool_calls: Some(ToolCallPolicy::Strip { + request: true, + response: true, + }), + }); + + // Later summary compaction for the same range. + stream.add_compaction(Compaction { + timestamp: ts(3), + from_turn: 0, + to_turn: 1, + summary: Some(SummaryPolicy { + summary: "All summarized.".into(), + }), + reasoning: None, + tool_calls: None, + }); + + stream.apply_projection(); + + let events = visible_events(&stream); + assert_eq!(events.len(), 2, "Summary should replace everything"); +} + +// --------------------------------------------------------------------------- +// Stacking: partial overlap +// --------------------------------------------------------------------------- + +#[test] +fn compaction_applies_only_to_covered_turns() { + let mut stream = two_turn_stream(); + + // Only compact turn 0. + stream.add_compaction(Compaction { + timestamp: ts(2), + from_turn: 0, + to_turn: 0, + summary: None, + reasoning: Some(ReasoningPolicy::Strip), + tool_calls: Some(ToolCallPolicy::Omit), + }); + + stream.apply_projection(); + + let events = visible_events(&stream); + + // Turn 0: request + message = 2 (reasoning stripped, tools omitted) + // Turn 1: request + reasoning + tool_req + tool_resp + message = 5 + assert_eq!(events.len(), 7); + + // Turn 1 reasoning should still be present. + assert!(events.iter().any(|k| matches!( + k, + EventKind::ChatResponse(ChatResponse::Reasoning { reasoning }) + if reasoning.contains("error types") + ))); +} + +// --------------------------------------------------------------------------- +// Compaction range exceeds actual turn count +// --------------------------------------------------------------------------- + +#[test] +fn compaction_beyond_max_turn_is_clamped() { + let mut stream = two_turn_stream(); + // Range extends beyond existing turns. + stream.add_compaction(Compaction { + timestamp: ts(2), + from_turn: 0, + to_turn: 99, + summary: None, + reasoning: Some(ReasoningPolicy::Strip), + tool_calls: None, + }); + + stream.apply_projection(); + + // Should still work — reasoning stripped from both turns. + let events = visible_events(&stream); + assert!( + !events + .iter() + .any(|k| matches!(k, EventKind::ChatResponse(ChatResponse::Reasoning { .. }))), + ); +} + +// --------------------------------------------------------------------------- +// Config deltas survive, compaction events consumed +// --------------------------------------------------------------------------- + +#[test] +fn config_deltas_preserved_through_projection() { + let mut stream = two_turn_stream(); + + let partial = jp_config::PartialAppConfig::empty(); + stream.add_config_delta(partial); + + let config_before = stream.config().unwrap().to_partial(); + + stream.add_compaction(Compaction { + timestamp: ts(2), + from_turn: 0, + to_turn: 1, + summary: Some(SummaryPolicy { + summary: "all gone".into(), + }), + reasoning: None, + tool_calls: None, + }); + + stream.apply_projection(); + + let config_after = stream.config().unwrap().to_partial(); + assert_eq!( + serde_json::to_value(&config_before).unwrap(), + serde_json::to_value(&config_after).unwrap(), + ); +} + +#[test] +fn compaction_events_consumed_by_projection() { + let mut stream = two_turn_stream(); + stream.add_compaction(Compaction { + timestamp: ts(2), + from_turn: 0, + to_turn: 0, + summary: None, + reasoning: Some(ReasoningPolicy::Strip), + tool_calls: None, + }); + assert_eq!(stream.compactions().count(), 1); + + stream.apply_projection(); + + assert_eq!( + stream.compactions().count(), + 0, + "Compaction events should be consumed by projection" + ); +} + +// --------------------------------------------------------------------------- +// Edge: empty stream +// --------------------------------------------------------------------------- + +#[test] +fn empty_stream_with_compaction() { + let mut stream = ConversationStream::new_test(); + stream.add_compaction(Compaction { + timestamp: ts(0), + from_turn: 0, + to_turn: 0, + summary: Some(SummaryPolicy { + summary: "nothing here".into(), + }), + reasoning: None, + tool_calls: None, + }); + + stream.apply_projection(); + + assert!(stream.is_empty()); +} + +// --------------------------------------------------------------------------- +// Re-compaction of projected streams +// --------------------------------------------------------------------------- + +/// After projection, old compaction events are gone. Adding a new compaction +/// to the already-projected stream and projecting again should only apply the +/// new compaction — the first projection's effects are baked into the events, +/// and the original compaction doesn't re-apply. +#[test] +fn recompact_projected_stream_with_new_compaction() { + let mut stream = two_turn_stream(); + + // First compaction: strip reasoning from both turns. + stream.add_compaction(Compaction { + timestamp: ts(2), + from_turn: 0, + to_turn: 1, + summary: None, + reasoning: Some(ReasoningPolicy::Strip), + tool_calls: None, + }); + + stream.apply_projection(); + + // Reasoning is gone, tool calls remain. + assert_eq!(stream.compactions().count(), 0); + let has_reasoning = stream.iter().any(|e| { + e.event + .as_chat_response() + .is_some_and(ChatResponse::is_reasoning) + }); + assert!( + !has_reasoning, + "Reasoning should be stripped after first projection" + ); + let tool_call_count = stream + .iter() + .filter(|e| e.event.is_tool_call_request()) + .count(); + assert_eq!( + tool_call_count, 2, + "Tool calls should survive first projection" + ); + + // Second compaction: now strip tool calls from turn 0 only. + stream.add_compaction(Compaction { + timestamp: ts(3), + from_turn: 0, + to_turn: 0, + summary: None, + reasoning: None, + tool_calls: Some(ToolCallPolicy::Omit), + }); + + stream.apply_projection(); + + // Turn 0 tool calls should now be gone. + // Turn 1 tool calls should remain (not covered by second compaction). + let remaining_tool_names: Vec<_> = stream + .iter() + .filter_map(|e| e.event.as_tool_call_request().map(|r| r.name.clone())) + .collect(); + assert_eq!( + remaining_tool_names, + vec!["fs_modify_file"], + "Only turn 1's tool call should remain" + ); + + // Reasoning should still be gone — the first projection already removed + // it, and no new reasoning-strip compaction was needed. + let has_reasoning = stream.iter().any(|e| { + e.event + .as_chat_response() + .is_some_and(ChatResponse::is_reasoning) + }); + assert!( + !has_reasoning, + "Reasoning should stay gone after re-compaction" + ); + + // No compaction events should remain. + assert_eq!(stream.compactions().count(), 0); +} + +// --------------------------------------------------------------------------- +// Turn index assignment +// --------------------------------------------------------------------------- + +#[test] +fn turn_indices_basic() { + use super::assign_turn_indices; + use crate::stream::InternalEvent; + + let events = vec![ + InternalEvent::Event(Box::new(ConversationEvent::new(TurnStart, ts(0)))), + InternalEvent::Event(Box::new(ConversationEvent::new( + ChatRequest::from("q1"), + ts(0), + ))), + InternalEvent::Event(Box::new(ConversationEvent::new(TurnStart, ts(1)))), + InternalEvent::Event(Box::new(ConversationEvent::new( + ChatRequest::from("q2"), + ts(1), + ))), + InternalEvent::Event(Box::new(ConversationEvent::new(TurnStart, ts(2)))), + InternalEvent::Event(Box::new(ConversationEvent::new( + ChatRequest::from("q3"), + ts(2), + ))), + ]; + + let indices = assign_turn_indices(&events); + assert_eq!(indices, vec![0, 0, 1, 1, 2, 2]); +} diff --git a/crates/jp_conversation/src/stream/turn_iter.rs b/crates/jp_conversation/src/stream/turn_iter.rs index dd07643a..316fa12e 100644 --- a/crates/jp_conversation/src/stream/turn_iter.rs +++ b/crates/jp_conversation/src/stream/turn_iter.rs @@ -17,11 +17,19 @@ use super::ConversationEventWithConfigRef; /// [`TurnStart`]: crate::event::TurnStart #[derive(Debug)] pub struct Turn<'a> { + /// The 0-based index of this turn in the conversation. + index: usize, /// The events in this turn, including the leading `TurnStart` (if present). events: Vec>, } impl<'a> Turn<'a> { + /// The 0-based index of this turn in the conversation. + #[must_use] + pub const fn index(&self) -> usize { + self.index + } + /// Iterate over the events in this turn. pub fn iter(&self) -> std::slice::Iter<'_, ConversationEventWithConfigRef<'a>> { self.events.iter() @@ -61,14 +69,22 @@ impl<'a> IterTurns<'a> { for event in events { if event.event.is_turn_start() && !current.is_empty() { - turns.push(Turn { events: current }); + let index = turns.len(); + turns.push(Turn { + index, + events: current, + }); current = Vec::new(); } current.push(event); } if !current.is_empty() { - turns.push(Turn { events: current }); + let index = turns.len(); + turns.push(Turn { + index, + events: current, + }); } Self(turns.into_iter()) diff --git a/crates/jp_conversation/src/stream/turn_iter_tests.rs b/crates/jp_conversation/src/stream/turn_iter_tests.rs index d043f4c6..67808392 100644 --- a/crates/jp_conversation/src/stream/turn_iter_tests.rs +++ b/crates/jp_conversation/src/stream/turn_iter_tests.rs @@ -29,6 +29,38 @@ fn single_turn() { assert_eq!(turns[0].iter().count(), 3); } +#[test] +fn turn_index() { + let mut stream = ConversationStream::new_test(); + stream.extend(vec![ + ConversationEvent::new(TurnStart, ts(0, 0, 0)), + ConversationEvent::new(ChatRequest::from("Q1"), ts(0, 0, 1)), + ConversationEvent::new(TurnStart, ts(0, 1, 0)), + ConversationEvent::new(ChatRequest::from("Q2"), ts(0, 1, 1)), + ConversationEvent::new(TurnStart, ts(0, 2, 0)), + ConversationEvent::new(ChatRequest::from("Q3"), ts(0, 2, 1)), + ]); + + let turns: Vec<_> = stream.iter_turns().collect(); + assert_eq!(turns[0].index(), 0); + assert_eq!(turns[1].index(), 1); + assert_eq!(turns[2].index(), 2); +} + +#[test] +fn turn_index_with_implicit_leading_turn() { + let mut stream = ConversationStream::new_test(); + stream.extend(vec![ + ConversationEvent::new(ChatRequest::from("orphan"), ts(0, 0, 0)), + ConversationEvent::new(TurnStart, ts(0, 1, 0)), + ConversationEvent::new(ChatRequest::from("Q1"), ts(0, 1, 1)), + ]); + + let turns: Vec<_> = stream.iter_turns().collect(); + assert_eq!(turns[0].index(), 0); // implicit turn + assert_eq!(turns[1].index(), 1); +} + #[test] fn multiple_turns() { let mut stream = ConversationStream::new_test(); diff --git a/crates/jp_conversation/src/stream_tests.rs b/crates/jp_conversation/src/stream_tests.rs index 20aacc26..40a55e72 100644 --- a/crates/jp_conversation/src/stream_tests.rs +++ b/crates/jp_conversation/src/stream_tests.rs @@ -6,8 +6,13 @@ use jp_config::{ use serde_json::{Map, Value}; use super::*; -use crate::event::{ - ChatResponse, InquiryQuestion, InquiryRequest, InquiryResponse, InquirySource, ToolCallRequest, +use crate::{ + Compaction, CompactionRange, RangeBound, ReasoningPolicy, ToolCallPolicy, + event::{ + ChatResponse, InquiryQuestion, InquiryRequest, InquiryResponse, InquirySource, + ToolCallRequest, + }, + resolve_range, }; #[test] @@ -726,7 +731,7 @@ fn roundtrip_delta(delta: ConfigDelta) -> ConfigDelta { let deserialized: InternalEvent = serde_json::from_value(json).unwrap(); match deserialized { InternalEvent::ConfigDelta(d) => d, - InternalEvent::Event(_) => panic!("expected ConfigDelta"), + _ => panic!("expected ConfigDelta"), } } @@ -889,3 +894,368 @@ fn test_from_parts_tolerates_config_deltas_with_only_unknown_fields() { let result = ConversationStream::from_parts(base_config, events).unwrap(); assert_eq!(result.len(), 2); // TurnStart + ChatRequest } + +// --- Compaction event invariant tests --- + +fn make_compaction(from: usize, to: usize) -> Compaction { + Compaction { + timestamp: Utc.with_ymd_and_hms(2025, 7, 1, 12, 0, 0).unwrap(), + from_turn: from, + to_turn: to, + summary: None, + reasoning: Some(ReasoningPolicy::Strip), + tool_calls: Some(ToolCallPolicy::Strip { + request: true, + response: true, + }), + } +} + +#[test] +fn test_compaction_not_counted_by_len() { + let mut stream = ConversationStream::new_test(); + stream.start_turn(ChatRequest::from("hello")); + let len_before = stream.len(); + + stream.add_compaction(make_compaction(0, 0)); + + assert_eq!(stream.len(), len_before); +} + +#[test] +fn test_compaction_not_counted_by_is_empty() { + let mut stream = ConversationStream::new_test(); + assert!(stream.is_empty()); + + stream.add_compaction(make_compaction(0, 0)); + + assert!( + stream.is_empty(), + "Compaction alone should not make stream non-empty" + ); +} + +#[test] +fn test_compaction_preserved_by_retain() { + let mut stream = ConversationStream::new_test(); + stream.start_turn(ChatRequest::from("hello")); + stream.add_compaction(make_compaction(0, 0)); + + // Retain nothing — all conversation events removed. + stream.retain(|_| false); + + assert_eq!(stream.len(), 0); + assert_eq!( + stream.compactions().count(), + 1, + "Compaction should survive retain" + ); +} + +#[test] +fn test_compaction_skipped_by_iter() { + let mut stream = ConversationStream::new_test(); + stream.start_turn(ChatRequest::from("hello")); + stream.add_compaction(make_compaction(0, 0)); + stream.push(ConversationEvent::new( + ChatResponse::message("world"), + Utc.with_ymd_and_hms(2025, 7, 1, 12, 0, 1).unwrap(), + )); + + let events: Vec<_> = stream.iter().collect(); + // TurnStart + ChatRequest + ChatResponse = 3 events, no compaction. + assert_eq!(events.len(), 3); + assert!( + events + .iter() + .all(|e| !matches!(&e.event.kind, EventKind::TurnStart(_)) || e.event.is_turn_start()), + "Iterator should only yield ConversationEvents" + ); +} + +#[test] +fn test_compaction_skipped_by_into_iter() { + let mut stream = ConversationStream::new_test(); + stream.start_turn(ChatRequest::from("hello")); + stream.add_compaction(make_compaction(0, 0)); + stream.push(ConversationEvent::new( + ChatResponse::message("world"), + Utc.with_ymd_and_hms(2025, 7, 1, 12, 0, 1).unwrap(), + )); + + assert_eq!(stream.into_iter().count(), 3); +} + +#[test] +fn test_compaction_preserved_by_sanitize() { + let mut stream = ConversationStream::new_test(); + stream.push(TurnStart); + stream.push(ConversationEvent::new( + ChatRequest::from("hello"), + Utc.with_ymd_and_hms(2025, 7, 1, 12, 0, 0).unwrap(), + )); + stream.add_compaction(make_compaction(0, 0)); + stream.push(ConversationEvent::new( + ChatResponse::message("hi"), + Utc.with_ymd_and_hms(2025, 7, 1, 12, 0, 1).unwrap(), + )); + + stream.sanitize(); + + assert_eq!( + stream.compactions().count(), + 1, + "Compaction should survive sanitize" + ); + assert_eq!(stream.len(), 3); // TurnStart + ChatRequest + ChatResponse +} + +#[test] +fn test_compaction_roundtrip_via_to_parts_from_parts() { + let mut stream = ConversationStream::new_test(); + stream.start_turn(ChatRequest::from("hello")); + stream.add_compaction(make_compaction(0, 0)); + + let (base_config, events) = stream.to_parts().unwrap(); + + // Verify the compaction event is present in serialized form. + let compaction_count = events + .iter() + .filter(|v| v.get("type").and_then(|t| t.as_str()) == Some("compaction")) + .count(); + assert_eq!(compaction_count, 1); + + // Roundtrip. + let restored = ConversationStream::from_parts(base_config, events) + .unwrap() + .with_created_at(stream.created_at); + + assert_eq!(restored.len(), stream.len()); + assert_eq!(restored.compactions().count(), 1); + + let c = restored.compactions().next().unwrap(); + assert_eq!(c.from_turn, 0); + assert_eq!(c.to_turn, 0); + assert_eq!(c.reasoning, Some(ReasoningPolicy::Strip)); +} + +#[test] +fn test_compactions_accessor() { + let mut stream = ConversationStream::new_test(); + assert_eq!(stream.compactions().count(), 0); + + stream.add_compaction(make_compaction(0, 2)); + stream.add_compaction(make_compaction(3, 5)); + + let compactions: Vec<_> = stream.compactions().collect(); + assert_eq!(compactions.len(), 2); + assert_eq!(compactions[0].from_turn, 0); + assert_eq!(compactions[0].to_turn, 2); + assert_eq!(compactions[1].from_turn, 3); + assert_eq!(compactions[1].to_turn, 5); +} + +#[test] +fn test_compaction_does_not_affect_config() { + let mut stream = ConversationStream::new_test(); + stream.start_turn(ChatRequest::from("hello")); + + let config_before = stream.config().unwrap().to_partial(); + stream.add_compaction(make_compaction(0, 0)); + let config_after = stream.config().unwrap().to_partial(); + + assert_eq!( + serde_json::to_value(&config_before).unwrap(), + serde_json::to_value(&config_after).unwrap(), + ); +} + +// --- turn_count, turn_at_time, resolve_compaction_range --- + +#[test] +fn test_turn_count_empty() { + let stream = ConversationStream::new_test(); + assert_eq!(stream.turn_count(), 0); +} + +#[test] +fn test_turn_count_two_turns() { + let mut stream = ConversationStream::new_test(); + stream.start_turn("hello"); + stream.push(ConversationEvent::new( + ChatResponse::message("hi"), + Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 1).unwrap(), + )); + stream.start_turn("bye"); + assert_eq!(stream.turn_count(), 2); +} + +#[test] +fn test_turn_at_time() { + let mut stream = ConversationStream::new_test(); + stream.push(ConversationEvent::new( + TurnStart, + Utc.with_ymd_and_hms(2025, 1, 1, 10, 0, 0).unwrap(), + )); + stream.push(ConversationEvent::new( + ChatRequest::from("q1"), + Utc.with_ymd_and_hms(2025, 1, 1, 10, 0, 0).unwrap(), + )); + stream.push(ConversationEvent::new( + TurnStart, + Utc.with_ymd_and_hms(2025, 1, 1, 12, 0, 0).unwrap(), + )); + stream.push(ConversationEvent::new( + ChatRequest::from("q2"), + Utc.with_ymd_and_hms(2025, 1, 1, 12, 0, 0).unwrap(), + )); + + let idx = |dt| stream.turn_at_time(dt).map(|t| t.index()); + + // Before first turn. + assert_eq!( + idx(Utc.with_ymd_and_hms(2025, 1, 1, 9, 0, 0).unwrap()), + None + ); + // During first turn. + assert_eq!( + idx(Utc.with_ymd_and_hms(2025, 1, 1, 11, 0, 0).unwrap()), + Some(0) + ); + // Exactly at second turn start. + assert_eq!( + idx(Utc.with_ymd_and_hms(2025, 1, 1, 12, 0, 0).unwrap()), + Some(1) + ); + // After second turn. + assert_eq!( + idx(Utc.with_ymd_and_hms(2025, 1, 1, 15, 0, 0).unwrap()), + Some(1) + ); +} + +#[test] +fn test_resolve_range_defaults() { + let mut stream = ConversationStream::new_test(); + stream.start_turn("a"); + stream.start_turn("b"); + stream.start_turn("c"); + + let range = resolve_range(&stream, None, None).unwrap(); + assert_eq!(range, CompactionRange { + from_turn: 0, + to_turn: 2 + }); +} + +#[test] +fn test_resolve_range_absolute() { + let mut stream = ConversationStream::new_test(); + stream.start_turn("a"); + stream.start_turn("b"); + stream.start_turn("c"); + stream.start_turn("d"); + + let range = resolve_range( + &stream, + Some(RangeBound::Absolute(1)), + Some(RangeBound::Absolute(2)), + ) + .unwrap(); + assert_eq!(range, CompactionRange { + from_turn: 1, + to_turn: 2 + }); +} + +#[test] +fn test_resolve_range_from_end() { + let mut stream = ConversationStream::new_test(); + stream.start_turn("a"); + stream.start_turn("b"); + stream.start_turn("c"); + stream.start_turn("d"); // turns 0..3 + + // FromEnd(1) on `to` means "1 before last" = turn 2. + let range = resolve_range(&stream, None, Some(RangeBound::FromEnd(1))).unwrap(); + assert_eq!(range, CompactionRange { + from_turn: 0, + to_turn: 2 + }); +} + +#[test] +fn test_resolve_range_after_last_compaction() { + let mut stream = ConversationStream::new_test(); + stream.start_turn("a"); + stream.start_turn("b"); + stream.start_turn("c"); + stream.start_turn("d"); + + // No compactions yet → AfterLastCompaction resolves to 0. + let range = resolve_range(&stream, Some(RangeBound::AfterLastCompaction), None).unwrap(); + assert_eq!(range.from_turn, 0); + + // Add a compaction covering turns 0..1. + stream.add_compaction(make_compaction(0, 1)); + + // AfterLastCompaction → to_turn + 1 = 2. + let range = resolve_range(&stream, Some(RangeBound::AfterLastCompaction), None).unwrap(); + assert_eq!(range.from_turn, 2); + assert_eq!(range.to_turn, 3); +} + +#[test] +fn test_resolve_range_empty_stream() { + let stream = ConversationStream::new_test(); + assert!(resolve_range(&stream, None, None).is_none()); +} + +#[test] +fn test_resolve_range_inverted_returns_none() { + let mut stream = ConversationStream::new_test(); + stream.start_turn("a"); + stream.start_turn("b"); + + // from=1, to=0 → empty range. + let range = resolve_range( + &stream, + Some(RangeBound::Absolute(1)), + Some(RangeBound::Absolute(0)), + ); + assert!(range.is_none()); +} + +#[test] +fn test_resolve_range_clamps_beyond_max() { + let mut stream = ConversationStream::new_test(); + stream.start_turn("a"); + stream.start_turn("b"); // turns 0..1 + + let range = resolve_range( + &stream, + Some(RangeBound::Absolute(0)), + Some(RangeBound::Absolute(99)), + ) + .unwrap(); + assert_eq!(range.to_turn, 1); +} + +/// Roundtrip a [`Compaction`] through [`InternalEvent`] serialization. +#[test] +fn test_internal_event_compaction_roundtrip() { + let compaction = make_compaction(0, 5); + let event = InternalEvent::Compaction(compaction.clone()); + let json = serde_json::to_value(&event).unwrap(); + + assert_eq!(json["type"], "compaction"); + assert_eq!(json["from_turn"], 0); + assert_eq!(json["to_turn"], 5); + assert_eq!(json["reasoning"], "strip"); + + let deserialized: InternalEvent = serde_json::from_value(json).unwrap(); + let InternalEvent::Compaction(result) = deserialized else { + panic!("expected Compaction"); + }; + assert_eq!(result, compaction); +} diff --git a/crates/jp_conversation/src/thread.rs b/crates/jp_conversation/src/thread.rs index c011335c..a38360a8 100644 --- a/crates/jp_conversation/src/thread.rs +++ b/crates/jp_conversation/src/thread.rs @@ -168,6 +168,7 @@ impl Thread { system_parts.push(section.render()); } + events.apply_projection(); events.retain(|e| e.kind.is_provider_visible()); ThreadParts { diff --git a/crates/jp_llm/tests/fixtures/anthropic/test_chat_completion_stream__conversation_stream.snap b/crates/jp_llm/tests/fixtures/anthropic/test_chat_completion_stream__conversation_stream.snap index ad9231fa..bf5e58d1 100644 --- a/crates/jp_llm/tests/fixtures/anthropic/test_chat_completion_stream__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/anthropic/test_chat_completion_stream__conversation_stream.snap @@ -65,6 +65,13 @@ expression: v } } }, + "compaction": { + "rules": { + "value": [], + "strategy": "replace", + "discard_when_merged": false + } + }, "attachments": { "value": [], "strategy": "replace", diff --git a/crates/jp_llm/tests/fixtures/anthropic/test_image_attachment__conversation_stream.snap b/crates/jp_llm/tests/fixtures/anthropic/test_image_attachment__conversation_stream.snap index 8ac60625..7c80315a 100644 --- a/crates/jp_llm/tests/fixtures/anthropic/test_image_attachment__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/anthropic/test_image_attachment__conversation_stream.snap @@ -62,6 +62,13 @@ expression: v } } }, + "compaction": { + "rules": { + "value": [], + "strategy": "replace", + "discard_when_merged": false + } + }, "attachments": { "value": [], "strategy": "replace", diff --git a/crates/jp_llm/tests/fixtures/anthropic/test_multi_turn_conversation__conversation_stream.snap b/crates/jp_llm/tests/fixtures/anthropic/test_multi_turn_conversation__conversation_stream.snap index 6d8e4124..3910eb43 100644 --- a/crates/jp_llm/tests/fixtures/anthropic/test_multi_turn_conversation__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/anthropic/test_multi_turn_conversation__conversation_stream.snap @@ -65,6 +65,13 @@ expression: v } } }, + "compaction": { + "rules": { + "value": [], + "strategy": "replace", + "discard_when_merged": false + } + }, "attachments": { "value": [], "strategy": "replace", diff --git a/crates/jp_llm/tests/fixtures/anthropic/test_opus_4_6_adaptive_thinking__conversation_stream.snap b/crates/jp_llm/tests/fixtures/anthropic/test_opus_4_6_adaptive_thinking__conversation_stream.snap index ca6a2d2e..9f9e8715 100644 --- a/crates/jp_llm/tests/fixtures/anthropic/test_opus_4_6_adaptive_thinking__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/anthropic/test_opus_4_6_adaptive_thinking__conversation_stream.snap @@ -65,6 +65,13 @@ expression: v } } }, + "compaction": { + "rules": { + "value": [], + "strategy": "replace", + "discard_when_merged": false + } + }, "attachments": { "value": [], "strategy": "replace", diff --git a/crates/jp_llm/tests/fixtures/anthropic/test_opus_4_6_max_effort__conversation_stream.snap b/crates/jp_llm/tests/fixtures/anthropic/test_opus_4_6_max_effort__conversation_stream.snap index 971c4f72..f628e7ea 100644 --- a/crates/jp_llm/tests/fixtures/anthropic/test_opus_4_6_max_effort__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/anthropic/test_opus_4_6_max_effort__conversation_stream.snap @@ -65,6 +65,13 @@ expression: v } } }, + "compaction": { + "rules": { + "value": [], + "strategy": "replace", + "discard_when_merged": false + } + }, "attachments": { "value": [], "strategy": "replace", diff --git a/crates/jp_llm/tests/fixtures/anthropic/test_redacted_thinking__conversation_stream.snap b/crates/jp_llm/tests/fixtures/anthropic/test_redacted_thinking__conversation_stream.snap index 1d319432..1ee38eb6 100644 --- a/crates/jp_llm/tests/fixtures/anthropic/test_redacted_thinking__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/anthropic/test_redacted_thinking__conversation_stream.snap @@ -62,6 +62,13 @@ expression: v } } }, + "compaction": { + "rules": { + "value": [], + "strategy": "replace", + "discard_when_merged": false + } + }, "attachments": { "value": [], "strategy": "replace", diff --git a/crates/jp_llm/tests/fixtures/anthropic/test_request_chaining__conversation_stream.snap b/crates/jp_llm/tests/fixtures/anthropic/test_request_chaining__conversation_stream.snap index 53902c0e..2e295f82 100644 --- a/crates/jp_llm/tests/fixtures/anthropic/test_request_chaining__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/anthropic/test_request_chaining__conversation_stream.snap @@ -67,6 +67,13 @@ expression: v } } }, + "compaction": { + "rules": { + "value": [], + "strategy": "replace", + "discard_when_merged": false + } + }, "attachments": { "value": [], "strategy": "replace", diff --git a/crates/jp_llm/tests/fixtures/anthropic/test_structured_output__conversation_stream.snap b/crates/jp_llm/tests/fixtures/anthropic/test_structured_output__conversation_stream.snap index 4a5142b6..67752607 100644 --- a/crates/jp_llm/tests/fixtures/anthropic/test_structured_output__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/anthropic/test_structured_output__conversation_stream.snap @@ -62,6 +62,13 @@ expression: v } } }, + "compaction": { + "rules": { + "value": [], + "strategy": "replace", + "discard_when_merged": false + } + }, "attachments": { "value": [], "strategy": "replace", diff --git a/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_auto__conversation_stream.snap b/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_auto__conversation_stream.snap index ade77446..2aee1389 100644 --- a/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_auto__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_auto__conversation_stream.snap @@ -62,6 +62,13 @@ expression: v } } }, + "compaction": { + "rules": { + "value": [], + "strategy": "replace", + "discard_when_merged": false + } + }, "attachments": { "value": [], "strategy": "replace", diff --git a/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_function__conversation_stream.snap b/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_function__conversation_stream.snap index 7a07304a..8c475ea4 100644 --- a/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_function__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_function__conversation_stream.snap @@ -62,6 +62,13 @@ expression: v } } }, + "compaction": { + "rules": { + "value": [], + "strategy": "replace", + "discard_when_merged": false + } + }, "attachments": { "value": [], "strategy": "replace", diff --git a/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_reasoning__conversation_stream.snap index b0cf085a..84691c1d 100644 --- a/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_reasoning__conversation_stream.snap @@ -62,6 +62,13 @@ expression: v } } }, + "compaction": { + "rules": { + "value": [], + "strategy": "replace", + "discard_when_merged": false + } + }, "attachments": { "value": [], "strategy": "replace", diff --git a/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_required_no_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_required_no_reasoning__conversation_stream.snap index dcfbd58c..71d08489 100644 --- a/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_required_no_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_required_no_reasoning__conversation_stream.snap @@ -62,6 +62,13 @@ expression: v } } }, + "compaction": { + "rules": { + "value": [], + "strategy": "replace", + "discard_when_merged": false + } + }, "attachments": { "value": [], "strategy": "replace", diff --git a/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_required_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_required_reasoning__conversation_stream.snap index 48a896e0..c63e0796 100644 --- a/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_required_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_required_reasoning__conversation_stream.snap @@ -62,6 +62,13 @@ expression: v } } }, + "compaction": { + "rules": { + "value": [], + "strategy": "replace", + "discard_when_merged": false + } + }, "attachments": { "value": [], "strategy": "replace", diff --git a/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_stream__conversation_stream.snap b/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_stream__conversation_stream.snap index d46d7a24..adc7803b 100644 --- a/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_stream__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_stream__conversation_stream.snap @@ -62,6 +62,13 @@ expression: v } } }, + "compaction": { + "rules": { + "value": [], + "strategy": "replace", + "discard_when_merged": false + } + }, "attachments": { "value": [], "strategy": "replace", diff --git a/crates/jp_llm/tests/fixtures/cerebras/test_chat_completion_stream__conversation_stream.snap b/crates/jp_llm/tests/fixtures/cerebras/test_chat_completion_stream__conversation_stream.snap index 518ef6a0..09ac360b 100644 --- a/crates/jp_llm/tests/fixtures/cerebras/test_chat_completion_stream__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/cerebras/test_chat_completion_stream__conversation_stream.snap @@ -65,6 +65,13 @@ expression: v } } }, + "compaction": { + "rules": { + "value": [], + "strategy": "replace", + "discard_when_merged": false + } + }, "attachments": { "value": [], "strategy": "replace", diff --git a/crates/jp_llm/tests/fixtures/cerebras/test_multi_turn_conversation__conversation_stream.snap b/crates/jp_llm/tests/fixtures/cerebras/test_multi_turn_conversation__conversation_stream.snap index f65b753d..57040324 100644 --- a/crates/jp_llm/tests/fixtures/cerebras/test_multi_turn_conversation__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/cerebras/test_multi_turn_conversation__conversation_stream.snap @@ -65,6 +65,13 @@ expression: v } } }, + "compaction": { + "rules": { + "value": [], + "strategy": "replace", + "discard_when_merged": false + } + }, "attachments": { "value": [], "strategy": "replace", diff --git a/crates/jp_llm/tests/fixtures/cerebras/test_structured_output__conversation_stream.snap b/crates/jp_llm/tests/fixtures/cerebras/test_structured_output__conversation_stream.snap index 74fdb753..83d970d7 100644 --- a/crates/jp_llm/tests/fixtures/cerebras/test_structured_output__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/cerebras/test_structured_output__conversation_stream.snap @@ -62,6 +62,13 @@ expression: v } } }, + "compaction": { + "rules": { + "value": [], + "strategy": "replace", + "discard_when_merged": false + } + }, "attachments": { "value": [], "strategy": "replace", diff --git a/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_auto__conversation_stream.snap b/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_auto__conversation_stream.snap index 3e7f8f1d..461f299f 100644 --- a/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_auto__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_auto__conversation_stream.snap @@ -62,6 +62,13 @@ expression: v } } }, + "compaction": { + "rules": { + "value": [], + "strategy": "replace", + "discard_when_merged": false + } + }, "attachments": { "value": [], "strategy": "replace", diff --git a/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_function__conversation_stream.snap b/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_function__conversation_stream.snap index 9bffd2eb..8d7fa4da 100644 --- a/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_function__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_function__conversation_stream.snap @@ -62,6 +62,13 @@ expression: v } } }, + "compaction": { + "rules": { + "value": [], + "strategy": "replace", + "discard_when_merged": false + } + }, "attachments": { "value": [], "strategy": "replace", diff --git a/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_reasoning__conversation_stream.snap index 7540b4ec..f1e1af8a 100644 --- a/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_reasoning__conversation_stream.snap @@ -62,6 +62,13 @@ expression: v } } }, + "compaction": { + "rules": { + "value": [], + "strategy": "replace", + "discard_when_merged": false + } + }, "attachments": { "value": [], "strategy": "replace", diff --git a/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_required_no_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_required_no_reasoning__conversation_stream.snap index 30622bf2..becd775f 100644 --- a/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_required_no_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_required_no_reasoning__conversation_stream.snap @@ -62,6 +62,13 @@ expression: v } } }, + "compaction": { + "rules": { + "value": [], + "strategy": "replace", + "discard_when_merged": false + } + }, "attachments": { "value": [], "strategy": "replace", diff --git a/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_required_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_required_reasoning__conversation_stream.snap index abdcdad9..b85278e4 100644 --- a/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_required_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_required_reasoning__conversation_stream.snap @@ -62,6 +62,13 @@ expression: v } } }, + "compaction": { + "rules": { + "value": [], + "strategy": "replace", + "discard_when_merged": false + } + }, "attachments": { "value": [], "strategy": "replace", diff --git a/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_stream__conversation_stream.snap b/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_stream__conversation_stream.snap index 60a70762..8e3271e8 100644 --- a/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_stream__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_stream__conversation_stream.snap @@ -62,6 +62,13 @@ expression: v } } }, + "compaction": { + "rules": { + "value": [], + "strategy": "replace", + "discard_when_merged": false + } + }, "attachments": { "value": [], "strategy": "replace", diff --git a/crates/jp_llm/tests/fixtures/google/test_chat_completion_stream__conversation_stream.snap b/crates/jp_llm/tests/fixtures/google/test_chat_completion_stream__conversation_stream.snap index acee47c0..201b9a71 100644 --- a/crates/jp_llm/tests/fixtures/google/test_chat_completion_stream__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/google/test_chat_completion_stream__conversation_stream.snap @@ -65,6 +65,13 @@ expression: v } } }, + "compaction": { + "rules": { + "value": [], + "strategy": "replace", + "discard_when_merged": false + } + }, "attachments": { "value": [], "strategy": "replace", diff --git a/crates/jp_llm/tests/fixtures/google/test_gemini_3_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/google/test_gemini_3_reasoning__conversation_stream.snap index a104d578..53abcf20 100644 --- a/crates/jp_llm/tests/fixtures/google/test_gemini_3_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/google/test_gemini_3_reasoning__conversation_stream.snap @@ -65,6 +65,13 @@ expression: v } } }, + "compaction": { + "rules": { + "value": [], + "strategy": "replace", + "discard_when_merged": false + } + }, "attachments": { "value": [], "strategy": "replace", diff --git a/crates/jp_llm/tests/fixtures/google/test_image_attachment__conversation_stream.snap b/crates/jp_llm/tests/fixtures/google/test_image_attachment__conversation_stream.snap index 0c65195c..2b8b9cce 100644 --- a/crates/jp_llm/tests/fixtures/google/test_image_attachment__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/google/test_image_attachment__conversation_stream.snap @@ -62,6 +62,13 @@ expression: v } } }, + "compaction": { + "rules": { + "value": [], + "strategy": "replace", + "discard_when_merged": false + } + }, "attachments": { "value": [], "strategy": "replace", diff --git a/crates/jp_llm/tests/fixtures/google/test_multi_turn_conversation__conversation_stream.snap b/crates/jp_llm/tests/fixtures/google/test_multi_turn_conversation__conversation_stream.snap index f4d0c108..0c40c169 100644 --- a/crates/jp_llm/tests/fixtures/google/test_multi_turn_conversation__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/google/test_multi_turn_conversation__conversation_stream.snap @@ -65,6 +65,13 @@ expression: v } } }, + "compaction": { + "rules": { + "value": [], + "strategy": "replace", + "discard_when_merged": false + } + }, "attachments": { "value": [], "strategy": "replace", diff --git a/crates/jp_llm/tests/fixtures/google/test_structured_output__conversation_stream.snap b/crates/jp_llm/tests/fixtures/google/test_structured_output__conversation_stream.snap index beb2361b..ad2f85f1 100644 --- a/crates/jp_llm/tests/fixtures/google/test_structured_output__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/google/test_structured_output__conversation_stream.snap @@ -62,6 +62,13 @@ expression: v } } }, + "compaction": { + "rules": { + "value": [], + "strategy": "replace", + "discard_when_merged": false + } + }, "attachments": { "value": [], "strategy": "replace", diff --git a/crates/jp_llm/tests/fixtures/google/test_tool_call_auto__conversation_stream.snap b/crates/jp_llm/tests/fixtures/google/test_tool_call_auto__conversation_stream.snap index 5aa668a6..681f681f 100644 --- a/crates/jp_llm/tests/fixtures/google/test_tool_call_auto__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/google/test_tool_call_auto__conversation_stream.snap @@ -62,6 +62,13 @@ expression: v } } }, + "compaction": { + "rules": { + "value": [], + "strategy": "replace", + "discard_when_merged": false + } + }, "attachments": { "value": [], "strategy": "replace", diff --git a/crates/jp_llm/tests/fixtures/google/test_tool_call_function__conversation_stream.snap b/crates/jp_llm/tests/fixtures/google/test_tool_call_function__conversation_stream.snap index 678e27d8..7d9b6d6d 100644 --- a/crates/jp_llm/tests/fixtures/google/test_tool_call_function__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/google/test_tool_call_function__conversation_stream.snap @@ -62,6 +62,13 @@ expression: v } } }, + "compaction": { + "rules": { + "value": [], + "strategy": "replace", + "discard_when_merged": false + } + }, "attachments": { "value": [], "strategy": "replace", diff --git a/crates/jp_llm/tests/fixtures/google/test_tool_call_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/google/test_tool_call_reasoning__conversation_stream.snap index fae21562..b169de2d 100644 --- a/crates/jp_llm/tests/fixtures/google/test_tool_call_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/google/test_tool_call_reasoning__conversation_stream.snap @@ -62,6 +62,13 @@ expression: v } } }, + "compaction": { + "rules": { + "value": [], + "strategy": "replace", + "discard_when_merged": false + } + }, "attachments": { "value": [], "strategy": "replace", diff --git a/crates/jp_llm/tests/fixtures/google/test_tool_call_required_no_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/google/test_tool_call_required_no_reasoning__conversation_stream.snap index 24a2dddf..ee930ca0 100644 --- a/crates/jp_llm/tests/fixtures/google/test_tool_call_required_no_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/google/test_tool_call_required_no_reasoning__conversation_stream.snap @@ -62,6 +62,13 @@ expression: v } } }, + "compaction": { + "rules": { + "value": [], + "strategy": "replace", + "discard_when_merged": false + } + }, "attachments": { "value": [], "strategy": "replace", diff --git a/crates/jp_llm/tests/fixtures/google/test_tool_call_required_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/google/test_tool_call_required_reasoning__conversation_stream.snap index 3c6cd039..b6b90935 100644 --- a/crates/jp_llm/tests/fixtures/google/test_tool_call_required_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/google/test_tool_call_required_reasoning__conversation_stream.snap @@ -62,6 +62,13 @@ expression: v } } }, + "compaction": { + "rules": { + "value": [], + "strategy": "replace", + "discard_when_merged": false + } + }, "attachments": { "value": [], "strategy": "replace", diff --git a/crates/jp_llm/tests/fixtures/google/test_tool_call_stream__conversation_stream.snap b/crates/jp_llm/tests/fixtures/google/test_tool_call_stream__conversation_stream.snap index 5f9af157..617ee1f3 100644 --- a/crates/jp_llm/tests/fixtures/google/test_tool_call_stream__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/google/test_tool_call_stream__conversation_stream.snap @@ -62,6 +62,13 @@ expression: v } } }, + "compaction": { + "rules": { + "value": [], + "strategy": "replace", + "discard_when_merged": false + } + }, "attachments": { "value": [], "strategy": "replace", diff --git a/crates/jp_llm/tests/fixtures/llamacpp/test_chat_completion_stream__conversation_stream.snap b/crates/jp_llm/tests/fixtures/llamacpp/test_chat_completion_stream__conversation_stream.snap index 540e48cd..ccc330f9 100644 --- a/crates/jp_llm/tests/fixtures/llamacpp/test_chat_completion_stream__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/llamacpp/test_chat_completion_stream__conversation_stream.snap @@ -65,6 +65,13 @@ expression: v } } }, + "compaction": { + "rules": { + "value": [], + "strategy": "replace", + "discard_when_merged": false + } + }, "attachments": { "value": [], "strategy": "replace", diff --git a/crates/jp_llm/tests/fixtures/llamacpp/test_image_attachment__conversation_stream.snap b/crates/jp_llm/tests/fixtures/llamacpp/test_image_attachment__conversation_stream.snap index 49cf5a15..1b41ebe8 100644 --- a/crates/jp_llm/tests/fixtures/llamacpp/test_image_attachment__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/llamacpp/test_image_attachment__conversation_stream.snap @@ -62,6 +62,13 @@ expression: v } } }, + "compaction": { + "rules": { + "value": [], + "strategy": "replace", + "discard_when_merged": false + } + }, "attachments": { "value": [], "strategy": "replace", diff --git a/crates/jp_llm/tests/fixtures/llamacpp/test_multi_turn_conversation__conversation_stream.snap b/crates/jp_llm/tests/fixtures/llamacpp/test_multi_turn_conversation__conversation_stream.snap index 9d6702a4..c369f50e 100644 --- a/crates/jp_llm/tests/fixtures/llamacpp/test_multi_turn_conversation__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/llamacpp/test_multi_turn_conversation__conversation_stream.snap @@ -65,6 +65,13 @@ expression: v } } }, + "compaction": { + "rules": { + "value": [], + "strategy": "replace", + "discard_when_merged": false + } + }, "attachments": { "value": [], "strategy": "replace", diff --git a/crates/jp_llm/tests/fixtures/llamacpp/test_structured_output__conversation_stream.snap b/crates/jp_llm/tests/fixtures/llamacpp/test_structured_output__conversation_stream.snap index 0963ba44..ab1743ca 100644 --- a/crates/jp_llm/tests/fixtures/llamacpp/test_structured_output__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/llamacpp/test_structured_output__conversation_stream.snap @@ -62,6 +62,13 @@ expression: v } } }, + "compaction": { + "rules": { + "value": [], + "strategy": "replace", + "discard_when_merged": false + } + }, "attachments": { "value": [], "strategy": "replace", diff --git a/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_auto__conversation_stream.snap b/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_auto__conversation_stream.snap index 3b64f2fe..eaed6d63 100644 --- a/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_auto__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_auto__conversation_stream.snap @@ -62,6 +62,13 @@ expression: v } } }, + "compaction": { + "rules": { + "value": [], + "strategy": "replace", + "discard_when_merged": false + } + }, "attachments": { "value": [], "strategy": "replace", diff --git a/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_function__conversation_stream.snap b/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_function__conversation_stream.snap index eb501a4b..f9f82b98 100644 --- a/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_function__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_function__conversation_stream.snap @@ -62,6 +62,13 @@ expression: v } } }, + "compaction": { + "rules": { + "value": [], + "strategy": "replace", + "discard_when_merged": false + } + }, "attachments": { "value": [], "strategy": "replace", diff --git a/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_reasoning__conversation_stream.snap index 6852dc97..e3a5b881 100644 --- a/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_reasoning__conversation_stream.snap @@ -65,6 +65,13 @@ expression: v } } }, + "compaction": { + "rules": { + "value": [], + "strategy": "replace", + "discard_when_merged": false + } + }, "attachments": { "value": [], "strategy": "replace", diff --git a/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_required_no_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_required_no_reasoning__conversation_stream.snap index d7badda3..d42b31eb 100644 --- a/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_required_no_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_required_no_reasoning__conversation_stream.snap @@ -62,6 +62,13 @@ expression: v } } }, + "compaction": { + "rules": { + "value": [], + "strategy": "replace", + "discard_when_merged": false + } + }, "attachments": { "value": [], "strategy": "replace", diff --git a/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_required_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_required_reasoning__conversation_stream.snap index 6852dc97..e3a5b881 100644 --- a/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_required_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_required_reasoning__conversation_stream.snap @@ -65,6 +65,13 @@ expression: v } } }, + "compaction": { + "rules": { + "value": [], + "strategy": "replace", + "discard_when_merged": false + } + }, "attachments": { "value": [], "strategy": "replace", diff --git a/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_stream__conversation_stream.snap b/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_stream__conversation_stream.snap index 3b64f2fe..eaed6d63 100644 --- a/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_stream__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_stream__conversation_stream.snap @@ -62,6 +62,13 @@ expression: v } } }, + "compaction": { + "rules": { + "value": [], + "strategy": "replace", + "discard_when_merged": false + } + }, "attachments": { "value": [], "strategy": "replace", diff --git a/crates/jp_llm/tests/fixtures/ollama/test_chat_completion_stream__conversation_stream.snap b/crates/jp_llm/tests/fixtures/ollama/test_chat_completion_stream__conversation_stream.snap index a491e679..22c1242f 100644 --- a/crates/jp_llm/tests/fixtures/ollama/test_chat_completion_stream__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/ollama/test_chat_completion_stream__conversation_stream.snap @@ -65,6 +65,13 @@ expression: v } } }, + "compaction": { + "rules": { + "value": [], + "strategy": "replace", + "discard_when_merged": false + } + }, "attachments": { "value": [], "strategy": "replace", diff --git a/crates/jp_llm/tests/fixtures/ollama/test_image_attachment__conversation_stream.snap b/crates/jp_llm/tests/fixtures/ollama/test_image_attachment__conversation_stream.snap index c39e80d9..3b7628f1 100644 --- a/crates/jp_llm/tests/fixtures/ollama/test_image_attachment__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/ollama/test_image_attachment__conversation_stream.snap @@ -62,6 +62,13 @@ expression: v } } }, + "compaction": { + "rules": { + "value": [], + "strategy": "replace", + "discard_when_merged": false + } + }, "attachments": { "value": [], "strategy": "replace", diff --git a/crates/jp_llm/tests/fixtures/ollama/test_multi_turn_conversation__conversation_stream.snap b/crates/jp_llm/tests/fixtures/ollama/test_multi_turn_conversation__conversation_stream.snap index c0a9ac5a..34eac96c 100644 --- a/crates/jp_llm/tests/fixtures/ollama/test_multi_turn_conversation__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/ollama/test_multi_turn_conversation__conversation_stream.snap @@ -65,6 +65,13 @@ expression: v } } }, + "compaction": { + "rules": { + "value": [], + "strategy": "replace", + "discard_when_merged": false + } + }, "attachments": { "value": [], "strategy": "replace", diff --git a/crates/jp_llm/tests/fixtures/ollama/test_structured_output__conversation_stream.snap b/crates/jp_llm/tests/fixtures/ollama/test_structured_output__conversation_stream.snap index a2f77900..a554daad 100644 --- a/crates/jp_llm/tests/fixtures/ollama/test_structured_output__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/ollama/test_structured_output__conversation_stream.snap @@ -62,6 +62,13 @@ expression: v } } }, + "compaction": { + "rules": { + "value": [], + "strategy": "replace", + "discard_when_merged": false + } + }, "attachments": { "value": [], "strategy": "replace", diff --git a/crates/jp_llm/tests/fixtures/ollama/test_tool_call_auto__conversation_stream.snap b/crates/jp_llm/tests/fixtures/ollama/test_tool_call_auto__conversation_stream.snap index abe432a1..b4dc6fd0 100644 --- a/crates/jp_llm/tests/fixtures/ollama/test_tool_call_auto__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/ollama/test_tool_call_auto__conversation_stream.snap @@ -62,6 +62,13 @@ expression: v } } }, + "compaction": { + "rules": { + "value": [], + "strategy": "replace", + "discard_when_merged": false + } + }, "attachments": { "value": [], "strategy": "replace", diff --git a/crates/jp_llm/tests/fixtures/ollama/test_tool_call_function__conversation_stream.snap b/crates/jp_llm/tests/fixtures/ollama/test_tool_call_function__conversation_stream.snap index 0fdc2d4d..c6b94c64 100644 --- a/crates/jp_llm/tests/fixtures/ollama/test_tool_call_function__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/ollama/test_tool_call_function__conversation_stream.snap @@ -62,6 +62,13 @@ expression: v } } }, + "compaction": { + "rules": { + "value": [], + "strategy": "replace", + "discard_when_merged": false + } + }, "attachments": { "value": [], "strategy": "replace", diff --git a/crates/jp_llm/tests/fixtures/ollama/test_tool_call_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/ollama/test_tool_call_reasoning__conversation_stream.snap index 9b7b54e5..c1a55d29 100644 --- a/crates/jp_llm/tests/fixtures/ollama/test_tool_call_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/ollama/test_tool_call_reasoning__conversation_stream.snap @@ -62,6 +62,13 @@ expression: v } } }, + "compaction": { + "rules": { + "value": [], + "strategy": "replace", + "discard_when_merged": false + } + }, "attachments": { "value": [], "strategy": "replace", diff --git a/crates/jp_llm/tests/fixtures/ollama/test_tool_call_required_no_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/ollama/test_tool_call_required_no_reasoning__conversation_stream.snap index 81b1a688..dd497c46 100644 --- a/crates/jp_llm/tests/fixtures/ollama/test_tool_call_required_no_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/ollama/test_tool_call_required_no_reasoning__conversation_stream.snap @@ -62,6 +62,13 @@ expression: v } } }, + "compaction": { + "rules": { + "value": [], + "strategy": "replace", + "discard_when_merged": false + } + }, "attachments": { "value": [], "strategy": "replace", diff --git a/crates/jp_llm/tests/fixtures/ollama/test_tool_call_required_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/ollama/test_tool_call_required_reasoning__conversation_stream.snap index 0aee1dbf..63877735 100644 --- a/crates/jp_llm/tests/fixtures/ollama/test_tool_call_required_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/ollama/test_tool_call_required_reasoning__conversation_stream.snap @@ -62,6 +62,13 @@ expression: v } } }, + "compaction": { + "rules": { + "value": [], + "strategy": "replace", + "discard_when_merged": false + } + }, "attachments": { "value": [], "strategy": "replace", diff --git a/crates/jp_llm/tests/fixtures/ollama/test_tool_call_stream__conversation_stream.snap b/crates/jp_llm/tests/fixtures/ollama/test_tool_call_stream__conversation_stream.snap index c4fb1d19..aef6056d 100644 --- a/crates/jp_llm/tests/fixtures/ollama/test_tool_call_stream__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/ollama/test_tool_call_stream__conversation_stream.snap @@ -62,6 +62,13 @@ expression: v } } }, + "compaction": { + "rules": { + "value": [], + "strategy": "replace", + "discard_when_merged": false + } + }, "attachments": { "value": [], "strategy": "replace", diff --git a/crates/jp_llm/tests/fixtures/openai/test_chat_completion_stream__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openai/test_chat_completion_stream__conversation_stream.snap index 59732c5c..8b2cfc42 100644 --- a/crates/jp_llm/tests/fixtures/openai/test_chat_completion_stream__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openai/test_chat_completion_stream__conversation_stream.snap @@ -65,6 +65,13 @@ expression: v } } }, + "compaction": { + "rules": { + "value": [], + "strategy": "replace", + "discard_when_merged": false + } + }, "attachments": { "value": [], "strategy": "replace", diff --git a/crates/jp_llm/tests/fixtures/openai/test_image_attachment__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openai/test_image_attachment__conversation_stream.snap index cee941a6..dc273857 100644 --- a/crates/jp_llm/tests/fixtures/openai/test_image_attachment__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openai/test_image_attachment__conversation_stream.snap @@ -62,6 +62,13 @@ expression: v } } }, + "compaction": { + "rules": { + "value": [], + "strategy": "replace", + "discard_when_merged": false + } + }, "attachments": { "value": [], "strategy": "replace", diff --git a/crates/jp_llm/tests/fixtures/openai/test_multi_turn_conversation__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openai/test_multi_turn_conversation__conversation_stream.snap index f8722c29..1ec1c463 100644 --- a/crates/jp_llm/tests/fixtures/openai/test_multi_turn_conversation__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openai/test_multi_turn_conversation__conversation_stream.snap @@ -65,6 +65,13 @@ expression: v } } }, + "compaction": { + "rules": { + "value": [], + "strategy": "replace", + "discard_when_merged": false + } + }, "attachments": { "value": [], "strategy": "replace", diff --git a/crates/jp_llm/tests/fixtures/openai/test_structured_output__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openai/test_structured_output__conversation_stream.snap index 535d5255..81b28ddc 100644 --- a/crates/jp_llm/tests/fixtures/openai/test_structured_output__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openai/test_structured_output__conversation_stream.snap @@ -62,6 +62,13 @@ expression: v } } }, + "compaction": { + "rules": { + "value": [], + "strategy": "replace", + "discard_when_merged": false + } + }, "attachments": { "value": [], "strategy": "replace", diff --git a/crates/jp_llm/tests/fixtures/openai/test_tool_call_auto__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openai/test_tool_call_auto__conversation_stream.snap index 051347e8..e05280c3 100644 --- a/crates/jp_llm/tests/fixtures/openai/test_tool_call_auto__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openai/test_tool_call_auto__conversation_stream.snap @@ -62,6 +62,13 @@ expression: v } } }, + "compaction": { + "rules": { + "value": [], + "strategy": "replace", + "discard_when_merged": false + } + }, "attachments": { "value": [], "strategy": "replace", diff --git a/crates/jp_llm/tests/fixtures/openai/test_tool_call_function__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openai/test_tool_call_function__conversation_stream.snap index 445c4f1c..2eca5160 100644 --- a/crates/jp_llm/tests/fixtures/openai/test_tool_call_function__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openai/test_tool_call_function__conversation_stream.snap @@ -62,6 +62,13 @@ expression: v } } }, + "compaction": { + "rules": { + "value": [], + "strategy": "replace", + "discard_when_merged": false + } + }, "attachments": { "value": [], "strategy": "replace", diff --git a/crates/jp_llm/tests/fixtures/openai/test_tool_call_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openai/test_tool_call_reasoning__conversation_stream.snap index aac7ec5f..999ca9d8 100644 --- a/crates/jp_llm/tests/fixtures/openai/test_tool_call_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openai/test_tool_call_reasoning__conversation_stream.snap @@ -62,6 +62,13 @@ expression: v } } }, + "compaction": { + "rules": { + "value": [], + "strategy": "replace", + "discard_when_merged": false + } + }, "attachments": { "value": [], "strategy": "replace", diff --git a/crates/jp_llm/tests/fixtures/openai/test_tool_call_required_no_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openai/test_tool_call_required_no_reasoning__conversation_stream.snap index a0616d76..6b1cca6e 100644 --- a/crates/jp_llm/tests/fixtures/openai/test_tool_call_required_no_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openai/test_tool_call_required_no_reasoning__conversation_stream.snap @@ -62,6 +62,13 @@ expression: v } } }, + "compaction": { + "rules": { + "value": [], + "strategy": "replace", + "discard_when_merged": false + } + }, "attachments": { "value": [], "strategy": "replace", diff --git a/crates/jp_llm/tests/fixtures/openai/test_tool_call_required_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openai/test_tool_call_required_reasoning__conversation_stream.snap index 72582123..cb413488 100644 --- a/crates/jp_llm/tests/fixtures/openai/test_tool_call_required_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openai/test_tool_call_required_reasoning__conversation_stream.snap @@ -62,6 +62,13 @@ expression: v } } }, + "compaction": { + "rules": { + "value": [], + "strategy": "replace", + "discard_when_merged": false + } + }, "attachments": { "value": [], "strategy": "replace", diff --git a/crates/jp_llm/tests/fixtures/openai/test_tool_call_stream__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openai/test_tool_call_stream__conversation_stream.snap index f8f2d637..e7ec3f6d 100644 --- a/crates/jp_llm/tests/fixtures/openai/test_tool_call_stream__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openai/test_tool_call_stream__conversation_stream.snap @@ -62,6 +62,13 @@ expression: v } } }, + "compaction": { + "rules": { + "value": [], + "strategy": "replace", + "discard_when_merged": false + } + }, "attachments": { "value": [], "strategy": "replace", diff --git a/crates/jp_llm/tests/fixtures/openrouter/anthropic_test_sub_provider_event_metadata__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openrouter/anthropic_test_sub_provider_event_metadata__conversation_stream.snap index fbdd1180..2bd410de 100644 --- a/crates/jp_llm/tests/fixtures/openrouter/anthropic_test_sub_provider_event_metadata__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openrouter/anthropic_test_sub_provider_event_metadata__conversation_stream.snap @@ -65,6 +65,13 @@ expression: v } } }, + "compaction": { + "rules": { + "value": [], + "strategy": "replace", + "discard_when_merged": false + } + }, "attachments": { "value": [], "strategy": "replace", diff --git a/crates/jp_llm/tests/fixtures/openrouter/google_test_sub_provider_event_metadata__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openrouter/google_test_sub_provider_event_metadata__conversation_stream.snap index 9787a76f..996ab910 100644 --- a/crates/jp_llm/tests/fixtures/openrouter/google_test_sub_provider_event_metadata__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openrouter/google_test_sub_provider_event_metadata__conversation_stream.snap @@ -65,6 +65,13 @@ expression: v } } }, + "compaction": { + "rules": { + "value": [], + "strategy": "replace", + "discard_when_merged": false + } + }, "attachments": { "value": [], "strategy": "replace", diff --git a/crates/jp_llm/tests/fixtures/openrouter/minimax_test_sub_provider_event_metadata__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openrouter/minimax_test_sub_provider_event_metadata__conversation_stream.snap index da29b770..231261ab 100644 --- a/crates/jp_llm/tests/fixtures/openrouter/minimax_test_sub_provider_event_metadata__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openrouter/minimax_test_sub_provider_event_metadata__conversation_stream.snap @@ -65,6 +65,13 @@ expression: v } } }, + "compaction": { + "rules": { + "value": [], + "strategy": "replace", + "discard_when_merged": false + } + }, "attachments": { "value": [], "strategy": "replace", diff --git a/crates/jp_llm/tests/fixtures/openrouter/test_chat_completion_stream__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openrouter/test_chat_completion_stream__conversation_stream.snap index a2cb1bd2..6aec5585 100644 --- a/crates/jp_llm/tests/fixtures/openrouter/test_chat_completion_stream__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openrouter/test_chat_completion_stream__conversation_stream.snap @@ -65,6 +65,13 @@ expression: v } } }, + "compaction": { + "rules": { + "value": [], + "strategy": "replace", + "discard_when_merged": false + } + }, "attachments": { "value": [], "strategy": "replace", diff --git a/crates/jp_llm/tests/fixtures/openrouter/test_image_attachment__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openrouter/test_image_attachment__conversation_stream.snap index 946192c2..5144cc75 100644 --- a/crates/jp_llm/tests/fixtures/openrouter/test_image_attachment__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openrouter/test_image_attachment__conversation_stream.snap @@ -62,6 +62,13 @@ expression: v } } }, + "compaction": { + "rules": { + "value": [], + "strategy": "replace", + "discard_when_merged": false + } + }, "attachments": { "value": [], "strategy": "replace", diff --git a/crates/jp_llm/tests/fixtures/openrouter/test_multi_turn_conversation__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openrouter/test_multi_turn_conversation__conversation_stream.snap index d30b56f9..a9d54d28 100644 --- a/crates/jp_llm/tests/fixtures/openrouter/test_multi_turn_conversation__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openrouter/test_multi_turn_conversation__conversation_stream.snap @@ -65,6 +65,13 @@ expression: v } } }, + "compaction": { + "rules": { + "value": [], + "strategy": "replace", + "discard_when_merged": false + } + }, "attachments": { "value": [], "strategy": "replace", diff --git a/crates/jp_llm/tests/fixtures/openrouter/test_structured_output__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openrouter/test_structured_output__conversation_stream.snap index 3f1bdb90..cf24800a 100644 --- a/crates/jp_llm/tests/fixtures/openrouter/test_structured_output__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openrouter/test_structured_output__conversation_stream.snap @@ -62,6 +62,13 @@ expression: v } } }, + "compaction": { + "rules": { + "value": [], + "strategy": "replace", + "discard_when_merged": false + } + }, "attachments": { "value": [], "strategy": "replace", diff --git a/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_auto__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_auto__conversation_stream.snap index 55fa1ad2..14f026b2 100644 --- a/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_auto__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_auto__conversation_stream.snap @@ -62,6 +62,13 @@ expression: v } } }, + "compaction": { + "rules": { + "value": [], + "strategy": "replace", + "discard_when_merged": false + } + }, "attachments": { "value": [], "strategy": "replace", diff --git a/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_function__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_function__conversation_stream.snap index ce2707c8..de8bdea1 100644 --- a/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_function__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_function__conversation_stream.snap @@ -62,6 +62,13 @@ expression: v } } }, + "compaction": { + "rules": { + "value": [], + "strategy": "replace", + "discard_when_merged": false + } + }, "attachments": { "value": [], "strategy": "replace", diff --git a/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_reasoning__conversation_stream.snap index fa9c8fe1..671f1486 100644 --- a/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_reasoning__conversation_stream.snap @@ -62,6 +62,13 @@ expression: v } } }, + "compaction": { + "rules": { + "value": [], + "strategy": "replace", + "discard_when_merged": false + } + }, "attachments": { "value": [], "strategy": "replace", diff --git a/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_required_no_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_required_no_reasoning__conversation_stream.snap index 08ae24ee..9f49aeb0 100644 --- a/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_required_no_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_required_no_reasoning__conversation_stream.snap @@ -62,6 +62,13 @@ expression: v } } }, + "compaction": { + "rules": { + "value": [], + "strategy": "replace", + "discard_when_merged": false + } + }, "attachments": { "value": [], "strategy": "replace", diff --git a/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_required_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_required_reasoning__conversation_stream.snap index 33c396e6..3a0ba364 100644 --- a/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_required_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_required_reasoning__conversation_stream.snap @@ -62,6 +62,13 @@ expression: v } } }, + "compaction": { + "rules": { + "value": [], + "strategy": "replace", + "discard_when_merged": false + } + }, "attachments": { "value": [], "strategy": "replace", diff --git a/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_stream__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_stream__conversation_stream.snap index 4a9d3813..76ac5615 100644 --- a/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_stream__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_stream__conversation_stream.snap @@ -62,6 +62,13 @@ expression: v } } }, + "compaction": { + "rules": { + "value": [], + "strategy": "replace", + "discard_when_merged": false + } + }, "attachments": { "value": [], "strategy": "replace", diff --git a/crates/jp_llm/tests/fixtures/openrouter/x-ai_test_sub_provider_event_metadata__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openrouter/x-ai_test_sub_provider_event_metadata__conversation_stream.snap index ce8c33e9..0e4010bd 100644 --- a/crates/jp_llm/tests/fixtures/openrouter/x-ai_test_sub_provider_event_metadata__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openrouter/x-ai_test_sub_provider_event_metadata__conversation_stream.snap @@ -65,6 +65,13 @@ expression: v } } }, + "compaction": { + "rules": { + "value": [], + "strategy": "replace", + "discard_when_merged": false + } + }, "attachments": { "value": [], "strategy": "replace", diff --git a/docs/.vitepress/rfd-summaries.json b/docs/.vitepress/rfd-summaries.json index 8c15f079..ea4e65ed 100644 --- a/docs/.vitepress/rfd-summaries.json +++ b/docs/.vitepress/rfd-summaries.json @@ -252,7 +252,7 @@ "summary": "Extend config wizard with frecency-based field ordering using CLI usage tracking data." }, "064-non-destructive-conversation-compaction.md": { - "hash": "120eefa19a2055ca80931a37555a13a4c467bb07de0e603073a7bac5023514c1", + "hash": "8aa1947751180f51980fb15ed0e698097b7aab2e8505eb382e8b13b8ff98c3fc", "summary": "Non-destructive conversation compaction through overlay events that project reduced views without mutating stored data." }, "065-typed-resource-model-for-attachments.md": { diff --git a/docs/rfd/064-non-destructive-conversation-compaction.md b/docs/rfd/064-non-destructive-conversation-compaction.md index 8328d956..ec528d60 100644 --- a/docs/rfd/064-non-destructive-conversation-compaction.md +++ b/docs/rfd/064-non-destructive-conversation-compaction.md @@ -77,37 +77,49 @@ and yields the appropriate view to the provider. jp conversation compact [ID] [OPTIONS] ``` -Compacts the active conversation (or the specified one) by appending a -compaction event. The original events are untouched. +Compacts the active conversation (or the specified one) by appending one or more +compaction events based on the configured rules. The original events are +untouched. ```sh # Compact with workspace defaults jp conversation compact -# Compact using a named profile -jp conversation compact --profile heavy +# Compact with overrides from a config file +jp -c compaction/heavy conversation compact + +# Override range via flags +jp conversation compact --keep-last 5 # Compact a specific range jp conversation compact --from 5h --to 1h -# Compact everything except the last 3 turns -jp conversation compact --keep-last 3 +# Strip only reasoning +jp conversation compact --reasoning # Preview what would change jp conversation compact --dry-run + +# Remove all compaction events (undo) +jp conversation compact --reset ``` **Flags:** | Flag | Default | Description | |--------------------|-----------------------|------------------------------------------| -| `--profile ` | `default` | Named compaction profile from config. | +| `--keep-first ` | from config | Preserve the first N turns. | +| `--keep-last ` | from config | Preserve the last N turns. | | `--from ` | start of conversation | Start of the compacted range | -| | | (inclusive). | +| | | (inclusive). Overrides `--keep-first`. | | `--to ` | end of conversation | End of the compacted range (inclusive). | -| `--keep ` | from config | Shorthand for `--to` N turns ago. | -| `--dry-run` | `false` | Preview mechanical effects without | -| | | applying. | +| | | Overrides `--keep-last`. | +| `--reasoning` | from config | Strip reasoning (thinking) blocks. | +| `--tools` | from config | Strip tool call arguments/responses. | +| `--summarize` | from config | Generate an LLM summary for the range. | +| `--dry-run` | `false` | Preview effects without applying. | +| `--reset` | `false` | Remove all compaction events from the | +| | | stream. | Range bounds accept several formats: @@ -122,32 +134,97 @@ Range bounds accept several formats: `--from` without a value defaults to `last`. All bounds are **resolved to absolute turn indices at creation time** and stored as integers. -#### The `--compact` Flag on `query` +`--reset` removes all `InternalEvent::Compaction` variants from the stream, +restoring the raw event history. The projection layer then has nothing to apply, +and the LLM sees the original uncompacted events. This is useful for undoing +compaction when the result is unsatisfactory. + +#### The `--compact` Flag (DSL) + +The `--compact` flag is available on `query`, `fork`, and `compact` itself. It +supports two forms: + +- **Bare `--compact`** (no value): apply the compaction rules from the resolved + configuration. +- **`--compact=SPEC`** (with a DSL value): apply an inline compaction rule. Each + `--compact=SPEC` adds one compaction event. + +Both forms compose: bare `--compact` includes config rules, and each +`--compact=SPEC` adds a DSL rule. When only `--compact=SPEC` is present (no +bare `--compact`), config rules are not included — only the explicit DSL rules +apply. + +The short flag is `-k`. ```sh -# Compact with default profile, then query +# Apply config rules, then query jp query --compact -- "Continue working on the feature" -# Compact with a named profile, then query -jp query --compact=heavy "Continue working on the feature" -``` +# Apply config rules via short flag +jp query -k -- "Continue" -Equivalent to `jp conversation compact` followed by `jp query`. `--compact` -alone uses the conversation's default profile; `--compact=` uses the named -profile. +# Inline DSL: summarize all but last 3, then query +jp query -k s:..-3 -- "Continue" -#### The `--compact` Flag on `fork` +# Two inline rules on fork +jp conversation fork -k r:..-20 -k s:..-3 -```sh -# Fork and compact with default profile -jp conversation fork --compact +# Mix config rules + inline rule +jp query --compact -k s:..-1 -- "Continue" +``` -# Fork and compact with a named profile -jp conversation fork --compact=heavy +##### DSL Grammar + +``` +SPEC = POLICIES [":" RANGE] +POLICIES = POLICY ["+" POLICY]* +POLICY = "r" | "reasoning" + | "t" | "tools" + | "s" | "summarize" +RANGE = [BOUND] ".." [BOUND] # explicit range (at least "..") + | BOUND # single-number shorthand +BOUND = INTEGER # positive = absolute turn, negative = from end ``` -Forks the conversation and appends a compaction event to the fork. Uses the -forked conversation's resolved compaction config. +The range describes **which turns the policy applies to** (consistent with +`from_turn`/`to_turn` in the `Compaction` event). Turns outside the range are +unaffected. + +**Range semantics:** + +Full `FROM..TO` form: + +| Syntax | Meaning | +|-------------|-----------------------------------------------------| +| `..` | All turns (start to end). | +| `5..` | Turn 5 onward (keeps first 5 uncompacted). | +| `..-3` | Start through 3-from-end (keeps last 3). | +| `5..-3` | Turn 5 through 3-from-end (keeps first 5, last 3). | + +Single-number shorthands: + +| Syntax | Expands to | Meaning | +|--------|------------|---------------------------------| +| `-3` | `..-3` | Keep last 3 uncompacted. | +| `5` | `5..` | Keep first 5 uncompacted. | + +**Examples:** + +| DSL spec | Meaning | +|---------------|------------------------------------------------| +| `s` | Summarize, range from config defaults. | +| `r+t` | Strip reasoning + tools, range from config. | +| `s:..-3` | Summarize all but last 3 turns. | +| `r+t:..-3` | Strip reasoning + tools, keep last 3. | +| `s:..` | Summarize all events. | +| `r:5..` | Strip reasoning from turn 5 onward. | +| `s:5..-3` | Summarize turns 5 through 3-from-end. | +| `s:-3` | Summarize all but last 3 (shorthand). | +| `r:-20` | Strip reasoning, keep last 20 (shorthand). | + +When a DSL spec omits the range, the config's `keep_first` and `keep_last` +defaults are used. When a DSL spec is provided, the policies are self-contained +— no policies are inherited from config. The DSL defines the complete rule. #### Viewing Compacted Conversations @@ -322,7 +399,7 @@ Raw stream (turns 0-2, then turns 3+ uncompacted): Turn 2: ChatResponse::Message("Added tracing-based logging.") ``` -With the `default` profile (`reasoning: Strip, tool_calls: Strip`): +With default config (`reasoning: strip, tool_calls: strip`): ```txt Compaction event (after turn 2): @@ -357,7 +434,7 @@ while `fs_create_file` and `fs_modify_file` have their arguments stripped (per-tool hint `request = "strip"` because they carry large file content). Messages and conversation structure are preserved. -With the `heavy` profile (`summary: Summarize`): +With a summarization config (`-c compaction/heavy`): ```txt Compaction event (after turn 2): @@ -375,13 +452,13 @@ Projected view: ...turns 3+ uncompacted... ``` -The two profiles show the distinction: +These two configurations show the distinction: -- **`default` (mechanical):** Conversation structure is preserved. Reasoning is +- **Mechanical (default):** Conversation structure is preserved. Reasoning is stripped, tool responses are replaced with status lines. Messages and tool call requests remain — the model sees the full flow of what happened, minus the bulk. -- **`heavy` (summarization):** Everything in the range is replaced by a single +- **Summarization (heavy):** Everything in the range is replaced by a single summary. The summarizer reads ALL raw events (messages, reasoning, tool calls) to produce the summary, so tool usage and decisions are captured in the text. No orphaned events remain. @@ -411,6 +488,12 @@ Compaction B (turn 30): from=0, to=30, tool_calls=Strip { request: false, respon \* `summary` takes precedence over per-type policies when both cover an event. +This stacking behavior is what makes multi-rule configurations and the DSL +work: each rule produces a separate compaction event, and the projection layer +composes them at read time. Rule ordering does not affect correctness — the +projection resolves conflicts by timestamp, and summaries always read the raw +(uncompacted) stream. + #### Summary Overlap Resolution Summaries are holistic representations of a range — they cannot be split or @@ -467,7 +550,7 @@ Produces a compaction with `tool_calls: Some(ToolCallPolicy::Strip { .. })` for the specified range. At projection time, tool response content is replaced with a status line (`[compacted] {tool_name}: {success|error}`) and/or request arguments are replaced with a compact summary. Which fields are stripped depends -on the profile and per-tool hints. +on the rule configuration and per-tool hints. **Impact:** High. Tool responses and arguments (especially for file-writing tools) dominate token count in coding conversations. @@ -483,53 +566,111 @@ events in the range. The summarization prompt instructs the model to preserve key decisions, file paths, error resolutions, and the current state of the task. The model and -prompt are configurable per-profile (see [Configuration](#configuration)). +prompt are configurable (see [Configuration](#configuration)). **Impact:** High. Replaces an arbitrary number of turns with a short summary. ### Configuration -Compaction is configured at the workspace and conversation level, following the -same defaults-plus-named-profiles pattern used by tool configuration. +Compaction is configured at the workspace and conversation level under +`conversation.compaction`. Configuration defines compaction **rules** — each +rule produces one `Compaction` event when applied. Variation across workspaces +or conversations is handled through JP's standard config layering (`-c` flag, +`config.d/` directories), not through a custom profile mechanism. ```toml [conversation.compaction] -# The profile to use when --profile is not specified. -default_profile = "default" +# Reserved for future features (e.g. auto-compaction). -# Number of recent turns to preserve (used by profiles that don't -# override it). Shorthand for setting `to` to N turns ago. +# Rules are applied in order. Each rule produces one compaction event. +[[conversation.compaction.rules]] +keep_first = 1 keep_last = 3 +reasoning = "strip" +tool_calls = "strip" +``` + +To define alternative compaction configurations, create config files in the +workspace's `config.d/` directory and load them with `-c`: + +```toml +# .jp/config.d/compaction/heavy.toml +# +# Usage: jp -c compaction/heavy conversation compact +# jp -c compaction/heavy query --compact -- "Continue" -# Default compaction profile. Applied by `--compact` with no arguments. -[conversation.compaction.profiles.default] +[[conversation.compaction.rules]] +keep_last = 20 reasoning = "strip" + +[[conversation.compaction.rules]] +keep_first = 1 +keep_last = 3 tool_calls = "strip" -# A heavier profile that includes summarization. -# When summary is set, it replaces all events in the range — -# reasoning and tool_calls policies are not needed. -[conversation.compaction.profiles.heavy.summary] -policy = "summarize" +[conversation.compaction.rules.summary] model = "anthropic/claude-haiku" -# instructions = """ -# Summarize this conversation for continuity. Preserve: -# - File paths and code structures discussed -# - Key decisions and their rationale -# - Current task state and next steps -# """ - -# A minimal profile for quick cleanup. -[conversation.compaction.profiles.light] +``` + +```toml +# .jp/config.d/compaction/light.toml +# +# Usage: jp -c compaction/light conversation compact + +[[conversation.compaction.rules]] +keep_last = 5 reasoning = "strip" ``` -Profiles define which per-type policies to apply. The range (`from`, `to`, -`keep_last`) comes from the CLI flags or the top-level `keep_last` default. A -profile does not encode a range — ranges are an invocation-time concern. +Multiple `-c` files compose via `MergedVec` append semantics: + +```sh +# Appends both rule sets: strip reasoning + summarize middle +jp -c compaction/strip-reasoning -c compaction/summarize-middle conversation compact +``` + +#### Rules Array and Merging + +The `rules` field is a `MergedVec` with `append` as the +default merge strategy. When multiple config sources define rules, they are +concatenated in load order. + +The built-in default (strip reasoning + tools, keep last 3) uses +`discard_when_merged: true`, so it is dropped as soon as any user-defined rule +is present. This means compaction works out of the box without configuration, +but defining even one rule replaces the defaults entirely. -Conversation-level overrides (via `--cfg`) can change any of these for a -specific conversation. +If no rules are configured (and no DSL spec is provided), `jp conversation +compact` applies the built-in default. + +#### Rule Fields + +Each rule in the array defines a single compaction operation: + +| Field | Type | Default | Description | +|--------------|-------------------|---------|-----------------------------------| +| `keep_first` | turns or duration | `1` | Turns to preserve at the start. | +| `keep_last` | turns or duration | `3` | Turns to preserve at the end. | +| `reasoning` | `"strip"` | — | Strip reasoning blocks. | +| `tool_calls` | mode string | — | Strip or omit tool calls. | +| `summary` | table | — | Generate an LLM summary. | + +`keep_first` and `keep_last` accept a positive integer (turn count) or a +duration string (e.g. `"5h"`). + +`tool_calls` accepts: `"strip"` (both), `"strip-responses"`, `"strip-requests"`, +`"omit"`. + +`summary` is a nested table: + +```toml +[conversation.compaction.rules.summary] +model = "anthropic/claude-haiku" # optional, defaults to main assistant model +instructions = "..." # optional, custom summarization prompt +``` + +When `summary` is set, it replaces all events in the range — `reasoning` and +`tool_calls` on the same rule are ignored. ### Per-Tool Compaction Hints @@ -542,9 +683,9 @@ request = "keep" # "keep" | "strip" response = "strip" # "keep" | "strip" ``` -Per-tool hints override the profile's `Strip` policy for individual tools. A -tool with `response = "keep"` is exempted from response stripping even under a -policy that sets `response: true`. +Per-tool hints override the rule's `Strip` policy for individual tools. A tool +with `response = "keep"` is exempted from response stripping even under a rule +that sets `response: true`. Example defaults for the JP project: @@ -606,6 +747,15 @@ fork-by-default as a safety net. Rejected because: 4. **Conflated concerns.** Destructive compaction mixes "what to send to the LLM" (a view concern) with "what to store on disk" (a persistence concern). +### Named compaction profiles + +A `profiles` map keyed by name (e.g. `default`, `heavy`, `light`) inside +`conversation.compaction`, with a `--profile` flag to select one at invocation +time. Rejected because JP's config pipeline already provides this capability: +variant configs live in `config.d/` files and are loaded with `-c`. Profiles +would duplicate the config layering mechanism with a compaction-specific lookup +that adds complexity without adding capability. + ### Automatic compaction on every turn Compact transparently when approaching the context window limit. Rejected for @@ -617,7 +767,7 @@ control, trigger conditions) that warrant a separate proposal. One "compact" that does everything. Rejected: different conversations need different compaction. A coding conversation benefits from tool response -stripping; a discussion benefits from summarization. Named profiles with +stripping; a discussion benefits from summarization. Composable rules with per-type policies let users tailor the operation. ## Non-Goals @@ -687,6 +837,16 @@ per-type policies let users tailor the operation. user/assistant alternation that providers expect. Needs testing across Anthropic, OpenAI, Google, and local providers. +- **Migration of `Event::Patch` to the overlay model.** The `Event::Patch` + mechanism (introduced for stale thinking-block signature recovery in + Anthropic and Google providers) currently mutates historical events in the + conversation stream in-place. This is a known deviation from the append-only + principle. Once the projection layer from Phase 2 exists, `Event::Patch` + should be migrated to append a metadata-patch event to the stream, with the + projection layer applying it at request-build time. The `PatchAction` + vocabulary should not be expanded beyond `RemoveMetadata` until this + migration is complete. + ## Implementation Plan ### Phase 1: Compaction Event Model @@ -702,11 +862,11 @@ Can be merged independently. No behavioral changes. ### Phase 2: Projection Layer -1. Add `ConversationStream::projected_iter()` that applies compaction overlays - to yield the projected view. +1. Add `ConversationStream::apply_projection()` that applies compaction overlays + to transform the event list. 2. Implement the stacking semantics (latest-wins per content type). 3. Implement summary injection (synthetic `ChatRequest`/`ChatResponse` pair). -4. Wire `Thread::into_parts()` to use `projected_iter()`. +4. Wire `Thread::into_parts()` to call `apply_projection()`. 5. Add unit tests for each policy type, stacking, and summary overlap auto-extension. @@ -719,33 +879,35 @@ affect what the LLM sees. a `Compaction` event. 2. Implement range bound resolution (negative integers, duration strings, `last` → absolute turn index). -3. Add the `jp conversation compact` CLI command with `--profile`, `--from`, - `--to`, `--keep-last`, `--dry-run`. -4. Add `--compact[=profile]` to `jp conversation fork`. +3. Add the `jp conversation compact` command with `--keep-first`, `--keep-last`, + `--from`, `--to`, `--reasoning`, `--tools`, `--dry-run`, `--reset`. +4. Add `--compact` / `-k` to `jp conversation fork`. 5. Add `--compacted` to `jp conversation print`. 6. Add integration tests. Depends on Phase 2. -### Phase 4: Configuration +### Phase 4: Configuration and DSL -1. Add `conversation.compaction` config section with `default_profile`, - `keep_last`. -2. Add `conversation.compaction.profiles` support (named policy sets). +1. Add `conversation.compaction` config section with `rules` as + `MergedVec`. +2. Implement built-in default rule with `discard_when_merged: true`. 3. Add per-tool `compaction` hints to `ToolConfig`. -4. Wire profiles into the CLI (`--profile` flag, `--compact` defaults). -5. Add config tests. +4. Implement the `--compact[=SPEC]` DSL parser. +5. Wire `--compact` / `-k` into `query`, `fork`, and `compact` with composition + semantics (bare `--compact` = config rules, `--compact=SPEC` = DSL rule, + both compose). +6. Add config and DSL tests. -Depends on Phase 3. Can be partially parallelized with Phase 3 (config types can -be defined before the CLI is wired up). +Depends on Phase 3. Can be partially parallelized with Phase 3 (config types +and DSL parser can be built before the CLI is wired up). ### Phase 5: LLM-Assisted Summarization 1. Implement the `summarize` strategy: read raw events, call the configured model, produce `SummaryPolicy { summary }`. 2. Implement the summary overlap auto-extension logic. -3. Add `--compact[=profile]` to `jp query`. -4. Add integration tests (with mock LLM). +3. Add integration tests (with mock LLM). Depends on Phase 2. Can proceed in parallel with Phases 3 and 4.