feat: select rewind points directly from Console history

This commit is contained in:
2026-10-11 14:50:49 +09:00
parent 04bdf1da23
commit 5ac3b1e482
27 changed files with 694 additions and 545 deletions
+21 -39
View File
@@ -349,13 +349,11 @@ pub enum Method {
Compact { Compact {
command: WorkerCommandEnvelope, command: WorkerCommandEnvelope,
}, },
/// Ask the Worker to list valid rewind targets from its authoritative session log. /// Rewind to just before a user input identified by its public Session entry ID.
ListRewindTargets, /// Validate the target against the current segment at execution time.
/// Truncate the current session back to the selected rewind target and /// Later appended history is discarded too; tool side effects are not undone.
/// return the selected user input to the client composer.
RewindTo { RewindTo {
target: RewindTargetId, entry_id: String,
expected_head_entries: usize,
}, },
Shutdown { Shutdown {
command: WorkerCommandEnvelope, command: WorkerCommandEnvelope,
@@ -1421,12 +1419,6 @@ pub enum Event {
context: Option<CompletionContext>, context: Option<CompletionContext>,
entries: Vec<CompletionEntry>, 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 /// A rewind has truncated the authoritative session. `session` is the
/// retained canonical snapshot clients should use to reseed display state. /// retained canonical snapshot clients should use to reseed display state.
RewindApplied { RewindApplied {
@@ -1569,33 +1561,6 @@ pub struct CompletionEntry {
pub invocation: Option<FeatureInvocationDescriptor>, 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)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
@@ -1998,6 +1963,23 @@ pub enum Permission {
mod tests { mod tests {
use super::*; 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] #[test]
fn scope_rule_defaults_to_resolved_symlink_policy() { fn scope_rule_defaults_to_resolved_symlink_policy() {
let rule: ScopeRule = serde_json::from_value(serde_json::json!({ let rule: ScopeRule = serde_json::from_value(serde_json::json!({
+8 -10
View File
@@ -15,14 +15,14 @@ use crate::{
InvocationClientAdapter, InvocationCompletion, InvocationValue, InvokeKind, MemoryWorkerEvent, InvocationClientAdapter, InvocationCompletion, InvocationValue, InvokeKind, MemoryWorkerEvent,
Method, PasteArtifactAvailability, PasteArtifactMediaType, PasteArtifactRef, Method, PasteArtifactAvailability, PasteArtifactMediaType, PasteArtifactRef,
PendingSubmissionSummary, PendingSubmissionsSnapshot, Permission, ReasoningConfig, PendingSubmissionSummary, PendingSubmissionsSnapshot, Permission, ReasoningConfig,
RewindSummary, RewindTarget, RewindTargetId, RunFailureKind, RunResult, RunResumeSource, RewindSummary, RunFailureKind, RunResult, RunResumeSource, RunYieldReason, ScopeRule, Segment,
RunYieldReason, ScopeRule, Segment, SessionContentPart, SessionConversationTurn, SessionContentPart, SessionConversationTurn, SessionEntryProvenance,
SessionEntryProvenance, SessionHistoryLineageBoundary, SessionHistoryPage, SessionMessageRole, SessionHistoryLineageBoundary, SessionHistoryPage, SessionMessageRole, SessionSnapshot,
SessionSnapshot, SessionSnapshotEntry, SessionSnapshotEntryData, SessionToolAttachment, SessionSnapshotEntry, SessionSnapshotEntryData, SessionToolAttachment, SubmissionDisposition,
SubmissionDisposition, SymlinkPolicy, ToolResultDisposition, TurnResult, SymlinkPolicy, ToolResultDisposition, TurnResult, UploadedFileAvailability, UploadedFileRef,
UploadedFileAvailability, UploadedFileRef, WorkerBusyState, WorkerCommandAcknowledgement, WorkerBusyState, WorkerCommandAcknowledgement, WorkerCommandDisposition, WorkerCommandEnvelope,
WorkerCommandDisposition, WorkerCommandEnvelope, WorkerCommandKind, WorkerEvent, WorkerCommandKind, WorkerEvent, WorkerMaintenanceState, WorkerRunState, WorkerState,
WorkerMaintenanceState, WorkerRunState, WorkerState, WorkerStateSnapshot, WorkerStatus, WorkerStateSnapshot, WorkerStatus,
subscription::{ subscription::{
EventSubscriptionSelector, MAX_CORRELATION_ID_BYTES, MAX_REJECTION_MESSAGE_BYTES, EventSubscriptionSelector, MAX_CORRELATION_ID_BYTES, MAX_REJECTION_MESSAGE_BYTES,
MAX_RESOURCE_ID_BYTES, MAX_SUBSCRIPTION_COLLECTION_ITEMS, 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::<UploadedFileRef>(&cfg, &mut output);
push_decl::<ScopeRule>(&cfg, &mut output); push_decl::<ScopeRule>(&cfg, &mut output);
push_decl::<CompletionEntry>(&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::<RewindSummary>(&cfg, &mut output);
push_decl::<InFlightBlock>(&cfg, &mut output); push_decl::<InFlightBlock>(&cfg, &mut output);
push_decl::<InFlightCompaction>(&cfg, &mut output); push_decl::<InFlightCompaction>(&cfg, &mut output);
-1
View File
@@ -401,7 +401,6 @@ pub enum WorkerInputKind {
UserIfIdle, UserIfIdle,
Notify, Notify,
Compact, Compact,
ListRewindTargets,
RegisterPeer, RegisterPeer,
} }
+44 -59
View File
@@ -5,7 +5,7 @@ use std::time::{Duration, Instant};
use protocol::{ use protocol::{
AlertLevel, AlertSource, CompletionEntry, CompletionKind, ErrorCode, Event, InFlightBlock, AlertLevel, AlertSource, CompletionEntry, CompletionKind, ErrorCode, Event, InFlightBlock,
InFlightSnapshot, InFlightToolCallState, InternalWorkerRef, InternalWorkerSnapshot, Method, InFlightSnapshot, InFlightToolCallState, InternalWorkerRef, InternalWorkerSnapshot, Method,
RewindTarget, RunResult, Segment, WorkerCommandEnvelope, WorkerStateSnapshot, WorkerStatus, RunResult, Segment, WorkerCommandEnvelope, WorkerStateSnapshot, WorkerStatus,
}; };
use crate::block::{ use crate::block::{
@@ -90,10 +90,15 @@ pub struct RewindPickerScroll {
pub tail_top_offset: usize, pub tail_top_offset: usize,
} }
#[derive(Debug, Clone)]
pub struct RewindEntry {
pub entry_id: String,
pub preview: String,
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct RewindPickerState { pub struct RewindPickerState {
pub head_entries: usize, pub targets: Vec<RewindEntry>,
pub targets: Vec<RewindTarget>,
pub selected: usize, pub selected: usize,
pub scroll: RewindPickerScroll, pub scroll: RewindPickerScroll,
/// True after Enter submitted an authoritative `RewindTo` and before the /// True after Enter submitted an authoritative `RewindTo` and before the
@@ -104,10 +109,9 @@ pub struct RewindPickerState {
} }
impl RewindPickerState { impl RewindPickerState {
pub fn new(head_entries: usize, targets: Vec<RewindTarget>) -> Self { pub fn new(targets: Vec<RewindEntry>) -> Self {
let selected = targets.iter().position(|t| t.eligible).unwrap_or(0); let selected = 0;
Self { Self {
head_entries,
targets, targets,
selected, selected,
scroll: RewindPickerScroll::default(), 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) self.targets.get(self.selected)
} }
} }
@@ -334,7 +338,7 @@ pub struct App {
completion_target: String, completion_target: String,
/// Dedicated main-view rewind picker state. /// Dedicated main-view rewind picker state.
pub rewind_picker: Option<RewindPickerState>, pub rewind_picker: Option<RewindPickerState>,
rewind_request_pending: bool, rewind_entries: Vec<RewindEntry>,
/// After a successful rewind restore, ignore any queued live-update events /// After a successful rewind restore, ignore any queued live-update events
/// until the authoritative Worker status/snapshot catches up. This prevents /// until the authoritative Worker status/snapshot catches up. This prevents
/// old stream tail events that were already in transit from re-polluting the /// old stream tail events that were already in transit from re-polluting the
@@ -419,7 +423,7 @@ impl App {
pending_feature_edit: None, pending_feature_edit: None,
completion_target: protocol::new_submission_request_id(), completion_target: protocol::new_submission_request_id(),
rewind_picker: None, rewind_picker: None,
rewind_request_pending: false, rewind_entries: Vec::new(),
rewind_refresh_fence: false, rewind_refresh_fence: false,
greeting: None, greeting: None,
task_store: TaskStore::new(), task_store: TaskStore::new(),
@@ -1529,7 +1533,19 @@ impl App {
Event::PendingSubmissionsChanged { pending } => { Event::PendingSubmissionsChanged { pending } => {
self.pending_submissions = 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.turn_index += 1;
self.blocks.push(Block::TurnHeader { self.blocks.push(Block::TurnHeader {
turn: self.turn_index, turn: self.turn_index,
@@ -2038,15 +2054,6 @@ impl App {
state.selected = 0; 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 { Event::RewindApplied {
session, session,
input, input,
@@ -2467,14 +2474,14 @@ impl App {
return None; return None;
} }
self.completion = None; self.completion = None;
self.rewind_picker = None; self.rewind_picker = Some(RewindPickerState::new(
self.rewind_request_pending = true; self.rewind_entries.iter().rev().cloned().collect(),
Some(Method::ListRewindTargets) ));
None
} }
pub fn close_rewind_picker(&mut self) { pub fn close_rewind_picker(&mut self) {
self.rewind_picker = None; self.rewind_picker = None;
self.rewind_request_pending = false;
} }
pub fn cancel_rewind_picker(&mut self) { pub fn cancel_rewind_picker(&mut self) {
@@ -2539,17 +2546,8 @@ impl App {
); );
return None; return None;
} }
let (target_id, expected_head_entries) = match picker.selected_target() { let entry_id = match picker.selected_target() {
Some(target) if target.eligible => (target.id.clone(), target.expected_head_entries), Some(target) => target.entry_id.clone(),
Some(target) => {
self.push_command_diagnostic(
target
.disabled_reason
.clone()
.unwrap_or_else(|| "rewind target is disabled".into()),
);
return None;
}
None => { None => {
self.push_command_diagnostic("no rewind target is available"); self.push_command_diagnostic("no rewind target is available");
return None; return None;
@@ -2558,10 +2556,7 @@ impl App {
if let Some(picker) = self.rewind_picker.as_mut() { if let Some(picker) = self.rewind_picker.as_mut() {
picker.applying = true; picker.applying = true;
} }
Some(Method::RewindTo { Some(Method::RewindTo { entry_id })
target: target_id,
expected_head_entries,
})
} }
fn command_environment(&self) -> CommandEnvironment { fn command_environment(&self) -> CommandEnvironment {
@@ -2597,10 +2592,8 @@ impl App {
command: self.next_command_envelope(), command: self.next_command_envelope(),
}); });
} }
if let Some(Method::ListRewindTargets) = method.as_ref() { if result.open_rewind_picker {
self.completion = None; return self.request_rewind_picker();
self.rewind_picker = None;
self.rewind_request_pending = true;
} }
method method
} }
@@ -2913,10 +2906,15 @@ impl App {
} }
self.assistant_streaming = false; self.assistant_streaming = false;
self.rewind_entries.clear();
for entry in &session.entries { for entry in &session.entries {
use protocol::{SessionContentPart, SessionMessageRole, SessionSnapshotEntryData}; use protocol::{SessionContentPart, SessionMessageRole, SessionSnapshotEntryData};
match &entry.data { match &entry.data {
SessionSnapshotEntryData::UserInput { segments } => { 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.turn_index += 1;
self.blocks.push(Block::TurnHeader { self.blocks.push(Block::TurnHeader {
turn: self.turn_index, turn: self.turn_index,
@@ -3364,23 +3362,10 @@ mod rewind_refresh_tests {
fn app_with_rewind_picker() -> App { fn app_with_rewind_picker() -> App {
let mut app = App::new("test".into()); let mut app = App::new("test".into());
app.connected = true; app.connected = true;
app.rewind_picker = Some(RewindPickerState::new( app.rewind_picker = Some(RewindPickerState::new(vec![RewindEntry {
5, entry_id: "user-1".into(),
vec![RewindTarget { preview: "test input".into(),
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 app
} }
+11 -3
View File
@@ -52,6 +52,7 @@ pub struct CommandEnvironment {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct CommandExecution { pub struct CommandExecution {
pub method: Option<Method>, pub method: Option<Method>,
pub open_rewind_picker: bool,
pub diagnostics: Vec<CommandDiagnostic>, pub diagnostics: Vec<CommandDiagnostic>,
pub exit_command_mode: bool, pub exit_command_mode: bool,
pub clear_input: bool, pub clear_input: bool,
@@ -60,6 +61,7 @@ pub struct CommandExecution {
impl CommandExecution { impl CommandExecution {
pub fn diagnostic(message: impl Into<String>) -> Self { pub fn diagnostic(message: impl Into<String>) -> Self {
Self { Self {
open_rewind_picker: false,
method: None, method: None,
diagnostics: vec![CommandDiagnostic::new(message)], diagnostics: vec![CommandDiagnostic::new(message)],
exit_command_mode: false, exit_command_mode: false,
@@ -69,6 +71,7 @@ impl CommandExecution {
pub fn notice(message: impl Into<String>) -> Self { pub fn notice(message: impl Into<String>) -> Self {
Self { Self {
open_rewind_picker: false,
method: None, method: None,
diagnostics: vec![CommandDiagnostic::new(message)], diagnostics: vec![CommandDiagnostic::new(message)],
exit_command_mode: true, exit_command_mode: true,
@@ -409,6 +412,7 @@ fn compact_command(invocation: CommandInvocation<'_>) -> CommandExecution {
let _ = invocation.environment; let _ = invocation.environment;
let _ = invocation.args.raw(); let _ = invocation.args.raw();
CommandExecution { CommandExecution {
open_rewind_picker: false,
method: Some(Method::Compact { method: Some(Method::Compact {
command: protocol::WorkerCommandEnvelope::new(0), command: protocol::WorkerCommandEnvelope::new(0),
}), }),
@@ -423,7 +427,8 @@ fn rewind_command(invocation: CommandInvocation<'_>) -> CommandExecution {
let _ = invocation.environment; let _ = invocation.environment;
let _ = invocation.args.raw(); let _ = invocation.args.raw();
CommandExecution { CommandExecution {
method: Some(Method::ListRewindTargets), open_rewind_picker: true,
method: None,
diagnostics: vec![CommandDiagnostic::new("rewind picker requested")], diagnostics: vec![CommandDiagnostic::new("rewind picker requested")],
exit_command_mode: true, exit_command_mode: true,
clear_input: true, clear_input: true,
@@ -435,6 +440,7 @@ fn peer_command(invocation: CommandInvocation<'_>) -> CommandExecution {
let _ = invocation.environment; let _ = invocation.environment;
let name = invocation.args.argv()[0].clone(); let name = invocation.args.argv()[0].clone();
CommandExecution { CommandExecution {
open_rewind_picker: false,
method: Some(Method::RegisterPeer { name: name.clone() }), method: Some(Method::RegisterPeer { name: name.clone() }),
diagnostics: vec![CommandDiagnostic::new(format!( diagnostics: vec![CommandDiagnostic::new(format!(
"peer metadata registration requested with `{name}`" "peer metadata registration requested with `{name}`"
@@ -521,7 +527,8 @@ mod tests {
let registry = CommandRegistry::builtins(); let registry = CommandRegistry::builtins();
for command in ["rewind", "rollback"] { for command in ["rewind", "rollback"] {
let result = registry.dispatch(command, &env()); 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.exit_command_mode);
assert!(result.clear_input); assert!(result.clear_input);
assert!(result.diagnostics[0].message.contains("rewind picker")); assert!(result.diagnostics[0].message.contains("rewind picker"));
@@ -549,7 +556,8 @@ mod tests {
let mut paused = env(); let mut paused = env();
paused.paused = true; paused.paused = true;
let result = registry.dispatch("rewind", &paused); let result = registry.dispatch("rewind", &paused);
assert!(matches!(result.method, Some(Method::ListRewindTargets))); assert!(result.method.is_none());
assert!(result.open_rewind_picker);
} }
#[test] #[test]
+31 -69
View File
@@ -18,7 +18,7 @@ use crossterm::terminal::{EnterAlternateScreen, LeaveAlternateScreen};
use crossterm::{Command, execute}; use crossterm::{Command, execute};
use protocol::{Event, Method, Segment, UploadedFileRef, WorkerStatus}; use protocol::{Event, Method, Segment, UploadedFileRef, WorkerStatus};
#[cfg(feature = "e2e-test")] #[cfg(feature = "e2e-test")]
use protocol::{Greeting, RewindSummary, RewindTarget, RewindTargetId}; use protocol::{Greeting, RewindSummary};
use ratatui::Terminal; use ratatui::Terminal;
use ratatui::backend::CrosstermBackend; use ratatui::backend::CrosstermBackend;
use standalone::{StandaloneHost, StandaloneLaunchConfig}; use standalone::{StandaloneHost, StandaloneLaunchConfig};
@@ -758,10 +758,10 @@ async fn run_e2e_rewind_fixture(
}); });
let (_reader, mut term_rx) = TerminalEventReader::spawn()?; let (_reader, mut term_rx) = TerminalEventReader::spawn()?;
let target_id = RewindTargetId { app.handle_worker_event(Event::UserMessage {
segment_id: uuid::Uuid::from_u128(1), entry_id: Some("rewind-fixture-input".into()),
user_input_entry_index: 1, segments: vec![Segment::text("candidate rewind target")],
}; });
let mut rewind_submit_count = 0usize; let mut rewind_submit_count = 0usize;
let mut pending_apply: Option<std::time::Instant> = None; let mut pending_apply: Option<std::time::Instant> = None;
let apply_delay = Duration::from_millis(400); let apply_delay = Duration::from_millis(400);
@@ -803,45 +803,25 @@ async fn run_e2e_rewind_fixture(
.as_ref() .as_ref()
.map(|picker| picker.applying) .map(|picker| picker.applying)
.unwrap_or(false); .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 { match method {
Method::ListRewindTargets => { Method::RewindTo { entry_id } => {
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,
} => {
rewind_submit_count += 1; rewind_submit_count += 1;
pending_apply = Some(std::time::Instant::now()); pending_apply = Some(std::time::Instant::now());
crate::e2e_observer::emit( crate::e2e_observer::emit(
"single_worker", "single_worker",
"rewind_submit_sent", "rewind_submit_sent",
serde_json::json!({ serde_json::json!({
"segment_id": target.segment_id.to_string(), "entry_id": entry_id,
"user_input_entry_index": target.user_input_entry_index,
"expected_head_entries": expected_head_entries,
"submit_count": rewind_submit_count, "submit_count": rewind_submit_count,
}), }),
); );
@@ -1725,10 +1705,7 @@ mod tests {
use super::*; use super::*;
use crate::text_selection::{HistoryViewport, SelectionRow}; use crate::text_selection::{HistoryViewport, SelectionRow};
use async_trait::async_trait; use async_trait::async_trait;
use protocol::{ use protocol::{Event, Segment, UploadedFileAvailability, UploadedFileRef, WorkerStatus};
Event, RewindTarget, RewindTargetId, Segment, UploadedFileAvailability, UploadedFileRef,
WorkerStatus,
};
#[test] #[test]
fn standalone_console_starts_with_in_process_connection_ready() { fn standalone_console_starts_with_in_process_connection_ready() {
@@ -3119,14 +3096,17 @@ mod tests {
&mut app, &mut app,
KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL), 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); app.set_worker_status(WorkerStatus::Paused);
let paused = handle_key( let paused = handle_key(
&mut app, &mut app,
KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL), KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL),
); );
assert!(matches!(paused, Some(Method::ListRewindTargets))); assert!(paused.is_none());
assert!(app.rewind_picker.is_some());
} }
#[test] #[test]
@@ -3148,21 +3128,13 @@ mod tests {
fn rewind_picker_close_returns_to_history_view() { fn rewind_picker_close_returns_to_history_view() {
let mut app = App::new("agent".to_string()); let mut app = App::new("agent".to_string());
app.connected = true; app.connected = true;
app.handle_worker_event(Event::RewindTargets {
head_entries: 1,
targets: vec![],
});
assert!(app.rewind_picker.is_none()); assert!(app.rewind_picker.is_none());
let method = handle_key( let method = handle_key(
&mut app, &mut app,
KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL), KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL),
); );
assert!(matches!(method, Some(Method::ListRewindTargets))); assert!(method.is_none());
app.handle_worker_event(Event::RewindTargets {
head_entries: 1,
targets: vec![],
});
assert!(app.rewind_picker.is_some()); assert!(app.rewind_picker.is_some());
let method = handle_key(&mut app, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); let method = handle_key(&mut app, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
@@ -3244,13 +3216,13 @@ mod tests {
#[test] #[test]
fn rewind_apply_rejects_non_empty_composer_and_paused_status() { fn rewind_apply_rejects_non_empty_composer_and_paused_status() {
let mut app = App::new("agent".to_string()); 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"); type_keys(&mut app, "draft");
assert!(app.submit_rewind_picker().is_none()); assert!(app.submit_rewind_picker().is_none());
assert!(has_alert(&app, "composer is not empty")); assert!(has_alert(&app, "composer is not empty"));
let mut app = App::new("agent".to_string()); 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); app.set_worker_status(WorkerStatus::Paused);
assert!(app.submit_rewind_picker().is_none()); assert!(app.submit_rewind_picker().is_none());
assert!(has_alert( assert!(has_alert(
@@ -3265,7 +3237,7 @@ mod tests {
app.scroll.top_offset = 3; app.scroll.top_offset = 3;
app.scroll.turn_starts = vec![0, 5, 9]; app.scroll.turn_starts = vec![0, 5, 9];
app.scroll.total_lines = 42; 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_top_offset = app.scroll.top_offset;
let original_turn_starts = app.scroll.turn_starts.clone(); let original_turn_starts = app.scroll.turn_starts.clone();
let original_total_lines = app.scroll.total_lines; let original_total_lines = app.scroll.total_lines;
@@ -3282,20 +3254,10 @@ mod tests {
assert_eq!(app.scroll.total_lines, original_total_lines); assert_eq!(app.scroll.total_lines, original_total_lines);
} }
fn rewind_target() -> RewindTarget { fn rewind_target() -> crate::app::RewindEntry {
RewindTarget { crate::app::RewindEntry {
id: RewindTargetId { entry_id: "user-1".into(),
segment_id: uuid::Uuid::nil(), preview: "test input".into(),
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,
} }
} }
+2 -27
View File
@@ -728,7 +728,7 @@ fn draw_rewind_picker(
.fg(Color::Yellow) .fg(Color::Yellow)
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
), ),
Span::raw(format!(" head={} ", picker.head_entries)), Span::raw(" "),
]; ];
header.extend(action_spans); header.extend(action_spans);
logical.push(Line::from(header)); logical.push(Line::from(header));
@@ -751,38 +751,13 @@ fn draw_rewind_picker(
Style::default() Style::default()
.bg(Color::DarkGray) .bg(Color::DarkGray)
.add_modifier(Modifier::BOLD) .add_modifier(Modifier::BOLD)
} else if target.eligible {
Style::default()
} else { } 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![ logical.push(Line::from(vec![
Span::styled(marker.to_owned(), base_style), 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), 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),
)));
}
} }
} }
+1 -2
View File
@@ -12,13 +12,12 @@ pub enum WorkerInputKind {
UserIfIdle, UserIfIdle,
Notify, Notify,
Compact, Compact,
ListRewindTargets,
RegisterPeer, RegisterPeer,
} }
impl WorkerInputKind { impl WorkerInputKind {
pub fn is_empty_content_allowed(&self) -> bool { pub fn is_empty_content_allowed(&self) -> bool {
matches!(self, Self::Compact | Self::ListRewindTargets) matches!(self, Self::Compact)
} }
} }
+10 -10
View File
@@ -6752,16 +6752,16 @@ fn input_protocol_event(input: &WorkerInput) -> Option<protocol::Event> {
// Submit is projected only after the Worker commits UserInput. Queued // Submit is projected only after the Worker commits UserInput. Queued
// payloads must never become model- or client-visible history early. // payloads must never become model- or client-visible history early.
WorkerInputKind::User | WorkerInputKind::UserIfIdle | WorkerInputKind::Notify => None, WorkerInputKind::User | WorkerInputKind::UserIfIdle | WorkerInputKind::Notify => None,
WorkerInputKind::Compact WorkerInputKind::Compact | WorkerInputKind::RegisterPeer => {
| WorkerInputKind::ListRewindTargets Some(protocol::Event::SystemItem {
| WorkerInputKind::RegisterPeer => Some(protocol::Event::SystemItem { entry_id: None,
entry_id: None, item: serde_json::json!({
item: serde_json::json!({ "kind": "embedded_worker_command_input",
"kind": "embedded_worker_command_input", "command": input.kind,
"command": input.kind, "content": input.content.clone(),
"content": input.content.clone(), }),
}), })
}), }
} }
} }
+1 -2
View File
@@ -4255,7 +4255,6 @@ where
unreachable!("Notify input is dispatched before ordinary input mapping") unreachable!("Notify input is dispatched before ordinary input mapping")
} }
WorkerInputKind::Compact => unreachable!("compact input is dispatched above"), WorkerInputKind::Compact => unreachable!("compact input is dispatched above"),
WorkerInputKind::ListRewindTargets => (Method::ListRewindTargets, None),
WorkerInputKind::RegisterPeer => ( WorkerInputKind::RegisterPeer => (
Method::RegisterPeer { Method::RegisterPeer {
name: input.content.trim().to_string(), name: input.content.trim().to_string(),
@@ -7884,7 +7883,7 @@ mod tests {
)); ));
let old_send = backend.run_on_adapter_runtime(async move { let old_send = backend.run_on_adapter_runtime(async move {
old_handle old_handle
.send(Method::ListRewindTargets) .send(Method::ListWorkers)
.await .await
.map_err(|error| error.to_string()) .map_err(|error| error.to_string())
}); });
+11 -55
View File
@@ -32,8 +32,8 @@ use protocol::{
AlertLevel, AlertSource, CommandEvent as ProtocolCommandEvent, AlertLevel, AlertSource, CommandEvent as ProtocolCommandEvent,
CommandSnapshot as ProtocolCommandSnapshot, CommandStatus as ProtocolCommandStatus, CommandSnapshot as ProtocolCommandSnapshot, CommandStatus as ProtocolCommandStatus,
CommandStream as ProtocolCommandStream, CommandStreamSlice as ProtocolCommandStreamSlice, CommandStream as ProtocolCommandStream, CommandStreamSlice as ProtocolCommandStreamSlice,
ErrorCode, Event, Method, RewindTargetId, RunResult, TurnResult, UploadedFileRef, ErrorCode, Event, Method, RunResult, TurnResult, UploadedFileRef, WorkerBusyState,
WorkerBusyState, WorkerCommandAcknowledgement, WorkerCommandDisposition, WorkerCommandEnvelope, WorkerCommandAcknowledgement, WorkerCommandDisposition, WorkerCommandEnvelope,
WorkerCommandKind, WorkerMaintenanceState, WorkerRunState, WorkerState, WorkerStatus, WorkerCommandKind, WorkerMaintenanceState, WorkerRunState, WorkerState, WorkerStatus,
}; };
use workdir::{ use workdir::{
@@ -2971,32 +2971,9 @@ async fn controller_loop<C, St>(
} }
} }
Method::ListRewindTargets => match shared_state.catalog_status() { Method::RewindTo { entry_id } => 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() {
WorkerStatus::Idle => { WorkerStatus::Idle => {
if apply_rewind( if apply_rewind(&mut worker, &working_event_tx, entry_id).await {
&mut worker,
&working_event_tx,
target,
expected_head_entries,
)
.await
{
worker.clear_in_flight_events(); worker.clear_in_flight_events();
publish_context_usage( publish_context_usage(
&shared_state, &shared_state,
@@ -3018,8 +2995,9 @@ async fn controller_loop<C, St>(
WorkerStatus::Running | WorkerStatus::Stopped => { WorkerStatus::Running | WorkerStatus::Stopped => {
let _ = working_event_tx.send(Event::Error { let _ = working_event_tx.send(Event::Error {
code: ErrorCode::AlreadyRunning, code: ErrorCode::AlreadyRunning,
message: "Worker is already executing a turn; rewind can only run while idle or paused" message:
.into(), "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 { let _ = working_event_tx.send(Event::Error {
code: ErrorCode::AlreadyRunning, 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(), .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>( async fn apply_rewind<C, St>(
worker: &mut Worker<C, St>, worker: &mut Worker<C, St>,
working_event_tx: &broadcast::Sender<Event>, working_event_tx: &broadcast::Sender<Event>,
target: RewindTargetId, entry_id: String,
expected_head_entries: usize,
) -> bool ) -> bool
where where
C: LlmClient + 'static, C: LlmClient + 'static,
St: Store, St: Store,
{ {
match worker.rewind_to(target, expected_head_entries).await { match worker.rewind_to(entry_id).await {
Ok(applied) => { Ok(applied) => {
let session = let session =
session_store::public_snapshot::project_current_session_snapshot(&applied.entries); session_store::public_snapshot::project_current_session_snapshot(&applied.entries);
+179 -140
View File
@@ -531,8 +531,8 @@ use crate::skill::{SkillActivationResponse, SkillClientError};
use async_trait::async_trait; use async_trait::async_trait;
use protocol::{ use protocol::{
AlertLevel, AlertSource, CompactionLifecycle, CompactionLifecycleState, CompactionPhase, AlertLevel, AlertSource, CompactionLifecycle, CompactionLifecycleState, CompactionPhase,
CompactionTrigger, ErrorCode, Event, InFlightCompaction, RewindSummary, RewindTarget, CompactionTrigger, ErrorCode, Event, InFlightCompaction, RewindSummary, RunFailureKind,
RewindTargetId, RunFailureKind, RunResumeSource, RunYieldReason, Segment, RunResumeSource, RunYieldReason, Segment,
}; };
use tokio::net::UnixStream; use tokio::net::UnixStream;
use tokio::sync::broadcast; use tokio::sync::broadcast;
@@ -3714,22 +3714,15 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
&self.store &self.store
} }
/// List user-submitted turns in newest-first order for the manual rewind picker. /// Rewind the current segment to just before the specified public user input.
pub fn list_rewind_targets(&self) -> Result<(usize, Vec<RewindTarget>), RewindError> { 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 loc = self.segment_state.location();
let entries = self.store.read_all(loc.session_id, loc.segment_id)?; resolve_rewind_input(
Ok(( &self.store.read_all(loc.session_id, loc.segment_id)?,
entries.len(), &entry_id,
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> {
let _rewrite_guard = self let _rewrite_guard = self
.prepare_session_rewrite(SessionRewriteKind::Rewind) .prepare_session_rewrite(SessionRewriteKind::Rewind)
.await .await
@@ -3742,29 +3735,9 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
.lock() .lock()
.expect("segment append lock poisoned"); .expect("segment append lock poisoned");
let loc = self.segment_state.location(); 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)?; let entries = self.store.read_all(loc.session_id, loc.segment_id)?;
if entries.len() != expected_head_entries { let (entry_index, input) = resolve_rewind_input(&entries, &entry_id)?;
return Err(RewindError::Invalid(format!( let truncate_entries = rewind_truncate_entries(&entries, entry_index);
"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 retained = entries[..truncate_entries].to_vec(); let retained = entries[..truncate_entries].to_vec();
let tool_side_effect_warning = suffix_has_tool_side_effects(&entries[truncate_entries..]); let tool_side_effect_warning = suffix_has_tool_side_effects(&entries[truncate_entries..]);
let state = segment_log::collect_state(&retained); let state = segment_log::collect_state(&retained);
@@ -8994,37 +8967,21 @@ pub struct RewindAppliedState {
pub summary: RewindSummary, pub summary: RewindSummary,
} }
fn build_rewind_targets(segment_id: uuid::Uuid, entries: &[LogEntry]) -> Vec<RewindTarget> { fn resolve_rewind_input(
let head_entries = entries.len(); entries: &[LogEntry],
let mut turn_index = 0usize; entry_id: &str,
let mut targets = Vec::new(); ) -> Result<(usize, Vec<Segment>), RewindError> {
for (entry_index, entry) in entries.iter().enumerate() { // Resolve only actual user submissions in this segment, never seeded
let (segments, ts) = match entry { // ancestor history or a display position inferred from matching text.
LogEntry::AnnotatedUserInput { segments, ts, .. } => (segments, ts), entries.iter().enumerate().find_map(|(index, entry)| {
_ => continue, let LogEntry::AnnotatedUserInput { history, segments, .. } = entry else { return None; };
}; let user = history.iter().find(|entry| matches!(&entry.item,
turn_index += 1; session_store::LoggedItem::Message { role: session_store::LoggedRole::User, .. }
let truncate_entries = rewind_truncate_entries(entries, entry_index); ))?;
let tool_warning = suffix_has_tool_side_effects(&entries[truncate_entries..]); (user.metadata.entry_id.0 == entry_id).then(|| (index, segments.clone()))
targets.push(RewindTarget { }).ok_or_else(|| RewindError::Invalid(
id: RewindTargetId { "rewind entry is not a user input in the current segment; it may have been removed or belong to another history".into()
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 rewind_truncate_entries(entries: &[LogEntry], user_input_entry_index: usize) -> usize { 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 { .. }) matches!(item, session_store::LoggedItem::ToolCall { .. })
} }
#[cfg(test)]
fn preview_segments(segments: &[Segment]) -> String { fn preview_segments(segments: &[Segment]) -> String {
let mut preview = String::new(); let mut preview = String::new();
for segment in segments { 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] #[tokio::test]
async fn rewind_target_listing_is_newest_first_and_warns_on_tool_suffix() { async fn rewind_resolves_duplicate_text_by_public_entry_identity() {
let (_dir, worker) = rewind_test_worker().await; let (_dir, mut worker) = rewind_test_worker().await;
append_user_turn(&worker, 10, "first message"); append_user_turn(&worker, 10, "same input");
append_user_turn(&worker, 20, "second message"); 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( append_test_entry(
&worker, &worker,
LogEntry::AnnotatedToolResult { LogEntry::AnnotatedAssistantItem {
ts: 30, ts: 12,
entry: crate::session_history::test_logged_history_entry( entry: assistant,
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,
},
),
}, },
); );
let (head_entries, targets) = worker.list_rewind_targets().unwrap();
let loc = worker.segment_state.location(); 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!( #[tokio::test]
head_entries, async fn rewind_rejects_user_input_from_seeded_ancestor_history() {
worker let (_dir, mut worker) = rewind_test_worker().await;
.store let input = crate::session_history::test_logged_history_entry(
.read_all(loc.session_id, loc.segment_id) session_store::LoggedItem::from(agen::Item::user_message("ancestor input")),
.unwrap()
.len()
); );
assert_eq!(targets.len(), 2); let id = input.metadata.entry_id.0.clone();
assert_eq!(targets[0].preview, "second message"); let loc = worker.segment_state.location();
assert_eq!(targets[1].preview, "first message"); 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!( assert!(
targets[0] worker
.warning .rewind_to(id)
.as_ref() .await
.unwrap() .unwrap_err()
.contains("tool side effects") .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 targets = rewind_test_inputs(&worker);
let expected_truncate_entries = targets[0].truncate_entries; let expected_truncate_entries = targets[0].1;
let target = targets[0].id.clone(); 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!(preview_segments(&applied.input), "second message");
assert_eq!( assert_eq!(
@@ -12405,10 +12454,10 @@ mod build_summary_prompt_tests {
.pending_submission_handle() .pending_submission_handle()
.accept_notification("notification-1".into(), "keep me".into()) .accept_notification("notification-1".into(), "keep me".into())
.unwrap(); .unwrap();
let (head_entries, targets) = worker.list_rewind_targets().unwrap(); let targets = rewind_test_inputs(&worker);
worker worker
.rewind_to(targets.last().unwrap().id.clone(), head_entries) .rewind_to(targets.last().unwrap().0.clone())
.await .await
.unwrap(); .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.len(), 2);
assert_eq!(targets[0].preview, "second message"); assert!(targets[0].1 > 0);
assert!(targets[0].truncate_entries > 0);
assert!(targets[0].warning.is_some());
let applied = worker let applied = worker.rewind_to(targets[0].0.clone()).await.unwrap();
.rewind_to(targets[0].id.clone(), head_entries)
.await
.unwrap();
assert_eq!(applied.summary.truncated_to_entries, 5); assert_eq!(applied.summary.truncated_to_entries, 5);
assert!(matches!( assert!(matches!(
applied.entries.first(), applied.entries.first(),
@@ -12539,19 +12583,20 @@ mod build_summary_prompt_tests {
} }
#[tokio::test] #[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; let (_dir, mut worker) = rewind_test_worker().await;
append_user_turn(&worker, 10, "first message"); 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"); append_user_turn(&worker, 20, "newer message");
let err = worker let applied = worker.rewind_to(targets[0].0.clone()).await.unwrap();
.rewind_to(targets[0].id.clone(), head_entries) assert_eq!(preview_segments(&applied.input), "first message");
.await assert!(rewind_test_inputs(&worker).is_empty());
.unwrap_err() let err = worker.rewind_to(targets[0].0.clone()).await.unwrap_err();
.to_string(); assert!(
err.to_string()
assert!(err.contains("session head changed")); .contains("not a user input in the current segment")
);
} }
#[tokio::test] #[tokio::test]
@@ -15421,7 +15466,7 @@ permission = "write"
assert!(pending.pending_notifications.is_empty()); assert!(pending.pending_notifications.is_empty());
assert!(pending.activating_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 target = targets.last().unwrap();
let old = store let old = store
.read_all(original.session_id(), original.segment_id()) .read_all(original.session_id(), original.segment_id())
@@ -15431,7 +15476,7 @@ permission = "write"
store store
.invocation_failure .invocation_failure
.store(replacement_fault, Ordering::SeqCst); .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 let disk = store
.read_all(original.session_id(), original.segment_id()) .read_all(original.session_id(), original.segment_id())
.unwrap(); .unwrap();
@@ -15456,15 +15501,9 @@ permission = "write"
replacement_fault replacement_fault
); );
let applied = applied.unwrap(); let applied = applied.unwrap();
assert_eq!( assert_eq!(applied.summary.truncated_to_entries, target.1);
applied.summary.truncated_to_entries, assert_eq!(applied.summary.discarded_entries, old.len() - target.1);
target.truncate_entries assert_eq!(applied.entries.len(), target.1);
);
assert_eq!(
applied.summary.discarded_entries,
head - target.truncate_entries
);
assert_eq!(applied.entries.len(), target.truncate_entries);
assert_eq!(original.segment_state.entries_written(), disk.len()); assert_eq!(original.segment_state.entries_written(), disk.len());
assert_eq!( assert_eq!(
serde_json::to_value(original.sink.subscribe_with_snapshot().0) serde_json::to_value(original.sink.subscribe_with_snapshot().0)
@@ -15474,10 +15513,10 @@ permission = "write"
} else { } else {
assert!(applied.is_err()); assert!(applied.is_err());
} }
assert_eq!(disk.len(), target.truncate_entries + 1); assert_eq!(disk.len(), target.1 + 1);
assert_eq!( assert_eq!(
serde_json::to_value(&disk[..target.truncate_entries]).unwrap(), serde_json::to_value(&disk[..target.1]).unwrap(),
serde_json::to_value(&old[..target.truncate_entries]).unwrap() serde_json::to_value(&old[..target.1]).unwrap()
); );
let LogEntry::Extension { let LogEntry::Extension {
domain, payload, .. domain, payload, ..
+1 -1
View File
@@ -2730,7 +2730,7 @@ async fn shutdown_closes_method_admission_before_terminal_confirmation() {
loop { loop {
tokio::select! { tokio::select! {
biased; biased;
result = handle.send(Method::ListRewindTargets) => { result = handle.send(Method::ListWorkers) => {
if result.is_err() { if result.is_err() {
break; break;
} }
-4
View File
@@ -705,7 +705,6 @@ pub enum WorkerInputKind {
UserIfIdle, UserIfIdle,
Notify, Notify,
Compact, Compact,
ListRewindTargets,
RegisterPeer, RegisterPeer,
} }
@@ -3271,7 +3270,6 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
WorkerInputKind::UserIfIdle => EmbeddedWorkerInputKind::UserIfIdle, WorkerInputKind::UserIfIdle => EmbeddedWorkerInputKind::UserIfIdle,
WorkerInputKind::Notify => EmbeddedWorkerInputKind::Notify, WorkerInputKind::Notify => EmbeddedWorkerInputKind::Notify,
WorkerInputKind::Compact => EmbeddedWorkerInputKind::Compact, WorkerInputKind::Compact => EmbeddedWorkerInputKind::Compact,
WorkerInputKind::ListRewindTargets => EmbeddedWorkerInputKind::ListRewindTargets,
WorkerInputKind::RegisterPeer => EmbeddedWorkerInputKind::RegisterPeer, WorkerInputKind::RegisterPeer => EmbeddedWorkerInputKind::RegisterPeer,
}, },
content: request.content, content: request.content,
@@ -5310,7 +5308,6 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
WorkerInputKind::UserIfIdle => EmbeddedWorkerInputKind::UserIfIdle, WorkerInputKind::UserIfIdle => EmbeddedWorkerInputKind::UserIfIdle,
WorkerInputKind::Notify => EmbeddedWorkerInputKind::Notify, WorkerInputKind::Notify => EmbeddedWorkerInputKind::Notify,
WorkerInputKind::Compact => EmbeddedWorkerInputKind::Compact, WorkerInputKind::Compact => EmbeddedWorkerInputKind::Compact,
WorkerInputKind::ListRewindTargets => EmbeddedWorkerInputKind::ListRewindTargets,
WorkerInputKind::RegisterPeer => EmbeddedWorkerInputKind::RegisterPeer, WorkerInputKind::RegisterPeer => EmbeddedWorkerInputKind::RegisterPeer,
}, },
content: request.content, content: request.content,
@@ -6748,7 +6745,6 @@ mod tests {
EmbeddedWorkerInputKind::UserIfIdle, EmbeddedWorkerInputKind::UserIfIdle,
EmbeddedWorkerInputKind::Notify, EmbeddedWorkerInputKind::Notify,
EmbeddedWorkerInputKind::Compact, EmbeddedWorkerInputKind::Compact,
EmbeddedWorkerInputKind::ListRewindTargets,
EmbeddedWorkerInputKind::RegisterPeer, EmbeddedWorkerInputKind::RegisterPeer,
] { ] {
let input = EmbeddedWorkerInput { let input = EmbeddedWorkerInput {
-2
View File
@@ -6930,7 +6930,6 @@ fn worker_input_request_from_api(
"user" => WorkerInputKind::User, "user" => WorkerInputKind::User,
"notify" => WorkerInputKind::Notify, "notify" => WorkerInputKind::Notify,
"compact" => WorkerInputKind::Compact, "compact" => WorkerInputKind::Compact,
"list_rewind_targets" => WorkerInputKind::ListRewindTargets,
"register_peer" => WorkerInputKind::RegisterPeer, "register_peer" => WorkerInputKind::RegisterPeer,
value => { value => {
return Err(Error::InvalidInput(format!( return Err(Error::InvalidInput(format!(
@@ -29168,7 +29167,6 @@ pub(crate) fn authorize_browser_worker_method(
| protocol::Method::Cancel { .. } | protocol::Method::Cancel { .. }
| protocol::Method::Pause { .. } | protocol::Method::Pause { .. }
| protocol::Method::Compact { .. } | protocol::Method::Compact { .. }
| protocol::Method::ListRewindTargets
| protocol::Method::RewindTo { .. } | protocol::Method::RewindTo { .. }
| protocol::Method::Shutdown { .. } | protocol::Method::Shutdown { .. }
| protocol::Method::ListCompletions { .. } | protocol::Method::ListCompletions { .. }
@@ -19,6 +19,7 @@ type State = {
params?: { params?: {
input?: import("../../../web/workspace/src/lib/generated/protocol.ts").Segment[]; input?: import("../../../web/workspace/src/lib/generated/protocol.ts").Segment[];
submission_request_id?: string; submission_request_id?: string;
entry_id?: string;
}; };
}[]; }[];
events: { event: string }[]; events: { event: string }[];
@@ -502,3 +503,50 @@ Deno.test("production selected invocation preserves multiple positional typed ar
JSON.stringify({ status: "pass", fixture: await state(base), errors }, null, 2), JSON.stringify({ status: "pass", fixture: await state(base), errors }, null, 2),
); );
})); }));
Deno.test("production rewind selects transcript rows locally and sends only entry_id on Enter", () =>
fixture(async (page, base, errors, caseOutput) => {
await control(base, { seed_rewind_history: true });
await page.reload();
await page.getByText("Ready. Choose a Feature", { exact: false }).waitFor();
await draft(page, ":rewind");
await page.keyboard.press("Enter");
const selected = page.locator(".rewind-selected");
await selected.waitFor();
assert((await selected.innerText()).includes("Review the release notes"));
assertEquals((await state(base)).methods, [], "selection must not request a candidate list");
assertEquals(await page.locator(".rewind-targets").count(), 0);
await page.keyboard.press("ArrowUp");
assert((await selected.innerText()).includes("Earlier rewind input"));
await page.keyboard.press("ArrowDown");
assert((await selected.innerText()).includes("Review the release notes"));
assert(await page.locator(".cm-content").evaluate((node) => node === document.activeElement));
await page.screenshot({ path: join(caseOutput, "rewind-selection.png") });
await page.keyboard.press("Escape");
assertEquals(await selected.count(), 0);
assertEquals((await state(base)).methods, []);
await draft(page, ":rewind");
await page.keyboard.press("Enter");
await selected.waitFor();
await control(base, { rewind: "reject" });
await page.keyboard.press("Enter");
await page.getByText("Synthetic rewind rejection", { exact: true }).first().waitFor();
await selected.waitFor();
await control(base, { rewind: "hold" });
await page.keyboard.press("Enter");
await page.keyboard.press("Enter");
const sent = await eventually(base, (value) => value.methods.filter((method) => method.method === "rewind_to").length === 2);
assertEquals(sent.methods, [
{ method: "rewind_to", params: { entry_id: "user-1" } },
{ method: "rewind_to", params: { entry_id: "user-1" } },
]);
await control(base, { apply_rewind: true });
await page.waitForFunction(() => document.querySelector(".cm-content")?.textContent === "Review the release notes before sending.");
assertEquals(await selected.count(), 0);
assertEquals(await page.locator(".cm-content").getAttribute("contenteditable"), "true");
await page.keyboard.type(" edited");
assertEquals(await page.locator(".cm-content").textContent(), "Review the release notes before sending. edited");
assertEquals(errors, []);
await page.screenshot({ path: join(caseOutput, "rewind-applied.png") });
}, true));
@@ -144,6 +144,8 @@ const events: unknown[] = [];
let submitMode: "accept" | "reject" | "hold" = "accept"; let submitMode: "accept" | "reject" | "hold" = "accept";
let uploadMode: "normal" | "fail" | "hold" = "normal"; let uploadMode: "normal" | "fail" | "hold" = "normal";
const pendingAcceptances: (() => void)[] = []; const pendingAcceptances: (() => void)[] = [];
let rewindMode: "accept" | "reject" | "hold" = "accept";
const pendingRewinds: (() => void)[] = [];
const cancelledUploads = new Set<string>(); const cancelledUploads = new Set<string>();
const heldUploads = new Map<string, (response: Response) => void>(); const heldUploads = new Map<string, (response: Response) => void>();
const releaseHeldUploads = new Map<string, () => void>(); const releaseHeldUploads = new Map<string, () => void>();
@@ -197,6 +199,12 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
const control = await request.json(); const control = await request.json();
if (["accept", "reject", "hold"].includes(control.submit)) submitMode = control.submit; if (["accept", "reject", "hold"].includes(control.submit)) submitMode = control.submit;
if (["normal", "fail", "hold"].includes(control.upload)) uploadMode = control.upload; if (["normal", "fail", "hold"].includes(control.upload)) uploadMode = control.upload;
if (["accept", "reject", "hold"].includes(control.rewind)) rewindMode = control.rewind;
if (control.seed_rewind_history) {
entries.unshift({ ...entries[0], entry_id: "user-older", content: [{ kind: "text", text: "Earlier rewind input" }] },
{ ...entries[1], entry_id: "assistant-older", content: [{ kind: "text", text: "Earlier reply" }] });
}
if (control.apply_rewind) { for (const apply of pendingRewinds.splice(0)) apply(); }
if (control.accept_pending) { for (const accept of pendingAcceptances.splice(0)) accept(); } if (control.accept_pending) { for (const accept of pendingAcceptances.splice(0)) accept(); }
if (control.release_uploads) { if (control.release_uploads) {
for (const release of [...releaseHeldUploads.values()]) release(); for (const release of [...releaseHeldUploads.values()]) release();
@@ -365,6 +373,21 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
methods.push(method); methods.push(method);
const subscription_id = frame.message.subscription_id; const subscription_id = frame.message.subscription_id;
if (subscriptions.get(subscription_id) !== "worker_protocol") return; if (subscriptions.get(subscription_id) !== "worker_protocol") return;
const emit = (event: unknown) => {
events.push(event);
if (socket.readyState !== WebSocket.OPEN) return;
send({
protocol_version: 2,
frame: "event",
message: {
event: "event",
data: {
subscription_id,
payload: { event: "worker_protocol", data: { worker_id: workerId, event } },
},
},
});
};
if (method.method === "list_completions") { if (method.method === "list_completions") {
const { kind, prefix, request_id, context } = method.params; const { kind, prefix, request_id, context } = method.params;
let candidates: unknown[] = []; let candidates: unknown[] = [];
@@ -424,24 +447,24 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
}, },
}, },
}); });
} else if (method.method === "rewind_to") {
const index = entries.findIndex((entry) => entry.entry_id === method.params.entry_id && entry.role === "user");
if (rewindMode === "reject" || index < 0) {
emit({ event: "error", data: { code: "invalid_request", message: "Synthetic rewind rejection" } });
} else {
const apply = () => {
const input = entries[index].content.map((part) => ({ kind: "text", content: part.text }));
const discarded_entries = entries.length - index;
entries.splice(index);
emit({ event: "rewind_applied", data: { session, input, summary: {
truncated_to_entries: index, discarded_entries, tool_side_effect_warning: true,
} } });
};
if (rewindMode === "hold") pendingRewinds.push(apply); else apply();
}
} else if (method.method === "submit") { } else if (method.method === "submit") {
const emit = (event: unknown) => {
events.push(event);
if (socket.readyState !== WebSocket.OPEN) return;
send({
protocol_version: 2,
frame: "event",
message: {
event: "event",
data: {
subscription_id,
payload: { event: "worker_protocol", data: { worker_id: workerId, event } },
},
},
});
};
const submission_request_id = method.params.submission_request_id; const submission_request_id = method.params.submission_request_id;
const accept = () => const accept = () => {
emit({ emit({
event: "submission_accepted", event: "submission_accepted",
data: { data: {
@@ -450,6 +473,20 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
disposition: "started", disposition: "started",
}, },
}); });
// A held admission models an in-flight run; immediate fixture runs
// finish synchronously. Publish authoritative state, not only an ack.
emit({
event: "worker_state",
data: {
snapshot: {
last_command_id: 0,
state: submitMode === "hold"
? { kind: "busy", state: { kind: "run", state: "running" } }
: { kind: "idle" },
},
},
});
};
if (submitMode === "reject") { if (submitMode === "reject") {
emit({ emit({
event: "submission_rejected", event: "submission_rejected",
File diff suppressed because one or more lines are too long
+2 -6
View File
@@ -155,10 +155,6 @@ symlink_policy: SymlinkPolicy, };
export type CompletionEntry = { value: string, is_dir: boolean, description?: string | null, usage?: string | null, invocation?: FeatureInvocationDescriptor | null, }; export type CompletionEntry = { value: string, is_dir: boolean, description?: string | null, usage?: string | null, invocation?: FeatureInvocationDescriptor | null, };
export type RewindTargetId = { segment_id: string, user_input_entry_index: number, };
export type RewindTarget = { id: RewindTargetId, expected_head_entries: number, truncate_entries: number, turn_index: number, timestamp_ms: number | null, preview: string, eligible: boolean, disabled_reason: string | null, warning: string | null, };
export type RewindSummary = { truncated_to_entries: number, discarded_entries: number, tool_side_effect_warning: boolean, }; export type RewindSummary = { truncated_to_entries: number, discarded_entries: number, tool_side_effect_warning: boolean, };
export type InFlightBlock = { "kind": "text", text: string, finished?: boolean, } | { "kind": "thinking", text: string, finished?: boolean, } | { "kind": "tool_call", id: string, name: string, args: string, state?: InFlightToolCallState, }; export type InFlightBlock = { "kind": "text", text: string, finished?: boolean, } | { "kind": "thinking", text: string, finished?: boolean, } | { "kind": "tool_call", id: string, name: string, args: string, state?: InFlightToolCallState, };
@@ -416,7 +412,7 @@ export type SubscriptionFramePayload = { "frame": "request", "message": Subscrip
export type SubscriptionFrame = { protocol_version: number, } & ({ "frame": "request", "message": SubscriptionRequest } | { "frame": "response", "message": SubscriptionResponse } | { "frame": "event", "message": SubscriptionEvent } | { "frame": "worker_protocol", "message": SubscriptionWorkerProtocolMethod }); export type SubscriptionFrame = { protocol_version: number, } & ({ "frame": "request", "message": SubscriptionRequest } | { "frame": "response", "message": SubscriptionResponse } | { "frame": "event", "message": SubscriptionEvent } | { "frame": "worker_protocol", "message": SubscriptionWorkerProtocolMethod });
export type Method = { "method": "submit", "params": { submission_request_id: string, input: Array<Segment>, } } | { "method": "notify", "params": { notification_request_id: string, message: string, } } | { "method": "worker_event", "params": WorkerEvent } | { "method": "list_pending_submissions" } | { "method": "cancel_pending_submission", "params": { submission_id: string, } } | { "method": "clear_pending_submissions", "params": { expected_submission_ids: Array<string>, } } | { "method": "continue_pending", "params": { expected_head_id: string, } } | { "method": "resume", "params": { command: WorkerCommandEnvelope, } } | { "method": "cancel", "params": { command: WorkerCommandEnvelope, } } | { "method": "pause", "params": { command: WorkerCommandEnvelope, } } | { "method": "compact", "params": { command: WorkerCommandEnvelope, } } | { "method": "list_rewind_targets" } | { "method": "rewind_to", "params": { target: RewindTargetId, expected_head_entries: number, } } | { "method": "shutdown", "params": { command: WorkerCommandEnvelope, } } | { "method": "list_completions", "params": { kind: CompletionKind, prefix: string, export type Method = { "method": "submit", "params": { submission_request_id: string, input: Array<Segment>, } } | { "method": "notify", "params": { notification_request_id: string, message: string, } } | { "method": "worker_event", "params": WorkerEvent } | { "method": "list_pending_submissions" } | { "method": "cancel_pending_submission", "params": { submission_id: string, } } | { "method": "clear_pending_submissions", "params": { expected_submission_ids: Array<string>, } } | { "method": "continue_pending", "params": { expected_head_id: string, } } | { "method": "resume", "params": { command: WorkerCommandEnvelope, } } | { "method": "cancel", "params": { command: WorkerCommandEnvelope, } } | { "method": "pause", "params": { command: WorkerCommandEnvelope, } } | { "method": "compact", "params": { command: WorkerCommandEnvelope, } } | { "method": "rewind_to", "params": { entry_id: string, } } | { "method": "shutdown", "params": { command: WorkerCommandEnvelope, } } | { "method": "list_completions", "params": { kind: CompletionKind, prefix: string,
/** /**
* Client query identity. Echoed verbatim so identical-prefix ABA * Client query identity. Echoed verbatim so identical-prefix ABA
* responses cannot cross edits, target switches, or permission changes. * responses cannot cross edits, target switches, or permission changes.
@@ -468,4 +464,4 @@ in_flight?: InFlightSnapshot,
* Parent-owned Internal Worker sessions visible to this client. * Parent-owned Internal Worker sessions visible to this client.
* Service-private Internal Workers are deliberately excluded. * Service-private Internal Workers are deliberately excluded.
*/ */
internal_workers?: Array<InternalWorkerSnapshot>, } } | { "event": "internal_worker", "data": { worker: InternalWorkerRef, event: Event, } } | { "event": "internal_worker_removed", "data": { worker: InternalWorkerRef, } } | { "event": "segment_rotated", "data": { session: SessionSnapshot, } } | { "event": "worker_state", "data": { snapshot: WorkerStateSnapshot, } } | { "event": "command_acknowledged", "data": { acknowledgement: WorkerCommandAcknowledgement, } } | { "event": "command", "data": { event: CommandEvent, } } | { "event": "completions", "data": { kind: CompletionKind, prefix: string, request_id?: string | null, context?: CompletionContext | null, entries: Array<CompletionEntry>, } } | { "event": "rewind_targets", "data": { head_entries: number, targets: Array<RewindTarget>, } } | { "event": "rewind_applied", "data": { session: SessionSnapshot, input: Array<Segment>, summary: RewindSummary, } } | { "event": "workers_listed", "data": { workers: unknown, } } | { "event": "worker_restored", "data": { result: unknown, } } | { "event": "peer_registered", "data": { result: unknown, } } | { "event": "alert", "data": Alert } | { "event": "memory_worker", "data": MemoryWorkerEvent } | { "event": "compaction_progress", "data": { compaction: InFlightCompaction | null, } } | { "event": "compact_start", "data": { lifecycle: CompactionLifecycle, } } | { "event": "compact_done", "data": { lifecycle: CompactionLifecycle, } } | { "event": "compact_failed", "data": { lifecycle: CompactionLifecycle, } } | { "event": "shutdown" }; internal_workers?: Array<InternalWorkerSnapshot>, } } | { "event": "internal_worker", "data": { worker: InternalWorkerRef, event: Event, } } | { "event": "internal_worker_removed", "data": { worker: InternalWorkerRef, } } | { "event": "segment_rotated", "data": { session: SessionSnapshot, } } | { "event": "worker_state", "data": { snapshot: WorkerStateSnapshot, } } | { "event": "command_acknowledged", "data": { acknowledgement: WorkerCommandAcknowledgement, } } | { "event": "command", "data": { event: CommandEvent, } } | { "event": "completions", "data": { kind: CompletionKind, prefix: string, request_id?: string | null, context?: CompletionContext | null, entries: Array<CompletionEntry>, } } | { "event": "rewind_applied", "data": { session: SessionSnapshot, input: Array<Segment>, summary: RewindSummary, } } | { "event": "workers_listed", "data": { workers: unknown, } } | { "event": "worker_restored", "data": { result: unknown, } } | { "event": "peer_registered", "data": { result: unknown, } } | { "event": "alert", "data": Alert } | { "event": "memory_worker", "data": MemoryWorkerEvent } | { "event": "compaction_progress", "data": { compaction: InFlightCompaction | null, } } | { "event": "compact_start", "data": { lifecycle: CompactionLifecycle, } } | { "event": "compact_done", "data": { lifecycle: CompactionLifecycle, } } | { "event": "compact_failed", "data": { lifecycle: CompactionLifecycle, } } | { "event": "shutdown" };
@@ -738,7 +738,10 @@
state: EditorState.create({ state: EditorState.create({
extensions: editorExtensions = [ extensions: editorExtensions = [
history(), history(),
Prec.highest(EditorView.domEventHandlers({ keydown: completionKeydown })), Prec.highest(EditorView.domEventHandlers({ keydown(event) {
onkeydown?.(event);
return event.defaultPrevented || completionKeydown(event);
} })),
Prec.highest(keymap.of([ Prec.highest(keymap.of([
{ {
key: "ArrowUp", key: "ArrowUp",
@@ -851,8 +854,6 @@
return true; return true;
}, },
keydown(event) { keydown(event) {
onkeydown?.(event);
if (event.defaultPrevented) return true;
if ( if (
shouldSubmitChatKey(event, { shouldSubmitChatKey(event, {
mode: "mod-enter", mode: "mod-enter",
@@ -7,10 +7,11 @@
type Props = { type Props = {
item: ConsoleLine; item: ConsoleLine;
rewindSelected?: boolean;
attachmentUrl?: (attachment: SessionToolAttachment) => string | null; attachmentUrl?: (attachment: SessionToolAttachment) => string | null;
}; };
let { item, attachmentUrl = () => null }: Props = $props(); let { item, rewindSelected = false, attachmentUrl = () => null }: Props = $props();
let detailOpen = $state(false); let detailOpen = $state(false);
let nowMs = $state(Date.now()); let nowMs = $state(Date.now());
@@ -83,6 +84,8 @@
<li <li
class={`console-line ${lineClass(item)} ${toolClass(item)}`} class={`console-line ${lineClass(item)} ${toolClass(item)}`}
class:error-line={item.error} class:error-line={item.error}
class:rewind-selected={rewindSelected}
aria-current={rewindSelected ? "true" : undefined}
data-console-line-id={item.id} data-console-line-id={item.id}
> >
{#if item.compaction} {#if item.compaction}
@@ -165,6 +168,12 @@
</li> </li>
<style> <style>
.rewind-selected {
outline: 2px solid var(--accent);
outline-offset: -2px;
background: color-mix(in srgb, var(--accent) 12%, transparent);
}
.console-line { .console-line {
min-width: 0; min-width: 0;
padding: var(--space-1) 0; padding: var(--space-1) 0;
@@ -17,7 +17,7 @@ Deno.test("compact and rewind remain explicit Composer commands", () => {
ok: true, request: { kind: "compact", content: "" }, notice: "compact requested", ok: true, request: { kind: "compact", content: "" }, notice: "compact requested",
}); });
assertEquals(buildComposerRequest(":rewind"), { assertEquals(buildComposerRequest(":rewind"), {
ok: true, request: { kind: "list_rewind_targets", content: "" }, notice: "rewind targets requested", ok: true, action: "rewind",
}); });
}); });
@@ -5,7 +5,6 @@ export type WorkerConsoleInputKind =
| "user" | "user"
| "notify" | "notify"
| "compact" | "compact"
| "list_rewind_targets"
| "register_peer"; | "register_peer";
export type WorkerConsoleInputRequest = { export type WorkerConsoleInputRequest = {
@@ -15,7 +14,7 @@ export type WorkerConsoleInputRequest = {
}; };
export type ComposerCommandResult = export type ComposerCommandResult =
| { ok: true; request?: WorkerConsoleInputRequest; notice?: string } | { ok: true; request?: WorkerConsoleInputRequest; action?: "rewind"; notice?: string }
| { ok: false; message: string }; | { ok: false; message: string };
type CommandSpec = { type CommandSpec = {
@@ -43,7 +42,7 @@ export const COMMANDS: Record<string, CommandSpec> = {
}, },
rewind: { rewind: {
usage: ":rewind", usage: ":rewind",
description: "Ask the Worker for rewind targets.", description: "Select a user input in Console history with ↑/↓, then Enter to rewind.",
}, },
rollback: { rollback: {
usage: ":rollback", usage: ":rollback",
@@ -200,8 +199,7 @@ function buildColonCommand(commandLine: string): ComposerCommandResult {
} }
return { return {
ok: true, ok: true,
request: { kind: "list_rewind_targets", content: "" }, action: "rewind",
notice: "rewind targets requested",
}; };
case "peer": case "peer":
if (argv.length !== 1) { if (argv.length !== 1) {
@@ -1325,7 +1325,6 @@ export function applyProtocolEvent(
break; break;
case "memory_worker": case "memory_worker":
case "completions": case "completions":
case "rewind_targets":
case "workers_listed": case "workers_listed":
case "worker_restored": case "worker_restored":
case "peer_registered": case "peer_registered":
@@ -913,7 +913,7 @@ Deno.test("Worker Console removes redundant chrome and uses shared alerts", asyn
!page.includes("margin-inline: calc(-1 * var(--space-6))") && !page.includes("margin-inline: calc(-1 * var(--space-6))") &&
page.includes("import { pushWorkspaceAlert }") && page.includes("import { pushWorkspaceAlert }") &&
page.includes('title: "Worker control"') && page.includes('title: "Worker control"') &&
page.includes('title: "Rewind targets"') && !page.includes('aria-label="Rewind targets"') &&
page.includes('pushWorkspaceAlert("error"') && page.includes('pushWorkspaceAlert("error"') &&
!page.includes("controlNotice") && !page.includes("controlNotice") &&
!page.includes("console-notice") && !page.includes("console-notice") &&
@@ -1271,7 +1271,7 @@ Deno.test("Worker Console route resolves logical Worker authority before Runtime
'const workerRunning = $derived(workerState === "running");', 'const workerRunning = $derived(workerState === "running");',
) && ) &&
consolePage.includes( consolePage.includes(
'const canSendComposerControl = $derived(protocolState === "open" && !sending);', 'const canSendComposerControl = $derived(protocolState === "open" && !sending && !rewindApplying);',
) && ) &&
consolePage.includes('sendWorkerControl("cancel")') && consolePage.includes('sendWorkerControl("cancel")') &&
consolePage.includes("lifecycleMethod(command)") && consolePage.includes("lifecycleMethod(command)") &&
@@ -1280,7 +1280,7 @@ Deno.test("Worker Console route resolves logical Worker authority before Runtime
consolePage.includes("onsubmit={handleComposerSubmit}") && consolePage.includes("onsubmit={handleComposerSubmit}") &&
!consolePage.includes("disabled={!composerEditable}") && !consolePage.includes("disabled={!composerEditable}") &&
consolePage.includes("if (!canSendComposerControl) return;") && consolePage.includes("if (!canSendComposerControl) return;") &&
consolePage.includes("class:stop={workerRunning}") && consolePage.includes("class:stop={workerRunning && !rewindEntryId}") &&
consolePage.includes('"Stop Worker"') && consolePage.includes('"Stop Worker"') &&
consolePage.includes("disabled={composerSubmitDisabled}") && consolePage.includes("disabled={composerSubmitDisabled}") &&
!consolePage.includes("disabled={!inputReady || sending}") && !consolePage.includes("disabled={!inputReady || sending}") &&
@@ -44,7 +44,6 @@
Event as ProtocolEvent, Event as ProtocolEvent,
Method as ProtocolMethod, Method as ProtocolMethod,
PendingSubmissionsSnapshot, PendingSubmissionsSnapshot,
RewindTarget,
Segment, Segment,
SessionHistoryPage, SessionHistoryPage,
SessionToolAttachment, SessionToolAttachment,
@@ -176,8 +175,9 @@
reportComposerError(error instanceof Error ? error.message : String(error)); reportComposerError(error instanceof Error ? error.message : String(error));
} }
} }
let rewindTargets = $state<RewindTarget[]>([]); let rewindEntryId = $state<string | null>(null);
let rewindHeadEntries = $state(0); let rewindApplying = $state(false);
let rewindLoadingEarlier = false;
let protocolState = $state<"connecting" | "open" | "closed" | "error">( let protocolState = $state<"connecting" | "open" | "closed" | "error">(
"connecting", "connecting",
); );
@@ -305,6 +305,7 @@
const turnNavigationItems = $derived( const turnNavigationItems = $derived(
conversationTurnPreviewsFromLines(lines), conversationTurnPreviewsFromLines(lines),
); );
const rewindEntries = $derived(lines.filter((line) => line.kind === "user" && line.entryId));
const tasks = $derived(selectedConsoleProjection.tasks); const tasks = $derived(selectedConsoleProjection.tasks);
const diagnostics = $derived( const diagnostics = $derived(
mergeDiagnostics(worker?.diagnostics ?? [], streamDiagnostics), mergeDiagnostics(worker?.diagnostics ?? [], streamDiagnostics),
@@ -314,7 +315,7 @@
); );
const workerRunning = $derived(workerState === "running"); const workerRunning = $derived(workerState === "running");
const workerPaused = $derived(workerState === "paused"); const workerPaused = $derived(workerState === "paused");
const canSendComposerControl = $derived(protocolState === "open" && !sending); const canSendComposerControl = $derived(protocolState === "open" && !sending && !rewindApplying);
const canUploadAttachments = $derived(consoleTarget !== null); const canUploadAttachments = $derived(consoleTarget !== null);
const draftHasText = $derived(draft.content.trim().length > 0); const draftHasText = $derived(draft.content.trim().length > 0);
const draftHasAttachments = $derived( const draftHasAttachments = $derived(
@@ -325,7 +326,7 @@
delivery: "submit", delivery: "submit",
workerState, workerState,
protocolOpen: protocolState === "open", protocolOpen: protocolState === "open",
sending, sending: sending || rewindApplying,
hasText: draftHasText, hasText: draftHasText,
hasAttachments: draftHasAttachments, hasAttachments: draftHasAttachments,
}), }),
@@ -335,7 +336,7 @@
delivery: "queue", delivery: "queue",
workerState, workerState,
protocolOpen: protocolState === "open", protocolOpen: protocolState === "open",
sending, sending: sending || rewindApplying,
hasText: draftHasText, hasText: draftHasText,
hasAttachments: draftHasAttachments, hasAttachments: draftHasAttachments,
}), }),
@@ -345,14 +346,14 @@
delivery: "notify", delivery: "notify",
workerState, workerState,
protocolOpen: protocolState === "open", protocolOpen: protocolState === "open",
sending, sending: sending || rewindApplying,
hasText: draftHasText, hasText: draftHasText,
hasAttachments: draftHasAttachments, hasAttachments: draftHasAttachments,
}), }),
); );
const canStopFromComposer = $derived(workerRunning && canSendComposerControl); const canStopFromComposer = $derived(workerRunning && canSendComposerControl);
const composerSubmitDisabled = $derived( const composerSubmitDisabled = $derived(
workerRunning ? !canStopFromComposer : !canSubmitDraft, rewindEntryId ? !canSendComposerControl : workerRunning ? !canStopFromComposer : !canSubmitDraft,
); );
async function getJson<T>(path: string): Promise<T> { async function getJson<T>(path: string): Promise<T> {
@@ -773,6 +774,8 @@
) { ) {
handleProtocolCommandEvent(payload); handleProtocolCommandEvent(payload);
if (payload.event === "rewind_applied") { if (payload.event === "rewind_applied") {
rewindApplying = false;
rewindEntryId = null;
if (draft.segments.length === 0 && attachments.length === 0) { if (draft.segments.length === 0 && attachments.length === 0) {
composerInputElement?.restoreSegments(payload.data.input); composerInputElement?.restoreSegments(payload.data.input);
} else { } else {
@@ -824,6 +827,7 @@
} }
function handleComposerKeydown(event: KeyboardEvent) { function handleComposerKeydown(event: KeyboardEvent) {
if (handleRewindKeydown(event)) return;
if (event.key === "PageUp" || event.key === "PageDown") { if (event.key === "PageUp" || event.key === "PageDown") {
event.preventDefault(); event.preventDefault();
scrollConsoleByPage(event.key === "PageDown" ? 1 : -1); scrollConsoleByPage(event.key === "PageDown" ? 1 : -1);
@@ -934,17 +938,88 @@
sendWorkerControl(command); sendWorkerControl(command);
} }
function rewindTo(target: RewindTarget) { async function startRewindSelection(): Promise<void> {
sendControl( if (rewindApplying) return;
{ const targetKey = activeComposerTargetKey;
method: "rewind_to", await selectConsoleWorkerView(null);
params: { if (activeComposerTargetKey !== targetKey) return;
target: target.id, const target = rewindEntries.at(-1);
expected_head_entries: rewindHeadEntries, if (!target?.entryId) {
}, reportComposerError("No user input is loaded. Load earlier conversation to select a rewind point.");
}, return;
`Rewind to ${target.preview || "target"}`, }
); composerInputElement?.clear();
rewindEntryId = target.entryId;
autoFollowConsole = false;
await tick();
if (rewindEntryId !== target.entryId) return;
selectConsoleTurn(target.id);
autoFollowConsole = false;
composerInputElement?.focus();
}
async function moveRewindSelection(direction: -1 | 1): Promise<void> {
if (!rewindEntryId || rewindApplying || rewindLoadingEarlier) return;
const targetKey = activeComposerTargetKey;
const selected = rewindEntryId;
let index = rewindEntries.findIndex((entry) => entry.entryId === selected);
if (index === 0 && direction === -1 && selectedHistory.hasMore && selectedHistory.cursor && consoleTarget) {
rewindLoadingEarlier = true;
try {
await requestHistoryPage(consoleTarget, selectedHistoryKey, selectedHistory.cursor, reloadToken);
await tick();
} finally { rewindLoadingEarlier = false; }
if (activeComposerTargetKey !== targetKey || rewindEntryId !== selected) return;
index = rewindEntries.findIndex((entry) => entry.entryId === selected);
}
if (index < 0) { rewindEntryId = null; return; }
const target = rewindEntries[Math.max(0, Math.min(rewindEntries.length - 1, index + direction))];
rewindEntryId = target.entryId!;
await tick();
if (rewindEntryId !== target.entryId) return;
selectConsoleTurn(target.id);
autoFollowConsole = false;
}
function applySelectedRewind(): void {
if (!rewindEntryId || rewindApplying) return;
if (!canSendComposerControl) {
reportComposerError("Cannot send rewind while disconnected or another input is awaiting admission.");
return;
}
if (!rewindEntries.some((entry) => entry.entryId === rewindEntryId)) {
reportComposerError("The selected input is no longer in the displayed history.");
rewindEntryId = null;
return;
}
rewindApplying = true;
try {
sendProtocolMethod({ method: "rewind_to", params: { entry_id: rewindEntryId } });
} catch (error) {
rewindApplying = false;
reportComposerError(error instanceof Error ? error.message : String(error));
}
}
function handleRewindKeydown(event: KeyboardEvent): boolean {
if (!rewindEntryId || event.isComposing || event.keyCode === 229 ||
event.ctrlKey || event.metaKey || event.altKey || event.shiftKey) return false;
if (["ArrowUp", "ArrowDown", "Enter", "Escape"].includes(event.key)) {
event.preventDefault();
event.stopPropagation();
if (event.key === "Escape") rewindEntryId = null;
else if (event.key === "Enter") { if (!event.repeat) applySelectedRewind(); }
else void moveRewindSelection(event.key === "ArrowUp" ? -1 : 1);
return true;
}
// Typing resumes normal draft editing without disabling the composer.
if (event.key.length === 1 || event.key === "Backspace" || event.key === "Delete") rewindEntryId = null;
return false;
}
function handleConsoleKeydown(event: KeyboardEvent): void {
if (!event.defaultPrevented && !isEditableTarget(event.target) && handleRewindKeydown(event)) return;
handleWorkerControlShortcut(event);
} }
function composerRequestToProtocolMethod( function composerRequestToProtocolMethod(
@@ -974,8 +1049,6 @@
if (!method) throw new Error("Worker state snapshot is not available"); if (!method) throw new Error("Worker state snapshot is not available");
return method; return method;
} }
case "list_rewind_targets":
return { method: "list_rewind_targets" };
case "register_peer": case "register_peer":
return { return {
method: "register_peer", method: "register_peer",
@@ -992,6 +1065,7 @@
} }
function handleComposerChange(snapshot: ComposerDraftSnapshot) { function handleComposerChange(snapshot: ComposerDraftSnapshot) {
if (snapshot.document !== draft.document) rewindEntryId = null;
draft = snapshot; draft = snapshot;
composerDrafts.set(activeComposerTargetKey, cachedComposerDraft(snapshot)); composerDrafts.set(activeComposerTargetKey, cachedComposerDraft(snapshot));
} }
@@ -1057,13 +1131,14 @@
} }
function handleComposerSubmit() { function handleComposerSubmit() {
if (!canSendComposerControl) return; if (rewindEntryId) { applySelectedRewind(); return; }
const value = composerSnapshotOrReport(); const value = composerSnapshotOrReport();
if (!value) return; if (!value) return;
if (value.document.trimStart().startsWith(":")) { if (value.document.trimStart().startsWith(":")) {
handleComposerCommand(); handleComposerCommand();
return; return;
} }
if (!canSendComposerControl) return;
if (workerRunning) { if (workerRunning) {
sendWorkerControl("cancel"); sendWorkerControl("cancel");
return; return;
@@ -1275,6 +1350,11 @@
reportComposerError(command.message); reportComposerError(command.message);
return; return;
} }
if (command.action === "rewind") {
if (delivery !== "submit") { reportComposerError("Rewind is a local Console action, not queued input or a notification."); return; }
await startRewindSelection();
return;
}
if (!command.request) { if (!command.request) {
if (command.notice) pushWorkspaceAlert("info", command.notice, { title: "Console command" }); if (command.notice) pushWorkspaceAlert("info", command.notice, { title: "Console command" });
composerInputElement?.clear(); composerInputElement?.clear();
@@ -1284,7 +1364,7 @@
delivery, delivery,
workerState, workerState,
protocolOpen: protocolState === "open", protocolOpen: protocolState === "open",
sending, sending: sending || rewindApplying,
hasText: value.content.trim().length > 0, hasText: value.content.trim().length > 0,
hasAttachments: attachments.length > 0 || hasAttachments: attachments.length > 0 ||
value.segments.some((segment) => segment.kind === "uploaded_file"), value.segments.some((segment) => segment.kind === "uploaded_file"),
@@ -1622,19 +1702,11 @@
} }
return; return;
} }
if (event.event === "rewind_targets") {
rewindHeadEntries = event.data.head_entries;
rewindTargets = event.data.targets;
pushWorkspaceAlert(
"info",
event.data.targets.length === 0
? "No rewind targets are available."
: `Loaded ${event.data.targets.length} rewind target(s).`,
{ id: controlAlertId, title: "Rewind targets" },
);
return;
}
if (event.event === "error") { if (event.event === "error") {
if (rewindApplying) {
rewindApplying = false;
reportComposerError(event.data.message);
}
const error = new Error(event.data.message); const error = new Error(event.data.message);
if ( if (
fileCompletions.pending || featureCompletions.pending || fileCompletions.pending || featureCompletions.pending ||
@@ -1655,6 +1727,8 @@
function rejectPendingCompletion(error: Error, targetKey = activeComposerTargetKey) { function rejectPendingCompletion(error: Error, targetKey = activeComposerTargetKey) {
admissionDisconnected(targetKey); admissionDisconnected(targetKey);
rewindEntryId = null;
rewindApplying = false;
// Invalidate suggestions on disconnect without locking the draft. // Invalidate suggestions on disconnect without locking the draft.
composerInputElement?.closeCompletion(); composerInputElement?.closeCompletion();
fileCompletions.close(error); fileCompletions.close(error);
@@ -1693,6 +1767,7 @@
rememberCurrent = true, rememberCurrent = true,
) { ) {
if (sessionId === selectedWorkerViewSessionId) return; if (sessionId === selectedWorkerViewSessionId) return;
rewindEntryId = null;
const generation = ++workerViewSelectionGeneration; const generation = ++workerViewSelectionGeneration;
if (rememberCurrent) rememberConsoleWorkerViewScroll(); if (rememberCurrent) rememberConsoleWorkerViewScroll();
const target = workerViews.find((view) => view.sessionId === sessionId) ?? const target = workerViews.find((view) => view.sessionId === sessionId) ??
@@ -1799,6 +1874,8 @@
const target = consoleTarget; const target = consoleTarget;
const targetWorker = data.worker; const targetWorker = data.worker;
const targetWorkerError = data.workerError; const targetWorkerError = data.workerError;
rewindEntryId = null;
rewindApplying = false;
workerViewSelectionGeneration += 1; workerViewSelectionGeneration += 1;
selectedWorkerViewSessionId = null; selectedWorkerViewSessionId = null;
consoleViewScroll.clear(); consoleViewScroll.clear();
@@ -1854,7 +1931,7 @@
}); });
</script> </script>
<svelte:window onkeydown={handleWorkerControlShortcut} /> <svelte:window onkeydown={handleConsoleKeydown} />
<svelte:head> <svelte:head>
<title>Worker Console · Yoi Workspace</title> <title>Worker Console · Yoi Workspace</title>
@@ -1865,28 +1942,6 @@
</svelte:head> </svelte:head>
<div class="console-shell worker-console-shell"> <div class="console-shell worker-console-shell">
{#if rewindTargets.length > 0}
<section class="card rewind-targets" aria-label="Rewind targets">
<h3>Rewind targets</h3>
<div class="rewind-target-list">
{#each rewindTargets as target (JSON.stringify(target.id))}
<button
type="button"
class="secondary-button"
disabled={protocolState !== "open" || !target.eligible}
title={target.disabled_reason ?? target.warning ?? undefined}
onclick={() => rewindTo(target)}
>
{target.preview || `${target.turn_index}`}
{#if target.warning || target.disabled_reason}
<span>{target.warning ?? target.disabled_reason}</span>
{/if}
</button>
{/each}
</div>
</section>
{/if}
{#key selectedHistoryKey} {#key selectedHistoryKey}
<svelte:boundary onerror={(error) => console.error("Console transcript rendering failed", error)}> <svelte:boundary onerror={(error) => console.error("Console transcript rendering failed", error)}>
<div class:with-task-pane={taskPaneOpen} class="console-history"> <div class:with-task-pane={taskPaneOpen} class="console-history">
@@ -1932,7 +1987,7 @@
{#if (consoleDisplayState.kind === "ready" || consoleDisplayState.kind === "stale") && lines.length > 0} {#if (consoleDisplayState.kind === "ready" || consoleDisplayState.kind === "stale") && lines.length > 0}
<ol class="console-log"> <ol class="console-log">
{#each lines as item (item.id)} {#each lines as item (item.id)}
<ConsoleLineItem {item} {attachmentUrl} /> <ConsoleLineItem {item} {attachmentUrl} rewindSelected={rewindEntryId !== null && item.entryId === rewindEntryId} />
{/each} {/each}
</ol> </ol>
{/if} {/if}
@@ -2151,6 +2206,12 @@
accept="text/*,application/json,application/pdf,image/png,image/jpeg,image/gif,image/webp" accept="text/*,application/json,application/pdf,image/png,image/jpeg,image/gif,image/webp"
onchange={handleFileInput} onchange={handleFileInput}
/> />
{#if rewindEntryId || rewindApplying}
<div class="rewind-status" role="status">
{rewindApplying ? "Applying rewind…" : "Rewind · ↑↓ select input · Enter apply · Esc cancel"}
<span>Rewinds to before the selected input. Tool side effects are not undone.</span>
</div>
{/if}
<ComposerInput <ComposerInput
bind:this={composerInputElement} bind:this={composerInputElement}
historyScope={workspaceId} historyScope={workspaceId}
@@ -2268,16 +2329,18 @@
{/if} {/if}
<button <button
class="composer-send-button" class="composer-send-button"
class:stop={workerRunning} class:stop={workerRunning && !rewindEntryId}
type="submit" type="submit"
aria-label={workerRunning aria-label={rewindEntryId ? "Rewind to selected input" : workerRunning
? "Stop Worker" ? "Stop Worker"
: sending : sending
? "Sending message" ? "Sending message"
: "Send message"} : "Send message"}
disabled={composerSubmitDisabled} disabled={composerSubmitDisabled}
> >
{#if workerRunning} {#if rewindEntryId}
<svg class="composer-send-icon" aria-hidden="true" viewBox="0 0 24 24"><path d="M9 5L3 11L9 17M3 11H15A5 5 0 0 1 15 21" /></svg>
{:else if workerRunning}
<svg <svg
class="composer-send-icon" class="composer-send-icon"
aria-hidden="true" aria-hidden="true"
@@ -2325,6 +2388,9 @@
</div> </div>
<style> <style>
.rewind-status { font-size: var(--font-size-compact); color: var(--text-muted); }
.rewind-status span { display: block; }
.console-shell { .console-shell {
width: 100%; width: 100%;
max-width: 920px; max-width: 920px;
@@ -2409,28 +2475,7 @@
transform: translateX(10px); transform: translateX(10px);
} }
.rewind-targets {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3);
}
.rewind-targets h3 {
margin: 0;
font-size: var(--font-size-body);
}
.rewind-target-list {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.rewind-target-list span {
margin-left: var(--space-2);
color: var(--text-muted);
}
.console-history { .console-history {
display: grid; display: grid;
@@ -947,11 +947,15 @@ test.each(["idle", "running", "paused"] as const)("Composer completion executes
method: "compact", params: { command: expect.objectContaining({ command_id: 1 }) }, method: "compact", params: { command: expect.objectContaining({ command_id: 1 }) },
})); }));
await waitFor(() => expect(cm.state.doc.toString()).toBe("")); await waitFor(() => expect(cm.state.doc.toString()).toBe(""));
protocolEvent({ event: "user_message", data: { entry_id: "rewind-user", segments: [{ kind: "text", content: "rewind input" }] } });
await screen.findByText("rewind input");
const callsBeforeRewind = multiplexer.sendWorkerMethod.mock.calls.length;
cm.dispatch({ changes: { from: 0, insert: ":rew" }, selection: { anchor: 4 } }); cm.dispatch({ changes: { from: 0, insert: ":rew" }, selection: { anchor: 4 } });
const option = await screen.findByRole("option"); const option = await screen.findByRole("option");
await fireEvent.pointerDown(option); await fireEvent.pointerDown(option);
await fireEvent.click(option); await fireEvent.click(option);
await waitFor(() => expect(multiplexer.sendWorkerMethod).toHaveBeenCalledWith({ method: "list_rewind_targets" })); await waitFor(() => expect(view.container.querySelector(".rewind-selected")?.textContent).toContain("rewind input"));
expect(multiplexer.sendWorkerMethod).toHaveBeenCalledTimes(callsBeforeRewind);
expect(multiplexer.sendWorkerMethod.mock.calls.every(([method]) => method.method !== "submit" && method.method !== "cancel")).toBe(true); expect(multiplexer.sendWorkerMethod.mock.calls.every(([method]) => method.method !== "submit" && method.method !== "cancel")).toBe(true);
}); });
@@ -1701,3 +1705,119 @@ test("pending groups independently follow authoritative changes, including zero
latestListener().onFrame(subscribedFrame(session)); latestListener().onFrame(subscribedFrame(session));
await waitFor(() => expect(screen.queryByRole("region", { name: "Pending activations" })).toBeNull()); await waitFor(() => expect(screen.queryByRole("region", { name: "Pending activations" })).toBeNull());
}); });
async function enterRewind(cm: EditorView, form: HTMLFormElement) {
cm.focus();
cm.dispatch({ changes: { from: 0, to: cm.state.doc.length, insert: ":rewind" }, selection: { anchor: 7 } });
await fireEvent.submit(form);
await waitFor(() => expect(document.querySelector(".rewind-selected")).not.toBeNull());
}
function rewindSession(): SessionSnapshot {
return { ...emptySession(), entries: ["first", "second"].map((id) => ({
kind: "user_input", entry_id: id, timestamp: 1, provenance: "human_input", derived_from: [],
segments: [{ kind: "text", content: "identical input" }],
})) };
}
test("rewind selects existing history by ID, follows arrows, tolerates appended history and suppresses repeated Enter", async () => {
const { cm, form, view } = await liveComposer();
latestListener().onFrame(subscribedFrame(rewindSession()));
await waitFor(() => expect(view.container.querySelectorAll(".console-line.user")).toHaveLength(2));
const rows = () => [...view.container.querySelectorAll(".console-line.user")];
await enterRewind(cm, form);
expect(multiplexer.sendWorkerMethod).not.toHaveBeenCalled();
expect(rows()[1].classList.contains("rewind-selected")).toBe(true);
await fireEvent.keyDown(cm.contentDOM, { key: "ArrowUp" });
expect(rows()[0].classList.contains("rewind-selected")).toBe(true);
expect(cm.state.doc.toString()).toBe("");
await fireEvent.keyDown(cm.contentDOM, { key: "ArrowDown" });
expect(rows()[1].classList.contains("rewind-selected")).toBe(true);
protocolEvent({ event: "user_message", data: { entry_id: "third", segments: [{ kind: "text", content: "newly appended" }] } });
await screen.findByText("newly appended");
expect(rows()[1].classList.contains("rewind-selected")).toBe(true);
await fireEvent.keyDown(cm.contentDOM, { key: "Enter" });
await fireEvent.keyDown(cm.contentDOM, { key: "Enter", repeat: true });
await fireEvent.keyDown(cm.contentDOM, { key: "Enter" });
expect(multiplexer.sendWorkerMethod).toHaveBeenCalledExactlyOnceWith({ method: "rewind_to", params: { entry_id: "second" } });
expect(cm.state.readOnly).toBe(false);
protocolEvent({ event: "rewind_applied", data: { session: { ...rewindSession(), entries: rewindSession().entries.slice(0, 1) }, input: [{ kind: "text", content: "identical input" }], summary: { truncated_to_entries: 1, discarded_entries: 2, tool_side_effect_warning: true } } });
await waitFor(() => expect(cm.state.doc.toString()).toBe("identical input"));
expect(view.container.querySelector(".rewind-selected")).toBeNull();
expect(cm.hasFocus).toBe(true);
});
test("rewind Escape and draft editing leave selection without sending; rejection keeps it available", async () => {
const alert = vi.spyOn(alerts, "pushWorkspaceAlert");
const { cm, form, view } = await liveComposer();
latestListener().onFrame(subscribedFrame(rewindSession()));
await waitFor(() => expect(view.container.querySelectorAll(".console-line.user")).toHaveLength(2));
await enterRewind(cm, form);
await fireEvent.keyDown(cm.contentDOM, { key: "Escape" });
expect(view.container.querySelector(".rewind-selected")).toBeNull();
expect(multiplexer.sendWorkerMethod).not.toHaveBeenCalled();
await enterRewind(cm, form);
await fireEvent.keyDown(cm.contentDOM, { key: "Enter" });
protocolEvent({ event: "error", data: { code: "invalid_request", message: "Target is outside current segment" } });
await waitFor(() => expect(alert).toHaveBeenCalledWith("error", "Target is outside current segment", expect.any(Object)));
expect(view.container.querySelector(".rewind-selected")).not.toBeNull();
expect((screen.getByRole("button", { name: "Rewind to selected input" }) as HTMLButtonElement).disabled).toBe(false);
await fireEvent.paste(cm.contentDOM, { clipboardData: { getData: () => "new draft" } });
expect(cm.state.doc.toString()).toBe("new draft");
expect(view.container.querySelector(".rewind-selected")).toBeNull();
expect(multiplexer.sendWorkerMethod).toHaveBeenCalledTimes(1);
});
test("rewind reply preserves draft edits made while applying and selection is cleared on target change", async () => {
const { cm, form, view } = await liveComposer();
latestListener().onFrame(subscribedFrame(rewindSession()));
await waitFor(() => expect(view.container.querySelectorAll(".console-line.user")).toHaveLength(2));
await enterRewind(cm, form);
await fireEvent.keyDown(cm.contentDOM, { key: "Enter" });
await fireEvent.paste(cm.contentDOM, { clipboardData: { getData: () => "keep this edit" } });
protocolEvent({ event: "rewind_applied", data: { session: rewindSession(), input: [{ kind: "text", content: "old input" }], summary: { truncated_to_entries: 1, discarded_entries: 1, tool_side_effect_warning: false } } });
await settleMicrotasks();
expect(cm.state.doc.toString()).toBe("keep this edit");
await enterRewind(cm, form);
latestListener().onStatus?.("closed", { kind: "transport", message: "disconnected" });
await waitFor(() => expect(view.container.querySelector(".rewind-selected")).toBeNull());
expect(cm.state.readOnly).toBe(false);
latestListener().onFrame(subscribedFrame(rewindSession()));
await enterRewind(cm, form);
await view.rerender({ data: pageData("worker-b", "runtime-b") });
await waitFor(() => expect(multiplexer.subscribe).toHaveBeenCalledTimes(2));
expect(view.container.querySelector(".rewind-selected")).toBeNull();
});
test("rewind Up loads the next history page and selects its nearest user input", async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
return Response.json(!url.includes("/session/history") ? { availability: "live_protocol" }
: url.includes("cursor=") ? historyPage([1], null) : historyPage([2], "older-2"));
});
vi.stubGlobal("fetch", fetchMock);
const view = render(ConsolePage, { data: pageData() });
await waitFor(() => expect(multiplexer.subscribe).toHaveBeenCalledOnce());
latestListener().onFrame(subscribedFrame());
await waitFor(() => expect(view.container.querySelector(".console-line.user")?.textContent).toContain("question 2"));
const cm = EditorView.findFromDOM(view.container.querySelector(".cm-editor") as HTMLElement)!;
await enterRewind(cm, view.container.querySelector(".console-composer") as HTMLFormElement);
await fireEvent.keyDown(cm.contentDOM, { key: "ArrowUp" });
await waitFor(() => expect(view.container.querySelector(".rewind-selected")?.textContent).toContain("question 1"));
await fireEvent.keyDown(cm.contentDOM, { key: "Enter" });
expect(multiplexer.sendWorkerMethod).toHaveBeenCalledExactlyOnceWith({ method: "rewind_to", params: { entry_id: "user-1" } });
});
test("rewind does not confirm an IME Enter", async () => {
const { cm, form, view } = await liveComposer();
latestListener().onFrame(subscribedFrame(rewindSession()));
await waitFor(() => expect(view.container.querySelectorAll(".console-line.user")).toHaveLength(2));
await enterRewind(cm, form);
await fireEvent.compositionStart(cm.contentDOM);
await fireEvent.keyDown(cm.contentDOM, { key: "Enter", isComposing: true, keyCode: 229 });
expect(multiplexer.sendWorkerMethod).not.toHaveBeenCalled();
expect(cm.state.readOnly).toBe(false);
await fireEvent.compositionEnd(cm.contentDOM);
});