From 708a93c5685f78e774974cdbda44394d0df366a6 Mon Sep 17 00:00:00 2001 From: Shawn Hartsock Date: Sun, 28 Jun 2026 18:20:00 -0400 Subject: [PATCH] feat(agentic): resume_context tool + alias redirect for self-recovery (#714) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On resume, recall excludes the current conversation by design, so the model had no self-callable way to read THIS conversation's own pre-interrupt work. Adds a read-only resume_context tool that renders this conversation's recent turns (via a new RecallSource::this_conversation_recent — the opposite of search's filter), the current (plan_block), and the saved (now restored by #713); degrades to a clear "no history" line when headless. Advertised always. resolve_tool_alias redirects the instinctive reaches (resume, where_were_we, where_did_we_leave_off, catch_me_up, recap) to resume_context; what_was_i_doing still -> plan_get. recall's no-match text now points at resume_context (keeping the "no matches in past conversations" prefix so #717 telemetry still keys on it). The phantom-reach telemetry (#717) records the new aliases for free. Review fix: the real name resume_context is NOT in the alias list — it returns None so a direct call dispatches as a real tool, not a self-Rewrite that would pollute #717's signal. Fixes #714 Co-authored-by: Claude Opus 4.8 (1M context) --- newt-core/src/agentic/mod.rs | 4 + newt-core/src/agentic/recall.rs | 117 ++++++++++++++- newt-core/src/agentic/resume.rs | 253 ++++++++++++++++++++++++++++++++ newt-core/src/agentic/tools.rs | 117 ++++++++++++++- newt-core/tests/store.rs | 40 +++++ 5 files changed, 528 insertions(+), 3 deletions(-) create mode 100644 newt-core/src/agentic/resume.rs diff --git a/newt-core/src/agentic/mod.rs b/newt-core/src/agentic/mod.rs index 893b3a8a..524d91c1 100644 --- a/newt-core/src/agentic/mod.rs +++ b/newt-core/src/agentic/mod.rs @@ -96,6 +96,9 @@ mod note_sink; mod observation; mod permissions; mod recall; +// #714: the `resume_context` tool — a self-scoped read of THIS conversation's +// own pre-interrupt work (the affordance `recall` structurally cannot be). +mod resume; mod tools; mod transcript; mod trim; @@ -190,6 +193,7 @@ pub use permissions::{ PermissionRequest, }; pub use recall::{recall_tool_definition, RecallSource, StoreRecallSource}; +pub use resume::resume_context_tool_definition; pub use tools::{execute_tool, ocap_disabled, tool_definitions, venv_cmd_prefix}; pub use transcript::{ transcript_lines, transcript_lines_styled, TranscriptLine, TranscriptRole, TranscriptStyle, diff --git a/newt-core/src/agentic/recall.rs b/newt-core/src/agentic/recall.rs index 8598d7fb..28c689f9 100644 --- a/newt-core/src/agentic/recall.rs +++ b/newt-core/src/agentic/recall.rs @@ -38,6 +38,24 @@ pub trait RecallSource: Send + Sync { /// Return up to `limit` best-first hits for `query` (plain keywords; /// the executor has already pre-flighted the 17.3 sanitizer). fn search(&self, query: &str, limit: usize) -> anyhow::Result>; + + /// Return up to `limit` of THIS conversation's most recent durable turns — + /// the deliberate OPPOSITE of [`Self::search`]'s exclusion filter (#714). + /// + /// The `recall` contract refuses the active conversation (what's said here + /// is already in context); but after an interrupt + auto-resume the early + /// turns compaction cut from the live window survive ONLY in the store and + /// are reachable ONLY here. The `resume_context` tool uses this to let a + /// resumed thread read its own pre-interrupt work. Hits are oldest-first + /// (chronological), not bm25-ranked — this is a self-read, not a search. + /// + /// Default impl returns `Ok(vec![])` so existing mocks and headless callers + /// (which have no conversation store) compile and behave unchanged — only + /// [`StoreRecallSource`] overrides it. + fn this_conversation_recent(&self, limit: usize) -> anyhow::Result> { + let _ = limit; + Ok(Vec::new()) + } } /// The canonical [`RecallSource`]: [`crate::store::ConversationStore::search`] @@ -82,6 +100,62 @@ impl RecallSource for StoreRecallSource<'_> { .take(limit) .collect()) } + + fn this_conversation_recent(&self, limit: usize) -> anyhow::Result> { + // The OPPOSITE of `search`'s filter (#714): load THIS conversation's own + // record (the same path `/conversation restore` uses) and hand back its + // last `limit` turns. On resume these are the pre-interrupt turns + // compaction cut from the live window — durable in the store, refused by + // `recall` by design, reachable only here. + if limit == 0 { + return Ok(Vec::new()); + } + let record = self.store.load(self.current_conversation_id)?; + let start = record.turns.len().saturating_sub(limit); + Ok(record + .turns + .iter() + .enumerate() + .skip(start) + .map(|(i, turn)| SearchHit { + conversation_id: record.id.clone(), + title: record.title.clone(), + // Turns load without their §6 per-writer seq, so the 1-based + // position is the ordering label shown to the model. This is a + // self-read, not a recall rank — no bm25 score applies. + seq: (i + 1) as i64, + snippet: recent_turn_snippet(&turn.user, &turn.assistant), + rank: 0.0, + }) + .collect()) + } +} + +/// Per-field char cap when compacting a turn into a `resume_context` snippet +/// (#714) — a long turn must not blow the model's send budget on resume. +const RESUME_TURN_FIELD_CAP: usize = 600; + +/// Compact one turn (the user ask + the assistant reply) into a single readable +/// snippet for the `resume_context` self-read (#714): whitespace flattened (turn +/// text is multi-line) and each field length-capped. +fn recent_turn_snippet(user: &str, assistant: &str) -> String { + format!( + "you: {}\n me: {}", + flatten_and_cap(user, RESUME_TURN_FIELD_CAP), + flatten_and_cap(assistant, RESUME_TURN_FIELD_CAP), + ) +} + +/// Collapse internal whitespace to single spaces and truncate to `cap` chars +/// with a `[…]` marker — shared by [`recent_turn_snippet`]. +fn flatten_and_cap(s: &str, cap: usize) -> String { + let flat = s.split_whitespace().collect::>().join(" "); + if flat.chars().count() > cap { + let head: String = flat.chars().take(cap).collect(); + format!("{head}[…]") + } else { + flat + } } // --------------------------------------------------------------------------- @@ -184,8 +258,15 @@ pub(crate) fn execute_recall( Err(e) => return format!("error: {e}"), }; if hits.is_empty() { - let out = - format!("no matches in past conversations for {query:?} — try different keywords"); + // #714: recall structurally excludes the CURRENT conversation, so on a + // freshly-resumed thin context this dead-ends on the one conversation + // that matters. Append the redirect off the dead-end. (Must keep the + // "no matches in past conversations" prefix — `classify_phantom_reach` + // keys the #717 telemetry on it.) + let out = format!( + "no matches in past conversations for {query:?} — try different keywords. \ + To recover THIS conversation's own earlier work, call resume_context." + ); print_tool_output(&out, tool_output_lines, color); return out; } @@ -416,6 +497,38 @@ pub(crate) mod tests { ); assert!(out.contains("no matches"), "got: {out}"); assert!(!out.is_empty()); + // The prefix `classify_phantom_reach` keys on is preserved (#717). + assert!( + out.starts_with("no matches in past conversations"), + "got: {out}" + ); + // #714: the dead-end now redirects to the self-recovery tool. + assert!(out.contains("resume_context"), "got: {out}"); + } + + #[test] + fn this_conversation_recent_default_impl_is_empty() { + // #714: the trait's default impl returns empty so existing mocks / + // headless callers (no conversation store) compile + behave unchanged. + let source = MockSource { + hits: vec![hit("conv-a", "t", 1, "snip")], + ..Default::default() + }; + assert!( + source.this_conversation_recent(5).unwrap().is_empty(), + "default impl ignores `hits` and returns empty" + ); + } + + #[test] + fn recent_turn_snippet_flattens_and_caps() { + // #714: multi-line turn text collapses to one line per field. + let s = recent_turn_snippet("fix\nthe bug", "done\nshipping it"); + assert_eq!(s, "you: fix the bug\n me: done shipping it"); + // Over-long fields truncate with the […] marker. + let long = recent_turn_snippet(&"x".repeat(RESUME_TURN_FIELD_CAP + 50), "ok"); + assert!(long.contains("[…]"), "got: {long}"); + assert!(long.contains("me: ok"), "got: {long}"); } #[test] diff --git a/newt-core/src/agentic/resume.rs b/newt-core/src/agentic/resume.rs new file mode 100644 index 00000000..512d987b --- /dev/null +++ b/newt-core/src/agentic/resume.rs @@ -0,0 +1,253 @@ +//! The `resume_context` tool seam (#714) — a self-scoped, read-only recovery +//! of what THIS conversation was working on before an interrupt. +//! +//! On an interrupt + auto-resume the restored conversation BECOMES the active +//! one, and the model's two instinctive working-memory reaches both dead-end on +//! it by construction: +//! +//! - `recall` excludes the active conversation by design (`recall.rs`), so it +//! can never return the very thread the model wants to recover; and +//! - `state_get` reads the in-memory scratchpad (`scratchpad.rs`), which #713 +//! now rehydrates — but the model has to know the key to ask for it. +//! +//! `resume_context` is the missing affordance: one read-only tool that +//! concatenates, for THIS conversation, (a) its recent durable turns (via +//! [`RecallSource::this_conversation_recent`] — the deliberate opposite of +//! `recall`'s filter), (b) the current `` checklist, and (c) the saved +//! `` scratchpad. No args, no mutation. When no conversation store is +//! present (headless / eval), it degrades to a clear "no history this session" +//! line rather than a dead end. + +use super::display::{print_tool_call, print_tool_output}; +use super::recall::RecallSource; +use super::scheduled::{plan_block, StepLedger}; +use super::scratchpad::{scratchpad_state_block, ScratchpadStore}; + +/// How many of this conversation's recent turns `resume_context` pulls back. +const RESUME_DEFAULT_TURNS: usize = 6; + +/// Model-facing contract for `resume_context` — coaching for a small local LLM +/// reaching for "what was I doing?" after a resume. No args (a self-read), and +/// it names the three things it returns so the model knows it landed. +const RESUME_CONTEXT_DESCRIPTION: &str = + "Recover what you were working on — returns this conversation's recent \ + turns, your current , and saved . Use it after a resume or \ + when you've lost the thread. No args."; + +/// The `resume_context` tool definition. Unlike `recall`, this is advertised +/// ALWAYS (it is broadly useful and degrades gracefully when its sources are +/// absent), so the merged set carries it even in headless callers — the +/// executor returns a clear "no history this session" when the store is `None`. +pub fn resume_context_tool_definition() -> serde_json::Value { + serde_json::json!({ + "type": "function", + "function": { + "name": "resume_context", + "description": RESUME_CONTEXT_DESCRIPTION, + "parameters": { "type": "object", "properties": {}, "required": [] } + } + }) +} + +/// Execute a `resume_context` call (#714) — read-only. Concatenates this +/// conversation's recent turns, ``, and `` into one block. Every +/// branch returns a tool-result String, never a loop abort: +/// - `recall_source` is `None` (headless / eval) → a clear "no history" line; +/// - the turn-load backend fails → the failure is folded into the block as a +/// note, never propagated (the plan/state sections still render); +/// - the plan / state are empty → those sections are simply omitted. +pub(crate) fn execute_resume_context( + recall_source: Option<&dyn RecallSource>, + step_ledger: Option<&dyn StepLedger>, + scratchpad_store: Option<&dyn ScratchpadStore>, + color: bool, + tool_output_lines: usize, +) -> String { + print_tool_call("resume_context", "", color); + + let Some(source) = recall_source else { + // Headless / eval: no conversation store this session. Be explicit so + // the model switches strategy instead of re-probing a dead end. + let out = "no conversation history available this session — resume_context needs a \ + conversation store (headless / eval sessions have none)." + .to_string(); + print_tool_output(&out, tool_output_lines, color); + return out; + }; + + let mut out = String::from("Recovering what this conversation was working on.\n"); + + // (a) THIS conversation's recent durable turns — the pre-interrupt work the + // live window may have dropped to compaction. + out.push_str("\n— recent turns (oldest first) —\n"); + match source.this_conversation_recent(RESUME_DEFAULT_TURNS) { + Ok(hits) if !hits.is_empty() => { + for h in &hits { + out.push_str(&format!("[{}] {}\n", h.seq, h.snippet)); + } + } + Ok(_) => out.push_str("(no earlier turns recorded yet)\n"), + // A backend failure is a note in the block, not a loop abort — the + // plan / state below may still orient the model. + Err(e) => out.push_str(&format!("(could not load earlier turns: {e})\n")), + } + + // (b) the current checklist, read-only (omitted when empty). + if let Some(block) = step_ledger.and_then(plan_block) { + out.push('\n'); + out.push_str(&block); + out.push('\n'); + } + + // (c) the saved scratchpad, read-only (omitted when empty). Reuses + // the same capped renderer the per-turn injection uses. + if let Some(block) = scratchpad_store.and_then(scratchpad_state_block) { + out.push('\n'); + out.push_str(&block); + out.push('\n'); + } + + print_tool_output(&out, tool_output_lines, color); + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::store::SearchHit; + use std::sync::Mutex; + + /// A `RecallSource` whose `this_conversation_recent` serves canned hits — + /// the resume-side mirror of `recall::tests::MockSource`. + #[derive(Default)] + struct RecentMock { + recent: Vec, + fail_with: Option, + calls: Mutex>, + } + + impl RecallSource for RecentMock { + fn search(&self, _query: &str, _limit: usize) -> anyhow::Result> { + Ok(Vec::new()) + } + fn this_conversation_recent(&self, limit: usize) -> anyhow::Result> { + self.calls.lock().unwrap().push(limit); + match &self.fail_with { + Some(e) => Err(anyhow::anyhow!("{e}")), + None => Ok(self.recent.iter().take(limit).cloned().collect()), + } + } + } + + fn hit(seq: i64, snippet: &str) -> SearchHit { + SearchHit { + conversation_id: "conv-x".to_string(), + title: "the task".to_string(), + seq, + snippet: snippet.to_string(), + rank: 0.0, + } + } + + #[test] + fn definition_is_argless_and_named() { + let def = resume_context_tool_definition(); + assert_eq!(def["function"]["name"], "resume_context"); + assert_eq!( + def["function"]["parameters"]["properties"], + serde_json::json!({}) + ); + let desc = def["function"]["description"].as_str().unwrap(); + assert!(desc.contains("Recover what you were working on"), "{desc}"); + assert!( + desc.contains("") && desc.contains(""), + "{desc}" + ); + } + + #[test] + fn renders_turns_plan_and_state_together() { + use crate::agentic::scheduled::SessionStepLedger; + use crate::agentic::scratchpad::SessionScratchpadStore; + + let source = RecentMock { + recent: vec![hit(1, "you: start it\n me: ok"), hit(2, "you: more")], + ..Default::default() + }; + let ledger = SessionStepLedger::default(); + ledger.set_plan(&["scope it".to_string(), "build it".to_string()]); + let scratch = SessionScratchpadStore::default(); + scratch.set("current_task", "wire the PyO3 bindings".to_string()); + + let out = execute_resume_context( + Some(&source), + Some(&ledger as &dyn StepLedger), + Some(&scratch as &dyn ScratchpadStore), + false, + 20, + ); + + // (a) recent turns, oldest first, seq-labelled. + assert!(out.contains("— recent turns (oldest first) —"), "{out}"); + assert!(out.contains("[1] you: start it"), "{out}"); + assert!(out.contains("[2] you: more"), "{out}"); + // (b) the block, with the active step marked. + assert!( + out.contains("") && out.contains("→ 1. scope it"), + "{out}" + ); + // (c) the block. + assert!( + out.contains("") && out.contains("current_task: wire the PyO3 bindings"), + "{out}" + ); + // It asked the source for the default number of turns, read-only. + assert_eq!(*source.calls.lock().unwrap(), vec![RESUME_DEFAULT_TURNS]); + assert_eq!(ledger.count(), 2, "resume_context does not mutate the plan"); + assert_eq!(scratch.keys_count(), 1, "or the scratchpad"); + } + + #[test] + fn no_source_returns_clear_no_history_message() { + let out = execute_resume_context(None, None, None, false, 20); + assert!( + out.contains("no conversation history available this session"), + "{out}" + ); + } + + #[test] + fn empty_sources_render_without_panicking() { + // A source with no turns + no plan + no state → just the header and the + // "no earlier turns" note; the plan / state sections are omitted. + let source = RecentMock::default(); + let out = execute_resume_context(Some(&source), None, None, false, 20); + assert!(out.contains("(no earlier turns recorded yet)"), "{out}"); + assert!(!out.contains(""), "{out}"); + assert!(!out.contains(""), "{out}"); + } + + #[test] + fn turn_load_failure_is_folded_in_not_aborted() { + use crate::agentic::scheduled::SessionStepLedger; + let source = RecentMock { + fail_with: Some("db is on fire".to_string()), + ..Default::default() + }; + let ledger = SessionStepLedger::default(); + ledger.set_plan(&["recover".to_string()]); + let out = execute_resume_context( + Some(&source), + Some(&ledger as &dyn StepLedger), + None, + false, + 20, + ); + assert!( + out.contains("could not load earlier turns: db is on fire"), + "{out}" + ); + // The plan still renders despite the turn-load failure. + assert!(out.contains(""), "{out}"); + } +} diff --git a/newt-core/src/agentic/tools.rs b/newt-core/src/agentic/tools.rs index a5eb6521..ed4c00f3 100644 --- a/newt-core/src/agentic/tools.rs +++ b/newt-core/src/agentic/tools.rs @@ -243,6 +243,11 @@ pub(crate) fn merged_tool_definitions( serde_json::Value::Array(a) => a, other => vec![other], }; + // #714: `resume_context` is advertised ALWAYS — it is broadly useful and + // degrades gracefully (the executor returns a clear "no history this + // session" when its sources are `None`), so unlike the presence-gated tools + // it carries no `with_*` flag and rides in every session, headless included. + defs.push(super::resume::resume_context_tool_definition()); if with_save_note { defs.push(save_note_tool_definition()); } @@ -335,6 +340,9 @@ const ALL_TOOL_NAMES: &[&str] = &[ "plan_set", "plan_advance", "plan_get", + // #714: always-advertised self-recovery read (degrades gracefully when its + // sources are absent), so it is never treated as a hallucination. + "resume_context", ]; /// Returns `true` if a tool call looks like a hallucination: @@ -415,9 +423,19 @@ pub(crate) fn resolve_tool_alias(name: &str) -> Option { ))), // #716 PLAN-READ — read the current plan. plan_get takes no args, so the // foreign call's (empty) arg shape matches: safe to silently Rewrite. + // `what_was_i_doing` stays here (→ plan_get) — it asks specifically for + // the plan; the broader "where were we" reaches go to resume_context. "get_plan" | "show_plan" | "read_plan" | "current_plan" | "what_was_i_doing" => { Some(AliasOutcome::Rewrite("plan_get")) } + // #714 RESUME — the instinctive "where did we leave off" reach. All take + // no args (resume_context is a self-read), so the (empty) arg shape + // matches: safe to silently Rewrite. Meets the dead-end reach the issue + // observed (the model retrying recall) by landing it on the affordance + // built for exactly this case. + "resume" | "where_were_we" | "where_did_we_leave_off" | "catch_me_up" | "recap" => { + Some(AliasOutcome::Rewrite("resume_context")) + } // #716 CREW / DELEGATE — crew/team is the human-only `/team` toggle a // model cannot self-enable, and the targets may be unadvertised, so this // can only ever Correct (never silently Rewrite) and the message must NOT @@ -1337,6 +1355,19 @@ pub async fn execute_tool( None => "unknown tool: plan_get (scheduled planning is off)".to_string(), }, + // #714: self-scoped resume recovery — reads THIS conversation's recent + // turns (via the RecallSource's this_conversation_recent, the opposite + // of recall's filter), the , and the . Advertised ALWAYS, + // so it reuses the already-present recall_source / step_ledger / + // scratchpad_store params and degrades gracefully when they are None. + "resume_context" => super::resume::execute_resume_context( + recall_source, + step_ledger, + scratchpad_store, + color, + tool_output_lines, + ), + // Embedded git (PR4, #461): dispatch through the injected GitTool // (newt-git's LocalGitTool). `GitCaveats::from_session` projects the // session's authority onto the git surface (fail-closed: a read-only @@ -2001,6 +2032,17 @@ mod tests { ); } + #[test] + fn classify_phantom_resume_reach_is_a_rewrite() { + // #714 + #717: a "where were we" reach resolves through the alias seam to + // a Rewrite, so the telemetry already captures it (no new wiring needed). + let got = classify_phantom_reach("where_were_we", &serde_json::json!({}), "ignored", false); + assert_eq!( + got, + Some(crate::PhantomResolution::Rewrite("resume_context".into())) + ); + } + #[test] fn classify_phantom_real_success_is_none() { // An ordinary successful real tool call is not phantom telemetry. @@ -2150,7 +2192,10 @@ mod tests { "list_dir", "find", "use_skill", - "web_fetch" + "web_fetch", + // #714: advertised ALWAYS (no presence gate), so it joins the + // base set even with every `with_*` flag off. + "resume_context", ] ); } @@ -2861,6 +2906,40 @@ mod tests { assert_eq!(ledger.count(), 2, "plan_get is read-only"); } + #[tokio::test] + async fn resume_context_dispatch_degrades_without_a_recall_source() { + // #714: advertised ALWAYS, so dispatch never reports "unknown tool" — + // with no recall_source (headless) it returns the clear no-history line. + let caveats = crate::caveats::Caveats::top(); + let out = execute_tool( + "resume_context", + &serde_json::json!({}), + ".", + false, + 20, + &caveats, + &mut NoMcp, + None, // build_check_cmd + None, // note_sink + None, // recall_source + None, // memory_source + None, // permission_gate + None, // exec_floor + None, // git_tool + None, // crew_runner + None, // scratchpad_store + None, // code_search + None, // experience_store + None, // step_ledger + ) + .await; + assert!( + out.contains("no conversation history available this session"), + "{out}" + ); + assert!(!out.starts_with("unknown tool"), "{out}"); + } + #[test] fn run_build_check_reports_pass_fail_and_spawn_error() { let ws = tempfile::TempDir::new().unwrap(); @@ -3744,6 +3823,42 @@ mod execute_tool_branch_tests { } } + #[test] + fn alias_rewrites_resume_reaches_to_resume_context() { + // #714: the instinctive "where did we leave off" reaches redirect to the + // self-recovery tool, not plan_get. + for n in [ + "resume", + "where_were_we", + "where_did_we_leave_off", + "catch_me_up", + "recap", + ] { + assert!( + matches!( + resolve_tool_alias(n), + Some(AliasOutcome::Rewrite("resume_context")) + ), + "{n} should rewrite to resume_context" + ); + } + // The REAL tool name is not an alias: it returns None so a direct + // resume_context call dispatches as a real tool and is NOT logged as a + // phantom Rewrite by #717 telemetry (real names must return None). + assert!( + resolve_tool_alias("resume_context").is_none(), + "the real tool name must return None, not a self-Rewrite" + ); + // No regression: `what_was_i_doing` still asks specifically for the plan. + assert!( + matches!( + resolve_tool_alias("what_was_i_doing"), + Some(AliasOutcome::Rewrite("plan_get")) + ), + "what_was_i_doing must stay → plan_get" + ); + } + #[test] fn alias_corrects_crew_names_and_flags_team_gating() { for n in [ diff --git a/newt-core/tests/store.rs b/newt-core/tests/store.rs index 02c4a977..8f7c0e73 100644 --- a/newt-core/tests/store.rs +++ b/newt-core/tests/store.rs @@ -1856,6 +1856,46 @@ fn store_recall_source_truncates_to_limit_after_exclusion() { assert!(hits.iter().all(|h| h.conversation_id != current)); } +#[test] +fn this_conversation_recent_returns_the_current_conversations_own_last_turns() { + // #714: the OPPOSITE of search's filter — `resume_context` must read THIS + // conversation's own pre-interrupt turns (the affordance recall refuses). + use newt_core::{RecallSource as _, StoreRecallSource}; + + let root = tempfile::tempdir().unwrap(); + let workspace = tempfile::tempdir().unwrap(); + let store = ConversationStore::new(root.path(), workspace.path(), 100).unwrap(); + + let current = store.create("resumed work", None).unwrap(); + for i in 0..5 { + store + .append_turn(¤t, &format!("ask {i}"), &format!("reply {i}")) + .unwrap(); + } + // A different conversation must NOT leak in (this is a self-read, fenced to + // the current id — the mirror of search's exclusion). + let other = store.create("other work", None).unwrap(); + store.append_turn(&other, "unrelated", "unrelated").unwrap(); + + let source = StoreRecallSource::new(&store, ¤t); + let hits = source.this_conversation_recent(3).unwrap(); + assert_eq!(hits.len(), 3, "last `limit` turns only: {hits:?}"); + // All hits belong to THIS conversation — the opposite of recall's filter. + assert!( + hits.iter().all(|h| h.conversation_id == current), + "another conversation leaked into the self-read: {hits:?}" + ); + // Oldest-first within the window (turns 2,3,4 of 0..5), seq = 1-based pos. + assert!(hits[0].snippet.contains("ask 2") && hits[0].snippet.contains("reply 2")); + assert!(hits[2].snippet.contains("ask 4")); + assert_eq!(hits[0].seq, 3, "1-based position of turn index 2"); + assert_eq!(hits[2].seq, 5); + + // limit 0 → empty (the guard), and a limit past the end clamps to all turns. + assert!(source.this_conversation_recent(0).unwrap().is_empty()); + assert_eq!(source.this_conversation_recent(99).unwrap().len(), 5); +} + // ========================================================================= // Part 5 — 17.6: tool-event + token-usage recording (issue #246). // The turn grows past `(task, reply)`: `append_turn_full` persists the