feat: select rewind points directly from Console history
This commit is contained in:
+21
-39
@@ -349,13 +349,11 @@ pub enum Method {
|
||||
Compact {
|
||||
command: WorkerCommandEnvelope,
|
||||
},
|
||||
/// Ask the Worker to list valid rewind targets from its authoritative session log.
|
||||
ListRewindTargets,
|
||||
/// Truncate the current session back to the selected rewind target and
|
||||
/// return the selected user input to the client composer.
|
||||
/// Rewind to just before a user input identified by its public Session entry ID.
|
||||
/// Validate the target against the current segment at execution time.
|
||||
/// Later appended history is discarded too; tool side effects are not undone.
|
||||
RewindTo {
|
||||
target: RewindTargetId,
|
||||
expected_head_entries: usize,
|
||||
entry_id: String,
|
||||
},
|
||||
Shutdown {
|
||||
command: WorkerCommandEnvelope,
|
||||
@@ -1421,12 +1419,6 @@ pub enum Event {
|
||||
context: Option<CompletionContext>,
|
||||
entries: Vec<CompletionEntry>,
|
||||
},
|
||||
/// Reply to `Method::ListRewindTargets`. Clients should only open a picker
|
||||
/// in response to their own pending request; the event may be broadcast.
|
||||
RewindTargets {
|
||||
head_entries: usize,
|
||||
targets: Vec<RewindTarget>,
|
||||
},
|
||||
/// A rewind has truncated the authoritative session. `session` is the
|
||||
/// retained canonical snapshot clients should use to reseed display state.
|
||||
RewindApplied {
|
||||
@@ -1569,33 +1561,6 @@ pub struct CompletionEntry {
|
||||
pub invocation: Option<FeatureInvocationDescriptor>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
pub struct RewindTargetId {
|
||||
#[cfg_attr(feature = "typescript", ts(type = "string"))]
|
||||
#[cfg_attr(feature = "json-schema", schemars(with = "String"))]
|
||||
pub segment_id: uuid::Uuid,
|
||||
pub user_input_entry_index: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
pub struct RewindTarget {
|
||||
pub id: RewindTargetId,
|
||||
pub expected_head_entries: usize,
|
||||
pub truncate_entries: usize,
|
||||
pub turn_index: usize,
|
||||
pub timestamp_ms: Option<u64>,
|
||||
pub preview: String,
|
||||
pub eligible: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub disabled_reason: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub warning: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
@@ -1998,6 +1963,23 @@ pub enum Permission {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rewind_uses_public_entry_id_without_candidate_or_head_token() {
|
||||
let method = Method::RewindTo {
|
||||
entry_id: "user-entry".into(),
|
||||
};
|
||||
let wire = serde_json::json!({"method": "rewind_to", "params": {"entry_id": "user-entry"}});
|
||||
assert_eq!(serde_json::to_value(&method).unwrap(), wire);
|
||||
assert!(
|
||||
matches!(serde_json::from_value::<Method>(wire).unwrap(), Method::RewindTo { entry_id } if entry_id == "user-entry")
|
||||
);
|
||||
assert!(
|
||||
serde_json::from_value::<Method>(serde_json::json!({"method": "list_rewind_targets"}))
|
||||
.is_err()
|
||||
);
|
||||
assert!(serde_json::from_value::<Method>(serde_json::json!({"method": "rewind_to", "params": {"target": {"segment_id": uuid::Uuid::nil(), "user_input_entry_index": 1}, "expected_head_entries": 3}})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scope_rule_defaults_to_resolved_symlink_policy() {
|
||||
let rule: ScopeRule = serde_json::from_value(serde_json::json!({
|
||||
|
||||
@@ -15,14 +15,14 @@ use crate::{
|
||||
InvocationClientAdapter, InvocationCompletion, InvocationValue, InvokeKind, MemoryWorkerEvent,
|
||||
Method, PasteArtifactAvailability, PasteArtifactMediaType, PasteArtifactRef,
|
||||
PendingSubmissionSummary, PendingSubmissionsSnapshot, Permission, ReasoningConfig,
|
||||
RewindSummary, RewindTarget, RewindTargetId, RunFailureKind, RunResult, RunResumeSource,
|
||||
RunYieldReason, ScopeRule, Segment, SessionContentPart, SessionConversationTurn,
|
||||
SessionEntryProvenance, SessionHistoryLineageBoundary, SessionHistoryPage, SessionMessageRole,
|
||||
SessionSnapshot, SessionSnapshotEntry, SessionSnapshotEntryData, SessionToolAttachment,
|
||||
SubmissionDisposition, SymlinkPolicy, ToolResultDisposition, TurnResult,
|
||||
UploadedFileAvailability, UploadedFileRef, WorkerBusyState, WorkerCommandAcknowledgement,
|
||||
WorkerCommandDisposition, WorkerCommandEnvelope, WorkerCommandKind, WorkerEvent,
|
||||
WorkerMaintenanceState, WorkerRunState, WorkerState, WorkerStateSnapshot, WorkerStatus,
|
||||
RewindSummary, RunFailureKind, RunResult, RunResumeSource, RunYieldReason, ScopeRule, Segment,
|
||||
SessionContentPart, SessionConversationTurn, SessionEntryProvenance,
|
||||
SessionHistoryLineageBoundary, SessionHistoryPage, SessionMessageRole, SessionSnapshot,
|
||||
SessionSnapshotEntry, SessionSnapshotEntryData, SessionToolAttachment, SubmissionDisposition,
|
||||
SymlinkPolicy, ToolResultDisposition, TurnResult, UploadedFileAvailability, UploadedFileRef,
|
||||
WorkerBusyState, WorkerCommandAcknowledgement, WorkerCommandDisposition, WorkerCommandEnvelope,
|
||||
WorkerCommandKind, WorkerEvent, WorkerMaintenanceState, WorkerRunState, WorkerState,
|
||||
WorkerStateSnapshot, WorkerStatus,
|
||||
subscription::{
|
||||
EventSubscriptionSelector, MAX_CORRELATION_ID_BYTES, MAX_REJECTION_MESSAGE_BYTES,
|
||||
MAX_RESOURCE_ID_BYTES, MAX_SUBSCRIPTION_COLLECTION_ITEMS,
|
||||
@@ -111,8 +111,6 @@ pub fn generated_protocol_types() -> String {
|
||||
push_decl::<UploadedFileRef>(&cfg, &mut output);
|
||||
push_decl::<ScopeRule>(&cfg, &mut output);
|
||||
push_decl::<CompletionEntry>(&cfg, &mut output);
|
||||
push_decl::<RewindTargetId>(&cfg, &mut output);
|
||||
push_decl::<RewindTarget>(&cfg, &mut output);
|
||||
push_decl::<RewindSummary>(&cfg, &mut output);
|
||||
push_decl::<InFlightBlock>(&cfg, &mut output);
|
||||
push_decl::<InFlightCompaction>(&cfg, &mut output);
|
||||
|
||||
@@ -401,7 +401,6 @@ pub enum WorkerInputKind {
|
||||
UserIfIdle,
|
||||
Notify,
|
||||
Compact,
|
||||
ListRewindTargets,
|
||||
RegisterPeer,
|
||||
}
|
||||
|
||||
|
||||
+44
-59
@@ -5,7 +5,7 @@ use std::time::{Duration, Instant};
|
||||
use protocol::{
|
||||
AlertLevel, AlertSource, CompletionEntry, CompletionKind, ErrorCode, Event, InFlightBlock,
|
||||
InFlightSnapshot, InFlightToolCallState, InternalWorkerRef, InternalWorkerSnapshot, Method,
|
||||
RewindTarget, RunResult, Segment, WorkerCommandEnvelope, WorkerStateSnapshot, WorkerStatus,
|
||||
RunResult, Segment, WorkerCommandEnvelope, WorkerStateSnapshot, WorkerStatus,
|
||||
};
|
||||
|
||||
use crate::block::{
|
||||
@@ -90,10 +90,15 @@ pub struct RewindPickerScroll {
|
||||
pub tail_top_offset: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RewindEntry {
|
||||
pub entry_id: String,
|
||||
pub preview: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RewindPickerState {
|
||||
pub head_entries: usize,
|
||||
pub targets: Vec<RewindTarget>,
|
||||
pub targets: Vec<RewindEntry>,
|
||||
pub selected: usize,
|
||||
pub scroll: RewindPickerScroll,
|
||||
/// True after Enter submitted an authoritative `RewindTo` and before the
|
||||
@@ -104,10 +109,9 @@ pub struct RewindPickerState {
|
||||
}
|
||||
|
||||
impl RewindPickerState {
|
||||
pub fn new(head_entries: usize, targets: Vec<RewindTarget>) -> Self {
|
||||
let selected = targets.iter().position(|t| t.eligible).unwrap_or(0);
|
||||
pub fn new(targets: Vec<RewindEntry>) -> Self {
|
||||
let selected = 0;
|
||||
Self {
|
||||
head_entries,
|
||||
targets,
|
||||
selected,
|
||||
scroll: RewindPickerScroll::default(),
|
||||
@@ -115,7 +119,7 @@ impl RewindPickerState {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn selected_target(&self) -> Option<&RewindTarget> {
|
||||
pub fn selected_target(&self) -> Option<&RewindEntry> {
|
||||
self.targets.get(self.selected)
|
||||
}
|
||||
}
|
||||
@@ -334,7 +338,7 @@ pub struct App {
|
||||
completion_target: String,
|
||||
/// Dedicated main-view rewind picker state.
|
||||
pub rewind_picker: Option<RewindPickerState>,
|
||||
rewind_request_pending: bool,
|
||||
rewind_entries: Vec<RewindEntry>,
|
||||
/// After a successful rewind restore, ignore any queued live-update events
|
||||
/// until the authoritative Worker status/snapshot catches up. This prevents
|
||||
/// old stream tail events that were already in transit from re-polluting the
|
||||
@@ -419,7 +423,7 @@ impl App {
|
||||
pending_feature_edit: None,
|
||||
completion_target: protocol::new_submission_request_id(),
|
||||
rewind_picker: None,
|
||||
rewind_request_pending: false,
|
||||
rewind_entries: Vec::new(),
|
||||
rewind_refresh_fence: false,
|
||||
greeting: None,
|
||||
task_store: TaskStore::new(),
|
||||
@@ -1529,7 +1533,19 @@ impl App {
|
||||
Event::PendingSubmissionsChanged { pending } => {
|
||||
self.pending_submissions = pending;
|
||||
}
|
||||
Event::UserMessage { segments, .. } => {
|
||||
Event::UserMessage { segments, entry_id } => {
|
||||
if let Some(entry_id) = entry_id {
|
||||
if !self
|
||||
.rewind_entries
|
||||
.iter()
|
||||
.any(|entry| entry.entry_id == entry_id)
|
||||
{
|
||||
self.rewind_entries.push(RewindEntry {
|
||||
entry_id,
|
||||
preview: Segment::flatten_to_text(&segments),
|
||||
});
|
||||
}
|
||||
}
|
||||
self.turn_index += 1;
|
||||
self.blocks.push(Block::TurnHeader {
|
||||
turn: self.turn_index,
|
||||
@@ -2038,15 +2054,6 @@ impl App {
|
||||
state.selected = 0;
|
||||
}
|
||||
}
|
||||
Event::RewindTargets {
|
||||
head_entries,
|
||||
targets,
|
||||
} => {
|
||||
if self.rewind_request_pending {
|
||||
self.rewind_request_pending = false;
|
||||
self.rewind_picker = Some(RewindPickerState::new(head_entries, targets));
|
||||
}
|
||||
}
|
||||
Event::RewindApplied {
|
||||
session,
|
||||
input,
|
||||
@@ -2467,14 +2474,14 @@ impl App {
|
||||
return None;
|
||||
}
|
||||
self.completion = None;
|
||||
self.rewind_picker = None;
|
||||
self.rewind_request_pending = true;
|
||||
Some(Method::ListRewindTargets)
|
||||
self.rewind_picker = Some(RewindPickerState::new(
|
||||
self.rewind_entries.iter().rev().cloned().collect(),
|
||||
));
|
||||
None
|
||||
}
|
||||
|
||||
pub fn close_rewind_picker(&mut self) {
|
||||
self.rewind_picker = None;
|
||||
self.rewind_request_pending = false;
|
||||
}
|
||||
|
||||
pub fn cancel_rewind_picker(&mut self) {
|
||||
@@ -2539,17 +2546,8 @@ impl App {
|
||||
);
|
||||
return None;
|
||||
}
|
||||
let (target_id, expected_head_entries) = match picker.selected_target() {
|
||||
Some(target) if target.eligible => (target.id.clone(), target.expected_head_entries),
|
||||
Some(target) => {
|
||||
self.push_command_diagnostic(
|
||||
target
|
||||
.disabled_reason
|
||||
.clone()
|
||||
.unwrap_or_else(|| "rewind target is disabled".into()),
|
||||
);
|
||||
return None;
|
||||
}
|
||||
let entry_id = match picker.selected_target() {
|
||||
Some(target) => target.entry_id.clone(),
|
||||
None => {
|
||||
self.push_command_diagnostic("no rewind target is available");
|
||||
return None;
|
||||
@@ -2558,10 +2556,7 @@ impl App {
|
||||
if let Some(picker) = self.rewind_picker.as_mut() {
|
||||
picker.applying = true;
|
||||
}
|
||||
Some(Method::RewindTo {
|
||||
target: target_id,
|
||||
expected_head_entries,
|
||||
})
|
||||
Some(Method::RewindTo { entry_id })
|
||||
}
|
||||
|
||||
fn command_environment(&self) -> CommandEnvironment {
|
||||
@@ -2597,10 +2592,8 @@ impl App {
|
||||
command: self.next_command_envelope(),
|
||||
});
|
||||
}
|
||||
if let Some(Method::ListRewindTargets) = method.as_ref() {
|
||||
self.completion = None;
|
||||
self.rewind_picker = None;
|
||||
self.rewind_request_pending = true;
|
||||
if result.open_rewind_picker {
|
||||
return self.request_rewind_picker();
|
||||
}
|
||||
method
|
||||
}
|
||||
@@ -2913,10 +2906,15 @@ impl App {
|
||||
}
|
||||
self.assistant_streaming = false;
|
||||
|
||||
self.rewind_entries.clear();
|
||||
for entry in &session.entries {
|
||||
use protocol::{SessionContentPart, SessionMessageRole, SessionSnapshotEntryData};
|
||||
match &entry.data {
|
||||
SessionSnapshotEntryData::UserInput { segments } => {
|
||||
self.rewind_entries.push(RewindEntry {
|
||||
entry_id: entry.entry_id.clone(),
|
||||
preview: Segment::flatten_to_text(segments),
|
||||
});
|
||||
self.turn_index += 1;
|
||||
self.blocks.push(Block::TurnHeader {
|
||||
turn: self.turn_index,
|
||||
@@ -3364,23 +3362,10 @@ mod rewind_refresh_tests {
|
||||
fn app_with_rewind_picker() -> App {
|
||||
let mut app = App::new("test".into());
|
||||
app.connected = true;
|
||||
app.rewind_picker = Some(RewindPickerState::new(
|
||||
5,
|
||||
vec![RewindTarget {
|
||||
id: protocol::RewindTargetId {
|
||||
segment_id: uuid::Uuid::nil(),
|
||||
user_input_entry_index: 1,
|
||||
},
|
||||
expected_head_entries: 5,
|
||||
truncate_entries: 2,
|
||||
turn_index: 1,
|
||||
timestamp_ms: None,
|
||||
preview: "rewind target".into(),
|
||||
eligible: true,
|
||||
disabled_reason: None,
|
||||
warning: None,
|
||||
}],
|
||||
));
|
||||
app.rewind_picker = Some(RewindPickerState::new(vec![RewindEntry {
|
||||
entry_id: "user-1".into(),
|
||||
preview: "test input".into(),
|
||||
}]));
|
||||
app
|
||||
}
|
||||
|
||||
|
||||
@@ -52,6 +52,7 @@ pub struct CommandEnvironment {
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CommandExecution {
|
||||
pub method: Option<Method>,
|
||||
pub open_rewind_picker: bool,
|
||||
pub diagnostics: Vec<CommandDiagnostic>,
|
||||
pub exit_command_mode: bool,
|
||||
pub clear_input: bool,
|
||||
@@ -60,6 +61,7 @@ pub struct CommandExecution {
|
||||
impl CommandExecution {
|
||||
pub fn diagnostic(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
open_rewind_picker: false,
|
||||
method: None,
|
||||
diagnostics: vec![CommandDiagnostic::new(message)],
|
||||
exit_command_mode: false,
|
||||
@@ -69,6 +71,7 @@ impl CommandExecution {
|
||||
|
||||
pub fn notice(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
open_rewind_picker: false,
|
||||
method: None,
|
||||
diagnostics: vec![CommandDiagnostic::new(message)],
|
||||
exit_command_mode: true,
|
||||
@@ -409,6 +412,7 @@ fn compact_command(invocation: CommandInvocation<'_>) -> CommandExecution {
|
||||
let _ = invocation.environment;
|
||||
let _ = invocation.args.raw();
|
||||
CommandExecution {
|
||||
open_rewind_picker: false,
|
||||
method: Some(Method::Compact {
|
||||
command: protocol::WorkerCommandEnvelope::new(0),
|
||||
}),
|
||||
@@ -423,7 +427,8 @@ fn rewind_command(invocation: CommandInvocation<'_>) -> CommandExecution {
|
||||
let _ = invocation.environment;
|
||||
let _ = invocation.args.raw();
|
||||
CommandExecution {
|
||||
method: Some(Method::ListRewindTargets),
|
||||
open_rewind_picker: true,
|
||||
method: None,
|
||||
diagnostics: vec![CommandDiagnostic::new("rewind picker requested")],
|
||||
exit_command_mode: true,
|
||||
clear_input: true,
|
||||
@@ -435,6 +440,7 @@ fn peer_command(invocation: CommandInvocation<'_>) -> CommandExecution {
|
||||
let _ = invocation.environment;
|
||||
let name = invocation.args.argv()[0].clone();
|
||||
CommandExecution {
|
||||
open_rewind_picker: false,
|
||||
method: Some(Method::RegisterPeer { name: name.clone() }),
|
||||
diagnostics: vec![CommandDiagnostic::new(format!(
|
||||
"peer metadata registration requested with `{name}`"
|
||||
@@ -521,7 +527,8 @@ mod tests {
|
||||
let registry = CommandRegistry::builtins();
|
||||
for command in ["rewind", "rollback"] {
|
||||
let result = registry.dispatch(command, &env());
|
||||
assert!(matches!(result.method, Some(Method::ListRewindTargets)));
|
||||
assert!(result.method.is_none());
|
||||
assert!(result.open_rewind_picker);
|
||||
assert!(result.exit_command_mode);
|
||||
assert!(result.clear_input);
|
||||
assert!(result.diagnostics[0].message.contains("rewind picker"));
|
||||
@@ -549,7 +556,8 @@ mod tests {
|
||||
let mut paused = env();
|
||||
paused.paused = true;
|
||||
let result = registry.dispatch("rewind", &paused);
|
||||
assert!(matches!(result.method, Some(Method::ListRewindTargets)));
|
||||
assert!(result.method.is_none());
|
||||
assert!(result.open_rewind_picker);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -18,7 +18,7 @@ use crossterm::terminal::{EnterAlternateScreen, LeaveAlternateScreen};
|
||||
use crossterm::{Command, execute};
|
||||
use protocol::{Event, Method, Segment, UploadedFileRef, WorkerStatus};
|
||||
#[cfg(feature = "e2e-test")]
|
||||
use protocol::{Greeting, RewindSummary, RewindTarget, RewindTargetId};
|
||||
use protocol::{Greeting, RewindSummary};
|
||||
use ratatui::Terminal;
|
||||
use ratatui::backend::CrosstermBackend;
|
||||
use standalone::{StandaloneHost, StandaloneLaunchConfig};
|
||||
@@ -758,10 +758,10 @@ async fn run_e2e_rewind_fixture(
|
||||
});
|
||||
|
||||
let (_reader, mut term_rx) = TerminalEventReader::spawn()?;
|
||||
let target_id = RewindTargetId {
|
||||
segment_id: uuid::Uuid::from_u128(1),
|
||||
user_input_entry_index: 1,
|
||||
};
|
||||
app.handle_worker_event(Event::UserMessage {
|
||||
entry_id: Some("rewind-fixture-input".into()),
|
||||
segments: vec![Segment::text("candidate rewind target")],
|
||||
});
|
||||
let mut rewind_submit_count = 0usize;
|
||||
let mut pending_apply: Option<std::time::Instant> = None;
|
||||
let apply_delay = Duration::from_millis(400);
|
||||
@@ -803,45 +803,25 @@ async fn run_e2e_rewind_fixture(
|
||||
.as_ref()
|
||||
.map(|picker| picker.applying)
|
||||
.unwrap_or(false);
|
||||
if let Some(method) = handle_key(&mut app, key) {
|
||||
let was_open = app.rewind_picker.is_some();
|
||||
let method = handle_key(&mut app, key);
|
||||
if !was_open && app.rewind_picker.is_some() {
|
||||
crate::e2e_observer::emit(
|
||||
"single_worker",
|
||||
"rewind_picker_opened",
|
||||
serde_json::json!({"targets": 1, "selected_preview": "candidate rewind target"}),
|
||||
);
|
||||
}
|
||||
if let Some(method) = method {
|
||||
match method {
|
||||
Method::ListRewindTargets => {
|
||||
app.handle_worker_event(Event::RewindTargets {
|
||||
head_entries: 3,
|
||||
targets: vec![RewindTarget {
|
||||
id: target_id.clone(),
|
||||
expected_head_entries: 3,
|
||||
truncate_entries: 1,
|
||||
turn_index: 1,
|
||||
timestamp_ms: Some(1),
|
||||
preview: "candidate rewind target".to_string(),
|
||||
eligible: true,
|
||||
disabled_reason: None,
|
||||
warning: None,
|
||||
}],
|
||||
});
|
||||
crate::e2e_observer::emit(
|
||||
"single_worker",
|
||||
"rewind_picker_opened",
|
||||
serde_json::json!({
|
||||
"targets": 1,
|
||||
"selected_preview": "candidate rewind target",
|
||||
}),
|
||||
);
|
||||
}
|
||||
Method::RewindTo {
|
||||
target,
|
||||
expected_head_entries,
|
||||
} => {
|
||||
Method::RewindTo { entry_id } => {
|
||||
rewind_submit_count += 1;
|
||||
pending_apply = Some(std::time::Instant::now());
|
||||
crate::e2e_observer::emit(
|
||||
"single_worker",
|
||||
"rewind_submit_sent",
|
||||
serde_json::json!({
|
||||
"segment_id": target.segment_id.to_string(),
|
||||
"user_input_entry_index": target.user_input_entry_index,
|
||||
"expected_head_entries": expected_head_entries,
|
||||
"entry_id": entry_id,
|
||||
"submit_count": rewind_submit_count,
|
||||
}),
|
||||
);
|
||||
@@ -1725,10 +1705,7 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::text_selection::{HistoryViewport, SelectionRow};
|
||||
use async_trait::async_trait;
|
||||
use protocol::{
|
||||
Event, RewindTarget, RewindTargetId, Segment, UploadedFileAvailability, UploadedFileRef,
|
||||
WorkerStatus,
|
||||
};
|
||||
use protocol::{Event, Segment, UploadedFileAvailability, UploadedFileRef, WorkerStatus};
|
||||
|
||||
#[test]
|
||||
fn standalone_console_starts_with_in_process_connection_ready() {
|
||||
@@ -3119,14 +3096,17 @@ mod tests {
|
||||
&mut app,
|
||||
KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL),
|
||||
);
|
||||
assert!(matches!(idle, Some(Method::ListRewindTargets)));
|
||||
assert!(idle.is_none());
|
||||
assert!(app.rewind_picker.is_some());
|
||||
app.close_rewind_picker();
|
||||
|
||||
app.set_worker_status(WorkerStatus::Paused);
|
||||
let paused = handle_key(
|
||||
&mut app,
|
||||
KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL),
|
||||
);
|
||||
assert!(matches!(paused, Some(Method::ListRewindTargets)));
|
||||
assert!(paused.is_none());
|
||||
assert!(app.rewind_picker.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -3148,21 +3128,13 @@ mod tests {
|
||||
fn rewind_picker_close_returns_to_history_view() {
|
||||
let mut app = App::new("agent".to_string());
|
||||
app.connected = true;
|
||||
app.handle_worker_event(Event::RewindTargets {
|
||||
head_entries: 1,
|
||||
targets: vec![],
|
||||
});
|
||||
assert!(app.rewind_picker.is_none());
|
||||
|
||||
let method = handle_key(
|
||||
&mut app,
|
||||
KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL),
|
||||
);
|
||||
assert!(matches!(method, Some(Method::ListRewindTargets)));
|
||||
app.handle_worker_event(Event::RewindTargets {
|
||||
head_entries: 1,
|
||||
targets: vec![],
|
||||
});
|
||||
assert!(method.is_none());
|
||||
assert!(app.rewind_picker.is_some());
|
||||
|
||||
let method = handle_key(&mut app, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
@@ -3244,13 +3216,13 @@ mod tests {
|
||||
#[test]
|
||||
fn rewind_apply_rejects_non_empty_composer_and_paused_status() {
|
||||
let mut app = App::new("agent".to_string());
|
||||
app.rewind_picker = Some(crate::app::RewindPickerState::new(1, vec![rewind_target()]));
|
||||
app.rewind_picker = Some(crate::app::RewindPickerState::new(vec![rewind_target()]));
|
||||
type_keys(&mut app, "draft");
|
||||
assert!(app.submit_rewind_picker().is_none());
|
||||
assert!(has_alert(&app, "composer is not empty"));
|
||||
|
||||
let mut app = App::new("agent".to_string());
|
||||
app.rewind_picker = Some(crate::app::RewindPickerState::new(1, vec![rewind_target()]));
|
||||
app.rewind_picker = Some(crate::app::RewindPickerState::new(vec![rewind_target()]));
|
||||
app.set_worker_status(WorkerStatus::Paused);
|
||||
assert!(app.submit_rewind_picker().is_none());
|
||||
assert!(has_alert(
|
||||
@@ -3265,7 +3237,7 @@ mod tests {
|
||||
app.scroll.top_offset = 3;
|
||||
app.scroll.turn_starts = vec![0, 5, 9];
|
||||
app.scroll.total_lines = 42;
|
||||
app.rewind_picker = Some(crate::app::RewindPickerState::new(1, vec![rewind_target()]));
|
||||
app.rewind_picker = Some(crate::app::RewindPickerState::new(vec![rewind_target()]));
|
||||
let original_top_offset = app.scroll.top_offset;
|
||||
let original_turn_starts = app.scroll.turn_starts.clone();
|
||||
let original_total_lines = app.scroll.total_lines;
|
||||
@@ -3282,20 +3254,10 @@ mod tests {
|
||||
assert_eq!(app.scroll.total_lines, original_total_lines);
|
||||
}
|
||||
|
||||
fn rewind_target() -> RewindTarget {
|
||||
RewindTarget {
|
||||
id: RewindTargetId {
|
||||
segment_id: uuid::Uuid::nil(),
|
||||
user_input_entry_index: 0,
|
||||
},
|
||||
expected_head_entries: 1,
|
||||
truncate_entries: 0,
|
||||
turn_index: 1,
|
||||
timestamp_ms: Some(1),
|
||||
preview: "retry this".into(),
|
||||
eligible: true,
|
||||
disabled_reason: None,
|
||||
warning: None,
|
||||
fn rewind_target() -> crate::app::RewindEntry {
|
||||
crate::app::RewindEntry {
|
||||
entry_id: "user-1".into(),
|
||||
preview: "test input".into(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
-27
@@ -728,7 +728,7 @@ fn draw_rewind_picker(
|
||||
.fg(Color::Yellow)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::raw(format!(" head={} ", picker.head_entries)),
|
||||
Span::raw(" "),
|
||||
];
|
||||
header.extend(action_spans);
|
||||
logical.push(Line::from(header));
|
||||
@@ -751,38 +751,13 @@ fn draw_rewind_picker(
|
||||
Style::default()
|
||||
.bg(Color::DarkGray)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else if target.eligible {
|
||||
Style::default()
|
||||
} else {
|
||||
Style::default().fg(Color::DarkGray)
|
||||
Style::default()
|
||||
};
|
||||
let ts = target
|
||||
.timestamp_ms
|
||||
.map(|ts| format!("{}", ts))
|
||||
.unwrap_or_else(|| "-".into());
|
||||
logical.push(Line::from(vec![
|
||||
Span::styled(marker.to_owned(), base_style),
|
||||
Span::styled(
|
||||
format!(
|
||||
" turn {} idx {} ts {} ",
|
||||
target.turn_index, target.id.user_input_entry_index, ts
|
||||
),
|
||||
base_style,
|
||||
),
|
||||
Span::styled(target.preview.clone(), base_style),
|
||||
]));
|
||||
if let Some(warning) = target.warning.as_ref() {
|
||||
logical.push(Line::from(Span::styled(
|
||||
format!(" warning: {warning}"),
|
||||
Style::default().fg(Color::Yellow),
|
||||
)));
|
||||
}
|
||||
if let Some(reason) = target.disabled_reason.as_ref() {
|
||||
logical.push(Line::from(Span::styled(
|
||||
format!(" disabled: {reason}"),
|
||||
Style::default().fg(Color::Red),
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,13 +12,12 @@ pub enum WorkerInputKind {
|
||||
UserIfIdle,
|
||||
Notify,
|
||||
Compact,
|
||||
ListRewindTargets,
|
||||
RegisterPeer,
|
||||
}
|
||||
|
||||
impl WorkerInputKind {
|
||||
pub fn is_empty_content_allowed(&self) -> bool {
|
||||
matches!(self, Self::Compact | Self::ListRewindTargets)
|
||||
matches!(self, Self::Compact)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6752,16 +6752,16 @@ fn input_protocol_event(input: &WorkerInput) -> Option<protocol::Event> {
|
||||
// Submit is projected only after the Worker commits UserInput. Queued
|
||||
// payloads must never become model- or client-visible history early.
|
||||
WorkerInputKind::User | WorkerInputKind::UserIfIdle | WorkerInputKind::Notify => None,
|
||||
WorkerInputKind::Compact
|
||||
| WorkerInputKind::ListRewindTargets
|
||||
| WorkerInputKind::RegisterPeer => Some(protocol::Event::SystemItem {
|
||||
entry_id: None,
|
||||
item: serde_json::json!({
|
||||
"kind": "embedded_worker_command_input",
|
||||
"command": input.kind,
|
||||
"content": input.content.clone(),
|
||||
}),
|
||||
}),
|
||||
WorkerInputKind::Compact | WorkerInputKind::RegisterPeer => {
|
||||
Some(protocol::Event::SystemItem {
|
||||
entry_id: None,
|
||||
item: serde_json::json!({
|
||||
"kind": "embedded_worker_command_input",
|
||||
"command": input.kind,
|
||||
"content": input.content.clone(),
|
||||
}),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4255,7 +4255,6 @@ where
|
||||
unreachable!("Notify input is dispatched before ordinary input mapping")
|
||||
}
|
||||
WorkerInputKind::Compact => unreachable!("compact input is dispatched above"),
|
||||
WorkerInputKind::ListRewindTargets => (Method::ListRewindTargets, None),
|
||||
WorkerInputKind::RegisterPeer => (
|
||||
Method::RegisterPeer {
|
||||
name: input.content.trim().to_string(),
|
||||
@@ -7884,7 +7883,7 @@ mod tests {
|
||||
));
|
||||
let old_send = backend.run_on_adapter_runtime(async move {
|
||||
old_handle
|
||||
.send(Method::ListRewindTargets)
|
||||
.send(Method::ListWorkers)
|
||||
.await
|
||||
.map_err(|error| error.to_string())
|
||||
});
|
||||
|
||||
@@ -32,8 +32,8 @@ use protocol::{
|
||||
AlertLevel, AlertSource, CommandEvent as ProtocolCommandEvent,
|
||||
CommandSnapshot as ProtocolCommandSnapshot, CommandStatus as ProtocolCommandStatus,
|
||||
CommandStream as ProtocolCommandStream, CommandStreamSlice as ProtocolCommandStreamSlice,
|
||||
ErrorCode, Event, Method, RewindTargetId, RunResult, TurnResult, UploadedFileRef,
|
||||
WorkerBusyState, WorkerCommandAcknowledgement, WorkerCommandDisposition, WorkerCommandEnvelope,
|
||||
ErrorCode, Event, Method, RunResult, TurnResult, UploadedFileRef, WorkerBusyState,
|
||||
WorkerCommandAcknowledgement, WorkerCommandDisposition, WorkerCommandEnvelope,
|
||||
WorkerCommandKind, WorkerMaintenanceState, WorkerRunState, WorkerState, WorkerStatus,
|
||||
};
|
||||
use workdir::{
|
||||
@@ -2971,32 +2971,9 @@ async fn controller_loop<C, St>(
|
||||
}
|
||||
}
|
||||
|
||||
Method::ListRewindTargets => match shared_state.catalog_status() {
|
||||
WorkerStatus::Idle | WorkerStatus::Paused => {
|
||||
emit_rewind_targets(&worker, &working_event_tx)
|
||||
}
|
||||
WorkerStatus::Running | WorkerStatus::Stopped => {
|
||||
let _ = working_event_tx.send(Event::Error {
|
||||
code: ErrorCode::AlreadyRunning,
|
||||
message: "Worker is already executing a turn; rewind can only run while idle or paused"
|
||||
.into(),
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
Method::RewindTo {
|
||||
target,
|
||||
expected_head_entries,
|
||||
} => match shared_state.catalog_status() {
|
||||
Method::RewindTo { entry_id } => match shared_state.catalog_status() {
|
||||
WorkerStatus::Idle => {
|
||||
if apply_rewind(
|
||||
&mut worker,
|
||||
&working_event_tx,
|
||||
target,
|
||||
expected_head_entries,
|
||||
)
|
||||
.await
|
||||
{
|
||||
if apply_rewind(&mut worker, &working_event_tx, entry_id).await {
|
||||
worker.clear_in_flight_events();
|
||||
publish_context_usage(
|
||||
&shared_state,
|
||||
@@ -3018,8 +2995,9 @@ async fn controller_loop<C, St>(
|
||||
WorkerStatus::Running | WorkerStatus::Stopped => {
|
||||
let _ = working_event_tx.send(Event::Error {
|
||||
code: ErrorCode::AlreadyRunning,
|
||||
message: "Worker is already executing a turn; rewind can only run while idle or paused"
|
||||
.into(),
|
||||
message:
|
||||
"Worker is already executing a turn; rewind can only run while idle"
|
||||
.into(),
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -3668,10 +3646,10 @@ where
|
||||
);
|
||||
}
|
||||
}
|
||||
Some(Method::ListRewindTargets | Method::RewindTo { .. }) => {
|
||||
Some(Method::RewindTo { .. }) => {
|
||||
let _ = working_event_tx.send(Event::Error {
|
||||
code: ErrorCode::AlreadyRunning,
|
||||
message: "Worker is already executing a turn; rewind/compact can only run while idle or paused"
|
||||
message: "Worker is already executing a turn; rewind/compact can only run while idle"
|
||||
.into(),
|
||||
});
|
||||
}
|
||||
@@ -3749,38 +3727,16 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_rewind_targets<C, St>(worker: &Worker<C, St>, working_event_tx: &broadcast::Sender<Event>)
|
||||
where
|
||||
C: LlmClient + 'static,
|
||||
St: Store,
|
||||
{
|
||||
match worker.list_rewind_targets() {
|
||||
Ok((head_entries, targets)) => {
|
||||
let _ = working_event_tx.send(Event::RewindTargets {
|
||||
head_entries,
|
||||
targets,
|
||||
});
|
||||
}
|
||||
Err(err) => {
|
||||
let _ = working_event_tx.send(Event::Error {
|
||||
code: ErrorCode::Internal,
|
||||
message: err.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn apply_rewind<C, St>(
|
||||
worker: &mut Worker<C, St>,
|
||||
working_event_tx: &broadcast::Sender<Event>,
|
||||
target: RewindTargetId,
|
||||
expected_head_entries: usize,
|
||||
entry_id: String,
|
||||
) -> bool
|
||||
where
|
||||
C: LlmClient + 'static,
|
||||
St: Store,
|
||||
{
|
||||
match worker.rewind_to(target, expected_head_entries).await {
|
||||
match worker.rewind_to(entry_id).await {
|
||||
Ok(applied) => {
|
||||
let session =
|
||||
session_store::public_snapshot::project_current_session_snapshot(&applied.entries);
|
||||
|
||||
+179
-140
@@ -531,8 +531,8 @@ use crate::skill::{SkillActivationResponse, SkillClientError};
|
||||
use async_trait::async_trait;
|
||||
use protocol::{
|
||||
AlertLevel, AlertSource, CompactionLifecycle, CompactionLifecycleState, CompactionPhase,
|
||||
CompactionTrigger, ErrorCode, Event, InFlightCompaction, RewindSummary, RewindTarget,
|
||||
RewindTargetId, RunFailureKind, RunResumeSource, RunYieldReason, Segment,
|
||||
CompactionTrigger, ErrorCode, Event, InFlightCompaction, RewindSummary, RunFailureKind,
|
||||
RunResumeSource, RunYieldReason, Segment,
|
||||
};
|
||||
use tokio::net::UnixStream;
|
||||
use tokio::sync::broadcast;
|
||||
@@ -3714,22 +3714,15 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
||||
&self.store
|
||||
}
|
||||
|
||||
/// List user-submitted turns in newest-first order for the manual rewind picker.
|
||||
pub fn list_rewind_targets(&self) -> Result<(usize, Vec<RewindTarget>), RewindError> {
|
||||
/// Rewind the current segment to just before the specified public user input.
|
||||
pub async fn rewind_to(&mut self, entry_id: String) -> Result<RewindAppliedState, RewindError> {
|
||||
// Reject invalid targets before invoking rewrite hooks or quiescing tasks.
|
||||
// Resolve again under the append lock after the asynchronous barrier.
|
||||
let loc = self.segment_state.location();
|
||||
let entries = self.store.read_all(loc.session_id, loc.segment_id)?;
|
||||
Ok((
|
||||
entries.len(),
|
||||
build_rewind_targets(loc.segment_id, &entries),
|
||||
))
|
||||
}
|
||||
|
||||
/// Truncate the current segment to just before a previously listed user input.
|
||||
pub async fn rewind_to(
|
||||
&mut self,
|
||||
target: RewindTargetId,
|
||||
expected_head_entries: usize,
|
||||
) -> Result<RewindAppliedState, RewindError> {
|
||||
resolve_rewind_input(
|
||||
&self.store.read_all(loc.session_id, loc.segment_id)?,
|
||||
&entry_id,
|
||||
)?;
|
||||
let _rewrite_guard = self
|
||||
.prepare_session_rewrite(SessionRewriteKind::Rewind)
|
||||
.await
|
||||
@@ -3742,29 +3735,9 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
||||
.lock()
|
||||
.expect("segment append lock poisoned");
|
||||
let loc = self.segment_state.location();
|
||||
if target.segment_id != loc.segment_id {
|
||||
return Err(RewindError::Invalid(
|
||||
"rewind target belongs to a different segment".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let entries = self.store.read_all(loc.session_id, loc.segment_id)?;
|
||||
if entries.len() != expected_head_entries {
|
||||
return Err(RewindError::Invalid(format!(
|
||||
"session head changed since picker opened (expected {expected_head_entries}, current {})",
|
||||
entries.len()
|
||||
)));
|
||||
}
|
||||
|
||||
let input = match entries.get(target.user_input_entry_index) {
|
||||
Some(LogEntry::AnnotatedUserInput { segments, .. }) => segments.clone(),
|
||||
_ => {
|
||||
return Err(RewindError::Invalid(
|
||||
"rewind target is no longer a user message".into(),
|
||||
));
|
||||
}
|
||||
};
|
||||
let truncate_entries = rewind_truncate_entries(&entries, target.user_input_entry_index);
|
||||
let (entry_index, input) = resolve_rewind_input(&entries, &entry_id)?;
|
||||
let truncate_entries = rewind_truncate_entries(&entries, entry_index);
|
||||
let retained = entries[..truncate_entries].to_vec();
|
||||
let tool_side_effect_warning = suffix_has_tool_side_effects(&entries[truncate_entries..]);
|
||||
let state = segment_log::collect_state(&retained);
|
||||
@@ -8994,37 +8967,21 @@ pub struct RewindAppliedState {
|
||||
pub summary: RewindSummary,
|
||||
}
|
||||
|
||||
fn build_rewind_targets(segment_id: uuid::Uuid, entries: &[LogEntry]) -> Vec<RewindTarget> {
|
||||
let head_entries = entries.len();
|
||||
let mut turn_index = 0usize;
|
||||
let mut targets = Vec::new();
|
||||
for (entry_index, entry) in entries.iter().enumerate() {
|
||||
let (segments, ts) = match entry {
|
||||
LogEntry::AnnotatedUserInput { segments, ts, .. } => (segments, ts),
|
||||
_ => continue,
|
||||
};
|
||||
turn_index += 1;
|
||||
let truncate_entries = rewind_truncate_entries(entries, entry_index);
|
||||
let tool_warning = suffix_has_tool_side_effects(&entries[truncate_entries..]);
|
||||
targets.push(RewindTarget {
|
||||
id: RewindTargetId {
|
||||
segment_id,
|
||||
user_input_entry_index: entry_index,
|
||||
},
|
||||
expected_head_entries: head_entries,
|
||||
truncate_entries,
|
||||
turn_index,
|
||||
timestamp_ms: Some(*ts),
|
||||
preview: preview_segments(segments),
|
||||
eligible: true,
|
||||
disabled_reason: None,
|
||||
warning: tool_warning.then(|| {
|
||||
"history suffix will be discarded; tool side effects are not undone".into()
|
||||
}),
|
||||
});
|
||||
}
|
||||
targets.reverse();
|
||||
targets
|
||||
fn resolve_rewind_input(
|
||||
entries: &[LogEntry],
|
||||
entry_id: &str,
|
||||
) -> Result<(usize, Vec<Segment>), RewindError> {
|
||||
// Resolve only actual user submissions in this segment, never seeded
|
||||
// ancestor history or a display position inferred from matching text.
|
||||
entries.iter().enumerate().find_map(|(index, entry)| {
|
||||
let LogEntry::AnnotatedUserInput { history, segments, .. } = entry else { return None; };
|
||||
let user = history.iter().find(|entry| matches!(&entry.item,
|
||||
session_store::LoggedItem::Message { role: session_store::LoggedRole::User, .. }
|
||||
))?;
|
||||
(user.metadata.entry_id.0 == entry_id).then(|| (index, segments.clone()))
|
||||
}).ok_or_else(|| RewindError::Invalid(
|
||||
"rewind entry is not a user input in the current segment; it may have been removed or belong to another history".into()
|
||||
))
|
||||
}
|
||||
|
||||
fn rewind_truncate_entries(entries: &[LogEntry], user_input_entry_index: usize) -> usize {
|
||||
@@ -9052,6 +9009,7 @@ fn logged_item_is_tool_call(item: &session_store::LoggedItem) -> bool {
|
||||
matches!(item, session_store::LoggedItem::ToolCall { .. })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn preview_segments(segments: &[Segment]) -> String {
|
||||
let mut preview = String::new();
|
||||
for segment in segments {
|
||||
@@ -12305,48 +12263,139 @@ mod build_summary_prompt_tests {
|
||||
);
|
||||
}
|
||||
|
||||
fn rewind_test_inputs<C: LlmClient + 'static, St: Store>(
|
||||
worker: &Worker<C, St>,
|
||||
) -> Vec<(String, usize)> {
|
||||
let loc = worker.segment_state.location();
|
||||
let entries = worker
|
||||
.store
|
||||
.read_all(loc.session_id, loc.segment_id)
|
||||
.unwrap();
|
||||
let public = session_store::public_snapshot::project_current_session_snapshot(&entries);
|
||||
public
|
||||
.entries
|
||||
.iter()
|
||||
.filter_map(|entry| {
|
||||
if !matches!(
|
||||
entry.data,
|
||||
protocol::SessionSnapshotEntryData::UserInput { .. }
|
||||
) {
|
||||
return None;
|
||||
}
|
||||
let index = entries.iter().position(|log| {
|
||||
matches!(log, LogEntry::AnnotatedUserInput { history, .. }
|
||||
if history.iter().any(|h| h.metadata.entry_id.0 == entry.entry_id))
|
||||
})?;
|
||||
Some((
|
||||
entry.entry_id.clone(),
|
||||
rewind_truncate_entries(&entries, index),
|
||||
))
|
||||
})
|
||||
.rev()
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rewind_target_listing_is_newest_first_and_warns_on_tool_suffix() {
|
||||
let (_dir, worker) = rewind_test_worker().await;
|
||||
append_user_turn(&worker, 10, "first message");
|
||||
append_user_turn(&worker, 20, "second message");
|
||||
async fn rewind_resolves_duplicate_text_by_public_entry_identity() {
|
||||
let (_dir, mut worker) = rewind_test_worker().await;
|
||||
append_user_turn(&worker, 10, "same input");
|
||||
append_user_turn(&worker, 20, "same input");
|
||||
let inputs = rewind_test_inputs(&worker);
|
||||
assert_ne!(inputs[0].0, inputs[1].0);
|
||||
worker.rewind_to(inputs[0].0.clone()).await.unwrap();
|
||||
assert_eq!(rewind_test_inputs(&worker), vec![inputs[1].clone()]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rewind_rejects_unknown_foreign_and_non_user_ids_without_changing_log() {
|
||||
let (_dir, mut worker) = rewind_test_worker().await;
|
||||
let (_other_dir, other) = rewind_test_worker().await;
|
||||
append_user_turn(&worker, 10, "same input");
|
||||
append_user_turn(&other, 10, "same input");
|
||||
let assistant =
|
||||
crate::session_history::test_logged_history_entry(session_store::LoggedItem::Message {
|
||||
role: session_store::LoggedRole::Assistant,
|
||||
content: vec![session_store::LoggedContentPart::Text {
|
||||
text: "answer".into(),
|
||||
}],
|
||||
});
|
||||
let assistant_id = assistant.metadata.entry_id.0.clone();
|
||||
append_test_entry(
|
||||
&worker,
|
||||
LogEntry::AnnotatedToolResult {
|
||||
ts: 30,
|
||||
entry: crate::session_history::test_logged_history_entry(
|
||||
session_store::LoggedItem::ToolResult {
|
||||
call_id: "call-1".into(),
|
||||
summary: "wrote a file".into(),
|
||||
content: None,
|
||||
attachments: Vec::new(),
|
||||
disposition: Default::default(),
|
||||
is_error: false,
|
||||
},
|
||||
),
|
||||
LogEntry::AnnotatedAssistantItem {
|
||||
ts: 12,
|
||||
entry: assistant,
|
||||
},
|
||||
);
|
||||
|
||||
let (head_entries, targets) = worker.list_rewind_targets().unwrap();
|
||||
let loc = worker.segment_state.location();
|
||||
let before = worker
|
||||
.store
|
||||
.read_all(loc.session_id, loc.segment_id)
|
||||
.unwrap();
|
||||
for id in [
|
||||
String::new(),
|
||||
"missing-entry".into(),
|
||||
rewind_test_inputs(&other)[0].0.clone(),
|
||||
assistant_id,
|
||||
] {
|
||||
assert!(
|
||||
worker
|
||||
.rewind_to(id)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("not a user input")
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(
|
||||
worker
|
||||
.store
|
||||
.read_all(loc.session_id, loc.segment_id)
|
||||
.unwrap()
|
||||
)
|
||||
.unwrap(),
|
||||
serde_json::to_value(&before).unwrap()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
head_entries,
|
||||
worker
|
||||
.store
|
||||
.read_all(loc.session_id, loc.segment_id)
|
||||
.unwrap()
|
||||
.len()
|
||||
#[tokio::test]
|
||||
async fn rewind_rejects_user_input_from_seeded_ancestor_history() {
|
||||
let (_dir, mut worker) = rewind_test_worker().await;
|
||||
let input = crate::session_history::test_logged_history_entry(
|
||||
session_store::LoggedItem::from(agen::Item::user_message("ancestor input")),
|
||||
);
|
||||
assert_eq!(targets.len(), 2);
|
||||
assert_eq!(targets[0].preview, "second message");
|
||||
assert_eq!(targets[1].preview, "first message");
|
||||
let id = input.metadata.entry_id.0.clone();
|
||||
let loc = worker.segment_state.location();
|
||||
let mut entries = worker
|
||||
.store
|
||||
.read_all(loc.session_id, loc.segment_id)
|
||||
.unwrap();
|
||||
let LogEntry::AnnotatedSegmentStart { history, .. } = &mut entries[0] else {
|
||||
panic!("segment start");
|
||||
};
|
||||
history.push(input);
|
||||
worker
|
||||
.store
|
||||
.create_segment(loc.session_id, loc.segment_id, &entries)
|
||||
.unwrap();
|
||||
assert!(
|
||||
targets[0]
|
||||
.warning
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.contains("tool side effects")
|
||||
worker
|
||||
.rewind_to(id)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("current segment")
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(
|
||||
worker
|
||||
.store
|
||||
.read_all(loc.session_id, loc.segment_id)
|
||||
.unwrap()
|
||||
)
|
||||
.unwrap(),
|
||||
serde_json::to_value(entries).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12371,11 +12420,11 @@ mod build_summary_prompt_tests {
|
||||
),
|
||||
},
|
||||
);
|
||||
let (head_entries, targets) = worker.list_rewind_targets().unwrap();
|
||||
let expected_truncate_entries = targets[0].truncate_entries;
|
||||
let target = targets[0].id.clone();
|
||||
let targets = rewind_test_inputs(&worker);
|
||||
let expected_truncate_entries = targets[0].1;
|
||||
let target = targets[0].0.clone();
|
||||
|
||||
let applied = worker.rewind_to(target, head_entries).await.unwrap();
|
||||
let applied = worker.rewind_to(target).await.unwrap();
|
||||
|
||||
assert_eq!(preview_segments(&applied.input), "second message");
|
||||
assert_eq!(
|
||||
@@ -12405,10 +12454,10 @@ mod build_summary_prompt_tests {
|
||||
.pending_submission_handle()
|
||||
.accept_notification("notification-1".into(), "keep me".into())
|
||||
.unwrap();
|
||||
let (head_entries, targets) = worker.list_rewind_targets().unwrap();
|
||||
let targets = rewind_test_inputs(&worker);
|
||||
|
||||
worker
|
||||
.rewind_to(targets.last().unwrap().id.clone(), head_entries)
|
||||
.rewind_to(targets.last().unwrap().0.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -12465,16 +12514,11 @@ mod build_summary_prompt_tests {
|
||||
},
|
||||
);
|
||||
|
||||
let (head_entries, targets) = worker.list_rewind_targets().unwrap();
|
||||
let targets = rewind_test_inputs(&worker);
|
||||
assert_eq!(targets.len(), 2);
|
||||
assert_eq!(targets[0].preview, "second message");
|
||||
assert!(targets[0].truncate_entries > 0);
|
||||
assert!(targets[0].warning.is_some());
|
||||
assert!(targets[0].1 > 0);
|
||||
|
||||
let applied = worker
|
||||
.rewind_to(targets[0].id.clone(), head_entries)
|
||||
.await
|
||||
.unwrap();
|
||||
let applied = worker.rewind_to(targets[0].0.clone()).await.unwrap();
|
||||
assert_eq!(applied.summary.truncated_to_entries, 5);
|
||||
assert!(matches!(
|
||||
applied.entries.first(),
|
||||
@@ -12539,19 +12583,20 @@ mod build_summary_prompt_tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rewind_apply_rejects_stale_head() {
|
||||
async fn rewind_by_entry_id_includes_history_appended_after_selection() {
|
||||
let (_dir, mut worker) = rewind_test_worker().await;
|
||||
append_user_turn(&worker, 10, "first message");
|
||||
let (head_entries, targets) = worker.list_rewind_targets().unwrap();
|
||||
let targets = rewind_test_inputs(&worker);
|
||||
append_user_turn(&worker, 20, "newer message");
|
||||
|
||||
let err = worker
|
||||
.rewind_to(targets[0].id.clone(), head_entries)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
|
||||
assert!(err.contains("session head changed"));
|
||||
let applied = worker.rewind_to(targets[0].0.clone()).await.unwrap();
|
||||
assert_eq!(preview_segments(&applied.input), "first message");
|
||||
assert!(rewind_test_inputs(&worker).is_empty());
|
||||
let err = worker.rewind_to(targets[0].0.clone()).await.unwrap_err();
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("not a user input in the current segment")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -15421,7 +15466,7 @@ permission = "write"
|
||||
assert!(pending.pending_notifications.is_empty());
|
||||
assert!(pending.activating_notifications.is_empty());
|
||||
}
|
||||
let (head, targets) = original.list_rewind_targets().unwrap();
|
||||
let targets = rewind_test_inputs(&original);
|
||||
let target = targets.last().unwrap();
|
||||
let old = store
|
||||
.read_all(original.session_id(), original.segment_id())
|
||||
@@ -15431,7 +15476,7 @@ permission = "write"
|
||||
store
|
||||
.invocation_failure
|
||||
.store(replacement_fault, Ordering::SeqCst);
|
||||
let applied = original.rewind_to(target.id.clone(), head).await;
|
||||
let applied = original.rewind_to(target.0.clone()).await;
|
||||
let disk = store
|
||||
.read_all(original.session_id(), original.segment_id())
|
||||
.unwrap();
|
||||
@@ -15456,15 +15501,9 @@ permission = "write"
|
||||
replacement_fault
|
||||
);
|
||||
let applied = applied.unwrap();
|
||||
assert_eq!(
|
||||
applied.summary.truncated_to_entries,
|
||||
target.truncate_entries
|
||||
);
|
||||
assert_eq!(
|
||||
applied.summary.discarded_entries,
|
||||
head - target.truncate_entries
|
||||
);
|
||||
assert_eq!(applied.entries.len(), target.truncate_entries);
|
||||
assert_eq!(applied.summary.truncated_to_entries, target.1);
|
||||
assert_eq!(applied.summary.discarded_entries, old.len() - target.1);
|
||||
assert_eq!(applied.entries.len(), target.1);
|
||||
assert_eq!(original.segment_state.entries_written(), disk.len());
|
||||
assert_eq!(
|
||||
serde_json::to_value(original.sink.subscribe_with_snapshot().0)
|
||||
@@ -15474,10 +15513,10 @@ permission = "write"
|
||||
} else {
|
||||
assert!(applied.is_err());
|
||||
}
|
||||
assert_eq!(disk.len(), target.truncate_entries + 1);
|
||||
assert_eq!(disk.len(), target.1 + 1);
|
||||
assert_eq!(
|
||||
serde_json::to_value(&disk[..target.truncate_entries]).unwrap(),
|
||||
serde_json::to_value(&old[..target.truncate_entries]).unwrap()
|
||||
serde_json::to_value(&disk[..target.1]).unwrap(),
|
||||
serde_json::to_value(&old[..target.1]).unwrap()
|
||||
);
|
||||
let LogEntry::Extension {
|
||||
domain, payload, ..
|
||||
|
||||
@@ -2730,7 +2730,7 @@ async fn shutdown_closes_method_admission_before_terminal_confirmation() {
|
||||
loop {
|
||||
tokio::select! {
|
||||
biased;
|
||||
result = handle.send(Method::ListRewindTargets) => {
|
||||
result = handle.send(Method::ListWorkers) => {
|
||||
if result.is_err() {
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -705,7 +705,6 @@ pub enum WorkerInputKind {
|
||||
UserIfIdle,
|
||||
Notify,
|
||||
Compact,
|
||||
ListRewindTargets,
|
||||
RegisterPeer,
|
||||
}
|
||||
|
||||
@@ -3271,7 +3270,6 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
|
||||
WorkerInputKind::UserIfIdle => EmbeddedWorkerInputKind::UserIfIdle,
|
||||
WorkerInputKind::Notify => EmbeddedWorkerInputKind::Notify,
|
||||
WorkerInputKind::Compact => EmbeddedWorkerInputKind::Compact,
|
||||
WorkerInputKind::ListRewindTargets => EmbeddedWorkerInputKind::ListRewindTargets,
|
||||
WorkerInputKind::RegisterPeer => EmbeddedWorkerInputKind::RegisterPeer,
|
||||
},
|
||||
content: request.content,
|
||||
@@ -5310,7 +5308,6 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
|
||||
WorkerInputKind::UserIfIdle => EmbeddedWorkerInputKind::UserIfIdle,
|
||||
WorkerInputKind::Notify => EmbeddedWorkerInputKind::Notify,
|
||||
WorkerInputKind::Compact => EmbeddedWorkerInputKind::Compact,
|
||||
WorkerInputKind::ListRewindTargets => EmbeddedWorkerInputKind::ListRewindTargets,
|
||||
WorkerInputKind::RegisterPeer => EmbeddedWorkerInputKind::RegisterPeer,
|
||||
},
|
||||
content: request.content,
|
||||
@@ -6748,7 +6745,6 @@ mod tests {
|
||||
EmbeddedWorkerInputKind::UserIfIdle,
|
||||
EmbeddedWorkerInputKind::Notify,
|
||||
EmbeddedWorkerInputKind::Compact,
|
||||
EmbeddedWorkerInputKind::ListRewindTargets,
|
||||
EmbeddedWorkerInputKind::RegisterPeer,
|
||||
] {
|
||||
let input = EmbeddedWorkerInput {
|
||||
|
||||
@@ -6930,7 +6930,6 @@ fn worker_input_request_from_api(
|
||||
"user" => WorkerInputKind::User,
|
||||
"notify" => WorkerInputKind::Notify,
|
||||
"compact" => WorkerInputKind::Compact,
|
||||
"list_rewind_targets" => WorkerInputKind::ListRewindTargets,
|
||||
"register_peer" => WorkerInputKind::RegisterPeer,
|
||||
value => {
|
||||
return Err(Error::InvalidInput(format!(
|
||||
@@ -29168,7 +29167,6 @@ pub(crate) fn authorize_browser_worker_method(
|
||||
| protocol::Method::Cancel { .. }
|
||||
| protocol::Method::Pause { .. }
|
||||
| protocol::Method::Compact { .. }
|
||||
| protocol::Method::ListRewindTargets
|
||||
| protocol::Method::RewindTo { .. }
|
||||
| protocol::Method::Shutdown { .. }
|
||||
| protocol::Method::ListCompletions { .. }
|
||||
|
||||
Reference in New Issue
Block a user