mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-08 08:04:25 -10:00
fix(tui): let undo roll back files for the turn it is undoing (#6483)
This commit is contained in:
1 parent
c6dfdd862c
commit
8a835d7c4d
6 files changed
+543
-23
No files matched your search
@@ -3369,6 +3369,7 @@ impl Engine {
|
||||
let total_tokens = self.session.total_usage.input_tokens
|
||||
+ self.session.total_usage.output_tokens;
|
||||
let snapshot = SessionSnapshot {
|
||||
session_id: self.session.id.clone(),
|
||||
messages: self.session.messages.to_vec(),
|
||||
total_tokens,
|
||||
model: self.session.model.clone(),
|
||||
|
||||
@@ -20,6 +20,15 @@ pub const USER_SHELL_TOOL_ID_PREFIX: &str = "user_shell_";
|
||||
/// Returned by `Op::GetSessionSnapshot` via a oneshot channel.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SessionSnapshot {
|
||||
/// The live conversation id this engine session is running under.
|
||||
///
|
||||
/// It is the id every workspace snapshot this conversation takes
|
||||
/// (`tool:` / `pre-turn:`) is tagged with, so a save of the conversation
|
||||
/// has to persist it as the session document's own id: a document minted
|
||||
/// under a different id leaves the thread bound to a session that owns no
|
||||
/// snapshots, and `/undo` and the file-revert endpoint both select by that
|
||||
/// binding (see `patch_undo_workspace_files`).
|
||||
pub session_id: String,
|
||||
pub messages: Vec<Message>,
|
||||
pub total_tokens: u64,
|
||||
pub model: String,
|
||||
|
||||
@@ -759,6 +759,11 @@ async fn persist_thread_cost(
|
||||
/// Unlike `POST /v1/sessions` (which reconstructs messages from stored turn
|
||||
/// items), this endpoint asks the engine for its live session snapshot so
|
||||
/// token counts and message ordering are authoritative.
|
||||
///
|
||||
/// `session_id` names the document to write. Omitted, the document is the
|
||||
/// conversation's own id (the one its workspace snapshots are tagged with),
|
||||
/// so a thread never ends up bound to a session that owns none of them — see
|
||||
/// [`crate::core::ops::SessionSnapshot::session_id`].
|
||||
pub(super) async fn save_current_session(
|
||||
State(state): State<RuntimeApiState>,
|
||||
Json(req): Json<SaveSessionRequest>,
|
||||
@@ -845,7 +850,16 @@ pub(super) async fn save_current_session(
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let mut session = crate::session_manager::create_saved_session_with_mode(
|
||||
// No session was named, so the conversation the thread is running
|
||||
// answers it — and the engine's live conversation id *is* that
|
||||
// conversation's identity: every `tool:` / `pre-turn:` workspace
|
||||
// snapshot it took is tagged with it, and `patch-undo` /
|
||||
// `file-revert` select snapshots by the thread's binding. Minting a
|
||||
// third uuid here (what this used to do) left the thread bound to a
|
||||
// document whose id owned none of those snapshots, so an undo forked
|
||||
// the conversation and left every file on disk untouched.
|
||||
let mut session = crate::session_manager::create_saved_session_with_id_and_mode(
|
||||
snapshot.session_id.clone(),
|
||||
&snapshot.messages,
|
||||
&snapshot.model,
|
||||
&snapshot.workspace,
|
||||
|
||||
@@ -5848,6 +5848,141 @@ async fn session_create_from_thread_returns_404_for_missing_thread() -> Result<(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `PUT /v1/sessions` with no `session_id` persists the conversation's own id,
|
||||
/// not a fresh uuid.
|
||||
///
|
||||
/// The engine tags every `tool:` / `pre-turn:` workspace snapshot with its live
|
||||
/// conversation id, and `patch-undo` / `file-revert` select snapshots by the
|
||||
/// thread's `session_id`. Minting a third uuid at save time left those two
|
||||
/// disagreeing for every thread whose first save minted the document, so the
|
||||
/// toolbar's Undo forked the conversation and rolled no files back, and the
|
||||
/// Changes panel's Revert refused a snapshot that existed (reported against the
|
||||
/// VS Code client on engine 0.10.0, where no thread's binding matched any
|
||||
/// snapshot in the workspace).
|
||||
#[tokio::test]
|
||||
async fn session_save_without_an_id_keeps_the_conversation_that_owns_the_snapshots() -> Result<()> {
|
||||
let root = std::env::temp_dir().join(format!("deepseek-session-identity-{}", Uuid::new_v4()));
|
||||
let sessions_dir = root.join("sessions");
|
||||
let Some((addr, runtime_threads, handle)) =
|
||||
spawn_test_server_with_root(root.clone(), sessions_dir.clone()).await?
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
let client = crate::tls::reqwest_client();
|
||||
|
||||
let created: serde_json::Value = client
|
||||
.post(format!("http://{addr}/v1/threads"))
|
||||
.json(&json!({
|
||||
"model": "deepseek-v4-pro",
|
||||
"mode": "agent",
|
||||
"workspace": root.join("workspace")
|
||||
}))
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json()
|
||||
.await?;
|
||||
let thread_id = created["id"]
|
||||
.as_str()
|
||||
.context("missing thread id")?
|
||||
.to_string();
|
||||
|
||||
// A new conversation's id is generated inside the engine, so only a mock
|
||||
// can state it. It stands in for the id the real engine stamps on every
|
||||
// snapshot the conversation takes.
|
||||
const CONVERSATION_ID: &str = "3f9c7b1e-58b4-4d2a-9f5a-8a1e0c6d2b70";
|
||||
let harness = crate::core::engine::mock_engine_handle();
|
||||
runtime_threads
|
||||
.install_test_engine(&thread_id, harness.handle.clone())
|
||||
.await?;
|
||||
let mut rx_op = harness.rx_op;
|
||||
tokio::spawn(async move {
|
||||
while let Some(op) = rx_op.recv().await {
|
||||
if let Op::GetSessionSnapshot { tx } = op {
|
||||
let snapshot = crate::core::ops::SessionSnapshot {
|
||||
session_id: CONVERSATION_ID.to_string(),
|
||||
messages: vec![Message {
|
||||
role: Role::User,
|
||||
content: vec![ContentBlock::Text {
|
||||
text: "One conversation, one id".to_string(),
|
||||
cache_control: None,
|
||||
}],
|
||||
}],
|
||||
total_tokens: 7,
|
||||
model: "deepseek-v4-pro".to_string(),
|
||||
model_provider: "deepseek".to_string(),
|
||||
model_provider_id: None,
|
||||
workspace: root.clone(),
|
||||
system_prompt: None,
|
||||
mode: "agent".to_string(),
|
||||
};
|
||||
if let Some(tx) = tx.lock().ok().and_then(|mut guard| guard.take()) {
|
||||
let _ = tx.send(snapshot);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let saved: serde_json::Value = client
|
||||
.put(format!("http://{addr}/v1/sessions"))
|
||||
.json(&json!({ "thread_id": thread_id }))
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
saved["session_id"], CONVERSATION_ID,
|
||||
"a save that names no session must persist the conversation's own id"
|
||||
);
|
||||
|
||||
// That is the id `patch_undo_workspace_files` and `revert_file_from_snapshot`
|
||||
// select snapshots by, so the binding and the snapshots must agree.
|
||||
let detail: serde_json::Value = client
|
||||
.get(format!("http://{addr}/v1/threads/{thread_id}"))
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json()
|
||||
.await?;
|
||||
assert_eq!(detail["thread"]["session_id"], CONVERSATION_ID);
|
||||
|
||||
// Saving again keeps the one document instead of collecting a new one per
|
||||
// visit — the drift above was also how one conversation came to hold
|
||||
// several session files.
|
||||
let documents = || -> Result<Vec<String>> {
|
||||
let mut names = Vec::new();
|
||||
for entry in fs::read_dir(&sessions_dir)? {
|
||||
names.push(entry?.file_name().to_string_lossy().into_owned());
|
||||
}
|
||||
names.sort();
|
||||
Ok(names)
|
||||
};
|
||||
let before = documents()?;
|
||||
let resaved: serde_json::Value = client
|
||||
.put(format!("http://{addr}/v1/sessions"))
|
||||
.json(&json!({ "thread_id": thread_id }))
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json()
|
||||
.await?;
|
||||
assert_eq!(resaved["session_id"], CONVERSATION_ID);
|
||||
assert_eq!(
|
||||
documents()?,
|
||||
before,
|
||||
"a second save writes the session it already owns, not another one"
|
||||
);
|
||||
assert!(
|
||||
sessions_dir
|
||||
.join(format!("{CONVERSATION_ID}.json"))
|
||||
.exists()
|
||||
);
|
||||
|
||||
handle.abort();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create a thread over HTTP and seed it with one user/assistant turn.
|
||||
/// Shared setup for the undo/patch-undo/retry endpoint tests.
|
||||
async fn create_seeded_thread(
|
||||
|
||||
@@ -772,27 +772,92 @@ fn session_messages_sha256(messages: &[Message]) -> Result<String> {
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Compare only fields represented by legacy seeding. A match identifies a
|
||||
/// prefix boundary; the saved messages themselves retain every raw block.
|
||||
/// The prompt text a user-role message contributes to a history comparison,
|
||||
/// or `None` when it carries none.
|
||||
///
|
||||
/// Tool results are user-role messages with no text. The per-turn
|
||||
/// `<turn_meta>` preamble is rebuilt from runtime facts when a turn is
|
||||
/// installed and never recorded on the turn's items, so it is not part of the
|
||||
/// conversation a reconstruction can identify — both sides of every comparison
|
||||
/// drop it, and `extract_user_prompt` is the repository's one rule for what a
|
||||
/// user's prompt is once that envelope is removed (it trims the block's edges
|
||||
/// too, and applies the same way on both sides).
|
||||
fn projected_user_text(message: &Message) -> Option<String> {
|
||||
if message.role.as_str() != "user" {
|
||||
return None;
|
||||
}
|
||||
let text = message
|
||||
.content
|
||||
.iter()
|
||||
.filter_map(|block| match block {
|
||||
ContentBlock::Text { text, .. } if !text.trim().is_empty() => Some(text.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.map(crate::session_manager::extract_user_prompt)
|
||||
.filter(|text| !text.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
(!text.is_empty()).then_some(text)
|
||||
}
|
||||
|
||||
fn projected_user_texts(messages: &[Message]) -> Vec<String> {
|
||||
messages.iter().filter_map(projected_user_text).collect()
|
||||
}
|
||||
|
||||
/// The message index in a saved transcript where the undone turn begins.
|
||||
///
|
||||
/// A backtrack may only keep the messages that belong to the turns before the
|
||||
/// undone one, and the transcript alone cannot say where that is: it is the
|
||||
/// model-visible history, carrying the per-turn `<turn_meta>` preamble and tool
|
||||
/// results as the route's compaction left them, while the turn records keep the
|
||||
/// prompt and the raw output. The prompts *are* recorded verbatim, and a turn
|
||||
/// begins with its user message, so walking the transcript's user text
|
||||
/// messages — the kept turns' prompts in order, then the undone turn's —
|
||||
/// locates the boundary exactly.
|
||||
///
|
||||
/// `None` refuses: the caller must not cut a history it cannot account for, and
|
||||
/// a transcript that drifted from the records (edited, purged, or belonging to
|
||||
/// another conversation) fails the prompt sequence rather than matching by
|
||||
/// coincidence.
|
||||
fn saved_history_boundary(
|
||||
messages: &[Message],
|
||||
kept_prompts: &[String],
|
||||
target_prompt: &str,
|
||||
) -> Option<usize> {
|
||||
let mut kept = kept_prompts.iter();
|
||||
let mut expected = kept.next();
|
||||
for (index, message) in messages.iter().enumerate() {
|
||||
let Some(text) = projected_user_text(message) else {
|
||||
continue;
|
||||
};
|
||||
match expected {
|
||||
Some(next) if next == &text => expected = kept.next(),
|
||||
// A kept prompt is still unaccounted for, so this transcript has
|
||||
// drifted from the records; even the undone turn's prompt here
|
||||
// would cut away a turn the backtrack must keep.
|
||||
Some(_) => return None,
|
||||
// The first user text after the whole kept prefix is where the
|
||||
// undone turn begins — and it has to be that turn's own prompt.
|
||||
None => return (text == target_prompt).then_some(index),
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The conversation identity two histories are compared by: user prompts (their
|
||||
/// `<turn_meta>` envelope removed), assistant text, thinking and tool calls,
|
||||
/// and tool results.
|
||||
///
|
||||
/// Everything a turn record cannot reproduce stays out — that preamble, image
|
||||
/// blocks, and the bytes the route's compaction left in a tool result — so a
|
||||
/// match identifies a prefix boundary rather than a byte-for-byte replay. The
|
||||
/// saved messages themselves retain every raw block.
|
||||
fn session_recovery_projection(messages: &[Message]) -> Vec<Value> {
|
||||
let mut projection = Vec::new();
|
||||
for message in messages {
|
||||
let role = message.role.as_str();
|
||||
if role == "user" {
|
||||
let text = message
|
||||
.content
|
||||
.iter()
|
||||
.filter_map(|block| match block {
|
||||
ContentBlock::Text { text, .. } if !text.trim().is_empty() => {
|
||||
Some(text.as_str())
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
if !text.is_empty() {
|
||||
projection.push(json!(["user", text]));
|
||||
}
|
||||
if let Some(text) = projected_user_text(message) {
|
||||
projection.push(json!(["user", text]));
|
||||
}
|
||||
for block in &message.content {
|
||||
match block {
|
||||
@@ -8911,12 +8976,43 @@ impl RuntimeThreadManager {
|
||||
let retained_messages = if covered <= target_turn_idx {
|
||||
messages.len()
|
||||
} else {
|
||||
let expected = session_recovery_projection(
|
||||
&self.reconstruct_messages_from_turns(&source_turns[..target_turn_idx])?,
|
||||
);
|
||||
(0..=messages.len())
|
||||
.find(|count| session_recovery_projection(&messages[..*count]) == expected)
|
||||
let kept_messages =
|
||||
self.reconstruct_messages_from_turns(&source_turns[..target_turn_idx])?;
|
||||
let kept_projection = session_recovery_projection(&kept_messages);
|
||||
// An exact projection match is the strongest proof: message for
|
||||
// message, this prefix *is* the kept history. It holds for a
|
||||
// transcript the records reproduce, and is tried first so those
|
||||
// shapes keep their exact boundary.
|
||||
if let Some(count) = (0..=messages.len()).find(|count| {
|
||||
session_recovery_projection(&messages[..*count]) == kept_projection
|
||||
}) {
|
||||
count
|
||||
} else {
|
||||
// It cannot hold for a real conversation: the model-visible
|
||||
// transcript carries the per-turn `<turn_meta>` preamble and
|
||||
// tool results as the route's compaction left them, neither
|
||||
// of which the records keep. The prompt is recorded
|
||||
// verbatim, so it still names the message the undone turn
|
||||
// begins at — see `saved_history_boundary`.
|
||||
let target_prompt = projected_user_texts(
|
||||
&self.reconstruct_messages_from_turns(
|
||||
&source_turns[target_turn_idx..=target_turn_idx],
|
||||
)?,
|
||||
)
|
||||
.into_iter()
|
||||
.next()
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"Turn {target_turn_id} records no user prompt to align the saved history with; the source thread was preserved"
|
||||
)
|
||||
})?;
|
||||
saved_history_boundary(
|
||||
&messages,
|
||||
&projected_user_texts(&kept_messages),
|
||||
&target_prompt,
|
||||
)
|
||||
.context("Cannot identify an exact saved-history boundary for this backtrack; the source thread was preserved")?
|
||||
}
|
||||
};
|
||||
forked.saved_session_checkpoint = Some(SavedSessionCheckpoint {
|
||||
covered_turn_id: kept_turns
|
||||
|
||||
@@ -434,6 +434,244 @@ mod recovery {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A backtrack over a turn that used a file tool must still find the
|
||||
/// boundary it keeps.
|
||||
///
|
||||
/// The saved transcript is the model-visible history, so it carries the
|
||||
/// per-turn `<turn_meta>` preamble (rebuilt from runtime facts, never
|
||||
/// recorded on an item) and tool results as the route's compaction left
|
||||
/// them (items keep the raw output). Neither is reproducible from the
|
||||
/// records, so a two-turn conversation whose first turn wrote a file could
|
||||
/// not be cut at all: undo answered "Cannot identify an exact saved-history
|
||||
/// boundary for this backtrack" and left the turn in place — reported from
|
||||
/// the VS Code client on engine 0.10.0, where reverting one file of the
|
||||
/// same conversation worked.
|
||||
#[tokio::test]
|
||||
async fn backtrack_over_a_file_tool_turn_finds_the_saved_history_boundary() -> Result<()> {
|
||||
let _env = crate::test_support::lock_test_env();
|
||||
let dir = tempfile::tempdir()?;
|
||||
let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", dir.path());
|
||||
let manager = RuntimeThreadManager::open(
|
||||
config(),
|
||||
dir.path().to_path_buf(),
|
||||
test_manager_config(dir.path().join("runtime")),
|
||||
)?;
|
||||
let thread = manager
|
||||
.create_thread(CreateThreadRequest::default())
|
||||
.await?;
|
||||
let base = Utc::now();
|
||||
let at = |seconds: i64| Some(base + chrono::Duration::seconds(seconds));
|
||||
|
||||
// A file tool records itself as a `FileChange` item that carries the
|
||||
// call identity — the same shape the transcript's tool_use/tool_result
|
||||
// pair came from.
|
||||
fn tool_metadata(call: &str, name: &str, input: &str) -> Value {
|
||||
json!({
|
||||
"tool_use_id": call,
|
||||
"tool_name": name,
|
||||
"tool_input": input,
|
||||
"tool_result_for": call,
|
||||
"is_error": false,
|
||||
})
|
||||
}
|
||||
let write_call = "call_write_test";
|
||||
let bash_call = "call_bash_test";
|
||||
let write_item = |id: &str, turn: &str, order: i64, path: &str, call: &str| {
|
||||
let mut item = sample_item(turn, id, TurnItemLifecycleStatus::Completed);
|
||||
item.kind = TurnItemKind::FileChange;
|
||||
item.started_at = at(order);
|
||||
item.ended_at = item.started_at;
|
||||
item.detail = Some(format!("Successfully wrote 5 bytes to {path}"));
|
||||
item.metadata = Some(tool_metadata(
|
||||
call,
|
||||
"write",
|
||||
&format!("{{\"path\":\"{path}\",\"content\":\"x\\n\"}}"),
|
||||
));
|
||||
item
|
||||
};
|
||||
let text_item = |id: &str, turn: &str, order: i64, kind: TurnItemKind, text: &str| {
|
||||
let mut item = sample_item(turn, id, TurnItemLifecycleStatus::Completed);
|
||||
item.kind = kind;
|
||||
item.started_at = at(order);
|
||||
item.ended_at = item.started_at;
|
||||
item.summary = text.to_string();
|
||||
item.detail = Some(text.to_string());
|
||||
item
|
||||
};
|
||||
|
||||
let turn_one = "turn_writes_file";
|
||||
let turn_two = "turn_after";
|
||||
let items = vec![
|
||||
text_item(
|
||||
"item_u1",
|
||||
turn_one,
|
||||
0,
|
||||
TurnItemKind::UserMessage,
|
||||
"write test.txt",
|
||||
),
|
||||
text_item(
|
||||
"item_r1",
|
||||
turn_one,
|
||||
1,
|
||||
TurnItemKind::AgentReasoning,
|
||||
"I will write it.",
|
||||
),
|
||||
write_item("item_f1", turn_one, 2, "test.txt", write_call),
|
||||
{
|
||||
let mut item = sample_item(turn_one, "item_t1", TurnItemLifecycleStatus::Completed);
|
||||
item.kind = TurnItemKind::ToolCall;
|
||||
item.started_at = at(3);
|
||||
item.ended_at = item.started_at;
|
||||
// The raw output keeps the newline the route's compaction trims
|
||||
// before the model ever sees it.
|
||||
item.detail = Some("test.txt\n".to_string());
|
||||
item.metadata = Some(tool_metadata(
|
||||
bash_call,
|
||||
"bash",
|
||||
"{\"command\":\"ls -l test.txt\"}",
|
||||
));
|
||||
item
|
||||
},
|
||||
text_item(
|
||||
"item_a1",
|
||||
turn_one,
|
||||
4,
|
||||
TurnItemKind::AgentMessage,
|
||||
"created test.txt",
|
||||
),
|
||||
text_item(
|
||||
"item_u2",
|
||||
turn_two,
|
||||
5,
|
||||
TurnItemKind::UserMessage,
|
||||
"write test2.txt",
|
||||
),
|
||||
write_item("item_f2", turn_two, 6, "test2.txt", "call_write_second"),
|
||||
text_item(
|
||||
"item_a2",
|
||||
turn_two,
|
||||
7,
|
||||
TurnItemKind::AgentMessage,
|
||||
"created test2.txt",
|
||||
),
|
||||
];
|
||||
for item in &items {
|
||||
manager.store.save_item(item)?;
|
||||
}
|
||||
for (turn_id, order) in [(turn_one, 0), (turn_two, 10)] {
|
||||
let mut turn = sample_turn(&thread.id, turn_id, RuntimeTurnStatus::Completed);
|
||||
turn.started_at = at(order);
|
||||
turn.ended_at = turn.started_at;
|
||||
manager.store.save_turn(&turn)?;
|
||||
}
|
||||
let mut stored = manager.get_thread(&thread.id).await?;
|
||||
stored.latest_turn_id = Some(turn_two.to_string());
|
||||
manager.store.save_thread(&stored)?;
|
||||
|
||||
// The model-visible transcript: prompts (each with the per-turn
|
||||
// preamble), the write pair the items cannot reproduce, and the bash
|
||||
// result as the route left it.
|
||||
let transcript: Vec<Message> = serde_json::from_value(json!([
|
||||
{"role":"user","content":[
|
||||
{"type":"text","text":"write test.txt"},
|
||||
{"type":"text","text":"<turn_meta>\nCurrent local date: 2026-09-24\n</turn_meta>"}]},
|
||||
{"role":"assistant","content":[
|
||||
{"type":"thinking","thinking":"I will write it."},
|
||||
{"type":"tool_use","id":write_call,"name":"write","input":{"path":"test.txt","content":"x\n"}}]},
|
||||
{"role":"user","content":[
|
||||
{"type":"tool_result","tool_use_id":write_call,"content":"Successfully wrote 5 bytes to test.txt"}]},
|
||||
{"role":"assistant","content":[
|
||||
{"type":"thinking","thinking":"verify"},
|
||||
{"type":"tool_use","id":bash_call,"name":"bash","input":{"command":"ls -l test.txt"}}]},
|
||||
{"role":"user","content":[
|
||||
{"type":"tool_result","tool_use_id":bash_call,"content":"test.txt"}]},
|
||||
{"role":"assistant","content":[
|
||||
{"type":"text","text":"created test.txt"}]},
|
||||
{"role":"user","content":[
|
||||
{"type":"text","text":"write test2.txt"},
|
||||
{"type":"text","text":"<turn_meta>\nCurrent local date: 2026-09-24\n</turn_meta>"}]},
|
||||
{"role":"assistant","content":[
|
||||
{"type":"text","text":"created test2.txt"}]}
|
||||
]))?;
|
||||
let saved = crate::session_manager::create_saved_session_with_id_and_mode(
|
||||
Uuid::new_v4().to_string(),
|
||||
&transcript,
|
||||
&thread.model,
|
||||
dir.path(),
|
||||
0,
|
||||
None,
|
||||
Some("agent"),
|
||||
);
|
||||
let sessions = crate::session_manager::SessionManager::new(
|
||||
crate::session_manager::default_sessions_dir()?,
|
||||
)?;
|
||||
{
|
||||
let _admission = manager.session_checkpoint_guard().await;
|
||||
sessions.save_session(&saved)?;
|
||||
manager
|
||||
.set_thread_session_checkpoint(&thread.id, &saved)
|
||||
.await?;
|
||||
}
|
||||
|
||||
let prepared = manager.prepare_fork_at_user_message(&thread.id, 0).await?;
|
||||
let (_, prefix, _) = prepared
|
||||
.own_session
|
||||
.as_ref()
|
||||
.context("a prepared backtrack carries the prefix it keeps")?;
|
||||
assert_eq!(
|
||||
prefix.as_slice(),
|
||||
&transcript[..6],
|
||||
"the fork keeps exactly the messages before the undone turn"
|
||||
);
|
||||
let (fork, _, _, _) = manager.publish_prepared_fork(prepared).await?;
|
||||
assert_eq!(manager.restore_thread_messages(&fork)?, transcript[..6]);
|
||||
|
||||
// Undoing the second turn back (`depth` 1) keeps nothing: the first
|
||||
// turn's own prompt is the anchor, so the boundary is the first message.
|
||||
let first_turn = manager.prepare_fork_at_user_message(&thread.id, 1).await?;
|
||||
let (_, prefix, _) = first_turn
|
||||
.own_session
|
||||
.as_ref()
|
||||
.context("a prepared backtrack carries the prefix it keeps")?;
|
||||
assert!(prefix.is_empty(), "undoing the first turn keeps no history");
|
||||
|
||||
// A transcript that no longer lines up with the turn records must refuse
|
||||
// rather than cut where it cannot account for the history: the undone
|
||||
// turn's prompt is the anchor here, not a coincidence to match.
|
||||
let mut drifted = transcript.clone();
|
||||
drifted[6] = serde_json::from_value(json!({
|
||||
"role":"user","content":[
|
||||
{"type":"text","text":"a prompt no turn records"},
|
||||
{"type":"text","text":"<turn_meta>\nCurrent local date: 2026-09-24\n</turn_meta>"}]}))?;
|
||||
let drifted_session = crate::session_manager::create_saved_session_with_id_and_mode(
|
||||
saved.metadata.id.clone(),
|
||||
&drifted,
|
||||
&thread.model,
|
||||
dir.path(),
|
||||
0,
|
||||
None,
|
||||
Some("agent"),
|
||||
);
|
||||
{
|
||||
let _admission = manager.session_checkpoint_guard().await;
|
||||
sessions.save_session(&drifted_session)?;
|
||||
manager
|
||||
.set_thread_session_checkpoint(&thread.id, &drifted_session)
|
||||
.await?;
|
||||
}
|
||||
let error = match manager.prepare_fork_at_user_message(&thread.id, 0).await {
|
||||
Ok(_) => panic!("a drifted transcript must not be cut"),
|
||||
Err(error) => error,
|
||||
};
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("Cannot identify an exact saved-history boundary"),
|
||||
"the refusal names the boundary it could not find: {error}"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn control_case(interrupt: bool, follow_up: bool) -> Result<()> {
|
||||
let _env = crate::test_support::lock_test_env();
|
||||
let dir = tempfile::tempdir()?;
|
||||
@@ -17839,6 +18077,33 @@ mod adoption_refusal {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_history_boundary_refuses_until_every_kept_prompt_is_seen() {
|
||||
let user = |text: &str| Message {
|
||||
role: Role::User,
|
||||
content: vec![ContentBlock::Text {
|
||||
text: text.to_string(),
|
||||
cache_control: None,
|
||||
}],
|
||||
};
|
||||
let kept = vec!["first".to_string(), "second".to_string()];
|
||||
|
||||
// The kept prompts in order, then the undone turn's prompt: cut there.
|
||||
let messages = vec![user("first"), user("second"), user("undo me")];
|
||||
assert_eq!(saved_history_boundary(&messages, &kept, "undo me"), Some(2));
|
||||
|
||||
// "second" never appears: matching "undo me" early would drop a kept turn.
|
||||
let drifted = vec![user("first"), user("undo me"), user("second")];
|
||||
assert_eq!(saved_history_boundary(&drifted, &kept, "undo me"), None);
|
||||
|
||||
// A repeated prompt stays aligned with the records.
|
||||
let repeated = vec![user("same"), user("same")];
|
||||
assert_eq!(
|
||||
saved_history_boundary(&repeated, &["same".to_string()], "same"),
|
||||
Some(1)
|
||||
);
|
||||
}
|
||||
|
||||
/// #6522 review: `/resume`, `/load` and launch warm the canonical sessions
|
||||
/// root on a blocking thread, so the confinement predicate that follows on
|
||||
/// the UI runtime is served from the cache instead of resolving a path.
|
||||
|
||||
Reference in new issue
Block a user