From 757800239a3c7b7b3d4c205e66911989d3da6193 Mon Sep 17 00:00:00 2001 From: Jean Mertz Date: Mon, 13 Apr 2026 13:13:21 +0200 Subject: [PATCH 1/4] feat(cli, config, conversation): Implement conversation compaction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `jp conversation compact` command and `--compact` / `-k` flag across `query`, `fork`, and `compact` commands, implementing RFD 064 (non-destructive conversation compaction). The core idea is that compaction is additive: a `Compaction` overlay event is appended to the conversation stream rather than modifying or deleting any existing events. The projection layer applies overlays at request-build time (`Thread::into_parts()`), so the LLM sees a reduced view while the full raw history is preserved on disk. **What users can do now:** ```sh # Compact with workspace defaults (strip reasoning + tools) jp conversation compact # Strip reasoning, keep last 5 turns jp conversation compact --reasoning --keep-last 5 # Preview without applying jp conversation compact --dry-run # Remove all compaction events (undo) jp conversation compact --reset # Compact before querying (inline DSL, summarize all but last 3 turns) jp query -k s:..-3 -- "Continue the task" # Fork and compact in one step jp conversation fork --compact ``` **DSL (`--compact=SPEC`)** supports `r` (reasoning), `t` (tools), `s` (summarize) as policy letters, combined with `+`, and an optional range after `:`. Examples: `s:..-3`, `r+t`, `s:5..-3`. Bare `--compact` applies config rules; `--compact=SPEC` applies inline rules; both forms compose. **Summarization** calls the configured LLM and stores the result in the `Compaction` event as a `SummaryPolicy`. At projection time the covered turns are replaced with a synthetic request/response pair containing the summary text. Summary range auto-extension prevents partial overlaps with existing summary compactions. **Configuration** is under `conversation.compaction.rules` — a `MergedVec` where each rule produces one compaction event. The built-in default (strip reasoning + tools) uses `discard_when_merged: true` so it is replaced the moment any user rule is present. Alternative compaction configurations can be expressed as individual configuration files loaded with `--cfg`, consistent with JP's existing config layering. **`jp conversation print --compacted`** shows the projected (assistant) view of the conversation, making it easy to verify the effect of compaction. The `IntoPartialAppConfig` trait gains a `handles` parameter so commands can access resolved conversation targets when building their config overrides. Ref: #57 Signed-off-by: Jean Mertz --- Cargo.lock | 1 + crates/jp_cli/src/cmd.rs | 17 +- crates/jp_cli/src/cmd/attachment.rs | 9 +- crates/jp_cli/src/cmd/attachment/add.rs | 1 + crates/jp_cli/src/cmd/attachment/rm.rs | 1 + crates/jp_cli/src/cmd/compact_flag.rs | 287 ++++ crates/jp_cli/src/cmd/compact_flag_tests.rs | 186 +++ crates/jp_cli/src/cmd/conversation.rs | 40 +- crates/jp_cli/src/cmd/conversation/compact.rs | 452 ++++++ .../src/cmd/conversation/compact_tests.rs | 150 ++ crates/jp_cli/src/cmd/conversation/fork.rs | 38 +- .../jp_cli/src/cmd/conversation/fork_tests.rs | 30 +- crates/jp_cli/src/cmd/conversation/print.rs | 21 +- .../src/cmd/conversation/print_tests.rs | 35 + .../jp_cli/src/cmd/conversation/summarize.rs | 150 ++ .../src/cmd/conversation/summarize_tests.rs | 87 ++ crates/jp_cli/src/cmd/init.rs | 1 + crates/jp_cli/src/cmd/query.rs | 39 + crates/jp_cli/src/cmd/query_tests.rs | 23 +- crates/jp_cli/src/ctx.rs | 14 +- crates/jp_cli/src/error.rs | 3 + crates/jp_cli/src/lib.rs | 118 +- crates/jp_cli/src/lib_tests.rs | 34 + crates/jp_config/src/conversation.rs | 13 + .../jp_config/src/conversation/compaction.rs | 433 ++++++ .../src/conversation/compaction_tests.rs | 90 ++ .../jp_config/src/conversation/tool_tests.rs | 4 +- crates/jp_config/src/lib.rs | 37 +- crates/jp_config/src/lib_tests.rs | 33 + crates/jp_config/src/model/id.rs | 2 + .../jp_config__tests__app_config_fields.snap | 1 + ...ig__tests__partial_app_config_default.snap | 5 + ...ts__partial_app_config_default_values.snap | 22 + ...s__partial_app_config_empty_serialize.snap | 5 + crates/jp_conversation/Cargo.toml | 1 + crates/jp_conversation/src/compaction.rs | 262 ++++ .../jp_conversation/src/compaction_tests.rs | 311 +++++ crates/jp_conversation/src/lib.rs | 5 + crates/jp_conversation/src/stream.rs | 244 +++- .../jp_conversation/src/stream/projection.rs | 274 ++++ .../src/stream/projection_tests.rs | 1210 +++++++++++++++++ .../jp_conversation/src/stream/turn_iter.rs | 20 +- .../src/stream/turn_iter_tests.rs | 32 + crates/jp_conversation/src/stream_tests.rs | 440 +++++- crates/jp_conversation/src/thread.rs | 1 + ...ompletion_stream__conversation_stream.snap | 7 + ...image_attachment__conversation_stream.snap | 7 + ...urn_conversation__conversation_stream.snap | 7 + ...daptive_thinking__conversation_stream.snap | 7 + ...s_4_6_max_effort__conversation_stream.snap | 7 + ...edacted_thinking__conversation_stream.snap | 7 + ...request_chaining__conversation_stream.snap | 7 + ...tructured_output__conversation_stream.snap | 7 + ...t_tool_call_auto__conversation_stream.snap | 7 + ...ol_call_function__conversation_stream.snap | 7 + ...l_call_reasoning__conversation_stream.snap | 7 + ...red_no_reasoning__conversation_stream.snap | 7 + ...quired_reasoning__conversation_stream.snap | 7 + ...tool_call_stream__conversation_stream.snap | 7 + ...ompletion_stream__conversation_stream.snap | 7 + ...urn_conversation__conversation_stream.snap | 7 + ...tructured_output__conversation_stream.snap | 7 + ...t_tool_call_auto__conversation_stream.snap | 7 + ...ol_call_function__conversation_stream.snap | 7 + ...l_call_reasoning__conversation_stream.snap | 7 + ...red_no_reasoning__conversation_stream.snap | 7 + ...quired_reasoning__conversation_stream.snap | 7 + ...tool_call_stream__conversation_stream.snap | 7 + ...ompletion_stream__conversation_stream.snap | 7 + ...mini_3_reasoning__conversation_stream.snap | 7 + ...image_attachment__conversation_stream.snap | 7 + ...urn_conversation__conversation_stream.snap | 7 + ...tructured_output__conversation_stream.snap | 7 + ...t_tool_call_auto__conversation_stream.snap | 7 + ...ol_call_function__conversation_stream.snap | 7 + ...l_call_reasoning__conversation_stream.snap | 7 + ...red_no_reasoning__conversation_stream.snap | 7 + ...quired_reasoning__conversation_stream.snap | 7 + ...tool_call_stream__conversation_stream.snap | 7 + ...ompletion_stream__conversation_stream.snap | 7 + ...image_attachment__conversation_stream.snap | 7 + ...urn_conversation__conversation_stream.snap | 7 + ...tructured_output__conversation_stream.snap | 7 + ...t_tool_call_auto__conversation_stream.snap | 7 + ...ol_call_function__conversation_stream.snap | 7 + ...l_call_reasoning__conversation_stream.snap | 7 + ...red_no_reasoning__conversation_stream.snap | 7 + ...quired_reasoning__conversation_stream.snap | 7 + ...tool_call_stream__conversation_stream.snap | 7 + ...ompletion_stream__conversation_stream.snap | 7 + ...image_attachment__conversation_stream.snap | 7 + ...urn_conversation__conversation_stream.snap | 7 + ...tructured_output__conversation_stream.snap | 7 + ...t_tool_call_auto__conversation_stream.snap | 7 + ...ol_call_function__conversation_stream.snap | 7 + ...l_call_reasoning__conversation_stream.snap | 7 + ...red_no_reasoning__conversation_stream.snap | 7 + ...quired_reasoning__conversation_stream.snap | 7 + ...tool_call_stream__conversation_stream.snap | 7 + ...ompletion_stream__conversation_stream.snap | 7 + ...image_attachment__conversation_stream.snap | 7 + ...urn_conversation__conversation_stream.snap | 7 + ...tructured_output__conversation_stream.snap | 7 + ...t_tool_call_auto__conversation_stream.snap | 7 + ...ol_call_function__conversation_stream.snap | 7 + ...l_call_reasoning__conversation_stream.snap | 7 + ...red_no_reasoning__conversation_stream.snap | 7 + ...quired_reasoning__conversation_stream.snap | 7 + ...tool_call_stream__conversation_stream.snap | 7 + ...r_event_metadata__conversation_stream.snap | 7 + ...r_event_metadata__conversation_stream.snap | 7 + ...r_event_metadata__conversation_stream.snap | 7 + ...ompletion_stream__conversation_stream.snap | 7 + ...image_attachment__conversation_stream.snap | 7 + ...urn_conversation__conversation_stream.snap | 7 + ...tructured_output__conversation_stream.snap | 7 + ...t_tool_call_auto__conversation_stream.snap | 7 + ...ol_call_function__conversation_stream.snap | 7 + ...l_call_reasoning__conversation_stream.snap | 7 + ...red_no_reasoning__conversation_stream.snap | 7 + ...quired_reasoning__conversation_stream.snap | 7 + ...tool_call_stream__conversation_stream.snap | 7 + ...r_event_metadata__conversation_stream.snap | 7 + docs/.vitepress/rfd-summaries.json | 2 +- ...non-destructive-conversation-compaction.md | 332 +++-- 125 files changed, 5825 insertions(+), 237 deletions(-) create mode 100644 crates/jp_cli/src/cmd/compact_flag.rs create mode 100644 crates/jp_cli/src/cmd/compact_flag_tests.rs create mode 100644 crates/jp_cli/src/cmd/conversation/compact.rs create mode 100644 crates/jp_cli/src/cmd/conversation/compact_tests.rs create mode 100644 crates/jp_cli/src/cmd/conversation/summarize.rs create mode 100644 crates/jp_cli/src/cmd/conversation/summarize_tests.rs create mode 100644 crates/jp_config/src/conversation/compaction.rs create mode 100644 crates/jp_config/src/conversation/compaction_tests.rs create mode 100644 crates/jp_conversation/src/compaction.rs create mode 100644 crates/jp_conversation/src/compaction_tests.rs create mode 100644 crates/jp_conversation/src/stream/projection.rs create mode 100644 crates/jp_conversation/src/stream/projection_tests.rs diff --git a/Cargo.lock b/Cargo.lock index 39774431..c4e57718 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2261,6 +2261,7 @@ dependencies = [ "jp_attachment", "jp_config", "jp_id", + "proptest", "quick-xml", "serde", "serde_json", diff --git a/crates/jp_cli/src/cmd.rs b/crates/jp_cli/src/cmd.rs index 2bc6565d..fd4dcd77 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), @@ -403,6 +411,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 8ccbd61c..8d7d9935 100644 --- a/crates/jp_cli/src/cmd/attachment.rs +++ b/crates/jp_cli/src/cmd/attachment.rs @@ -48,10 +48,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..0314186f --- /dev/null +++ b/crates/jp_cli/src/cmd/compact_flag.rs @@ -0,0 +1,287 @@ +//! 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("Run conversation compaction rules") + .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]\n\nPolicies are joined with `+`:\n- \ + `r` / `reasoning`: strip reasoning blocks\n- `s` / `summarize`: generate an \ + LLM summary\n- `t` / `tools` (or `t=MODE`): strip tool calls; bare strips \ + both, or MODE is one of `strip`/`s`, `strip-requests`/`sreq`, \ + `strip-responses`/`sres`, `omit`/`o`\n\nRange: FROM..TO, single number, or \ + .. for all\n\nExamples: s:..-3, r+t, t=sreq: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, + /// `None` = no tool-call policy. + /// The mode mirrors the `--tools` flag. + pub tools: Option, + 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); + } + rule.tool_calls = self.tools; + 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: Option = None; + let mut summarize = false; + + for policy in policies_str.split('+') { + let policy = policy.trim(); + let (key, value) = match policy.split_once('=') { + Some((k, v)) => (k.trim(), Some(v.trim())), + None => (policy, None), + }; + + match key { + "r" | "reasoning" => { + if value.is_some() { + return Err("`reasoning` does not take a value".into()); + } + reasoning = true; + } + "s" | "summarize" => { + if value.is_some() { + return Err("`summarize` does not take a value".into()); + } + summarize = true; + } + "t" | "tools" => { + tools = Some(match value { + Some(v) => v.parse().map_err(|e| format!("{e}"))?, + // Bare `t` mirrors `--tools` without a value. + None => ToolCallsMode::Strip, + }); + } + "" => return Err("empty policy".into()), + other => return Err(format!("unknown policy '{other}'")), + } + } + + if !reasoning && tools.is_none() && !summarize { + return Err("at least one policy required (r, t=MODE, 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..b2f289a1 --- /dev/null +++ b/crates/jp_cli/src/cmd/compact_flag_tests.rs @@ -0,0 +1,186 @@ +use super::*; + +#[test] +fn parse_policy_only() { + assert_eq!("s".parse::().unwrap(), CompactSpec { + reasoning: false, + tools: None, + summarize: true, + range: None, + }); + assert_eq!("r+t=strip".parse::().unwrap(), CompactSpec { + reasoning: true, + tools: Some(ToolCallsMode::Strip), + summarize: false, + range: None, + }); + assert_eq!( + "reasoning+tools=strip+summarize" + .parse::() + .unwrap(), + CompactSpec { + reasoning: true, + tools: Some(ToolCallsMode::Strip), + summarize: true, + range: None, + } + ); +} + +#[test] +fn parse_tool_modes() { + let mode = |s: &str| s.parse::().unwrap().tools; + // Bare `t` / `tools` defaults to stripping both. + assert_eq!(mode("t"), Some(ToolCallsMode::Strip)); + assert_eq!(mode("tools"), Some(ToolCallsMode::Strip)); + assert_eq!(mode("t=strip"), Some(ToolCallsMode::Strip)); + assert_eq!(mode("t=s"), Some(ToolCallsMode::Strip)); + assert_eq!(mode("t=sreq"), Some(ToolCallsMode::StripRequests)); + assert_eq!( + mode("tools=strip-responses"), + Some(ToolCallsMode::StripResponses) + ); + assert_eq!(mode("t=o"), Some(ToolCallsMode::Omit)); +} + +#[test] +fn parse_tool_mode_with_range() { + assert_eq!("t=sres:..-3".parse::().unwrap(), CompactSpec { + reasoning: false, + tools: Some(ToolCallsMode::StripResponses), + summarize: false, + range: Some(DslRange { + keep_first: None, + keep_last: Some(3), + }), + }); +} + +#[test] +fn parse_with_range() { + assert_eq!("s:..-3".parse::().unwrap(), CompactSpec { + reasoning: false, + tools: None, + summarize: true, + range: Some(DslRange { + keep_first: None, + keep_last: Some(3), + }), + }); + assert_eq!( + "r+t=strip:5..-3".parse::().unwrap(), + CompactSpec { + reasoning: true, + tools: Some(ToolCallsMode::Strip), + summarize: false, + range: Some(DslRange { + keep_first: Some(5), + keep_last: Some(3), + }), + } + ); + assert_eq!("s:..".parse::().unwrap(), CompactSpec { + reasoning: false, + tools: None, + summarize: true, + range: Some(DslRange { + keep_first: None, + keep_last: None, + }), + }); + assert_eq!("r:5..".parse::().unwrap(), CompactSpec { + reasoning: true, + tools: None, + 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: None, + 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: None, + 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()); + // Unknown tool mode + assert!("t=nope".parse::().is_err()); + // Boolean policies reject values + assert!("r=strip".parse::().is_err()); + assert!("s=true".parse::().is_err()); +} + +#[test] +fn to_partial_rule_with_range() { + let spec = "r+t=strip:..-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!["t=sreq:..-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..4e83cdf4 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,15 @@ 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 +115,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..8c4a98f7 --- /dev/null +++ b/crates/jp_cli/src/cmd/conversation/compact.rs @@ -0,0 +1,452 @@ +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, ConversationMut}; + +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(short, long)] + reasoning: bool, + + /// Strip tool call content from the compacted range. + /// + /// Used without a value, strips both requests and responses. + /// Otherwise one of (with short aliases): + /// + /// - `strip` (`s`): strip request arguments and response content + /// - `strip-requests` (`sreq`): strip request arguments only + /// - `strip-responses` (`sres`): strip response content only + /// - `omit` (`o`): remove tool call pairs entirely + #[arg( + short, + long, + value_parser = parse_tool_calls_mode, + num_args = 0..=1, + default_missing_value = "strip", + )] + tools: Option, + + /// Generate an LLM summary for the compacted range. + /// + /// When enabled, the compacted turns are replaced with a single + /// LLM-generated summary. + #[arg(short, long)] + summarize: bool, + + /// 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 + || self.tools.is_some() + || self.summarize + } +} + +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 self.reasoning { + rule.reasoning = Some(ReasoningMode::Strip); + } + rule.tool_calls = self.tools; + if self.summarize { + 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}")) +} + +fn parse_tool_calls_mode(s: &str) -> Result { + s.parse().map_err(|_| { + "expected one of: strip (s), strip-requests (sreq), strip-responses (sres), omit (o)" + .to_string() + }) +} + +/// 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) +} + +/// Append compaction events to the conversation, announcing each one. +/// +/// The reported turn range is inclusive; `(N total)` is its turn count. +pub(crate) fn apply_compactions( + conv: &ConversationMut, + compactions: Vec, + printer: &jp_printer::Printer, +) { + for compaction in compactions { + let from = compaction.from_turn; + let to = compaction.to_turn; + let count = to - from + 1; + conv.update_events(|stream| stream.add_compaction(compaction)); + printer.println(format!("Compacted turns {from}..={to} ({count} total).")); + } +} + +/// 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(()); + } + + apply_compactions(&conv, compactions, &ctx.printer); + + 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())) + } + } +} + +#[cfg(test)] +#[path = "compact_tests.rs"] +mod tests; diff --git a/crates/jp_cli/src/cmd/conversation/compact_tests.rs b/crates/jp_cli/src/cmd/conversation/compact_tests.rs new file mode 100644 index 00000000..06db1bd2 --- /dev/null +++ b/crates/jp_cli/src/cmd/conversation/compact_tests.rs @@ -0,0 +1,150 @@ +use jp_config::{ + AppConfig, + conversation::compaction::{CompactionRuleConfig, RuleBound, ToolCallsMode}, +}; +use jp_conversation::{ + ConversationStream, ToolCallPolicy, + event::{ToolCallRequest, ToolCallResponse}, +}; +use jp_printer::Printer; +use serde_json::{Map, Value}; + +use super::{build_compaction_event, build_compaction_events_from_config}; + +fn runtime() -> tokio::runtime::Runtime { + tokio::runtime::Runtime::new().unwrap() +} + +/// Each `ToolCallsMode` from the config maps to the right `ToolCallPolicy` on +/// the produced `Compaction` event (the `jp_config` -\> `jp_conversation` +/// bridge that lives in `build_mechanical_compaction`). +#[test] +fn tool_calls_mode_maps_to_policy() { + // A few empty turns; `keep 0/0` makes the range cover all of them. + let mut stream = ConversationStream::new_test(); + for t in 0..4 { + stream.start_turn(format!("turn {t}")); + } + + let cfg = AppConfig::new_test(); + let rt = runtime(); + + let cases = [ + (ToolCallsMode::Strip, ToolCallPolicy::Strip { + request: true, + response: true, + }), + (ToolCallsMode::StripRequests, ToolCallPolicy::Strip { + request: true, + response: false, + }), + (ToolCallsMode::StripResponses, ToolCallPolicy::Strip { + request: false, + response: true, + }), + (ToolCallsMode::Omit, ToolCallPolicy::Omit), + ]; + + for (mode, expected) in cases { + let rule = CompactionRuleConfig { + keep_first: RuleBound::Turns(0), + keep_last: RuleBound::Turns(0), + reasoning: None, + tool_calls: Some(mode), + summary: None, + }; + let compaction = rt + .block_on(build_compaction_event( + &stream, + &cfg, + &rule, + None, + None, + &Printer::sink(), + )) + .unwrap() + .expect("non-empty range"); + assert_eq!(compaction.tool_calls, Some(expected), "mode {mode:?}"); + } +} + +/// End-to-end: a resolved config rule flows through `build_compaction_events` +/// into a `Compaction` event with the right range and policy, and projecting +/// the stream applies it — blanking request args in-range while keeping +/// responses and leaving out-of-range turns untouched. +#[test] +fn config_rule_strip_requests_blanks_args_through_projection() { + // 6-turn stream, each turn carrying one tool call with arguments. + let mut stream = ConversationStream::new_test(); + for t in 0..6 { + stream.start_turn(format!("turn {t}")); + stream + .current_turn_mut() + .add_tool_call_request(ToolCallRequest { + id: format!("t{t}"), + name: "tool".into(), + arguments: Map::from_iter([("k".into(), Value::from("v"))]), + }) + .add_tool_call_response(ToolCallResponse { + id: format!("t{t}"), + result: Ok("ok".into()), + }) + .build() + .unwrap(); + } + + // Resolved config rule: strip requests, keep first 1 and last 1. + let mut cfg = AppConfig::new_test(); + cfg.conversation.compaction.rules = vec![CompactionRuleConfig { + keep_first: RuleBound::Turns(1), + keep_last: RuleBound::Turns(1), + reasoning: None, + tool_calls: Some(ToolCallsMode::StripRequests), + summary: None, + }]; + + let compactions = runtime() + .block_on(build_compaction_events_from_config( + &stream, + &cfg, + None, + None, + &Printer::sink(), + )) + .unwrap(); + + // One rule -> one compaction. keep_first=1/keep_last=1 over 6 turns -> 1..=4, + // and `strip-requests` maps to `Strip { request: true, response: false }`. + assert_eq!(compactions.len(), 1); + assert_eq!((compactions[0].from_turn, compactions[0].to_turn), (1, 4)); + assert_eq!( + compactions[0].tool_calls, + Some(ToolCallPolicy::Strip { + request: true, + response: false, + }) + ); + + for compaction in compactions { + stream.add_compaction(compaction); + } + stream.apply_projection(); + + // Turns 1..=4: request args blanked, responses preserved. Turns 0 and 5 + // are out of range and untouched. + for t in 0..6 { + let req = stream + .iter() + .filter_map(|e| e.event.as_tool_call_request()) + .find(|r| r.id == format!("t{t}")) + .expect("request present"); + + if (1..=4).contains(&t) { + assert!(req.arguments.is_empty(), "turn {t} args should be blanked"); + let resp = stream.find_tool_call_response(&format!("t{t}")).unwrap(); + assert_eq!(resp.content(), "ok", "turn {t} response preserved"); + } else { + assert!(!req.arguments.is_empty(), "turn {t} args untouched"); + } + } +} diff --git a/crates/jp_cli/src/cmd/conversation/fork.rs b/crates/jp_cli/src/cmd/conversation/fork.rs index b39682b0..bc9c86d9 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)] @@ -47,6 +47,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, @@ -57,7 +61,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| { @@ -75,6 +79,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()); @@ -95,6 +116,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 be1ec714..45594544 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)] @@ -38,6 +41,7 @@ fn test_conversation_fork() { last: None, first: None, title: None, + compact: CompactFlag::default(), }, setup: |ctx| { let id = ConversationId::try_from(ctx.now()).unwrap(); @@ -81,6 +85,7 @@ fn test_conversation_fork() { last: None, first: None, title: None, + compact: CompactFlag::default(), }, setup: |ctx| { let id = ConversationId::try_from(ctx.now()).unwrap(); @@ -134,6 +139,7 @@ fn test_conversation_fork() { last: None, first: None, title: None, + compact: CompactFlag::default(), }, setup: |ctx| { let id = ConversationId::try_from(ctx.now()).unwrap(); @@ -189,6 +195,7 @@ fn test_conversation_fork() { last: None, first: None, title: None, + compact: CompactFlag::default(), }, setup: |ctx| { let id = ConversationId::try_from(ctx.now()).unwrap(); @@ -243,6 +250,7 @@ fn test_conversation_fork() { last: None, first: None, title: None, + compact: CompactFlag::default(), }, setup: |ctx| { let id = ConversationId::try_from(ctx.now()).unwrap(); @@ -298,6 +306,7 @@ fn test_conversation_fork() { last: Some(None), first: None, title: None, + compact: CompactFlag::default(), }, setup: |ctx| { let id = ConversationId::try_from(ctx.now()).unwrap(); @@ -376,6 +385,7 @@ fn test_conversation_fork() { last: Some(Some(2)), first: None, title: None, + compact: CompactFlag::default(), }, setup: |ctx| { let id = ConversationId::try_from(ctx.now()).unwrap(); @@ -453,6 +463,7 @@ fn test_conversation_fork() { last: Some(Some(10)), first: None, title: None, + compact: CompactFlag::default(), }, setup: |ctx| { let id = ConversationId::try_from(ctx.now()).unwrap(); @@ -498,6 +509,7 @@ fn test_conversation_fork() { last: Some(Some(1)), first: None, title: None, + compact: CompactFlag::default(), }, setup: |ctx| { let id = ConversationId::try_from(ctx.now()).unwrap(); @@ -538,6 +550,7 @@ fn test_conversation_fork() { from: None, until: None, last: None, + compact: CompactFlag::default(), first: Some(None), title: None, }, @@ -615,6 +628,7 @@ fn test_conversation_fork() { from: None, until: None, last: Some(Some(1)), + compact: CompactFlag::default(), first: Some(Some(2)), title: None, }, @@ -685,6 +699,7 @@ fn test_conversation_fork() { from: None, until: None, last: None, + compact: CompactFlag::default(), first: Some(Some(10)), title: None, }, @@ -730,6 +745,7 @@ fn test_conversation_fork() { from: None, until: None, last: None, + compact: CompactFlag::default(), first: None, title: Some("my custom title".to_owned()), }, @@ -763,6 +779,7 @@ fn test_conversation_fork() { last: None, first: None, title: None, + compact: CompactFlag::default(), }, setup: |ctx| { let id = ConversationId::try_from(ctx.now()).unwrap(); @@ -852,7 +869,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"); @@ -967,10 +987,14 @@ fn fork_targets_correct_source() { until: None, last: None, first: 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 def90457..5d2d1a5a 100644 --- a/crates/jp_cli/src/cmd/conversation/print.rs +++ b/crates/jp_cli/src/cmd/conversation/print.rs @@ -82,6 +82,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`. @@ -112,7 +117,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(); @@ -125,8 +137,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 ccf5ddca..0d77b6ec 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]); @@ -302,6 +309,7 @@ fn structured_fence_is_closed_at_end_of_replay() { turn: None, current_config: false, style: None, + compacted: false, }; let h = ctx.workspace.acquire_conversation(&id).unwrap(); print.run(&mut ctx, &[h]).unwrap(); @@ -341,6 +349,7 @@ fn structured_response_followed_by_message_closes_fence_first() { turn: None, current_config: false, style: None, + compacted: false, }; let h = ctx.workspace.acquire_conversation(&id).unwrap(); print.run(&mut ctx, &[h]).unwrap(); @@ -389,6 +398,7 @@ fn structured_to_message_in_same_turn_closes_fence_first() { turn: None, current_config: false, style: None, + compacted: false, }; let h = ctx.workspace.acquire_conversation(&id).unwrap(); print.run(&mut ctx, &[h]).unwrap(); @@ -427,6 +437,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]); @@ -451,6 +462,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]); @@ -474,6 +486,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]); @@ -526,6 +539,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]); @@ -566,6 +580,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]); @@ -607,6 +622,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]); @@ -636,6 +652,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]); @@ -681,6 +698,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]); @@ -734,6 +752,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]); @@ -803,6 +822,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]); @@ -840,6 +860,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]); @@ -875,6 +896,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]); @@ -913,6 +935,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]); @@ -932,6 +955,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]); @@ -977,6 +1001,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]); @@ -1054,6 +1079,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]); @@ -1107,6 +1133,7 @@ fn role_header_renders_user_label_from_author() { turn: None, current_config: false, style: None, + compacted: false, }; let h = ctx.workspace.acquire_conversation(&id).unwrap(); print.run(&mut ctx, &[h]).unwrap(); @@ -1132,6 +1159,7 @@ fn role_header_falls_back_to_user_label_without_author() { turn: None, current_config: false, style: None, + compacted: false, }; let h = ctx.workspace.acquire_conversation(&id).unwrap(); print.run(&mut ctx, &[h]).unwrap(); @@ -1157,6 +1185,7 @@ fn role_header_renders_assistant_label_with_model_suffix() { turn: None, current_config: false, style: None, + compacted: false, }; let h = ctx.workspace.acquire_conversation(&id).unwrap(); print.run(&mut ctx, &[h]).unwrap(); @@ -1183,6 +1212,7 @@ fn role_header_assistant_appears_once_per_turn() { turn: None, current_config: false, style: None, + compacted: false, }; let h = ctx.workspace.acquire_conversation(&id).unwrap(); print.run(&mut ctx, &[h]).unwrap(); @@ -1225,6 +1255,7 @@ fn role_header_assistant_emitted_before_first_tool_call() { turn: None, current_config: false, style: None, + compacted: false, }; let h = ctx.workspace.acquire_conversation(&id).unwrap(); print.run(&mut ctx, &[h]).unwrap(); @@ -1256,6 +1287,7 @@ fn role_header_does_not_emit_plain_hr_separator() { turn: None, current_config: false, style: None, + compacted: false, }; let h = ctx.workspace.acquire_conversation(&id).unwrap(); print.run(&mut ctx, &[h]).unwrap(); @@ -1306,6 +1338,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(); @@ -1357,6 +1390,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(); @@ -1418,6 +1452,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..db13692b --- /dev/null +++ b/crates/jp_cli/src/cmd/conversation/summarize.rs @@ -0,0 +1,150 @@ +//! 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()), + // `Patch` is applied upstream; `KeepAlive` is a liveness signal. + Event::Patch(_) | Event::KeepAlive => {} + } + } + + 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 5c5866d7..b67bfd58 100644 --- a/crates/jp_cli/src/cmd/init.rs +++ b/crates/jp_cli/src/cmd/init.rs @@ -386,6 +386,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 19a731bf..4a1f3238 100644 --- a/crates/jp_cli/src/cmd/query.rs +++ b/crates/jp_cli/src/cmd/query.rs @@ -325,6 +325,10 @@ pub(crate) struct Query { #[arg(short = 'U', long = "no-tool-use")] no_tool_use: bool, + /// Compact the conversation before querying. + #[command(flatten)] + compact: crate::cmd::compact_flag::CompactFlag, + /// Mount an external path into the workspace as a symlink and grant the /// assistant access to it. /// @@ -377,6 +381,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) = { @@ -832,6 +841,32 @@ 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?; + + super::conversation::compact::apply_compactions(&lock.as_mut(), compactions, &ctx.printer); + + Ok(()) + } + async fn acquire_lock( &self, ctx: &mut Ctx, @@ -1086,6 +1121,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, @@ -1111,6 +1147,7 @@ impl IntoPartialAppConfig for Query { expires_in: _, target: _, fork: _, + compact, title: _, no_title: _, mount, @@ -1151,6 +1188,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 7366f1a9..3d373934 100644 --- a/crates/jp_cli/src/cmd/query_tests.rs +++ b/crates/jp_cli/src/cmd/query_tests.rs @@ -101,7 +101,7 @@ fn build_query_config( .unwrap(); partial = query - .apply_cli_config(Some(workspace), partial, None) + .apply_cli_config(Some(workspace), partial, None, &[]) .unwrap(); build(partial).unwrap() @@ -160,6 +160,7 @@ fn test_query_tools_and_no_tools() { None, partial, None, + &[], ) .unwrap(); @@ -191,6 +192,7 @@ fn test_query_tools_and_no_tools() { None, partial, None, + &[], ) .unwrap(); @@ -222,6 +224,7 @@ fn test_query_tools_and_no_tools() { None, partial, None, + &[], ) .unwrap(); @@ -251,6 +254,7 @@ fn test_query_tools_and_no_tools() { None, partial, None, + &[], ) .unwrap(); @@ -281,6 +285,7 @@ fn test_query_tools_and_no_tools() { None, partial, None, + &[], ) .unwrap(); @@ -313,6 +318,7 @@ fn test_query_tools_and_no_tools() { None, partial, None, + &[], ) .unwrap(); @@ -347,6 +353,7 @@ fn test_explicit_tool_enabled_by_name() { None, partial, None, + &[], ) .unwrap(); @@ -374,6 +381,7 @@ fn test_enable_all_and_explicit_by_name() { None, partial, None, + &[], ) .unwrap(); @@ -405,6 +413,7 @@ fn test_enable_all_skips_unnamed_explicit() { None, partial, None, + &[], ) .unwrap(); @@ -438,6 +447,7 @@ fn test_interleaved_disable_all_then_enable_named() { None, partial, None, + &[], ) .unwrap(); @@ -477,6 +487,7 @@ fn test_interleaved_enable_all_then_disable_named() { None, partial, None, + &[], ) .unwrap(); @@ -511,6 +522,7 @@ fn test_interleaved_disable_all_then_enable_all() { None, partial, None, + &[], ) .unwrap(); @@ -551,6 +563,7 @@ fn test_interleaved_three_step_composition() { None, partial, None, + &[], ) .unwrap(); @@ -595,7 +608,7 @@ fn query_model_override_is_persisted_as_config_delta() { }; let partial = query - .apply_cli_config(None, base_config.to_partial(), None) + .apply_cli_config(None, base_config.to_partial(), None, &[]) .unwrap(); let runtime_config = build(partial).unwrap(); @@ -803,9 +816,11 @@ fn no_title_does_not_persist_into_partial_config() { no_title: true, ..Default::default() } - .apply_cli_config(None, base.clone(), None) + .apply_cli_config(None, base.clone(), None, &[]) .unwrap(); - let without_flag = Query::default().apply_cli_config(None, base, None).unwrap(); + let without_flag = Query::default() + .apply_cli_config(None, base, None, &[]) + .unwrap(); assert_eq!( with_flag.conversation.title.generate.auto, diff --git a/crates/jp_cli/src/ctx.rs b/crates/jp_cli/src/ctx.rs index 88ade11e..99b3aa77 100644 --- a/crates/jp_cli/src/ctx.rs +++ b/crates/jp_cli/src/ctx.rs @@ -195,18 +195,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 f96412f8..c3e23f3e 100644 --- a/crates/jp_cli/src/error.rs +++ b/crates/jp_cli/src/error.rs @@ -135,4 +135,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 8f829a5b..887cac78 100644 --- a/crates/jp_cli/src/lib.rs +++ b/crates/jp_cli/src/lib.rs @@ -40,7 +40,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::{ @@ -419,57 +419,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, @@ -709,6 +668,71 @@ 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 ec05acd5..fd68db57 100644 --- a/crates/jp_cli/src/lib_tests.rs +++ b/crates/jp_cli/src/lib_tests.rs @@ -548,3 +548,37 @@ fn query_model_override_persists_config_delta_through_session_targeting() { None => unsafe { env::remove_var("EDITOR") }, } } + +/// 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 c65c2a70..5906352b 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}, }, @@ -47,6 +49,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 @@ -93,6 +102,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()?, @@ -109,6 +119,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() @@ -128,6 +139,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), @@ -143,6 +155,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..9fb056b5 --- /dev/null +++ b/crates/jp_config/src/conversation/compaction.rs @@ -0,0 +1,433 @@ +//! Compaction configuration for conversations. + +use std::{fmt, mem, str::FromStr}; + +use schematic::{Config, ConfigEnum, PartialConfig as _}; +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 { + // `CompactionRuleConfig`'s per-field defaults (`keep_first = 1`, + // `keep_last = 3`) live in `default_values`, which the resolution path + // (`from_partial_with_defaults`) never reaches for vec elements — it + // fills the container, not each rule. Apply them per rule here so unset + // bounds resolve to the documented defaults rather than + // `RuleBound::default()` (0), which would compact the whole conversation. + let rule_defaults = PartialCompactionRuleConfig::default_values(&()) + .ok() + .flatten() + .unwrap_or_default(); + + let mut rules = self.rules.fill_from(defaults.rules); + for rule in rules.iter_mut() { + *rule = mem::take(rule).fill_from(rule_defaults.clone()); + } + + Self { 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, with short aliases: +/// +/// - `"strip"` / `"s"` → strip both request arguments and response content +/// - `"strip-responses"` / `"sres"` → strip response content only +/// - `"strip-requests"` / `"sreq"` → strip request arguments only +/// - `"omit"` / `"o"` → 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" | "s" => Ok(Self::Strip), + "strip-responses" | "strip_responses" | "sres" => Ok(Self::StripResponses), + "strip-requests" | "strip_requests" | "sreq" => Ok(Self::StripRequests), + "omit" | "o" => 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..3fa8dad0 --- /dev/null +++ b/crates/jp_config/src/conversation/compaction_tests.rs @@ -0,0 +1,90 @@ +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_parse_short_aliases() { + assert_eq!("s".parse::().unwrap(), ToolCallsMode::Strip); + assert_eq!( + "sreq".parse::().unwrap(), + ToolCallsMode::StripRequests + ); + assert_eq!( + "sres".parse::().unwrap(), + ToolCallsMode::StripResponses + ); + assert_eq!("o".parse::().unwrap(), ToolCallsMode::Omit); +} + +#[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/conversation/tool_tests.rs b/crates/jp_config/src/conversation/tool_tests.rs index 64fc0cd9..8e7d0102 100644 --- a/crates/jp_config/src/conversation/tool_tests.rs +++ b/crates/jp_config/src/conversation/tool_tests.rs @@ -13,7 +13,7 @@ fn access_on_mcp_tool_is_rejected_by_validation() { util::build, }; - let mut partial = PartialAppConfig::stub(); + let mut partial = PartialAppConfig::new_test(); partial .conversation .tools @@ -49,7 +49,7 @@ fn access_on_local_tool_is_accepted_by_validation() { util::build, }; - let mut partial = PartialAppConfig::stub(); + let mut partial = PartialAppConfig::new_test(); partial .conversation .tools diff --git a/crates/jp_config/src/lib.rs b/crates/jp_config/src/lib.rs index 3720cc11..d98798f9 100644 --- a/crates/jp_config/src/lib.rs +++ b/crates/jp_config/src/lib.rs @@ -319,22 +319,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. @@ -554,25 +539,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/lib_tests.rs b/crates/jp_config/src/lib_tests.rs index a3c489c5..10b6df2e 100644 --- a/crates/jp_config/src/lib_tests.rs +++ b/crates/jp_config/src/lib_tests.rs @@ -211,6 +211,39 @@ fn build_resolves_chained_aliases() { assert_eq!(resolved.name.to_string(), "claude-opus-4"); } +#[test] +fn compaction_rule_unset_bounds_resolve_to_field_defaults() { + use crate::{ + conversation::{ + compaction::{PartialCompactionRuleConfig, RuleBound, ToolCallsMode}, + tool::RunMode, + }, + model::id::{PartialModelIdConfig, PartialModelIdOrAliasConfig, ProviderId}, + types::vec::MergeableVec, + util::build, + }; + + let mut partial = PartialAppConfig::default(); + partial.conversation.tools.defaults.run = Some(RunMode::Ask); + partial.assistant.model.id = PartialModelIdOrAliasConfig::Id(PartialModelIdConfig { + provider: Some(ProviderId::Anthropic), + name: "claude-opus-4".parse().ok(), + }); + + // A rule that sets only a tool-call policy, leaving keep_first/keep_last + // unset — exactly what `jp c compact -t sreq` produces. + partial.conversation.compaction.rules = MergeableVec::Vec(vec![PartialCompactionRuleConfig { + tool_calls: Some(ToolCallsMode::StripRequests), + ..Default::default() + }]); + + let config = build(partial).expect("valid config"); + + let rule = &config.conversation.compaction.rules[0]; + assert_eq!(rule.keep_first, RuleBound::Turns(1)); + assert_eq!(rule.keep_last, RuleBound::Turns(3)); +} + #[test] fn build_rejects_alias_cycle() { use crate::{ diff --git a/crates/jp_config/src/model/id.rs b/crates/jp_config/src/model/id.rs index 296bf5e7..d979ab2b 100644 --- a/crates/jp_config/src/model/id.rs +++ b/crates/jp_config/src/model/id.rs @@ -34,6 +34,8 @@ pub enum ModelIdOrAliasConfig { /// /// The matching [`ModelIdConfig`] can be fetched using /// [`LlmProviderConfig::aliases`]. + /// + /// [`LlmProviderConfig::aliases`]: crate::providers::llm::LlmProviderConfig::aliases #[setting(with = "alias")] Alias(String), } 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 d8a6528a..85b13ce0 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 @@ -84,6 +84,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 e792cf37..bc3dda0c 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 @@ -67,6 +67,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 31726913..89b20cc3 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 @@ -123,6 +123,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 fc61467b..23b7458e 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 @@ -67,6 +67,11 @@ PartialAppConfig { }, tools: {}, }, + compaction: PartialCompactionConfig { + rules: Vec( + [], + ), + }, attachments: Vec( [], ), diff --git a/crates/jp_conversation/Cargo.toml b/crates/jp_conversation/Cargo.toml index a9c09f30..2c1df695 100644 --- a/crates/jp_conversation/Cargo.toml +++ b/crates/jp_conversation/Cargo.toml @@ -28,6 +28,7 @@ tracing = { workspace = true } [dev-dependencies] insta = { workspace = true, features = ["json"] } +proptest = { workspace = true, features = ["std"] } test-log = { workspace = true } [lints] diff --git a/crates/jp_conversation/src/compaction.rs b/crates/jp_conversation/src/compaction.rs new file mode 100644 index 00000000..5450ad03 --- /dev/null +++ b/crates/jp_conversation/src/compaction.rs @@ -0,0 +1,262 @@ +//! 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 { + /// Blank request arguments and/or replace response content with a status + /// line. + /// Preserves tool name, call ID, and success/error status. + Strip { + /// Empty the arguments to `{}`. + 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 9fd858a8..46befaf8 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 68172efa..3c001eaf 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}, @@ -41,6 +43,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 { @@ -69,10 +75,42 @@ 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) + } } } } +/// Whether an [`InternalEvent`] belongs to a single turn or applies to the +/// conversation as a whole. +/// +/// This is the single source of truth for which events survive turn-level +/// pruning (`pop`, `trim_chat_request`, `pop_if`, `retain`). +/// Adding a new `InternalEvent` variant forces a classification here — +/// [`InternalEvent::scope`] is an exhaustive match — so no pruning caller can +/// silently mistreat it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum EventScope { + /// Survives turn pruning: config deltas and compaction overlays apply to + /// the conversation regardless of position. + Global, + /// Belongs to a turn and is removed when that turn is pruned. + Turn, +} + impl InternalEvent { /// Convert an internal event into an [`ConversationEvent`]. /// Returns `None` if the event is a config delta. @@ -80,7 +118,7 @@ impl InternalEvent { fn into_event(self) -> Option { match self { Self::Event(event) => Some(*event), - Self::ConfigDelta(_) => None, + Self::ConfigDelta(_) | Self::Compaction(_) => None, } } @@ -89,7 +127,17 @@ impl InternalEvent { fn as_event(&self) -> Option<&ConversationEvent> { match self { Self::Event(event) => Some(event), - Self::ConfigDelta(_) => None, + Self::ConfigDelta(_) | Self::Compaction(_) => None, + } + } + + /// Classify the event as turn-scoped or global. + /// See [`EventScope`]. + #[must_use] + const fn scope(&self) -> EventScope { + match self { + Self::ConfigDelta(_) | Self::Compaction(_) => EventScope::Global, + Self::Event(_) => EventScope::Turn, } } } @@ -271,7 +319,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 { @@ -283,18 +331,38 @@ impl ConversationStream { /// Removes all events from the end of the stream, until a [`ChatRequest`] /// is found, returning that request. + /// + /// [`ConfigDelta`] and [`Compaction`] overlays encountered while trimming + /// are preserved (re-appended) rather than discarded — they apply to the + /// conversation as a whole, not to the turn being replayed. #[must_use] pub fn trim_chat_request(&mut self) -> Option { - loop { - if let Some(event) = self - .events - .pop()? - .into_event() - .and_then(ConversationEvent::into_chat_request) - { - return Some(event); + let mut preserved = Vec::new(); + let mut request = None; + + while let Some(internal) = self.events.pop() { + match internal.scope() { + EventScope::Global => preserved.push(internal), + EventScope::Turn => { + if let Some(req) = internal + .into_event() + .and_then(ConversationEvent::into_chat_request) + { + request = Some(req); + break; + } + // A non-chat-request conversation event (response, + // reasoning, tool call) — part of the replayed turn; drop. + } } } + + // Re-append preserved overlays in their original order, whether or not + // a request was found, so they survive the trim. + preserved.reverse(); + self.events.append(&mut preserved); + + request } /// Add a config delta to the stream. @@ -327,10 +395,54 @@ 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. /// This is the only public way to create turn boundaries. + /// + /// Global events (config deltas, compactions) are position-independent and + /// invisible to turn iteration ([`Self::iter`] skips them), so no attempt + /// is made to associate trailing globals with the new turn. pub fn start_turn(&mut self, request: impl Into) { self.push(ConversationEvent::now(TurnStart)); self.push(ConversationEvent::now(request.into())); @@ -441,28 +553,23 @@ impl ConversationStream { /// [`PartialAppConfig`] at the time the event was added. #[must_use] pub fn pop(&mut self) -> Option { - loop { - let config = match self.events.last() { - // No events, so we're done. - None => return None, - - // If the last event is a `ConversationEvent`, we handle it. - Some(InternalEvent::Event(_)) => self - .last() - .map_or_else(|| self.base_config.to_partial(), |v| v.config), - - // Any other event we remove, and continue. - _ => { - self.events.pop(); - continue; - } - }; + // The last conversation event, ignoring any trailing overlays + // (`ConfigDelta`/`Compaction`) that follow it. Those overlays are left + // in place rather than discarded — a `Compaction` references turn + // ranges and applies regardless of tail position. + let pos = self + .events + .iter() + .rposition(|e| e.scope() == EventScope::Turn)?; - return self.events.pop().and_then(|e| { - e.into_event() - .map(|event| ConversationEventWithConfig { event, config }) - }); - } + let config = self + .last() + .map_or_else(|| self.base_config.to_partial(), |v| v.config); + + self.events + .remove(pos) + .into_event() + .map(|event| ConversationEventWithConfig { event, config }) } /// Similar to [`Self::pop`], but only pops if the predicate returns `true`. @@ -470,16 +577,17 @@ impl ConversationStream { &mut self, f: impl Fn(&ConversationEvent) -> bool, ) -> Option { - if !self + let last_turn_event_matches = self .events .iter() .rev() - .find_map(|event| match event { - InternalEvent::Event(event) => Some(f(event)), - InternalEvent::ConfigDelta(_) => None, + .find_map(|event| match event.scope() { + EventScope::Turn => event.as_event().map(&f), + EventScope::Global => None, }) - .unwrap_or(false) - { + .unwrap_or(false); + + if !last_turn_event_matches { return None; } @@ -488,11 +596,11 @@ 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::Event(event) => f(event), + self.events.retain(|event| match event.scope() { + EventScope::Global => true, + EventScope::Turn => event.as_event().is_some_and(&mut f), }); } @@ -560,7 +668,7 @@ impl ConversationStream { return true; } match event { - InternalEvent::ConfigDelta(_) => true, + InternalEvent::ConfigDelta(_) | InternalEvent::Compaction(_) => true, InternalEvent::Event(e) => e.is_turn_start(), } }); @@ -790,6 +898,39 @@ 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. @@ -1027,6 +1168,7 @@ impl Iterator for IntoIter { config: self.current_config.clone(), }); } + InternalEvent::Compaction(_) => {} } } } @@ -1038,10 +1180,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 @@ -1103,6 +1245,7 @@ impl<'a> Iterator for Iter<'a> { config: self.front_config.clone(), }); } + InternalEvent::Compaction(_) => {} } } @@ -1162,6 +1305,7 @@ impl<'a> Iterator for IterMut<'a> { config: self.front_config.clone(), }); } + InternalEvent::Compaction(_) => {} } } @@ -1477,6 +1621,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..95a0ae8b --- /dev/null +++ b/crates/jp_conversation/src/stream/projection.rs @@ -0,0 +1,274 @@ +//! 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; + +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**: blanks request arguments and/or replaces response +/// content with a status line. +/// - **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, + )))); +} + +/// Blank a tool call request's arguments. +/// +/// Arguments are the dominant token sink (file contents, patches, prompts) and +/// aren't needed once a turn is compacted — the tool name, call ID, and (when +/// kept) the response carry the meaning. +/// Emptied to `{}` rather than a placeholder so there is nothing for the model +/// to echo into a live call. +fn strip_tool_request(event: &mut ConversationEvent) { + if let Some(req) = event.as_tool_call_request_mut() { + req.arguments = Map::new(); + } +} + +/// 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..12f3b404 --- /dev/null +++ b/crates/jp_conversation/src/stream/projection_tests.rs @@ -0,0 +1,1210 @@ +use std::collections::HashSet; + +use chrono::{TimeZone as _, Utc}; +use proptest::prelude::*; +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_request_only_blanks_args_and_keeps_response() { + 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: false, + }), + }); + + stream.apply_projection(); + + // The request that originally carried arguments is now blanked, but its + // name and call ID survive — and there's no `[compacted]` marker to echo. + let req = stream + .iter() + .filter_map(|e| e.event.as_tool_call_request().cloned()) + .find(|r| r.id == "tc2") + .expect("tc2 request present"); + assert!( + req.arguments.is_empty(), + "args blanked: {:?}", + req.arguments + ); + assert_eq!(req.name, "fs_modify_file", "tool name preserved"); + + // The response is left untouched under `strip-requests`. + let resp = stream.find_tool_call_response("tc2").unwrap(); + assert!( + !resp.content().starts_with("[compacted]"), + "response must be preserved: {}", + resp.content() + ); +} + +#[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 their arguments blanked. + for kind in &events { + if let EventKind::ToolCallRequest(req) = kind { + assert!( + req.arguments.is_empty(), + "Request arguments should be blanked: {:?}", + req.arguments + ); + } + } + + // 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); +} + +// --------------------------------------------------------------------------- +// Property tests: arbitrary streams + arbitrary compaction strategies +// --------------------------------------------------------------------------- + +/// One turn's optional contents. +/// Every turn also gets a `TurnStart` + request. +#[derive(Debug, Clone)] +struct TurnSpec { + reasoning: bool, + tool_calls: usize, + message: bool, +} + +/// A compaction overlay to append, with normalized (`from <= to`) bounds. +#[derive(Debug, Clone)] +struct CompactionSpec { + from: usize, + to: usize, + /// 0=reasoning, 1=strip both, 2=strip request, 3=strip response, 4=omit, + /// 5=summary. + policy: u8, +} + +fn turn_spec() -> impl Strategy { + (any::(), 0_usize..3, any::()).prop_map(|(reasoning, tool_calls, message)| { + TurnSpec { + reasoning, + tool_calls, + message, + } + }) +} + +fn compaction_spec() -> impl Strategy { + // Bounds range past the turn count so out-of-range / inverted ranges are + // exercised too. + (0_usize..8, 0_usize..8, 0_u8..6).prop_map(|(a, b, policy)| CompactionSpec { + from: a.min(b), + to: a.max(b), + policy, + }) +} + +fn build_arbitrary_stream(turns: &[TurnSpec]) -> ConversationStream { + let mut stream = ConversationStream::new_test(); + // Turn event timestamps don't affect projection (turns are delimited by + // `TurnStart` position, not time), so a constant is fine. + let at = ts(0); + for (t, spec) in turns.iter().enumerate() { + stream.push(ConversationEvent::new(TurnStart, at)); + stream.push(ConversationEvent::new(ChatRequest::from("q"), at)); + if spec.reasoning { + stream.push(ConversationEvent::new(ChatResponse::reasoning("r"), at)); + } + for i in 0..spec.tool_calls { + let id = format!("t{t}c{i}"); + stream.push(ConversationEvent::new( + ToolCallRequest { + id: id.clone(), + name: "tool".into(), + arguments: Map::from_iter([("k".into(), "v".into())]), + }, + at, + )); + stream.push(ConversationEvent::new( + ToolCallResponse { + id, + result: Ok("ok".into()), + }, + at, + )); + } + if spec.message { + stream.push(ConversationEvent::new(ChatResponse::message("m"), at)); + } + } + stream +} + +fn spec_to_compaction(spec: &CompactionSpec) -> Compaction { + let (summary, reasoning, tool_calls) = match spec.policy { + 0 => (None, Some(ReasoningPolicy::Strip), None), + 1 => ( + None, + None, + Some(ToolCallPolicy::Strip { + request: true, + response: true, + }), + ), + 2 => ( + None, + None, + Some(ToolCallPolicy::Strip { + request: true, + response: false, + }), + ), + 3 => ( + None, + None, + Some(ToolCallPolicy::Strip { + request: false, + response: true, + }), + ), + 4 => (None, None, Some(ToolCallPolicy::Omit)), + _ => ( + Some(SummaryPolicy { + summary: "s".into(), + }), + None, + None, + ), + }; + + Compaction { + // All compactions share a timestamp; the invariants under test hold + // regardless of which overlapping policy wins the latest-timestamp + // contest. + timestamp: ts(1), + from_turn: spec.from, + to_turn: spec.to, + summary, + reasoning, + tool_calls, + } +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(512))] + + /// For any stream and any set of compaction overlays, `apply_projection` + /// must hold these invariants regardless of strategy mix or range: + /// + /// - it does not panic, + /// - it consumes every compaction overlay, + /// - it leaves no orphaned tool response (one without its request), + /// - re-applying it to the now-overlay-free stream is a no-op. + #[test] + fn projection_holds_invariants( + turns in proptest::collection::vec(turn_spec(), 0..6), + specs in proptest::collection::vec(compaction_spec(), 0..5), + ) { + let mut stream = build_arbitrary_stream(&turns); + for spec in &specs { + stream.add_compaction(spec_to_compaction(spec)); + } + + // No panic (reaching here), and all overlays consumed. + stream.apply_projection(); + prop_assert_eq!(stream.compactions().count(), 0); + + // No orphaned tool responses: every response keeps a matching request. + let request_ids: HashSet = stream + .iter() + .filter_map(|e| e.event.as_tool_call_request().map(|r| r.id.clone())) + .collect(); + for e in stream.iter() { + if let Some(resp) = e.event.as_tool_call_response() { + prop_assert!( + request_ids.contains(&resp.id), + "orphaned tool response after projection: {}", + resp.id + ); + } + } + + // Idempotent: re-projecting an overlay-free stream changes nothing. + let snapshot = stream.clone(); + stream.apply_projection(); + prop_assert!(stream == snapshot, "re-projection must be a no-op"); + } +} + +// --------------------------------------------------------------------------- +// Property test: each strategy applies to exactly its (disjoint) turns +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum MechPolicy { + Reasoning, + StripBoth, + StripRequest, + StripResponse, + Omit, +} + +fn mech_policy() -> impl Strategy { + prop_oneof![ + Just(MechPolicy::Reasoning), + Just(MechPolicy::StripBoth), + Just(MechPolicy::StripRequest), + Just(MechPolicy::StripResponse), + Just(MechPolicy::Omit), + ] +} + +/// Lay segments out over `[0, num_turns)` as disjoint, ordered ranges with +/// gaps, so every turn is covered by at most one compaction. +fn layout_disjoint( + num_turns: usize, + segs: &[(usize, usize, MechPolicy)], +) -> Vec<(usize, usize, MechPolicy)> { + let mut out = Vec::new(); + let mut cursor: usize = 0; + for &(gap, len, policy) in segs { + cursor = cursor.saturating_add(gap); + if cursor >= num_turns { + break; + } + let to = (cursor + len.max(1) - 1).min(num_turns - 1); + out.push((cursor, to, policy)); + cursor = to + 1; + } + out +} + +/// Build a stream whose reasoning text, tool-call IDs, and messages are tagged +/// with their turn index, so each turn's content is locatable in the projected +/// output. +fn build_tagged_stream(turns: &[TurnSpec]) -> ConversationStream { + let mut stream = ConversationStream::new_test(); + let at = ts(0); + for (t, spec) in turns.iter().enumerate() { + stream.push(ConversationEvent::new(TurnStart, at)); + stream.push(ConversationEvent::new( + ChatRequest::from(format!("q{t}")), + at, + )); + if spec.reasoning { + stream.push(ConversationEvent::new( + ChatResponse::reasoning(format!("r{t}")), + at, + )); + } + for i in 0..spec.tool_calls { + let id = format!("t{t}c{i}"); + stream.push(ConversationEvent::new( + ToolCallRequest { + id: id.clone(), + name: "tool".into(), + arguments: Map::from_iter([("k".into(), "v".into())]), + }, + at, + )); + stream.push(ConversationEvent::new( + ToolCallResponse { + id, + result: Ok("ok".into()), + }, + at, + )); + } + if spec.message { + stream.push(ConversationEvent::new( + ChatResponse::message(format!("m{t}")), + at, + )); + } + } + stream +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(256))] + + /// Each mechanical strategy, applied to a disjoint turn range, must do + /// exactly what it claims to *its* turns and leave uncovered turns + /// untouched. + /// Disjoint ranges keep each turn under a single policy, so the expected + /// per-turn outcome is known without an oracle. + /// (Summary is excluded here because it collapses turn structure; it's + /// covered by the unit tests and the invariant fuzzer.) + #[test] + fn each_strategy_applies_to_its_turns( + turns in proptest::collection::vec(turn_spec(), 0..12), + segs in proptest::collection::vec((0_usize..3, 1_usize..4, mech_policy()), 0..5), + ) { + let num_turns = turns.len(); + let ranges = layout_disjoint(num_turns, &segs); + + let mut policy_by_turn: Vec> = vec![None; num_turns]; + let mut stream = build_tagged_stream(&turns); + for &(from, to, policy) in &ranges { + for slot in &mut policy_by_turn[from..=to] { + *slot = Some(policy); + } + let (reasoning, tool_calls) = match policy { + MechPolicy::Reasoning => (Some(ReasoningPolicy::Strip), None), + MechPolicy::StripBoth => ( + None, + Some(ToolCallPolicy::Strip { + request: true, + response: true, + }), + ), + MechPolicy::StripRequest => ( + None, + Some(ToolCallPolicy::Strip { + request: true, + response: false, + }), + ), + MechPolicy::StripResponse => ( + None, + Some(ToolCallPolicy::Strip { + request: false, + response: true, + }), + ), + MechPolicy::Omit => (None, Some(ToolCallPolicy::Omit)), + }; + stream.add_compaction(Compaction { + timestamp: ts(1), + from_turn: from, + to_turn: to, + summary: None, + reasoning, + tool_calls, + }); + } + + stream.apply_projection(); + + for t in 0..num_turns { + let spec = &turns[t]; + let reasoning_tag = format!("r{t}"); + let id_prefix = format!("t{t}c"); + + let reasoning_here = stream.iter().any(|e| { + matches!( + &e.event.kind, + EventKind::ChatResponse(ChatResponse::Reasoning { reasoning }) + if reasoning == &reasoning_tag + ) + }); + let requests: Vec<_> = stream + .iter() + .filter_map(|e| e.event.as_tool_call_request()) + .filter(|r| r.id.starts_with(&id_prefix)) + .cloned() + .collect(); + let responses: Vec<_> = stream + .iter() + .filter_map(|e| e.event.as_tool_call_response()) + .filter(|r| r.id.starts_with(&id_prefix)) + .cloned() + .collect(); + + match policy_by_turn[t] { + None => { + prop_assert_eq!(reasoning_here, spec.reasoning); + prop_assert_eq!(requests.len(), spec.tool_calls); + prop_assert!(requests.iter().all(|r| !r.arguments.is_empty())); + prop_assert!(responses.iter().all(|r| r.content() == "ok")); + } + Some(MechPolicy::Reasoning) => { + prop_assert!(!reasoning_here); + prop_assert_eq!(requests.len(), spec.tool_calls); + prop_assert!(requests.iter().all(|r| !r.arguments.is_empty())); + prop_assert!(responses.iter().all(|r| r.content() == "ok")); + } + Some(MechPolicy::StripBoth) => { + prop_assert_eq!(requests.len(), spec.tool_calls); + prop_assert!(requests.iter().all(|r| r.arguments.is_empty())); + prop_assert!(responses.iter().all(|r| r.content().starts_with("[compacted]"))); + prop_assert_eq!(reasoning_here, spec.reasoning); + } + Some(MechPolicy::StripRequest) => { + prop_assert!(requests.iter().all(|r| r.arguments.is_empty())); + prop_assert!(responses.iter().all(|r| r.content() == "ok")); + } + Some(MechPolicy::StripResponse) => { + prop_assert!(requests.iter().all(|r| !r.arguments.is_empty())); + prop_assert!(responses.iter().all(|r| r.content().starts_with("[compacted]"))); + } + Some(MechPolicy::Omit) => { + prop_assert!(requests.is_empty()); + prop_assert!(responses.is_empty()); + } + } + } + } +} + +// --------------------------------------------------------------------------- +// 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 3df7ec1a..ca67c182 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 3d8a794b..d0576b4c 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 c80de02d..bb74cbcf 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] @@ -128,6 +133,70 @@ fn test_trim_trailing_empty_turn_removes_lone_turn_start() { assert_eq!(stream.len(), 0); } +#[test] +fn pop_if_preserves_trailing_compaction() { + let mut stream = ConversationStream::new_test(); + stream.start_turn("hello"); + stream.add_compaction(Compaction::new(0, 0)); + + // The `-E` continue path pops the last chat request to replay it. The + // compaction was appended after that request, but it's an overlay over the + // whole conversation and must survive the pop. + let popped = stream.pop_if(ConversationEvent::is_chat_request); + assert!(popped.is_some(), "should pop the chat request"); + assert_eq!( + stream.compactions().count(), + 1, + "compaction overlay must survive popping the chat request" + ); +} + +#[test] +fn trim_chat_request_preserves_trailing_compaction() { + let mut stream = ConversationStream::new_test(); + stream.start_turn("hello"); + stream.add_compaction(Compaction::new(0, 0)); + + let request = stream.trim_chat_request(); + assert_eq!(request.map(|r| r.content), Some("hello".to_owned())); + assert_eq!( + stream.compactions().count(), + 1, + "compaction overlay must survive trim_chat_request" + ); +} + +#[test] +fn pop_and_trim_preserve_trailing_config_delta() { + // The other global event variant: a config delta must survive the same + // pruning paths as a compaction. + let mut partial = PartialAppConfig::empty(); + partial.conversation.tools.defaults.run = Some(RunMode::Unattended); + + let mut stream = ConversationStream::new_test(); + stream.start_turn("hello"); + stream.add_config_delta(partial); + + let base = ConversationStream::new_test().config().unwrap(); + let with_delta = stream.config().unwrap(); + assert_ne!(with_delta, base, "setup: the delta must change the config"); + + let mut trimmed = stream.clone(); + assert!(trimmed.trim_chat_request().is_some()); + assert_eq!( + trimmed.config().unwrap(), + with_delta, + "trim_chat_request must preserve the trailing config delta" + ); + + assert!(stream.pop_if(ConversationEvent::is_chat_request).is_some()); + assert_eq!( + stream.config().unwrap(), + with_delta, + "pop_if must preserve the trailing config delta" + ); +} + #[test] fn test_trim_trailing_empty_turn_keeps_non_empty_turn() { let mut stream = ConversationStream::new_test(); @@ -731,7 +800,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"), } } @@ -895,6 +964,371 @@ fn test_from_parts_tolerates_config_deltas_with_only_unknown_fields() { 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); +} + /// Characterization test: extending an empty stream from a source stream /// reproduces the source's observed iter sequence and serialized shape. /// diff --git a/crates/jp_conversation/src/thread.rs b/crates/jp_conversation/src/thread.rs index ae6cf953..9dbdb503 100644 --- a/crates/jp_conversation/src/thread.rs +++ b/crates/jp_conversation/src/thread.rs @@ -170,6 +170,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 96451682..9950da48 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 @@ -66,6 +66,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 37ec22d2..8f080103 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 @@ -63,6 +63,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 d705d651..a8df4373 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 @@ -66,6 +66,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 789533bb..a86bf2ab 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 @@ -66,6 +66,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 f621dc25..26f12ae3 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 @@ -66,6 +66,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 a1c5f813..99d4202b 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 @@ -63,6 +63,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 5922b333..7497c3a0 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 @@ -68,6 +68,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 43c28996..56eb2625 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 @@ -63,6 +63,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 e05780d1..f14354f3 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 @@ -63,6 +63,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 85853a1b..5529b7ef 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 @@ -63,6 +63,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 f8d221e9..469f772f 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 @@ -63,6 +63,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 9dfbc071..7279173a 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 @@ -63,6 +63,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 331c91f2..0d5fa4fb 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 @@ -63,6 +63,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 db0a21fe..56d83c88 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 @@ -63,6 +63,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 ef838caf..f54f9840 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 @@ -66,6 +66,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 4765dbfe..968e6af8 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 @@ -66,6 +66,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 e4a0742d..f3bcd6c7 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 @@ -63,6 +63,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 e2cd1014..abee1ec8 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 @@ -63,6 +63,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 d4a98943..61086af5 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 @@ -63,6 +63,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 f2e079ac..5522c001 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 @@ -63,6 +63,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 9357e360..e5bbc329 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 @@ -63,6 +63,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 ac4302d3..55cda0ca 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 @@ -63,6 +63,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 db735551..4d491187 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 @@ -63,6 +63,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 9457174a..493f058f 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 @@ -66,6 +66,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 e5dc0372..42284a45 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 @@ -66,6 +66,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 b8450814..01332ece 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 @@ -63,6 +63,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 b35e540c..924f2fcb 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 @@ -66,6 +66,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 af9b4956..9d64660b 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 @@ -63,6 +63,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 1435e4f0..8f02ed79 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 @@ -63,6 +63,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 a528c596..c682f4fb 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 @@ -63,6 +63,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 fe019bbe..77c1f3f0 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 @@ -63,6 +63,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 0cc408a2..b7117890 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 @@ -63,6 +63,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 7c88fcfd..129f57ce 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 @@ -63,6 +63,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 ef489e60..5e56f17a 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 @@ -63,6 +63,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 6f4f675e..e5c1797f 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 @@ -66,6 +66,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 7523ed3d..2d373d59 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 @@ -63,6 +63,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 d12f5cd1..cf2361de 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 @@ -66,6 +66,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 ea41c8c4..9ec6e9c6 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 @@ -63,6 +63,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 5cdbd6ab..e24ea84e 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 @@ -63,6 +63,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 712fdef5..a7beea5b 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 @@ -63,6 +63,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 bb8927f8..71bdb40c 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 @@ -63,6 +63,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 ce163d11..786694e5 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 @@ -63,6 +63,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 1586abb2..5aa08a6b 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 @@ -63,6 +63,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 daca25f4..3bc02ee1 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 @@ -63,6 +63,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 9bd8bb6d..ff4d8cc1 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 @@ -66,6 +66,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 4eaa4d75..833aefd2 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 @@ -63,6 +63,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 c25303da..aa020ad1 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 @@ -66,6 +66,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 a6e566a9..36408d18 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 @@ -63,6 +63,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 c30fae38..ecdb26b7 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 @@ -63,6 +63,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 0ae28d6a..ba096565 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 @@ -63,6 +63,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 0b19f55f..67155ede 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 @@ -63,6 +63,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 dde10ad9..a31e53ec 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 @@ -63,6 +63,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 93d72c8d..87ba8c4e 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 @@ -63,6 +63,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 ad47b0ac..e54eeb91 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 @@ -63,6 +63,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 7a06ad8b..71d0f141 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 @@ -66,6 +66,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 c3b91e12..3f22d379 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 @@ -63,6 +63,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 38932a3b..5ef030de 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 @@ -66,6 +66,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 89edaeb9..13b649fa 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 @@ -63,6 +63,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 f4846bfe..41be71f2 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 @@ -63,6 +63,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 2d4ec879..75959b86 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 @@ -63,6 +63,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 89a49609..e0e04b8c 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 @@ -63,6 +63,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 cc106a69..8d7a436c 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 @@ -63,6 +63,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 5eb0be85..303feba5 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 @@ -63,6 +63,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 819ba1ff..b648b823 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 @@ -63,6 +63,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 095898bc..6633dee0 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 @@ -66,6 +66,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 d86f4c34..e28c0e36 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 @@ -66,6 +66,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 ad8d70c6..ada83542 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 @@ -66,6 +66,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 dafcbf0a..5a81d9f0 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 @@ -66,6 +66,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 b3a58e4f..79fadf6d 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 @@ -63,6 +63,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 66883d66..560290d8 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 @@ -66,6 +66,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 9f58a422..92e2beb8 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 @@ -63,6 +63,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 d50c919f..c5ec8c2f 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 @@ -63,6 +63,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 138b55e4..427e76dd 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 @@ -63,6 +63,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 4d0d004a..b6a329f8 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 @@ -63,6 +63,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 6d5ec128..54702bb1 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 @@ -63,6 +63,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 a1850d70..e6c359c7 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 @@ -63,6 +63,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 08ca6363..c65a0979 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 @@ -63,6 +63,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 a847cdca..5ec97da0 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 @@ -66,6 +66,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 84cd3e35..c8f32479 100644 --- a/docs/.vitepress/rfd-summaries.json +++ b/docs/.vitepress/rfd-summaries.json @@ -248,7 +248,7 @@ "summary": "Extend config wizard with frecency-based field ordering using CLI usage tracking data." }, "064-non-destructive-conversation-compaction.md": { - "hash": "08a52362333f9b60954b7a52a9a378921d9331ee347ea4d6f512884e3a7ba2e1", + "hash": "956e358280e5d965c22beb31f178aeba7103a74a8078ed953773113184f6fae1", "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 52298b7b..0982d8dd 100644 --- a/docs/rfd/064-non-destructive-conversation-compaction.md +++ b/docs/rfd/064-non-destructive-conversation-compaction.md @@ -81,38 +81,49 @@ builds a projection plan, 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. +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: @@ -128,32 +139,100 @@ Range bounds accept several formats: 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 @@ -330,7 +409,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): @@ -366,7 +445,7 @@ 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): @@ -384,13 +463,13 @@ Projected view: ...turns 3+ uncompacted... ``` -The two profiles show the distinction: +These two configurations show the distinction: -- **`default` (mechanical):** Conversation structure is preserved. +- **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. @@ -422,6 +501,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 @@ -484,7 +569,7 @@ 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. +Which fields are stripped depends on the rule configuration and per-tool hints. **Impact:** High. Tool responses and arguments (especially for file-writing tools) dominate token @@ -501,56 +586,116 @@ When set, this replaces all provider-visible 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)). +The model and 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 @@ -563,9 +708,9 @@ request = "keep" # "keep" | "strip" response = "strip" # "keep" | "strip" ``` -Per-tool hints override the profile's `Strip` policy for individual tools. +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 -policy that sets `response: true`. +rule that sets `response: true`. Example defaults for the JP project: @@ -632,6 +777,16 @@ 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. @@ -646,7 +801,7 @@ 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 per-type policies let users tailor the operation. +Composable rules with per-type policies let users tailor the operation. ## Non-Goals @@ -719,6 +874,17 @@ Named profiles with 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 @@ -735,11 +901,11 @@ 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. @@ -752,34 +918,36 @@ After this phase, compaction events in the stream will affect what the LLM sees. Each produces 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). +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. From f3dfdfdfb2c028cf7ff2c27b1e385fb6d086a196 Mon Sep 17 00:00:00 2001 From: Jean Mertz Date: Thu, 4 Jun 2026 15:43:22 +0200 Subject: [PATCH 2/4] fixup! feat(cli, config, conversation): Implement conversation compaction Signed-off-by: Jean Mertz --- crates/jp_cli/src/cmd/conversation/summarize.rs | 5 +++-- docs/.vitepress/rfd-summaries.json | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/jp_cli/src/cmd/conversation/summarize.rs b/crates/jp_cli/src/cmd/conversation/summarize.rs index db13692b..72ab73a9 100644 --- a/crates/jp_cli/src/cmd/conversation/summarize.rs +++ b/crates/jp_cli/src/cmd/conversation/summarize.rs @@ -131,8 +131,9 @@ pub async fn generate_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. +/// 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, diff --git a/docs/.vitepress/rfd-summaries.json b/docs/.vitepress/rfd-summaries.json index c8f32479..d01e7787 100644 --- a/docs/.vitepress/rfd-summaries.json +++ b/docs/.vitepress/rfd-summaries.json @@ -248,7 +248,7 @@ "summary": "Extend config wizard with frecency-based field ordering using CLI usage tracking data." }, "064-non-destructive-conversation-compaction.md": { - "hash": "956e358280e5d965c22beb31f178aeba7103a74a8078ed953773113184f6fae1", + "hash": "b9a6665cf3c0d912b737c3254e4c84203623601d8b0fd07ff36ef1db3152d713", "summary": "Non-destructive conversation compaction through overlay events that project reduced views without mutating stored data." }, "065-typed-resource-model-for-attachments.md": { From dd6d353aaf5937e8a09cd97cc24809e6c4f5f88b Mon Sep 17 00:00:00 2001 From: Jean Mertz Date: Thu, 4 Jun 2026 23:44:09 +0200 Subject: [PATCH 3/4] review feedback Signed-off-by: Jean Mertz --- crates/jp_cli/src/cmd/compact_flag.rs | 109 +++++----- crates/jp_cli/src/cmd/compact_flag_tests.rs | 67 +++++-- crates/jp_cli/src/cmd/conversation/compact.rs | 186 ++++++++++++------ .../src/cmd/conversation/compact_tests.rs | 59 +++++- .../jp_cli/src/cmd/conversation/summarize.rs | 13 +- crates/jp_cli/src/cmd/query.rs | 10 +- .../jp_config/src/conversation/compaction.rs | 76 ++++++- .../src/conversation/compaction_tests.rs | 27 +++ crates/jp_config/src/lib.rs | 12 ++ crates/jp_config/src/lib_tests.rs | 31 +++ crates/jp_conversation/src/compaction.rs | 89 ++++++--- .../jp_conversation/src/compaction_tests.rs | 9 +- crates/jp_conversation/src/stream.rs | 15 ++ .../jp_conversation/src/stream/projection.rs | 32 ++- .../src/stream/projection_tests.rs | 93 +++++++++ crates/jp_conversation/src/stream_tests.rs | 75 +++++++ ...non-destructive-conversation-compaction.md | 50 +++-- 17 files changed, 761 insertions(+), 192 deletions(-) diff --git a/crates/jp_cli/src/cmd/compact_flag.rs b/crates/jp_cli/src/cmd/compact_flag.rs index 0314186f..2b863abc 100644 --- a/crates/jp_cli/src/cmd/compact_flag.rs +++ b/crates/jp_cli/src/cmd/compact_flag.rs @@ -40,6 +40,14 @@ impl CompactFlag { self.use_config_rules || !self.specs.is_empty() } + /// The inline DSL specs converted to partial compaction rules. + pub(crate) fn dsl_rules(&self) -> Vec { + self.specs + .iter() + .map(CompactSpec::to_partial_rule) + .collect() + } + /// Apply DSL specs to the config partial. /// /// - If only specs (no bare `--compact`): replace the rules array. @@ -47,16 +55,11 @@ impl CompactFlag { /// 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() { + let rules = self.dsl_rules(); + if rules.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 { @@ -141,15 +144,19 @@ pub(crate) struct CompactSpec { pub range: Option, } -/// A parsed DSL range. +/// A parsed DSL range, Python-slice style. +/// +/// Each bound is an absolute turn index (positive in the DSL) or a from-end +/// offset (negative). +/// `None` means that end is open (the start or the end of the conversation). #[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, + /// Left bound (compaction start). + /// `None` = start of the conversation. + pub from: Option, + /// Right bound (compaction end). + /// `None` = end of the conversation. + pub to: Option, } impl CompactSpec { @@ -165,8 +172,10 @@ impl CompactSpec { } 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))); + // Open ends map to start / end: `Absolute(0)` is turn 0, `FromEnd(0)` + // is the last turn. + rule.keep_first = Some(range.from.clone().unwrap_or(RuleBound::Absolute(0))); + rule.keep_last = Some(range.to.clone().unwrap_or(RuleBound::FromEnd(0))); } rule @@ -233,52 +242,48 @@ impl FromStr for CompactSpec { } } +/// Parse one DSL range bound: a positive integer is an absolute turn index, a +/// negative integer is an offset from the end. +fn parse_dsl_bound(s: &str) -> Result { + if let Some(rest) = s.strip_prefix('-') { + let n = rest + .parse() + .map_err(|_| format!("invalid bound '-{rest}'"))?; + Ok(RuleBound::FromEnd(n)) + } else { + let n = s.parse().map_err(|_| format!("invalid bound '{s}'"))?; + Ok(RuleBound::Absolute(n)) + } +} + fn parse_dsl_range(s: &str) -> Result { - // Full range: FROM..TO + // Explicit range: FROM..TO (either side may be empty). Both ends are + // Python-slice style: positive = absolute turn, negative = from the end. if let Some((left, right)) = s.split_once("..") { - let keep_first = if left.is_empty() { + let from = if left.is_empty() { None } else { - let n: usize = left - .parse() - .map_err(|_| format!("invalid left bound '{left}'"))?; - Some(n) + Some(parse_dsl_bound(left)?) }; - - let keep_last = if right.is_empty() { + let to = 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}'" - )); + Some(parse_dsl_bound(right)?) }; - - return Ok(DslRange { - keep_first, - keep_last, - }); + return Ok(DslRange { from, to }); } - // 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, - }) + // Single-number shorthand: positive `N` = `N..` (keep first N), negative + // `-N` = `..-N` (keep last N). + match parse_dsl_bound(s)? { + bound @ RuleBound::FromEnd(_) => Ok(DslRange { + from: None, + to: Some(bound), + }), + bound => Ok(DslRange { + from: Some(bound), + to: None, + }), } } diff --git a/crates/jp_cli/src/cmd/compact_flag_tests.rs b/crates/jp_cli/src/cmd/compact_flag_tests.rs index b2f289a1..8b62bd7d 100644 --- a/crates/jp_cli/src/cmd/compact_flag_tests.rs +++ b/crates/jp_cli/src/cmd/compact_flag_tests.rs @@ -50,8 +50,8 @@ fn parse_tool_mode_with_range() { tools: Some(ToolCallsMode::StripResponses), summarize: false, range: Some(DslRange { - keep_first: None, - keep_last: Some(3), + from: None, + to: Some(RuleBound::FromEnd(3)), }), }); } @@ -63,8 +63,8 @@ fn parse_with_range() { tools: None, summarize: true, range: Some(DslRange { - keep_first: None, - keep_last: Some(3), + from: None, + to: Some(RuleBound::FromEnd(3)), }), }); assert_eq!( @@ -74,8 +74,8 @@ fn parse_with_range() { tools: Some(ToolCallsMode::Strip), summarize: false, range: Some(DslRange { - keep_first: Some(5), - keep_last: Some(3), + from: Some(RuleBound::Absolute(5)), + to: Some(RuleBound::FromEnd(3)), }), } ); @@ -84,8 +84,8 @@ fn parse_with_range() { tools: None, summarize: true, range: Some(DslRange { - keep_first: None, - keep_last: None, + from: None, + to: None, }), }); assert_eq!("r:5..".parse::().unwrap(), CompactSpec { @@ -93,32 +93,58 @@ fn parse_with_range() { tools: None, summarize: false, range: Some(DslRange { - keep_first: Some(5), - keep_last: None, + from: Some(RuleBound::Absolute(5)), + to: None, }), }); } +#[test] +fn parse_absolute_range() { + // Python-slice: positive bounds are absolute turn indices on both ends. + assert_eq!( + "s:5..10".parse::().unwrap().range, + Some(DslRange { + from: Some(RuleBound::Absolute(5)), + to: Some(RuleBound::Absolute(10)), + }) + ); + assert_eq!( + "s:..10".parse::().unwrap().range, + Some(DslRange { + from: None, + to: Some(RuleBound::Absolute(10)), + }) + ); + assert_eq!( + "s:-10..-3".parse::().unwrap().range, + Some(DslRange { + from: Some(RuleBound::FromEnd(10)), + to: Some(RuleBound::FromEnd(3)), + }) + ); +} + #[test] fn parse_single_number_shorthand() { - // Negative: keep last N + // Negative shorthand `-3` = `..-3` (keep last 3). assert_eq!("s:-3".parse::().unwrap(), CompactSpec { reasoning: false, tools: None, summarize: true, range: Some(DslRange { - keep_first: None, - keep_last: Some(3), + from: None, + to: Some(RuleBound::FromEnd(3)), }), }); - // Positive: keep first N + // Positive shorthand `5` = `5..` (keep first 5). assert_eq!("r:5".parse::().unwrap(), CompactSpec { reasoning: true, tools: None, summarize: false, range: Some(DslRange { - keep_first: Some(5), - keep_last: None, + from: Some(RuleBound::Absolute(5)), + to: None, }), }); } @@ -128,8 +154,8 @@ 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()); + // Non-numeric bound + assert!("s:5..x".parse::().is_err()); // Unknown tool mode assert!("t=nope".parse::().is_err()); // Boolean policies reject values @@ -144,8 +170,9 @@ fn to_partial_rule_with_range() { 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))); + // Open start maps to absolute turn 0; `-3` keeps the last 3. + assert_eq!(rule.keep_first, Some(RuleBound::Absolute(0))); + assert_eq!(rule.keep_last, Some(RuleBound::FromEnd(3))); } #[test] diff --git a/crates/jp_cli/src/cmd/conversation/compact.rs b/crates/jp_cli/src/cmd/conversation/compact.rs index 8c4a98f7..9a59be32 100644 --- a/crates/jp_cli/src/cmd/conversation/compact.rs +++ b/crates/jp_cli/src/cmd/conversation/compact.rs @@ -10,7 +10,8 @@ use jp_config::{ types::vec::MergeableVec, }; use jp_conversation::{ - Compaction, ConversationStream, RangeBound, ReasoningPolicy, SummaryPolicy, ToolCallPolicy, + Compaction, CompactionRange, ConversationStream, RangeBound, ReasoningPolicy, SummaryPolicy, + ToolCallPolicy, compaction::{extend_summary_range, resolve_range}, }; use jp_workspace::{ConversationHandle, ConversationMut}; @@ -61,7 +62,7 @@ pub(crate) struct Compact { to: Option, /// Strip reasoning (thinking) blocks from the compacted range. - #[arg(short, long)] + #[arg(short, long, conflicts_with = "compact")] reasoning: bool, /// Strip tool call content from the compacted range. @@ -79,6 +80,7 @@ pub(crate) struct Compact { value_parser = parse_tool_calls_mode, num_args = 0..=1, default_missing_value = "strip", + conflicts_with = "compact", )] tools: Option, @@ -86,7 +88,7 @@ pub(crate) struct Compact { /// /// When enabled, the compacted turns are replaced with a single /// LLM-generated summary. - #[arg(short, long)] + #[arg(short, long, conflicts_with = "compact")] summarize: bool, /// Preview what would change without applying. @@ -101,23 +103,23 @@ pub(crate) struct Compact { /// Compact using an inline DSL rule. /// - /// Can be used alongside the dedicated flags above, or on its own. + /// Mutually exclusive with the dedicated `--reasoning`/`--tools`/ + /// `--summarize` flags above: use either the flags or the DSL, not both. /// 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. + /// Returns `true` if any dedicated policy flag 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 - || self.tools.is_some() - || self.summarize + /// Policy flags (`--reasoning`/`--tools`/`--summarize`) build a single + /// ad-hoc rule. + /// Range flags (`--keep-first`/`--keep-last`/`--from`/`--to`) are + /// deliberately excluded: they are applied at runtime as range overrides on + /// the active rules, not as a rule of their own. + fn has_policy_overrides(&self) -> bool { + self.reasoning || self.tools.is_some() || self.summarize } } @@ -129,16 +131,19 @@ impl IntoPartialAppConfig for Compact { _merged_config: Option<&PartialAppConfig>, _handles: &[jp_workspace::ConversationHandle], ) -> Result> { - // Dedicated flags build a single ad-hoc rule. - if self.has_rule_overrides() { + // Dedicated policy flags build a single ad-hoc rule; inline DSL specs + // each build one. clap makes the two mutually exclusive (the policy + // flags `conflicts_with` the `compact` flag), so at most one side is + // ever populated here. + // + // Range flags are NOT rules — `compact_one` applies them as range + // overrides on whichever rules end up active (see `resolve_from` / + // `resolve_to`), so a range-only invocation narrows the configured + // rules instead of replacing them with a policy-less no-op. + let mut rules: Vec = Vec::new(); + + if self.has_policy_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 self.reasoning { rule.reasoning = Some(ReasoningMode::Strip); } @@ -146,12 +151,21 @@ impl IntoPartialAppConfig for Compact { if self.summarize { rule.summary = Some(PartialSummaryConfig::default()); } - - partial.conversation.compaction.rules = MergeableVec::Vec(vec![rule]); + rules.push(rule); } - // DSL specs (from --compact=SPEC / -k) compose on top. - self.compact_flag.apply_to_config(&mut partial); + rules.extend(self.compact_flag.dsl_rules()); + + if !rules.is_empty() { + // Explicit policy/DSL rules replace the configured rules, unless a + // bare `--compact` is also present, in which case they append to + // the config rules. + if self.compact_flag.use_config_rules { + partial.conversation.compaction.rules.extend(rules); + } else { + partial.conversation.compaction.rules = MergeableVec::Vec(rules); + } + } Ok(partial) } @@ -196,11 +210,35 @@ fn parse_tool_calls_mode(s: &str) -> Result { }) } +/// Resolve the turn range a single rule would compact. +/// +/// Applies the runtime range overrides (`--from`/`--to`/`--keep-first`/ +/// `--keep-last`) on top of the rule's own bounds and, for summary rules, +/// extends the range to subsume partially overlapping summaries. +/// +/// Shared by the dry-run preview and [`build_compaction_event`] so the preview +/// and the actual mutation always agree on the range. +fn resolve_rule_range( + events: &ConversationStream, + rule: &CompactionRuleConfig, + from_override: Option, + to_override: Option, +) -> Option { + 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 range = resolve_range(events, from, to)?; + Some(if rule.summary.is_some() { + extend_summary_range(events, range) + } else { + range + }) +} + /// 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`. +/// (`--from`/`--to`/`--keep-first`/`--keep-last`) 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( @@ -211,23 +249,11 @@ pub(crate) async fn build_compaction_event( 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 { + let Some(range) = resolve_rule_range(events, rule, from_override, to_override) 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 { + let summary_text = if rule.summary.is_some() { printer.println("Generating summary..."); let text = super::summarize::generate_summary( events, @@ -262,10 +288,16 @@ pub(crate) async fn build_compaction_events_from_config( to_override: Option, printer: &jp_printer::Printer, ) -> crate::Result> { + // Accumulate generated compactions onto a working stream so each rule's + // summary-overlap extension sees the compactions produced by earlier rules + // in this same invocation, not just those already on the stream. Turn + // iteration ignores compaction overlays, so this does not affect the events + // a summarizer reads or the turn-count used for range resolution. + let mut working = events.clone(); let mut compactions = Vec::new(); for rule in &cfg.conversation.compaction.rules { if let Some(c) = build_compaction_event( - events, + &working, cfg, rule, from_override.clone(), @@ -274,6 +306,7 @@ pub(crate) async fn build_compaction_events_from_config( ) .await? { + working.add_compaction(c.clone()); compactions.push(c); } } @@ -301,10 +334,17 @@ pub(crate) fn apply_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)), + // "Keep first N" means compaction starts at turn N. + RuleBound::Turns(n) | RuleBound::Absolute(n) => Some(RangeBound::Absolute(*n)), + RuleBound::FromEnd(n) => Some(RangeBound::FromEnd(*n)), RuleBound::Duration(d) => { - let dt = chrono::Utc::now() - *d; - Some(RangeBound::Absolute(events.turn_at_time(dt)?.index())) + // Preserve the opening `d` window: start compacting at the first + // turn after `conversation_start + d`. + let start = events.iter().next()?.event.timestamp; + let cutoff = start + chrono::Duration::from_std(*d).ok()?; + Some(RangeBound::Absolute( + events.turn_at_time(cutoff)?.index() + 1, + )) } RuleBound::AfterLastCompaction => Some(RangeBound::AfterLastCompaction), } @@ -313,7 +353,9 @@ fn keep_first_to_bound(bound: &RuleBound, events: &ConversationStream) -> Option /// 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)), + // "Keep last N" means compaction stops N turns before the end. + RuleBound::Turns(n) | RuleBound::FromEnd(n) => Some(RangeBound::FromEnd(*n)), + RuleBound::Absolute(n) => Some(RangeBound::Absolute(*n)), RuleBound::Duration(d) => { let dt = chrono::Utc::now() - *d; Some(RangeBound::Absolute(events.turn_at_time(dt)?.index())) @@ -390,19 +432,42 @@ impl Compact { return Ok(()); } - // --from/--to are runtime-resolved range overrides (they need the - // stream for duration and "last" resolution). They apply to all rules. + // Range overrides (`--from`/`--to`/`--keep-first`/`--keep-last`) are + // resolved at runtime (they need the stream for duration and "last" + // resolution) and apply on top of every active rule. 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 { + // Preview using the same per-rule range resolution as the real run + // (minus the summarizer and the mutation) so the reported ranges + // match what would actually be applied. + let mut working = events_snapshot.clone(); + let mut printed = false; + for rule in &cfg.conversation.compaction.rules { + let Some(range) = + resolve_rule_range(&working, rule, from_override.clone(), to_override.clone()) + else { + continue; + }; ctx.printer.println(format!( "Would compact turns {}..={}", range.from_turn, range.to_turn, )); - } else { + // Mirror the real run's overlap accumulation so later summary + // rules preview the same (possibly extended) ranges. + if rule.summary.is_some() { + working.add_compaction( + Compaction::new(range.from_turn, range.to_turn).with_summary( + SummaryPolicy { + summary: String::new(), + }, + ), + ); + } + printed = true; + } + if !printed { ctx.printer.println("Nothing to compact."); } return Ok(()); @@ -427,14 +492,23 @@ impl Compact { Ok(()) } - /// Resolve `--from` to a `RangeBound`, if present. + /// Resolve the `from` range override, preferring `--from` over + /// `--keep-first`. + /// Returns `None` when neither is set. fn resolve_from(&self, events: &ConversationStream) -> Option { - resolve_cli_bound(self.from.as_ref()?, events) + if let Some(bound) = self.from.as_ref() { + return resolve_cli_bound(bound, events); + } + keep_first_to_bound(self.keep_first.as_ref()?, events) } - /// Resolve `--to` to a `RangeBound`, if present. + /// Resolve the `to` range override, preferring `--to` over `--keep-last`. + /// Returns `None` when neither is set. fn resolve_to(&self, events: &ConversationStream) -> Option { - resolve_cli_bound(self.to.as_ref()?, events) + if let Some(bound) = self.to.as_ref() { + return resolve_cli_bound(bound, events); + } + keep_last_to_bound(self.keep_last.as_ref()?, events) } } diff --git a/crates/jp_cli/src/cmd/conversation/compact_tests.rs b/crates/jp_cli/src/cmd/conversation/compact_tests.rs index 06db1bd2..72431279 100644 --- a/crates/jp_cli/src/cmd/conversation/compact_tests.rs +++ b/crates/jp_cli/src/cmd/conversation/compact_tests.rs @@ -1,5 +1,6 @@ +use clap::Parser as _; use jp_config::{ - AppConfig, + AppConfig, PartialAppConfig, conversation::compaction::{CompactionRuleConfig, RuleBound, ToolCallsMode}, }; use jp_conversation::{ @@ -9,7 +10,61 @@ use jp_conversation::{ use jp_printer::Printer; use serde_json::{Map, Value}; -use super::{build_compaction_event, build_compaction_events_from_config}; +use super::{Compact, build_compaction_event, build_compaction_events_from_config}; +use crate::ctx::IntoPartialAppConfig as _; + +/// Parse a `Compact` from `jp conversation compact ` for flag tests. +fn parse_compact(args: &[&str]) -> Compact { + #[derive(clap::Parser)] + struct TestCli { + #[command(flatten)] + compact: Compact, + } + + let mut argv = vec!["compact"]; + argv.extend_from_slice(args); + TestCli::try_parse_from(argv).unwrap().compact +} + +#[test] +fn bare_compact_flag_parses_without_a_value() { + // Bare `--compact` (no value) means "apply config rules". + let compact = parse_compact(&["--compact"]); + assert!(compact.compact_flag.use_config_rules); + assert!(compact.compact_flag.specs.is_empty()); +} + +#[test] +fn keep_last_only_does_not_inject_a_policyless_rule() { + // Range-only flags must narrow the configured rules at runtime, not replace + // them with a policy-less rule (which would project to a no-op). + let compact = parse_compact(&["--keep-last", "5"]); + let partial = compact + .apply_cli_config(None, PartialAppConfig::default(), None, &[]) + .unwrap(); + assert!( + partial.conversation.compaction.rules.is_empty(), + "range-only flags must leave the rules array untouched" + ); +} + +#[test] +fn policy_flag_conflicts_with_dsl_spec() { + // Dedicated policy flags and the `-k` DSL are mutually exclusive: combining + // them is a parse error rather than silently dropping one side. + #[derive(clap::Parser)] + struct TestCli { + #[command(flatten)] + compact: Compact, + } + + let result = TestCli::try_parse_from(["compact", "--reasoning", "-k", "s:..-3"]); + assert!( + result.is_err(), + "--reasoning and -k DSL must conflict, got {:?}", + result.map(|c| c.compact.compact_flag.specs) + ); +} fn runtime() -> tokio::runtime::Runtime { tokio::runtime::Runtime::new().unwrap() diff --git a/crates/jp_cli/src/cmd/conversation/summarize.rs b/crates/jp_cli/src/cmd/conversation/summarize.rs index 72ab73a9..ab5ed322 100644 --- a/crates/jp_cli/src/cmd/conversation/summarize.rs +++ b/crates/jp_cli/src/cmd/conversation/summarize.rs @@ -47,7 +47,10 @@ pub async fn generate_summary( .and_then(|c| c.model.clone()) .unwrap_or_else(|| app_cfg.assistant.model.clone()); - let model_id = model.id.resolved(); + // Aliases are resolved by `AppConfig::resolve_aliases` (including compaction + // summary models) before we get here, so `resolved()` is safe. The owned id + // is reused for provider lookup below. + let model_id = model.id.resolved().clone(); let range_events = collect_range_events(events, range_from, range_to); @@ -55,11 +58,11 @@ pub async fn generate_summary( 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. + // Override the full assistant model (id plus parameters) so a + // summary-specific model can also set max tokens, temperature, reasoning, + // and provider-specific parameters — not just the model id. let mut partial = PartialAppConfig::empty(); - partial.assistant.model.id = - jp_config::model::id::PartialModelIdOrAliasConfig::Id(model_id.to_partial()); + partial.assistant.model = model.to_partial(); stream.add_config_delta(partial); let instructions = summary_cfg diff --git a/crates/jp_cli/src/cmd/query.rs b/crates/jp_cli/src/cmd/query.rs index 4a1f3238..fdd38f24 100644 --- a/crates/jp_cli/src/cmd/query.rs +++ b/crates/jp_cli/src/cmd/query.rs @@ -73,6 +73,7 @@ use jp_config::{ }, conversation::{ ConversationConfig, + compaction::PartialCompactionConfig, tool::{ Enable, ToolSource, access::{AccessConfig, PartialAccessConfig, PartialFsRuleConfig}, @@ -1107,7 +1108,14 @@ fn get_config_delta_from_cli( .map(|c| c.to_partial()) .map_err(jp_conversation::Error::from)?; - let partial = partial.delta(cfg.to_partial()); + let mut partial = partial.delta(cfg.to_partial()); + + // Compaction rules are a one-shot plan applied as overlay events by + // `apply_pre_query_compaction`, not persistent conversation config. Drop + // them from the recorded delta so an inline `-k SPEC` rule isn't replayed + // by future bare `--compact` invocations on the same conversation. + partial.conversation.compaction = PartialCompactionConfig::default(); + if partial.is_empty() { return Ok(None); } diff --git a/crates/jp_config/src/conversation/compaction.rs b/crates/jp_config/src/conversation/compaction.rs index 9fb056b5..1adee489 100644 --- a/crates/jp_config/src/conversation/compaction.rs +++ b/crates/jp_config/src/conversation/compaction.rs @@ -96,7 +96,18 @@ impl FillDefaults for PartialCompactionConfig { .flatten() .unwrap_or_default(); - let mut rules = self.rules.fill_from(defaults.rules); + // When the user supplies no rules, inherit the built-in defaults. + // `MergeableVec::fill_from` keeps the (empty) left-hand vec rather than + // copying the defaults, and taking `defaults.rules` directly would + // carry its `discard_when_merged` marker (dropped again at finalize). + // Unwrap the default values into a plain `Vec` so an otherwise empty + // config keeps the documented default rule instead of resolving to + // zero rules (which makes bare `jp conversation compact` a no-op). + let mut rules = if self.rules.is_empty() { + MergeableVec::Vec(defaults.rules.into()) + } else { + self.rules.fill_from(defaults.rules) + }; for rule in rules.iter_mut() { *rule = mem::take(rule).fill_from(rule_defaults.clone()); } @@ -124,7 +135,7 @@ 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). + /// `"5h"` — preserve turns from the first 5 hours of the conversation). /// /// Defaults to 1 (preserve the initial request). #[setting(default = default_keep_first)] @@ -285,12 +296,22 @@ impl ToPartial for SummaryConfig { /// A range bound for compaction rules. /// -/// Rules only accept relative bounds (stable across invocations). -/// CLI flags extend this with absolute turn indices and dates. +/// Config rules use the relative [`Turns`] form (a "keep N" count), which is +/// stable as the conversation grows. +/// The CLI `--from`/`--to` flags and the inline DSL extend this with absolute +/// and from-end bounds for one-shot invocations. +/// +/// [`Turns`]: Self::Turns #[derive(Debug, Clone, PartialEq, Eq)] pub enum RuleBound { - /// A number of turns to preserve. + /// A number of turns to preserve (relative; the stable config form). Turns(usize), + /// An absolute, 0-based turn index. + /// Written `@N` as a string. + Absolute(usize), + /// An offset from the end (`FromEnd(3)` = three turns before the last). + /// Written `-N` as a string. + FromEnd(usize), /// Preserve turns within this duration, e.g. `"5h"`, `"2days"`. Duration(std::time::Duration), /// Start after the most recent compaction's `to_turn`. @@ -306,6 +327,20 @@ impl FromStr for RuleBound { return Ok(Self::AfterLastCompaction); } + if let Some(rest) = s.strip_prefix('@') { + return rest + .parse() + .map(Self::Absolute) + .map_err(|_| format!("invalid absolute turn `{s}`").into()); + } + + if let Some(rest) = s.strip_prefix('-') { + return rest + .parse() + .map(Self::FromEnd) + .map_err(|_| format!("invalid from-end bound `{s}`").into()); + } + if let Ok(n) = s.parse::() { return Ok(Self::Turns(n)); } @@ -320,6 +355,8 @@ impl fmt::Display for RuleBound { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Turns(n) => write!(f, "{n}"), + Self::Absolute(n) => write!(f, "@{n}"), + Self::FromEnd(n) => write!(f, "-{n}"), Self::Duration(d) => write!(f, "{}", humantime::format_duration(*d)), Self::AfterLastCompaction => write!(f, "last"), } @@ -334,8 +371,33 @@ impl Serialize for RuleBound { impl<'de> Deserialize<'de> for RuleBound { fn deserialize>(deserializer: D) -> Result { - let s = String::deserialize(deserializer)?; - s.parse().map_err(serde::de::Error::custom) + struct RuleBoundVisitor; + + impl serde::de::Visitor<'_> for RuleBoundVisitor { + type Value = RuleBound; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a turn count, a duration string like `5h`, or `last`") + } + + fn visit_u64(self, v: u64) -> Result { + usize::try_from(v).map(RuleBound::Turns).map_err(E::custom) + } + + fn visit_i64(self, v: i64) -> Result { + usize::try_from(v) + .map(RuleBound::Turns) + .map_err(|_| E::custom(format!("turn count must be non-negative, got `{v}`"))) + } + + fn visit_str(self, v: &str) -> Result { + v.parse().map_err(E::custom) + } + } + + // `deserialize_any` lets self-describing formats (TOML, JSON, YAML) + // supply either an integer (`keep_first = 1`) or a string (`"5h"`). + deserializer.deserialize_any(RuleBoundVisitor) } } diff --git a/crates/jp_config/src/conversation/compaction_tests.rs b/crates/jp_config/src/conversation/compaction_tests.rs index 3fa8dad0..208d283d 100644 --- a/crates/jp_config/src/conversation/compaction_tests.rs +++ b/crates/jp_config/src/conversation/compaction_tests.rs @@ -60,6 +60,33 @@ fn reasoning_mode_parse() { ); } +#[test] +fn rule_bound_deserializes_from_integer_and_string() { + // Config files write bare integers (`keep_first = 1`); those must map to + // `Turns`, while string forms keep working. + assert_eq!( + serde_json::from_value::(serde_json::json!(3)).unwrap(), + RuleBound::Turns(3) + ); + assert_eq!( + serde_json::from_value::(serde_json::json!("last")).unwrap(), + RuleBound::AfterLastCompaction + ); + assert!(matches!( + serde_json::from_value::(serde_json::json!("5h")).unwrap(), + RuleBound::Duration(_) + )); +} + +#[test] +fn rule_config_deserializes_integer_bounds() { + // Mirrors the documented TOML: `keep_first = 1`, `keep_last = 3`. + let rule: PartialCompactionRuleConfig = + serde_json::from_value(serde_json::json!({ "keep_first": 1, "keep_last": 3 })).unwrap(); + assert_eq!(rule.keep_first, Some(RuleBound::Turns(1))); + assert_eq!(rule.keep_last, Some(RuleBound::Turns(3))); +} + #[test] fn rule_partial_roundtrip_json() { let rule = PartialCompactionRuleConfig { diff --git a/crates/jp_config/src/lib.rs b/crates/jp_config/src/lib.rs index d98798f9..e7bf953f 100644 --- a/crates/jp_config/src/lib.rs +++ b/crates/jp_config/src/lib.rs @@ -467,6 +467,18 @@ impl AppConfig { })?; } + for rule in &mut self.conversation.compaction.rules { + if let Some(summary) = rule.summary.as_mut() + && let Some(model) = summary.model.as_mut() + { + model.id.resolve_in_place(aliases).map_err(|e| { + Error::Custom( + format!("conversation.compaction.rules[].summary.model.id: {e}").into(), + ) + })?; + } + } + Ok(()) } } diff --git a/crates/jp_config/src/lib_tests.rs b/crates/jp_config/src/lib_tests.rs index 10b6df2e..4249644a 100644 --- a/crates/jp_config/src/lib_tests.rs +++ b/crates/jp_config/src/lib_tests.rs @@ -244,6 +244,37 @@ fn compaction_rule_unset_bounds_resolve_to_field_defaults() { assert_eq!(rule.keep_last, RuleBound::Turns(3)); } +#[test] +fn empty_config_preserves_default_compaction_rule() { + use crate::{ + conversation::{ + compaction::{ReasoningMode, RuleBound, ToolCallsMode}, + tool::RunMode, + }, + model::id::{PartialModelIdConfig, PartialModelIdOrAliasConfig, ProviderId}, + util::build, + }; + + // A config that sets only the required fields and leaves compaction + // untouched must still resolve to the built-in default rule, so bare + // `jp conversation compact` / `jp query --compact` are not no-ops. + let mut partial = PartialAppConfig::default(); + partial.conversation.tools.defaults.run = Some(RunMode::Ask); + partial.assistant.model.id = PartialModelIdOrAliasConfig::Id(PartialModelIdConfig { + provider: Some(ProviderId::Anthropic), + name: "claude-opus-4".parse().ok(), + }); + + let config = build(partial).expect("valid config"); + + let rules = &config.conversation.compaction.rules; + assert_eq!(rules.len(), 1, "default rule must survive an empty config"); + assert_eq!(rules[0].reasoning, Some(ReasoningMode::Strip)); + assert_eq!(rules[0].tool_calls, Some(ToolCallsMode::Strip)); + assert_eq!(rules[0].keep_first, RuleBound::Turns(1)); + assert_eq!(rules[0].keep_last, RuleBound::Turns(3)); +} + #[test] fn build_rejects_alias_cycle() { use crate::{ diff --git a/crates/jp_conversation/src/compaction.rs b/crates/jp_conversation/src/compaction.rs index 5450ad03..6d7b11c5 100644 --- a/crates/jp_conversation/src/compaction.rs +++ b/crates/jp_conversation/src/compaction.rs @@ -161,20 +161,26 @@ pub struct CompactionRange { /// 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. +/// A summary is a holistic representation of its range — it cannot be split, +/// and a finer summary cannot be nested inside a coarser one coherently. +/// So re-summarizing a region that any existing summary already touches is +/// treated as a *refresh*: the new range grows to the union of every +/// overlapping summary, and the summarizer re-reads the raw events for that +/// whole range. /// -/// The extension repeats until no partial overlaps remain, handling transitive -/// chains (A overlaps B, B overlaps C → extend to cover all three). +/// This covers both partial overlaps (each range covers turns the other +/// doesn't) and containment (the new range sits inside an existing summary, or +/// vice versa): in every case the result is a single summary spanning the union +/// rather than two synthetic pairs in the projected view. +/// +/// The extension repeats until stable, 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. +/// Returns the input range unchanged if no summary overlaps it. /// /// Call this before generating the summary text so the summarizer reads events -/// for the full extended range. +/// for the full refreshed range. #[must_use] pub fn extend_summary_range( stream: &crate::ConversationStream, @@ -192,16 +198,18 @@ pub fn extend_summary_range( continue; } + // Grow to the union of any summary we touch (partial overlap *or* + // containment), so adding a contained summary refreshes the whole + // enclosing range instead of nesting inside it. 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 intersects { + let new_from = from.min(c.from_turn); + let new_to = to.max(c.to_turn); + if new_from != from || new_to != to { + from = new_from; + to = new_to; + changed = true; + } } } @@ -234,21 +242,54 @@ pub fn resolve_range( } let last = count - 1; - let resolve = |bound: RangeBound| -> usize { - match bound { - RangeBound::Absolute(n) => n.min(last), + // The two ends clamp differently. A start past the end (e.g. `keep_first` + // larger than the conversation, or `--from last` once the latest compaction + // already reaches the end) must preserve every turn, so it resolves + // one-past-the-end and lets the `from > to` guard below produce an empty + // range. An end that asks to preserve more trailing turns than exist + // likewise yields nothing, so it returns `None` rather than clamping back + // onto a real turn (which would recompact it). + let resolve_from = |bound: RangeBound| -> Option { + Some(match bound { + RangeBound::Absolute(n) if n > last => return None, + RangeBound::Absolute(n) => n, RangeBound::FromEnd(n) => last.saturating_sub(n), + RangeBound::AfterLastCompaction => { + let pos = stream + .compactions() + .map(|c| c.to_turn + 1) + .max() + .unwrap_or(0); + if pos > last { + return None; + } + pos + } + }) + }; + + let resolve_to = |bound: RangeBound| -> Option { + Some(match bound { + RangeBound::Absolute(n) => n.min(last), + RangeBound::FromEnd(n) if n > last => return None, + RangeBound::FromEnd(n) => last - 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); + let from_turn = match from { + Some(bound) => resolve_from(bound)?, + None => 0, + }; + let to_turn = match to { + Some(bound) => resolve_to(bound)?, + None => last, + }; if from_turn > to_turn { return None; diff --git a/crates/jp_conversation/src/compaction_tests.rs b/crates/jp_conversation/src/compaction_tests.rs index 41d3ba1c..4a3fa104 100644 --- a/crates/jp_conversation/src/compaction_tests.rs +++ b/crates/jp_conversation/src/compaction_tests.rs @@ -259,13 +259,18 @@ 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. + // New [3, 5] sits inside the existing summary [0, 9]. A summary can't be + // nested inside another, so re-summarizing a contained range refreshes the + // whole enclosing range: the result grows to [0, 9]. let range = CompactionRange { from_turn: 3, to_turn: 5, }; let result = extend_summary_range(&stream, range); - assert_eq!(result, range); + assert_eq!(result, CompactionRange { + from_turn: 0, + to_turn: 9 + }); } #[test] diff --git a/crates/jp_conversation/src/stream.rs b/crates/jp_conversation/src/stream.rs index 3c001eaf..9605733b 100644 --- a/crates/jp_conversation/src/stream.rs +++ b/crates/jp_conversation/src/stream.rs @@ -936,9 +936,18 @@ impl ConversationStream { /// A turn is delimited by a [`TurnStart`] event. /// If there are `n` or fewer turns, the stream is left unchanged. /// + /// Dropping leading turns renumbers the remaining ones, which would leave + /// any [`Compaction`] overlays pointing at the wrong turns, so they are + /// removed whenever turns are actually dropped. + /// Compaction anchors are positional today; once stable event identifiers + /// ([RFD D24]) land and compaction migrates to them, truncation could + /// rebase overlays instead of dropping them. + /// + /// [RFD D24]: https://github.com/dcdpr/jp/blob/main/docs/rfd/drafts/D24-stable-event-identifiers.md /// [`TurnStart`]: crate::event::TurnStart pub fn retain_last_turns(&mut self, n: usize) { if n == 0 { + self.remove_compactions(); self.retain(|_| false); return; } @@ -953,6 +962,7 @@ impl ConversationStream { return; } + self.remove_compactions(); let skip = turn_count - n; let mut turns_seen = 0; let mut keeping = false; @@ -1010,6 +1020,10 @@ impl ConversationStream { /// If `first + last` is greater than or equal to the total number of turns, /// the stream is left unchanged. /// + /// Dropping the middle turns renumbers the trailing block, which would + /// leave any [`Compaction`] overlays pointing at the wrong turns, so they + /// are removed whenever turns are actually dropped. + /// /// [`TurnStart`]: crate::event::TurnStart pub fn retain_first_and_last_turns(&mut self, first: usize, last: usize) { let turn_count = self @@ -1022,6 +1036,7 @@ impl ConversationStream { return; } + self.remove_compactions(); let last_start = turn_count.saturating_sub(last); let mut turns_seen = 0; let mut keeping = false; diff --git a/crates/jp_conversation/src/stream/projection.rs b/crates/jp_conversation/src/stream/projection.rs index 95a0ae8b..c44ad300 100644 --- a/crates/jp_conversation/src/stream/projection.rs +++ b/crates/jp_conversation/src/stream/projection.rs @@ -13,7 +13,7 @@ use serde_json::Map; use super::InternalEvent; use crate::{ ReasoningPolicy, ToolCallPolicy, - event::{ChatRequest, ChatResponse, ConversationEvent}, + event::{ChatRequest, ChatResponse, ConversationEvent, TurnStart}, }; /// Resolved compaction policies for a single turn. @@ -30,12 +30,10 @@ struct TurnPolicy { } /// A summary that won the latest-timestamp contest for a set of turns. +#[derive(PartialEq, Eq)] 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. @@ -69,6 +67,20 @@ pub(super) fn apply(events: &mut Vec) { let policies = resolve_policies(max_turn, &compactions); let tool_names = build_tool_name_map(events); + // Inject a summary once per contiguous run of turns that resolve to the + // same winning summary. Injecting only at the originating `from_turn` drops + // the tail of a summary that a newer, fully-contained summary splits in two + // (e.g. A covers turns 0..=9, a newer B covers 3..=5: turns 6..=9 still + // belong to A and must be re-injected after B). + let inject_at_turn: HashSet = (0..policies.len()) + .filter(|&t| { + let Some(summary) = policies[t].summary.as_ref() else { + return false; + }; + t == 0 || policies[t - 1].summary.as_ref() != Some(summary) + }) + .collect(); + let mut projected = Vec::with_capacity(events.len()); let mut summaries_injected: HashSet = HashSet::new(); @@ -90,7 +102,7 @@ pub(super) fn apply(events: &mut Vec) { // Summary takes precedence over all per-type policies. if let Some(summary) = &policy.summary { - if summary.from_turn == turn && summaries_injected.insert(turn) { + if inject_at_turn.contains(&turn) && summaries_injected.insert(turn) { inject_summary(&mut projected, &summary.text, conv_event.timestamp); } // Drop the original event — it's covered by the summary. @@ -194,7 +206,6 @@ fn resolve_policies(max_turn: usize, compactions: &[crate::Compaction]) -> Vec Vec, summary: &str, timestamp: DateTime) { + events.push(InternalEvent::Event(Box::new(ConversationEvent::new( + TurnStart, timestamp, + )))); events.push(InternalEvent::Event(Box::new(ConversationEvent::new( ChatRequest::from("[Summary of previous conversation]"), timestamp, diff --git a/crates/jp_conversation/src/stream/projection_tests.rs b/crates/jp_conversation/src/stream/projection_tests.rs index 12f3b404..ddbc10e0 100644 --- a/crates/jp_conversation/src/stream/projection_tests.rs +++ b/crates/jp_conversation/src/stream/projection_tests.rs @@ -444,6 +444,99 @@ fn summary_partial_range() { assert!(matches!(events[2], EventKind::ChatRequest(r) if r.content == "add error handling")); } +#[test] +fn summary_is_injected_as_its_own_turn() { + let mut stream = two_turn_stream(); + // Summarize only the second turn (turn 1). + stream.add_compaction(Compaction { + timestamp: ts(2), + from_turn: 1, + to_turn: 1, + summary: Some(SummaryPolicy { + summary: "summary of turn 1".into(), + }), + reasoning: None, + tool_calls: None, + }); + + stream.apply_projection(); + + // The synthetic summary pair carries its own `TurnStart`, so it stays a + // distinct turn instead of folding into turn 0. + assert_eq!(stream.turn_count(), 2); + let turns: Vec<_> = stream.iter_turns().collect(); + let summary_turn = turns.last().unwrap(); + assert!( + summary_turn.iter().next().unwrap().event.is_turn_start(), + "summary turn must begin with a TurnStart" + ); + assert!(summary_turn.iter().any(|e| matches!( + &e.event.kind, + EventKind::ChatResponse(ChatResponse::Message { message }) + if message.contains("summary of turn 1") + ))); +} + +#[test] +fn contained_summary_reinjects_outer_summary_tail() { + // Four single-message turns. + let mut stream = ConversationStream::new_test(); + for t in 0..4 { + stream.push(ConversationEvent::new(TurnStart, ts(0))); + stream.push(ConversationEvent::new( + ChatRequest::from(format!("q{t}")), + ts(0), + )); + stream.push(ConversationEvent::new( + ChatResponse::message(format!("m{t}")), + ts(0), + )); + } + + // Outer summary over all turns, then a newer summary fully contained in it. + stream.add_compaction(Compaction { + timestamp: ts(1), + from_turn: 0, + to_turn: 3, + summary: Some(SummaryPolicy { + summary: "OUTER".into(), + }), + reasoning: None, + tool_calls: None, + }); + stream.add_compaction(Compaction { + timestamp: ts(2), + from_turn: 1, + to_turn: 2, + summary: Some(SummaryPolicy { + summary: "INNER".into(), + }), + reasoning: None, + tool_calls: None, + }); + + stream.apply_projection(); + + let messages: Vec = stream + .iter() + .filter_map(|e| match &e.event.kind { + EventKind::ChatResponse(ChatResponse::Message { message }) => Some(message.clone()), + _ => None, + }) + .collect(); + + // turn 0 -> OUTER, turns 1..=2 -> INNER, turn 3 -> OUTER again. The outer + // summary's tail (turn 3) must not be dropped just because OUTER was + // already injected at turn 0. + let outer = messages.iter().filter(|m| m.as_str() == "OUTER").count(); + let inner = messages.iter().filter(|m| m.as_str() == "INNER").count(); + assert_eq!( + outer, 2, + "outer summary should bracket the inner one: {messages:?}" + ); + assert_eq!(inner, 1, "{messages:?}"); +} + // --------------------------------------------------------------------------- // Stacking: latest timestamp wins // --------------------------------------------------------------------------- diff --git a/crates/jp_conversation/src/stream_tests.rs b/crates/jp_conversation/src/stream_tests.rs index bb74cbcf..35c2a1f9 100644 --- a/crates/jp_conversation/src/stream_tests.rs +++ b/crates/jp_conversation/src/stream_tests.rs @@ -1021,6 +1021,39 @@ fn test_compaction_preserved_by_retain() { ); } +#[test] +fn test_retain_last_turns_drops_compactions() { + let mut stream = ConversationStream::new_test(); + for t in 0..6 { + stream.start_turn(format!("turn {t}")); + } + // A compaction over the early turns; dropping leading turns renumbers the + // survivors, so this overlay would otherwise point at the wrong turns. + stream.add_compaction(make_compaction(0, 4)); + + stream.retain_last_turns(2); + + assert_eq!(stream.turn_count(), 2); + assert_eq!( + stream.compactions().count(), + 0, + "truncating turns must drop now-misaligned compactions" + ); +} + +#[test] +fn test_retain_last_turns_no_truncation_keeps_compactions() { + let mut stream = ConversationStream::new_test(); + stream.start_turn("a"); + stream.start_turn("b"); + stream.add_compaction(make_compaction(0, 1)); + + // No turns are dropped (2 <= 5), so the overlay stays valid and survives. + stream.retain_last_turns(5); + + assert_eq!(stream.compactions().count(), 1); +} + #[test] fn test_compaction_skipped_by_iter() { let mut stream = ConversationStream::new_test(); @@ -1274,6 +1307,48 @@ fn test_resolve_range_after_last_compaction() { assert_eq!(range.to_turn, 3); } +#[test] +fn test_resolve_range_after_last_compaction_at_end_is_none() { + 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 + + // The latest compaction already reaches the final turn, so incremental + // `--from last` has nothing left to do and must not recompact turn 3. + stream.add_compaction(make_compaction(0, 3)); + + let range = resolve_range(&stream, Some(RangeBound::AfterLastCompaction), None); + assert!(range.is_none(), "got {range:?}"); +} + +#[test] +fn test_resolve_range_keep_first_beyond_end_is_none() { + 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 + + // `keep_first = 5` on a 4-turn conversation preserves everything. + let range = resolve_range(&stream, Some(RangeBound::Absolute(5)), None); + assert!(range.is_none(), "got {range:?}"); +} + +#[test] +fn test_resolve_range_keep_last_beyond_end_is_none() { + 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 + + // `keep_last = 5` (`..-5`) on a 4-turn conversation preserves everything. + let range = resolve_range(&stream, None, Some(RangeBound::FromEnd(5))); + assert!(range.is_none(), "got {range:?}"); +} + #[test] fn test_resolve_range_empty_stream() { let stream = ConversationStream::new_test(); diff --git a/docs/rfd/064-non-destructive-conversation-compaction.md b/docs/rfd/064-non-destructive-conversation-compaction.md index 0982d8dd..9d63452e 100644 --- a/docs/rfd/064-non-destructive-conversation-compaction.md +++ b/docs/rfd/064-non-destructive-conversation-compaction.md @@ -188,31 +188,41 @@ 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 + | BOUND # single-bound shorthand +BOUND = INTEGER # >= 0: absolute turn index + | "-" INTEGER # < 0: offset from the end ``` 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. +Bounds are Python-slice style: a non-negative number is an absolute, 0-based +turn index, and a negative number is an offset from the end (`-1` is the last +turn). +Either end may use either form, and both ends are **inclusive**. +An omitted left bound means the start of the conversation; an omitted right +bound means the end. **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). | +| Syntax | Meaning | +| --------- | -------------------------------------------------- | +| `..` | All turns (start to end). | +| `5..` | Turn 5 through the end (keeps the first 5). | +| `..-3` | Start through 3-from-end (keeps the last 3). | +| `5..-3` | Turn 5 through 3-from-end (keeps first 5, last 3). | +| `5..10` | Absolute turns 5 through 10. | +| `..10` | Start through absolute turn 10. | +| `-10..-3` | 10-from-end through 3-from-end. | -Single-number shorthands: +Single-bound shorthands: -| Syntax | Expands to | Meaning | -| ------ | ---------- | ------------------------- | -| `-3` | `..-3` | Keep last 3 uncompacted. | -| `5` | `5..` | Keep first 5 uncompacted. | +| Syntax | Expands to | Meaning | +| ------ | ---------- | ----------------------------- | +| `-3` | `..-3` | Keep last 3 uncompacted. | +| `5` | `5..` | Turn 5 onward (keep first 5). | **Examples:** @@ -225,6 +235,7 @@ Single-number shorthands: | `s:..` | Summarize all events. | | `r:5..` | Strip reasoning from turn 5 onward. | | `s:5..-3` | Summarize turns 5 through 3-from-end. | +| `s:5..10` | Summarize absolute turns 5 through 10. | | `s:-3` | Summarize all but last 3 (shorthand). | | `r:-20` | Strip reasoning, keep last 20 (shorthand). | @@ -631,8 +642,8 @@ keep_first = 1 keep_last = 3 tool_calls = "strip" -[conversation.compaction.rules.summary] -model = "anthropic/claude-haiku" +[conversation.compaction.rules.summary.model] +id = "anthropic/claude-haiku" ``` ```toml @@ -686,12 +697,17 @@ duration string (e.g. `tool_calls` accepts: `"strip"` (both), `"strip-responses"`, `"strip-requests"`, `"omit"`. -`summary` is a nested table: +`summary` is a nested table. +`model` is itself a table (it mirrors `assistant.model`), so the model id goes +under `summary.model.id`: ```toml [conversation.compaction.rules.summary] -model = "anthropic/claude-haiku" # optional, defaults to main assistant model instructions = "..." # optional, custom summarization prompt + +# optional, defaults to the main assistant model +[conversation.compaction.rules.summary.model] +id = "anthropic/claude-haiku" ``` When `summary` is set, it replaces all events in the range — `reasoning` and From 4aba95dca3011d813cfdb5b6175e37e5c1df0c2c Mon Sep 17 00:00:00 2001 From: Jean Mertz Date: Fri, 5 Jun 2026 07:11:54 +0200 Subject: [PATCH 4/4] fixup! review feedback Signed-off-by: Jean Mertz --- ...test_chat_completion_stream__conversation_stream.snap | 9 ++++++++- .../test_image_attachment__conversation_stream.snap | 9 ++++++++- ...est_multi_turn_conversation__conversation_stream.snap | 9 ++++++++- ..._opus_4_6_adaptive_thinking__conversation_stream.snap | 9 ++++++++- .../test_opus_4_6_max_effort__conversation_stream.snap | 9 ++++++++- .../test_redacted_thinking__conversation_stream.snap | 9 ++++++++- .../test_request_chaining__conversation_stream.snap | 9 ++++++++- .../test_structured_output__conversation_stream.snap | 9 ++++++++- .../test_tool_call_auto__conversation_stream.snap | 9 ++++++++- .../test_tool_call_function__conversation_stream.snap | 9 ++++++++- .../test_tool_call_reasoning__conversation_stream.snap | 9 ++++++++- ..._call_required_no_reasoning__conversation_stream.snap | 9 ++++++++- ...ool_call_required_reasoning__conversation_stream.snap | 9 ++++++++- .../test_tool_call_stream__conversation_stream.snap | 9 ++++++++- ...test_chat_completion_stream__conversation_stream.snap | 9 ++++++++- ...est_multi_turn_conversation__conversation_stream.snap | 9 ++++++++- .../test_structured_output__conversation_stream.snap | 9 ++++++++- .../test_tool_call_auto__conversation_stream.snap | 9 ++++++++- .../test_tool_call_function__conversation_stream.snap | 9 ++++++++- .../test_tool_call_reasoning__conversation_stream.snap | 9 ++++++++- ..._call_required_no_reasoning__conversation_stream.snap | 9 ++++++++- ...ool_call_required_reasoning__conversation_stream.snap | 9 ++++++++- .../test_tool_call_stream__conversation_stream.snap | 9 ++++++++- ...test_chat_completion_stream__conversation_stream.snap | 9 ++++++++- .../test_gemini_3_reasoning__conversation_stream.snap | 9 ++++++++- .../test_image_attachment__conversation_stream.snap | 9 ++++++++- ...est_multi_turn_conversation__conversation_stream.snap | 9 ++++++++- .../test_structured_output__conversation_stream.snap | 9 ++++++++- .../google/test_tool_call_auto__conversation_stream.snap | 9 ++++++++- .../test_tool_call_function__conversation_stream.snap | 9 ++++++++- .../test_tool_call_reasoning__conversation_stream.snap | 9 ++++++++- ..._call_required_no_reasoning__conversation_stream.snap | 9 ++++++++- ...ool_call_required_reasoning__conversation_stream.snap | 9 ++++++++- .../test_tool_call_stream__conversation_stream.snap | 9 ++++++++- ...test_chat_completion_stream__conversation_stream.snap | 9 ++++++++- .../test_image_attachment__conversation_stream.snap | 9 ++++++++- ...est_multi_turn_conversation__conversation_stream.snap | 9 ++++++++- .../test_structured_output__conversation_stream.snap | 9 ++++++++- .../test_tool_call_auto__conversation_stream.snap | 9 ++++++++- .../test_tool_call_function__conversation_stream.snap | 9 ++++++++- .../test_tool_call_reasoning__conversation_stream.snap | 9 ++++++++- ..._call_required_no_reasoning__conversation_stream.snap | 9 ++++++++- ...ool_call_required_reasoning__conversation_stream.snap | 9 ++++++++- .../test_tool_call_stream__conversation_stream.snap | 9 ++++++++- ...test_chat_completion_stream__conversation_stream.snap | 9 ++++++++- .../test_image_attachment__conversation_stream.snap | 9 ++++++++- ...est_multi_turn_conversation__conversation_stream.snap | 9 ++++++++- .../test_structured_output__conversation_stream.snap | 9 ++++++++- .../ollama/test_tool_call_auto__conversation_stream.snap | 9 ++++++++- .../test_tool_call_function__conversation_stream.snap | 9 ++++++++- .../test_tool_call_reasoning__conversation_stream.snap | 9 ++++++++- ..._call_required_no_reasoning__conversation_stream.snap | 9 ++++++++- ...ool_call_required_reasoning__conversation_stream.snap | 9 ++++++++- .../test_tool_call_stream__conversation_stream.snap | 9 ++++++++- ...test_chat_completion_stream__conversation_stream.snap | 9 ++++++++- .../test_image_attachment__conversation_stream.snap | 9 ++++++++- ...est_multi_turn_conversation__conversation_stream.snap | 9 ++++++++- .../test_structured_output__conversation_stream.snap | 9 ++++++++- .../openai/test_tool_call_auto__conversation_stream.snap | 9 ++++++++- .../test_tool_call_function__conversation_stream.snap | 9 ++++++++- .../test_tool_call_reasoning__conversation_stream.snap | 9 ++++++++- ..._call_required_no_reasoning__conversation_stream.snap | 9 ++++++++- ...ool_call_required_reasoning__conversation_stream.snap | 9 ++++++++- .../test_tool_call_stream__conversation_stream.snap | 9 ++++++++- ...sub_provider_event_metadata__conversation_stream.snap | 9 ++++++++- ...sub_provider_event_metadata__conversation_stream.snap | 9 ++++++++- ...sub_provider_event_metadata__conversation_stream.snap | 9 ++++++++- ...test_chat_completion_stream__conversation_stream.snap | 9 ++++++++- .../test_image_attachment__conversation_stream.snap | 9 ++++++++- ...est_multi_turn_conversation__conversation_stream.snap | 9 ++++++++- .../test_structured_output__conversation_stream.snap | 9 ++++++++- .../test_tool_call_auto__conversation_stream.snap | 9 ++++++++- .../test_tool_call_function__conversation_stream.snap | 9 ++++++++- .../test_tool_call_reasoning__conversation_stream.snap | 9 ++++++++- ..._call_required_no_reasoning__conversation_stream.snap | 9 ++++++++- ...ool_call_required_reasoning__conversation_stream.snap | 9 ++++++++- .../test_tool_call_stream__conversation_stream.snap | 9 ++++++++- ...sub_provider_event_metadata__conversation_stream.snap | 9 ++++++++- 78 files changed, 624 insertions(+), 78 deletions(-) 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 9950da48..c61ccbd1 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 @@ -68,7 +68,14 @@ expression: v }, "compaction": { "rules": { - "value": [], + "value": [ + { + "keep_first": "1", + "keep_last": "3", + "reasoning": "strip", + "tool_calls": "strip" + } + ], "strategy": "replace", "discard_when_merged": false } 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 8f080103..dc037242 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 @@ -65,7 +65,14 @@ expression: v }, "compaction": { "rules": { - "value": [], + "value": [ + { + "keep_first": "1", + "keep_last": "3", + "reasoning": "strip", + "tool_calls": "strip" + } + ], "strategy": "replace", "discard_when_merged": false } 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 a8df4373..a80ee105 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 @@ -68,7 +68,14 @@ expression: v }, "compaction": { "rules": { - "value": [], + "value": [ + { + "keep_first": "1", + "keep_last": "3", + "reasoning": "strip", + "tool_calls": "strip" + } + ], "strategy": "replace", "discard_when_merged": false } 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 a86bf2ab..f6af7c72 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 @@ -68,7 +68,14 @@ expression: v }, "compaction": { "rules": { - "value": [], + "value": [ + { + "keep_first": "1", + "keep_last": "3", + "reasoning": "strip", + "tool_calls": "strip" + } + ], "strategy": "replace", "discard_when_merged": false } 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 26f12ae3..c9b81f54 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 @@ -68,7 +68,14 @@ expression: v }, "compaction": { "rules": { - "value": [], + "value": [ + { + "keep_first": "1", + "keep_last": "3", + "reasoning": "strip", + "tool_calls": "strip" + } + ], "strategy": "replace", "discard_when_merged": false } 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 99d4202b..8f084a2a 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 @@ -65,7 +65,14 @@ expression: v }, "compaction": { "rules": { - "value": [], + "value": [ + { + "keep_first": "1", + "keep_last": "3", + "reasoning": "strip", + "tool_calls": "strip" + } + ], "strategy": "replace", "discard_when_merged": false } 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 7497c3a0..8521477f 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 @@ -70,7 +70,14 @@ expression: v }, "compaction": { "rules": { - "value": [], + "value": [ + { + "keep_first": "1", + "keep_last": "3", + "reasoning": "strip", + "tool_calls": "strip" + } + ], "strategy": "replace", "discard_when_merged": false } 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 56eb2625..8f9571bf 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 @@ -65,7 +65,14 @@ expression: v }, "compaction": { "rules": { - "value": [], + "value": [ + { + "keep_first": "1", + "keep_last": "3", + "reasoning": "strip", + "tool_calls": "strip" + } + ], "strategy": "replace", "discard_when_merged": false } 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 f14354f3..7d60041b 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 @@ -65,7 +65,14 @@ expression: v }, "compaction": { "rules": { - "value": [], + "value": [ + { + "keep_first": "1", + "keep_last": "3", + "reasoning": "strip", + "tool_calls": "strip" + } + ], "strategy": "replace", "discard_when_merged": false } 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 5529b7ef..ea1d1ce1 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 @@ -65,7 +65,14 @@ expression: v }, "compaction": { "rules": { - "value": [], + "value": [ + { + "keep_first": "1", + "keep_last": "3", + "reasoning": "strip", + "tool_calls": "strip" + } + ], "strategy": "replace", "discard_when_merged": false } 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 469f772f..6508fca3 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 @@ -65,7 +65,14 @@ expression: v }, "compaction": { "rules": { - "value": [], + "value": [ + { + "keep_first": "1", + "keep_last": "3", + "reasoning": "strip", + "tool_calls": "strip" + } + ], "strategy": "replace", "discard_when_merged": false } 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 7279173a..34a2e123 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 @@ -65,7 +65,14 @@ expression: v }, "compaction": { "rules": { - "value": [], + "value": [ + { + "keep_first": "1", + "keep_last": "3", + "reasoning": "strip", + "tool_calls": "strip" + } + ], "strategy": "replace", "discard_when_merged": false } 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 0d5fa4fb..86bbee9b 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 @@ -65,7 +65,14 @@ expression: v }, "compaction": { "rules": { - "value": [], + "value": [ + { + "keep_first": "1", + "keep_last": "3", + "reasoning": "strip", + "tool_calls": "strip" + } + ], "strategy": "replace", "discard_when_merged": false } 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 56d83c88..03c6cc75 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 @@ -65,7 +65,14 @@ expression: v }, "compaction": { "rules": { - "value": [], + "value": [ + { + "keep_first": "1", + "keep_last": "3", + "reasoning": "strip", + "tool_calls": "strip" + } + ], "strategy": "replace", "discard_when_merged": false } 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 f54f9840..80b95f3c 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 @@ -68,7 +68,14 @@ expression: v }, "compaction": { "rules": { - "value": [], + "value": [ + { + "keep_first": "1", + "keep_last": "3", + "reasoning": "strip", + "tool_calls": "strip" + } + ], "strategy": "replace", "discard_when_merged": false } 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 968e6af8..ed5d5e0f 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 @@ -68,7 +68,14 @@ expression: v }, "compaction": { "rules": { - "value": [], + "value": [ + { + "keep_first": "1", + "keep_last": "3", + "reasoning": "strip", + "tool_calls": "strip" + } + ], "strategy": "replace", "discard_when_merged": false } 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 f3bcd6c7..f3708041 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 @@ -65,7 +65,14 @@ expression: v }, "compaction": { "rules": { - "value": [], + "value": [ + { + "keep_first": "1", + "keep_last": "3", + "reasoning": "strip", + "tool_calls": "strip" + } + ], "strategy": "replace", "discard_when_merged": false } 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 abee1ec8..b23c2e82 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 @@ -65,7 +65,14 @@ expression: v }, "compaction": { "rules": { - "value": [], + "value": [ + { + "keep_first": "1", + "keep_last": "3", + "reasoning": "strip", + "tool_calls": "strip" + } + ], "strategy": "replace", "discard_when_merged": false } 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 61086af5..4bee0696 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 @@ -65,7 +65,14 @@ expression: v }, "compaction": { "rules": { - "value": [], + "value": [ + { + "keep_first": "1", + "keep_last": "3", + "reasoning": "strip", + "tool_calls": "strip" + } + ], "strategy": "replace", "discard_when_merged": false } 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 5522c001..cd557a90 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 @@ -65,7 +65,14 @@ expression: v }, "compaction": { "rules": { - "value": [], + "value": [ + { + "keep_first": "1", + "keep_last": "3", + "reasoning": "strip", + "tool_calls": "strip" + } + ], "strategy": "replace", "discard_when_merged": false } 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 e5bbc329..8bf15c17 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 @@ -65,7 +65,14 @@ expression: v }, "compaction": { "rules": { - "value": [], + "value": [ + { + "keep_first": "1", + "keep_last": "3", + "reasoning": "strip", + "tool_calls": "strip" + } + ], "strategy": "replace", "discard_when_merged": false } 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 55cda0ca..e7e2f1a7 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 @@ -65,7 +65,14 @@ expression: v }, "compaction": { "rules": { - "value": [], + "value": [ + { + "keep_first": "1", + "keep_last": "3", + "reasoning": "strip", + "tool_calls": "strip" + } + ], "strategy": "replace", "discard_when_merged": false } 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 4d491187..80955339 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 @@ -65,7 +65,14 @@ expression: v }, "compaction": { "rules": { - "value": [], + "value": [ + { + "keep_first": "1", + "keep_last": "3", + "reasoning": "strip", + "tool_calls": "strip" + } + ], "strategy": "replace", "discard_when_merged": false } 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 493f058f..7c171679 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 @@ -68,7 +68,14 @@ expression: v }, "compaction": { "rules": { - "value": [], + "value": [ + { + "keep_first": "1", + "keep_last": "3", + "reasoning": "strip", + "tool_calls": "strip" + } + ], "strategy": "replace", "discard_when_merged": false } 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 42284a45..39cb547a 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 @@ -68,7 +68,14 @@ expression: v }, "compaction": { "rules": { - "value": [], + "value": [ + { + "keep_first": "1", + "keep_last": "3", + "reasoning": "strip", + "tool_calls": "strip" + } + ], "strategy": "replace", "discard_when_merged": false } 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 01332ece..57ed27c3 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 @@ -65,7 +65,14 @@ expression: v }, "compaction": { "rules": { - "value": [], + "value": [ + { + "keep_first": "1", + "keep_last": "3", + "reasoning": "strip", + "tool_calls": "strip" + } + ], "strategy": "replace", "discard_when_merged": false } 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 924f2fcb..c91b62a6 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 @@ -68,7 +68,14 @@ expression: v }, "compaction": { "rules": { - "value": [], + "value": [ + { + "keep_first": "1", + "keep_last": "3", + "reasoning": "strip", + "tool_calls": "strip" + } + ], "strategy": "replace", "discard_when_merged": false } 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 9d64660b..07be3393 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 @@ -65,7 +65,14 @@ expression: v }, "compaction": { "rules": { - "value": [], + "value": [ + { + "keep_first": "1", + "keep_last": "3", + "reasoning": "strip", + "tool_calls": "strip" + } + ], "strategy": "replace", "discard_when_merged": false } 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 8f02ed79..83fd418e 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 @@ -65,7 +65,14 @@ expression: v }, "compaction": { "rules": { - "value": [], + "value": [ + { + "keep_first": "1", + "keep_last": "3", + "reasoning": "strip", + "tool_calls": "strip" + } + ], "strategy": "replace", "discard_when_merged": false } 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 c682f4fb..551207e2 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 @@ -65,7 +65,14 @@ expression: v }, "compaction": { "rules": { - "value": [], + "value": [ + { + "keep_first": "1", + "keep_last": "3", + "reasoning": "strip", + "tool_calls": "strip" + } + ], "strategy": "replace", "discard_when_merged": false } 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 77c1f3f0..2b96332f 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 @@ -65,7 +65,14 @@ expression: v }, "compaction": { "rules": { - "value": [], + "value": [ + { + "keep_first": "1", + "keep_last": "3", + "reasoning": "strip", + "tool_calls": "strip" + } + ], "strategy": "replace", "discard_when_merged": false } 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 b7117890..f6b16431 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 @@ -65,7 +65,14 @@ expression: v }, "compaction": { "rules": { - "value": [], + "value": [ + { + "keep_first": "1", + "keep_last": "3", + "reasoning": "strip", + "tool_calls": "strip" + } + ], "strategy": "replace", "discard_when_merged": false } 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 129f57ce..61f36b71 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 @@ -65,7 +65,14 @@ expression: v }, "compaction": { "rules": { - "value": [], + "value": [ + { + "keep_first": "1", + "keep_last": "3", + "reasoning": "strip", + "tool_calls": "strip" + } + ], "strategy": "replace", "discard_when_merged": false } 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 5e56f17a..94795b76 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 @@ -65,7 +65,14 @@ expression: v }, "compaction": { "rules": { - "value": [], + "value": [ + { + "keep_first": "1", + "keep_last": "3", + "reasoning": "strip", + "tool_calls": "strip" + } + ], "strategy": "replace", "discard_when_merged": false } 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 e5c1797f..456c419b 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 @@ -68,7 +68,14 @@ expression: v }, "compaction": { "rules": { - "value": [], + "value": [ + { + "keep_first": "1", + "keep_last": "3", + "reasoning": "strip", + "tool_calls": "strip" + } + ], "strategy": "replace", "discard_when_merged": false } 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 2d373d59..8a11ad6e 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 @@ -65,7 +65,14 @@ expression: v }, "compaction": { "rules": { - "value": [], + "value": [ + { + "keep_first": "1", + "keep_last": "3", + "reasoning": "strip", + "tool_calls": "strip" + } + ], "strategy": "replace", "discard_when_merged": false } 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 cf2361de..1901c8d2 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 @@ -68,7 +68,14 @@ expression: v }, "compaction": { "rules": { - "value": [], + "value": [ + { + "keep_first": "1", + "keep_last": "3", + "reasoning": "strip", + "tool_calls": "strip" + } + ], "strategy": "replace", "discard_when_merged": false } 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 9ec6e9c6..6de3bb0c 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 @@ -65,7 +65,14 @@ expression: v }, "compaction": { "rules": { - "value": [], + "value": [ + { + "keep_first": "1", + "keep_last": "3", + "reasoning": "strip", + "tool_calls": "strip" + } + ], "strategy": "replace", "discard_when_merged": false } 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 e24ea84e..1b8cb66e 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 @@ -65,7 +65,14 @@ expression: v }, "compaction": { "rules": { - "value": [], + "value": [ + { + "keep_first": "1", + "keep_last": "3", + "reasoning": "strip", + "tool_calls": "strip" + } + ], "strategy": "replace", "discard_when_merged": false } 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 a7beea5b..0cf216e6 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 @@ -65,7 +65,14 @@ expression: v }, "compaction": { "rules": { - "value": [], + "value": [ + { + "keep_first": "1", + "keep_last": "3", + "reasoning": "strip", + "tool_calls": "strip" + } + ], "strategy": "replace", "discard_when_merged": false } 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 71bdb40c..8a1addf6 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,7 +65,14 @@ expression: v }, "compaction": { "rules": { - "value": [], + "value": [ + { + "keep_first": "1", + "keep_last": "3", + "reasoning": "strip", + "tool_calls": "strip" + } + ], "strategy": "replace", "discard_when_merged": false } 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 786694e5..ecc8ca28 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 @@ -65,7 +65,14 @@ expression: v }, "compaction": { "rules": { - "value": [], + "value": [ + { + "keep_first": "1", + "keep_last": "3", + "reasoning": "strip", + "tool_calls": "strip" + } + ], "strategy": "replace", "discard_when_merged": false } 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 5aa08a6b..bc230864 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,7 +65,14 @@ expression: v }, "compaction": { "rules": { - "value": [], + "value": [ + { + "keep_first": "1", + "keep_last": "3", + "reasoning": "strip", + "tool_calls": "strip" + } + ], "strategy": "replace", "discard_when_merged": false } 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 3bc02ee1..696640b8 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 @@ -65,7 +65,14 @@ expression: v }, "compaction": { "rules": { - "value": [], + "value": [ + { + "keep_first": "1", + "keep_last": "3", + "reasoning": "strip", + "tool_calls": "strip" + } + ], "strategy": "replace", "discard_when_merged": false } 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 ff4d8cc1..cc7a5ad2 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 @@ -68,7 +68,14 @@ expression: v }, "compaction": { "rules": { - "value": [], + "value": [ + { + "keep_first": "1", + "keep_last": "3", + "reasoning": "strip", + "tool_calls": "strip" + } + ], "strategy": "replace", "discard_when_merged": false } 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 833aefd2..ae946096 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 @@ -65,7 +65,14 @@ expression: v }, "compaction": { "rules": { - "value": [], + "value": [ + { + "keep_first": "1", + "keep_last": "3", + "reasoning": "strip", + "tool_calls": "strip" + } + ], "strategy": "replace", "discard_when_merged": false } 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 aa020ad1..6e0ed9ab 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 @@ -68,7 +68,14 @@ expression: v }, "compaction": { "rules": { - "value": [], + "value": [ + { + "keep_first": "1", + "keep_last": "3", + "reasoning": "strip", + "tool_calls": "strip" + } + ], "strategy": "replace", "discard_when_merged": false } 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 36408d18..77cbaa6f 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 @@ -65,7 +65,14 @@ expression: v }, "compaction": { "rules": { - "value": [], + "value": [ + { + "keep_first": "1", + "keep_last": "3", + "reasoning": "strip", + "tool_calls": "strip" + } + ], "strategy": "replace", "discard_when_merged": false } 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 ecdb26b7..6cdf08f3 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 @@ -65,7 +65,14 @@ expression: v }, "compaction": { "rules": { - "value": [], + "value": [ + { + "keep_first": "1", + "keep_last": "3", + "reasoning": "strip", + "tool_calls": "strip" + } + ], "strategy": "replace", "discard_when_merged": false } 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 ba096565..a63b540f 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 @@ -65,7 +65,14 @@ expression: v }, "compaction": { "rules": { - "value": [], + "value": [ + { + "keep_first": "1", + "keep_last": "3", + "reasoning": "strip", + "tool_calls": "strip" + } + ], "strategy": "replace", "discard_when_merged": false } 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 67155ede..40515247 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 @@ -65,7 +65,14 @@ expression: v }, "compaction": { "rules": { - "value": [], + "value": [ + { + "keep_first": "1", + "keep_last": "3", + "reasoning": "strip", + "tool_calls": "strip" + } + ], "strategy": "replace", "discard_when_merged": false } 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 a31e53ec..6c5883a2 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 @@ -65,7 +65,14 @@ expression: v }, "compaction": { "rules": { - "value": [], + "value": [ + { + "keep_first": "1", + "keep_last": "3", + "reasoning": "strip", + "tool_calls": "strip" + } + ], "strategy": "replace", "discard_when_merged": false } 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 87ba8c4e..931bb666 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 @@ -65,7 +65,14 @@ expression: v }, "compaction": { "rules": { - "value": [], + "value": [ + { + "keep_first": "1", + "keep_last": "3", + "reasoning": "strip", + "tool_calls": "strip" + } + ], "strategy": "replace", "discard_when_merged": false } 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 e54eeb91..d3e53d58 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 @@ -65,7 +65,14 @@ expression: v }, "compaction": { "rules": { - "value": [], + "value": [ + { + "keep_first": "1", + "keep_last": "3", + "reasoning": "strip", + "tool_calls": "strip" + } + ], "strategy": "replace", "discard_when_merged": false } 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 71d0f141..cd06fdbb 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 @@ -68,7 +68,14 @@ expression: v }, "compaction": { "rules": { - "value": [], + "value": [ + { + "keep_first": "1", + "keep_last": "3", + "reasoning": "strip", + "tool_calls": "strip" + } + ], "strategy": "replace", "discard_when_merged": false } 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 3f22d379..daa83028 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 @@ -65,7 +65,14 @@ expression: v }, "compaction": { "rules": { - "value": [], + "value": [ + { + "keep_first": "1", + "keep_last": "3", + "reasoning": "strip", + "tool_calls": "strip" + } + ], "strategy": "replace", "discard_when_merged": false } 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 5ef030de..8653370d 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 @@ -68,7 +68,14 @@ expression: v }, "compaction": { "rules": { - "value": [], + "value": [ + { + "keep_first": "1", + "keep_last": "3", + "reasoning": "strip", + "tool_calls": "strip" + } + ], "strategy": "replace", "discard_when_merged": false } 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 13b649fa..a4c273bc 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 @@ -65,7 +65,14 @@ expression: v }, "compaction": { "rules": { - "value": [], + "value": [ + { + "keep_first": "1", + "keep_last": "3", + "reasoning": "strip", + "tool_calls": "strip" + } + ], "strategy": "replace", "discard_when_merged": false } 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 41be71f2..f1e791ac 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 @@ -65,7 +65,14 @@ expression: v }, "compaction": { "rules": { - "value": [], + "value": [ + { + "keep_first": "1", + "keep_last": "3", + "reasoning": "strip", + "tool_calls": "strip" + } + ], "strategy": "replace", "discard_when_merged": false } 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 75959b86..62731432 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 @@ -65,7 +65,14 @@ expression: v }, "compaction": { "rules": { - "value": [], + "value": [ + { + "keep_first": "1", + "keep_last": "3", + "reasoning": "strip", + "tool_calls": "strip" + } + ], "strategy": "replace", "discard_when_merged": false } 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 e0e04b8c..5fe8967c 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 @@ -65,7 +65,14 @@ expression: v }, "compaction": { "rules": { - "value": [], + "value": [ + { + "keep_first": "1", + "keep_last": "3", + "reasoning": "strip", + "tool_calls": "strip" + } + ], "strategy": "replace", "discard_when_merged": false } 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 8d7a436c..adb38d58 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 @@ -65,7 +65,14 @@ expression: v }, "compaction": { "rules": { - "value": [], + "value": [ + { + "keep_first": "1", + "keep_last": "3", + "reasoning": "strip", + "tool_calls": "strip" + } + ], "strategy": "replace", "discard_when_merged": false } 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 303feba5..fd47499e 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 @@ -65,7 +65,14 @@ expression: v }, "compaction": { "rules": { - "value": [], + "value": [ + { + "keep_first": "1", + "keep_last": "3", + "reasoning": "strip", + "tool_calls": "strip" + } + ], "strategy": "replace", "discard_when_merged": false } 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 b648b823..1c2653f0 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 @@ -65,7 +65,14 @@ expression: v }, "compaction": { "rules": { - "value": [], + "value": [ + { + "keep_first": "1", + "keep_last": "3", + "reasoning": "strip", + "tool_calls": "strip" + } + ], "strategy": "replace", "discard_when_merged": false } 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 6633dee0..ab449eea 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 @@ -68,7 +68,14 @@ expression: v }, "compaction": { "rules": { - "value": [], + "value": [ + { + "keep_first": "1", + "keep_last": "3", + "reasoning": "strip", + "tool_calls": "strip" + } + ], "strategy": "replace", "discard_when_merged": false } 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 e28c0e36..9c8daa92 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 @@ -68,7 +68,14 @@ expression: v }, "compaction": { "rules": { - "value": [], + "value": [ + { + "keep_first": "1", + "keep_last": "3", + "reasoning": "strip", + "tool_calls": "strip" + } + ], "strategy": "replace", "discard_when_merged": false } 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 ada83542..45259562 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 @@ -68,7 +68,14 @@ expression: v }, "compaction": { "rules": { - "value": [], + "value": [ + { + "keep_first": "1", + "keep_last": "3", + "reasoning": "strip", + "tool_calls": "strip" + } + ], "strategy": "replace", "discard_when_merged": false } 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 5a81d9f0..58c437f7 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 @@ -68,7 +68,14 @@ expression: v }, "compaction": { "rules": { - "value": [], + "value": [ + { + "keep_first": "1", + "keep_last": "3", + "reasoning": "strip", + "tool_calls": "strip" + } + ], "strategy": "replace", "discard_when_merged": false } 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 79fadf6d..8291c9b6 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 @@ -65,7 +65,14 @@ expression: v }, "compaction": { "rules": { - "value": [], + "value": [ + { + "keep_first": "1", + "keep_last": "3", + "reasoning": "strip", + "tool_calls": "strip" + } + ], "strategy": "replace", "discard_when_merged": false } 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 560290d8..ff9c5262 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 @@ -68,7 +68,14 @@ expression: v }, "compaction": { "rules": { - "value": [], + "value": [ + { + "keep_first": "1", + "keep_last": "3", + "reasoning": "strip", + "tool_calls": "strip" + } + ], "strategy": "replace", "discard_when_merged": false } 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 92e2beb8..69c22b53 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 @@ -65,7 +65,14 @@ expression: v }, "compaction": { "rules": { - "value": [], + "value": [ + { + "keep_first": "1", + "keep_last": "3", + "reasoning": "strip", + "tool_calls": "strip" + } + ], "strategy": "replace", "discard_when_merged": false } 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 c5ec8c2f..b9f9aab9 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 @@ -65,7 +65,14 @@ expression: v }, "compaction": { "rules": { - "value": [], + "value": [ + { + "keep_first": "1", + "keep_last": "3", + "reasoning": "strip", + "tool_calls": "strip" + } + ], "strategy": "replace", "discard_when_merged": false } 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 427e76dd..d48dbe8d 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 @@ -65,7 +65,14 @@ expression: v }, "compaction": { "rules": { - "value": [], + "value": [ + { + "keep_first": "1", + "keep_last": "3", + "reasoning": "strip", + "tool_calls": "strip" + } + ], "strategy": "replace", "discard_when_merged": false } 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 b6a329f8..8c5d71fa 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 @@ -65,7 +65,14 @@ expression: v }, "compaction": { "rules": { - "value": [], + "value": [ + { + "keep_first": "1", + "keep_last": "3", + "reasoning": "strip", + "tool_calls": "strip" + } + ], "strategy": "replace", "discard_when_merged": false } 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 54702bb1..d978bc5b 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 @@ -65,7 +65,14 @@ expression: v }, "compaction": { "rules": { - "value": [], + "value": [ + { + "keep_first": "1", + "keep_last": "3", + "reasoning": "strip", + "tool_calls": "strip" + } + ], "strategy": "replace", "discard_when_merged": false } 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 e6c359c7..67fe117b 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 @@ -65,7 +65,14 @@ expression: v }, "compaction": { "rules": { - "value": [], + "value": [ + { + "keep_first": "1", + "keep_last": "3", + "reasoning": "strip", + "tool_calls": "strip" + } + ], "strategy": "replace", "discard_when_merged": false } 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 c65a0979..f4c4d320 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 @@ -65,7 +65,14 @@ expression: v }, "compaction": { "rules": { - "value": [], + "value": [ + { + "keep_first": "1", + "keep_last": "3", + "reasoning": "strip", + "tool_calls": "strip" + } + ], "strategy": "replace", "discard_when_merged": false } 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 5ec97da0..d0886b17 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 @@ -68,7 +68,14 @@ expression: v }, "compaction": { "rules": { - "value": [], + "value": [ + { + "keep_first": "1", + "keep_last": "3", + "reasoning": "strip", + "tool_calls": "strip" + } + ], "strategy": "replace", "discard_when_merged": false }