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};
const RESUME_DEFAULT_TURNS: usize = 6;
const RESUME_CONTEXT_DESCRIPTION: &str =
"Recover what you were working on — returns this conversation's recent \
turns, your current <plan>, and saved <state>. Use it after a resume or \
when you've lost the thread. No args.";
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": [] }
}
})
}
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 {
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");
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"),
Err(e) => out.push_str(&format!("(could not load earlier turns: {e})\n")),
}
if let Some(block) = step_ledger.and_then(plan_block) {
out.push('\n');
out.push_str(&block);
out.push('\n');
}
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;
#[derive(Default)]
struct RecentMock {
recent: Vec<SearchHit>,
fail_with: Option<String>,
calls: Mutex<Vec<usize>>,
}
impl RecallSource for RecentMock {
fn search(&self, _query: &str, _limit: usize) -> anyhow::Result<Vec<SearchHit>> {
Ok(Vec::new())
}
fn this_conversation_recent(&self, limit: usize) -> anyhow::Result<Vec<SearchHit>> {
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("<plan>") && desc.contains("<state>"),
"{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,
);
assert!(out.contains("— recent turns (oldest first) —"), "{out}");
assert!(out.contains("[1] you: start it"), "{out}");
assert!(out.contains("[2] you: more"), "{out}");
assert!(
out.contains("<plan>") && out.contains("→ 1. scope it"),
"{out}"
);
assert!(
out.contains("<state>") && out.contains("current_task: wire the PyO3 bindings"),
"{out}"
);
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() {
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("<plan>"), "{out}");
assert!(!out.contains("<state>"), "{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}"
);
assert!(out.contains("<plan>"), "{out}");
}
}