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 {
command: WorkerCommandEnvelope,
},
/// Ask the Worker to list valid rewind targets from its authoritative session log.
ListRewindTargets,
/// Truncate the current session back to the selected rewind target and
/// return the selected user input to the client composer.
/// Rewind to just before a user input identified by its public Session entry ID.
/// Validate the target against the current segment at execution time.
/// Later appended history is discarded too; tool side effects are not undone.
RewindTo {
target: RewindTargetId,
expected_head_entries: usize,
entry_id: String,
},
Shutdown {
command: WorkerCommandEnvelope,
@@ -1421,12 +1419,6 @@ pub enum Event {
context: Option<CompletionContext>,
entries: Vec<CompletionEntry>,
},
/// Reply to `Method::ListRewindTargets`. Clients should only open a picker
/// in response to their own pending request; the event may be broadcast.
RewindTargets {
head_entries: usize,
targets: Vec<RewindTarget>,
},
/// A rewind has truncated the authoritative session. `session` is the
/// retained canonical snapshot clients should use to reseed display state.
RewindApplied {
@@ -1569,33 +1561,6 @@ pub struct CompletionEntry {
pub invocation: Option<FeatureInvocationDescriptor>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct RewindTargetId {
#[cfg_attr(feature = "typescript", ts(type = "string"))]
#[cfg_attr(feature = "json-schema", schemars(with = "String"))]
pub segment_id: uuid::Uuid,
pub user_input_entry_index: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct RewindTarget {
pub id: RewindTargetId,
pub expected_head_entries: usize,
pub truncate_entries: usize,
pub turn_index: usize,
pub timestamp_ms: Option<u64>,
pub preview: String,
pub eligible: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub disabled_reason: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub warning: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
@@ -1998,6 +1963,23 @@ pub enum Permission {
mod tests {
use super::*;
#[test]
fn rewind_uses_public_entry_id_without_candidate_or_head_token() {
let method = Method::RewindTo {
entry_id: "user-entry".into(),
};
let wire = serde_json::json!({"method": "rewind_to", "params": {"entry_id": "user-entry"}});
assert_eq!(serde_json::to_value(&method).unwrap(), wire);
assert!(
matches!(serde_json::from_value::<Method>(wire).unwrap(), Method::RewindTo { entry_id } if entry_id == "user-entry")
);
assert!(
serde_json::from_value::<Method>(serde_json::json!({"method": "list_rewind_targets"}))
.is_err()
);
assert!(serde_json::from_value::<Method>(serde_json::json!({"method": "rewind_to", "params": {"target": {"segment_id": uuid::Uuid::nil(), "user_input_entry_index": 1}, "expected_head_entries": 3}})).is_err());
}
#[test]
fn scope_rule_defaults_to_resolved_symlink_policy() {
let rule: ScopeRule = serde_json::from_value(serde_json::json!({
+8 -10
View File
@@ -15,14 +15,14 @@ use crate::{
InvocationClientAdapter, InvocationCompletion, InvocationValue, InvokeKind, MemoryWorkerEvent,
Method, PasteArtifactAvailability, PasteArtifactMediaType, PasteArtifactRef,
PendingSubmissionSummary, PendingSubmissionsSnapshot, Permission, ReasoningConfig,
RewindSummary, RewindTarget, RewindTargetId, RunFailureKind, RunResult, RunResumeSource,
RunYieldReason, ScopeRule, Segment, SessionContentPart, SessionConversationTurn,
SessionEntryProvenance, SessionHistoryLineageBoundary, SessionHistoryPage, SessionMessageRole,
SessionSnapshot, SessionSnapshotEntry, SessionSnapshotEntryData, SessionToolAttachment,
SubmissionDisposition, SymlinkPolicy, ToolResultDisposition, TurnResult,
UploadedFileAvailability, UploadedFileRef, WorkerBusyState, WorkerCommandAcknowledgement,
WorkerCommandDisposition, WorkerCommandEnvelope, WorkerCommandKind, WorkerEvent,
WorkerMaintenanceState, WorkerRunState, WorkerState, WorkerStateSnapshot, WorkerStatus,
RewindSummary, RunFailureKind, RunResult, RunResumeSource, RunYieldReason, ScopeRule, Segment,
SessionContentPart, SessionConversationTurn, SessionEntryProvenance,
SessionHistoryLineageBoundary, SessionHistoryPage, SessionMessageRole, SessionSnapshot,
SessionSnapshotEntry, SessionSnapshotEntryData, SessionToolAttachment, SubmissionDisposition,
SymlinkPolicy, ToolResultDisposition, TurnResult, UploadedFileAvailability, UploadedFileRef,
WorkerBusyState, WorkerCommandAcknowledgement, WorkerCommandDisposition, WorkerCommandEnvelope,
WorkerCommandKind, WorkerEvent, WorkerMaintenanceState, WorkerRunState, WorkerState,
WorkerStateSnapshot, WorkerStatus,
subscription::{
EventSubscriptionSelector, MAX_CORRELATION_ID_BYTES, MAX_REJECTION_MESSAGE_BYTES,
MAX_RESOURCE_ID_BYTES, MAX_SUBSCRIPTION_COLLECTION_ITEMS,
@@ -111,8 +111,6 @@ pub fn generated_protocol_types() -> String {
push_decl::<UploadedFileRef>(&cfg, &mut output);
push_decl::<ScopeRule>(&cfg, &mut output);
push_decl::<CompletionEntry>(&cfg, &mut output);
push_decl::<RewindTargetId>(&cfg, &mut output);
push_decl::<RewindTarget>(&cfg, &mut output);
push_decl::<RewindSummary>(&cfg, &mut output);
push_decl::<InFlightBlock>(&cfg, &mut output);
push_decl::<InFlightCompaction>(&cfg, &mut output);
-1
View File
@@ -401,7 +401,6 @@ pub enum WorkerInputKind {
UserIfIdle,
Notify,
Compact,
ListRewindTargets,
RegisterPeer,
}
+44 -59
View File
@@ -5,7 +5,7 @@ use std::time::{Duration, Instant};
use protocol::{
AlertLevel, AlertSource, CompletionEntry, CompletionKind, ErrorCode, Event, InFlightBlock,
InFlightSnapshot, InFlightToolCallState, InternalWorkerRef, InternalWorkerSnapshot, Method,
RewindTarget, RunResult, Segment, WorkerCommandEnvelope, WorkerStateSnapshot, WorkerStatus,
RunResult, Segment, WorkerCommandEnvelope, WorkerStateSnapshot, WorkerStatus,
};
use crate::block::{
@@ -90,10 +90,15 @@ pub struct RewindPickerScroll {
pub tail_top_offset: usize,
}
#[derive(Debug, Clone)]
pub struct RewindEntry {
pub entry_id: String,
pub preview: String,
}
#[derive(Debug, Clone)]
pub struct RewindPickerState {
pub head_entries: usize,
pub targets: Vec<RewindTarget>,
pub targets: Vec<RewindEntry>,
pub selected: usize,
pub scroll: RewindPickerScroll,
/// True after Enter submitted an authoritative `RewindTo` and before the
@@ -104,10 +109,9 @@ pub struct RewindPickerState {
}
impl RewindPickerState {
pub fn new(head_entries: usize, targets: Vec<RewindTarget>) -> Self {
let selected = targets.iter().position(|t| t.eligible).unwrap_or(0);
pub fn new(targets: Vec<RewindEntry>) -> Self {
let selected = 0;
Self {
head_entries,
targets,
selected,
scroll: RewindPickerScroll::default(),
@@ -115,7 +119,7 @@ impl RewindPickerState {
}
}
pub fn selected_target(&self) -> Option<&RewindTarget> {
pub fn selected_target(&self) -> Option<&RewindEntry> {
self.targets.get(self.selected)
}
}
@@ -334,7 +338,7 @@ pub struct App {
completion_target: String,
/// Dedicated main-view rewind picker state.
pub rewind_picker: Option<RewindPickerState>,
rewind_request_pending: bool,
rewind_entries: Vec<RewindEntry>,
/// After a successful rewind restore, ignore any queued live-update events
/// until the authoritative Worker status/snapshot catches up. This prevents
/// old stream tail events that were already in transit from re-polluting the
@@ -419,7 +423,7 @@ impl App {
pending_feature_edit: None,
completion_target: protocol::new_submission_request_id(),
rewind_picker: None,
rewind_request_pending: false,
rewind_entries: Vec::new(),
rewind_refresh_fence: false,
greeting: None,
task_store: TaskStore::new(),
@@ -1529,7 +1533,19 @@ impl App {
Event::PendingSubmissionsChanged { pending } => {
self.pending_submissions = pending;
}
Event::UserMessage { segments, .. } => {
Event::UserMessage { segments, entry_id } => {
if let Some(entry_id) = entry_id {
if !self
.rewind_entries
.iter()
.any(|entry| entry.entry_id == entry_id)
{
self.rewind_entries.push(RewindEntry {
entry_id,
preview: Segment::flatten_to_text(&segments),
});
}
}
self.turn_index += 1;
self.blocks.push(Block::TurnHeader {
turn: self.turn_index,
@@ -2038,15 +2054,6 @@ impl App {
state.selected = 0;
}
}
Event::RewindTargets {
head_entries,
targets,
} => {
if self.rewind_request_pending {
self.rewind_request_pending = false;
self.rewind_picker = Some(RewindPickerState::new(head_entries, targets));
}
}
Event::RewindApplied {
session,
input,
@@ -2467,14 +2474,14 @@ impl App {
return None;
}
self.completion = None;
self.rewind_picker = None;
self.rewind_request_pending = true;
Some(Method::ListRewindTargets)
self.rewind_picker = Some(RewindPickerState::new(
self.rewind_entries.iter().rev().cloned().collect(),
));
None
}
pub fn close_rewind_picker(&mut self) {
self.rewind_picker = None;
self.rewind_request_pending = false;
}
pub fn cancel_rewind_picker(&mut self) {
@@ -2539,17 +2546,8 @@ impl App {
);
return None;
}
let (target_id, expected_head_entries) = match picker.selected_target() {
Some(target) if target.eligible => (target.id.clone(), target.expected_head_entries),
Some(target) => {
self.push_command_diagnostic(
target
.disabled_reason
.clone()
.unwrap_or_else(|| "rewind target is disabled".into()),
);
return None;
}
let entry_id = match picker.selected_target() {
Some(target) => target.entry_id.clone(),
None => {
self.push_command_diagnostic("no rewind target is available");
return None;
@@ -2558,10 +2556,7 @@ impl App {
if let Some(picker) = self.rewind_picker.as_mut() {
picker.applying = true;
}
Some(Method::RewindTo {
target: target_id,
expected_head_entries,
})
Some(Method::RewindTo { entry_id })
}
fn command_environment(&self) -> CommandEnvironment {
@@ -2597,10 +2592,8 @@ impl App {
command: self.next_command_envelope(),
});
}
if let Some(Method::ListRewindTargets) = method.as_ref() {
self.completion = None;
self.rewind_picker = None;
self.rewind_request_pending = true;
if result.open_rewind_picker {
return self.request_rewind_picker();
}
method
}
@@ -2913,10 +2906,15 @@ impl App {
}
self.assistant_streaming = false;
self.rewind_entries.clear();
for entry in &session.entries {
use protocol::{SessionContentPart, SessionMessageRole, SessionSnapshotEntryData};
match &entry.data {
SessionSnapshotEntryData::UserInput { segments } => {
self.rewind_entries.push(RewindEntry {
entry_id: entry.entry_id.clone(),
preview: Segment::flatten_to_text(segments),
});
self.turn_index += 1;
self.blocks.push(Block::TurnHeader {
turn: self.turn_index,
@@ -3364,23 +3362,10 @@ mod rewind_refresh_tests {
fn app_with_rewind_picker() -> App {
let mut app = App::new("test".into());
app.connected = true;
app.rewind_picker = Some(RewindPickerState::new(
5,
vec![RewindTarget {
id: protocol::RewindTargetId {
segment_id: uuid::Uuid::nil(),
user_input_entry_index: 1,
},
expected_head_entries: 5,
truncate_entries: 2,
turn_index: 1,
timestamp_ms: None,
preview: "rewind target".into(),
eligible: true,
disabled_reason: None,
warning: None,
}],
));
app.rewind_picker = Some(RewindPickerState::new(vec![RewindEntry {
entry_id: "user-1".into(),
preview: "test input".into(),
}]));
app
}
+11 -3
View File
@@ -52,6 +52,7 @@ pub struct CommandEnvironment {
#[derive(Debug, Clone)]
pub struct CommandExecution {
pub method: Option<Method>,
pub open_rewind_picker: bool,
pub diagnostics: Vec<CommandDiagnostic>,
pub exit_command_mode: bool,
pub clear_input: bool,
@@ -60,6 +61,7 @@ pub struct CommandExecution {
impl CommandExecution {
pub fn diagnostic(message: impl Into<String>) -> Self {
Self {
open_rewind_picker: false,
method: None,
diagnostics: vec![CommandDiagnostic::new(message)],
exit_command_mode: false,
@@ -69,6 +71,7 @@ impl CommandExecution {
pub fn notice(message: impl Into<String>) -> Self {
Self {
open_rewind_picker: false,
method: None,
diagnostics: vec![CommandDiagnostic::new(message)],
exit_command_mode: true,
@@ -409,6 +412,7 @@ fn compact_command(invocation: CommandInvocation<'_>) -> CommandExecution {
let _ = invocation.environment;
let _ = invocation.args.raw();
CommandExecution {
open_rewind_picker: false,
method: Some(Method::Compact {
command: protocol::WorkerCommandEnvelope::new(0),
}),
@@ -423,7 +427,8 @@ fn rewind_command(invocation: CommandInvocation<'_>) -> CommandExecution {
let _ = invocation.environment;
let _ = invocation.args.raw();
CommandExecution {
method: Some(Method::ListRewindTargets),
open_rewind_picker: true,
method: None,
diagnostics: vec![CommandDiagnostic::new("rewind picker requested")],
exit_command_mode: true,
clear_input: true,
@@ -435,6 +440,7 @@ fn peer_command(invocation: CommandInvocation<'_>) -> CommandExecution {
let _ = invocation.environment;
let name = invocation.args.argv()[0].clone();
CommandExecution {
open_rewind_picker: false,
method: Some(Method::RegisterPeer { name: name.clone() }),
diagnostics: vec![CommandDiagnostic::new(format!(
"peer metadata registration requested with `{name}`"
@@ -521,7 +527,8 @@ mod tests {
let registry = CommandRegistry::builtins();
for command in ["rewind", "rollback"] {
let result = registry.dispatch(command, &env());
assert!(matches!(result.method, Some(Method::ListRewindTargets)));
assert!(result.method.is_none());
assert!(result.open_rewind_picker);
assert!(result.exit_command_mode);
assert!(result.clear_input);
assert!(result.diagnostics[0].message.contains("rewind picker"));
@@ -549,7 +556,8 @@ mod tests {
let mut paused = env();
paused.paused = true;
let result = registry.dispatch("rewind", &paused);
assert!(matches!(result.method, Some(Method::ListRewindTargets)));
assert!(result.method.is_none());
assert!(result.open_rewind_picker);
}
#[test]
+31 -69
View File
@@ -18,7 +18,7 @@ use crossterm::terminal::{EnterAlternateScreen, LeaveAlternateScreen};
use crossterm::{Command, execute};
use protocol::{Event, Method, Segment, UploadedFileRef, WorkerStatus};
#[cfg(feature = "e2e-test")]
use protocol::{Greeting, RewindSummary, RewindTarget, RewindTargetId};
use protocol::{Greeting, RewindSummary};
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use standalone::{StandaloneHost, StandaloneLaunchConfig};
@@ -758,10 +758,10 @@ async fn run_e2e_rewind_fixture(
});
let (_reader, mut term_rx) = TerminalEventReader::spawn()?;
let target_id = RewindTargetId {
segment_id: uuid::Uuid::from_u128(1),
user_input_entry_index: 1,
};
app.handle_worker_event(Event::UserMessage {
entry_id: Some("rewind-fixture-input".into()),
segments: vec![Segment::text("candidate rewind target")],
});
let mut rewind_submit_count = 0usize;
let mut pending_apply: Option<std::time::Instant> = None;
let apply_delay = Duration::from_millis(400);
@@ -803,45 +803,25 @@ async fn run_e2e_rewind_fixture(
.as_ref()
.map(|picker| picker.applying)
.unwrap_or(false);
if let Some(method) = handle_key(&mut app, key) {
let was_open = app.rewind_picker.is_some();
let method = handle_key(&mut app, key);
if !was_open && app.rewind_picker.is_some() {
crate::e2e_observer::emit(
"single_worker",
"rewind_picker_opened",
serde_json::json!({"targets": 1, "selected_preview": "candidate rewind target"}),
);
}
if let Some(method) = method {
match method {
Method::ListRewindTargets => {
app.handle_worker_event(Event::RewindTargets {
head_entries: 3,
targets: vec![RewindTarget {
id: target_id.clone(),
expected_head_entries: 3,
truncate_entries: 1,
turn_index: 1,
timestamp_ms: Some(1),
preview: "candidate rewind target".to_string(),
eligible: true,
disabled_reason: None,
warning: None,
}],
});
crate::e2e_observer::emit(
"single_worker",
"rewind_picker_opened",
serde_json::json!({
"targets": 1,
"selected_preview": "candidate rewind target",
}),
);
}
Method::RewindTo {
target,
expected_head_entries,
} => {
Method::RewindTo { entry_id } => {
rewind_submit_count += 1;
pending_apply = Some(std::time::Instant::now());
crate::e2e_observer::emit(
"single_worker",
"rewind_submit_sent",
serde_json::json!({
"segment_id": target.segment_id.to_string(),
"user_input_entry_index": target.user_input_entry_index,
"expected_head_entries": expected_head_entries,
"entry_id": entry_id,
"submit_count": rewind_submit_count,
}),
);
@@ -1725,10 +1705,7 @@ mod tests {
use super::*;
use crate::text_selection::{HistoryViewport, SelectionRow};
use async_trait::async_trait;
use protocol::{
Event, RewindTarget, RewindTargetId, Segment, UploadedFileAvailability, UploadedFileRef,
WorkerStatus,
};
use protocol::{Event, Segment, UploadedFileAvailability, UploadedFileRef, WorkerStatus};
#[test]
fn standalone_console_starts_with_in_process_connection_ready() {
@@ -3119,14 +3096,17 @@ mod tests {
&mut app,
KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL),
);
assert!(matches!(idle, Some(Method::ListRewindTargets)));
assert!(idle.is_none());
assert!(app.rewind_picker.is_some());
app.close_rewind_picker();
app.set_worker_status(WorkerStatus::Paused);
let paused = handle_key(
&mut app,
KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL),
);
assert!(matches!(paused, Some(Method::ListRewindTargets)));
assert!(paused.is_none());
assert!(app.rewind_picker.is_some());
}
#[test]
@@ -3148,21 +3128,13 @@ mod tests {
fn rewind_picker_close_returns_to_history_view() {
let mut app = App::new("agent".to_string());
app.connected = true;
app.handle_worker_event(Event::RewindTargets {
head_entries: 1,
targets: vec![],
});
assert!(app.rewind_picker.is_none());
let method = handle_key(
&mut app,
KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL),
);
assert!(matches!(method, Some(Method::ListRewindTargets)));
app.handle_worker_event(Event::RewindTargets {
head_entries: 1,
targets: vec![],
});
assert!(method.is_none());
assert!(app.rewind_picker.is_some());
let method = handle_key(&mut app, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
@@ -3244,13 +3216,13 @@ mod tests {
#[test]
fn rewind_apply_rejects_non_empty_composer_and_paused_status() {
let mut app = App::new("agent".to_string());
app.rewind_picker = Some(crate::app::RewindPickerState::new(1, vec![rewind_target()]));
app.rewind_picker = Some(crate::app::RewindPickerState::new(vec![rewind_target()]));
type_keys(&mut app, "draft");
assert!(app.submit_rewind_picker().is_none());
assert!(has_alert(&app, "composer is not empty"));
let mut app = App::new("agent".to_string());
app.rewind_picker = Some(crate::app::RewindPickerState::new(1, vec![rewind_target()]));
app.rewind_picker = Some(crate::app::RewindPickerState::new(vec![rewind_target()]));
app.set_worker_status(WorkerStatus::Paused);
assert!(app.submit_rewind_picker().is_none());
assert!(has_alert(
@@ -3265,7 +3237,7 @@ mod tests {
app.scroll.top_offset = 3;
app.scroll.turn_starts = vec![0, 5, 9];
app.scroll.total_lines = 42;
app.rewind_picker = Some(crate::app::RewindPickerState::new(1, vec![rewind_target()]));
app.rewind_picker = Some(crate::app::RewindPickerState::new(vec![rewind_target()]));
let original_top_offset = app.scroll.top_offset;
let original_turn_starts = app.scroll.turn_starts.clone();
let original_total_lines = app.scroll.total_lines;
@@ -3282,20 +3254,10 @@ mod tests {
assert_eq!(app.scroll.total_lines, original_total_lines);
}
fn rewind_target() -> RewindTarget {
RewindTarget {
id: RewindTargetId {
segment_id: uuid::Uuid::nil(),
user_input_entry_index: 0,
},
expected_head_entries: 1,
truncate_entries: 0,
turn_index: 1,
timestamp_ms: Some(1),
preview: "retry this".into(),
eligible: true,
disabled_reason: None,
warning: None,
fn rewind_target() -> crate::app::RewindEntry {
crate::app::RewindEntry {
entry_id: "user-1".into(),
preview: "test input".into(),
}
}
+2 -27
View File
@@ -728,7 +728,7 @@ fn draw_rewind_picker(
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::raw(format!(" head={} ", picker.head_entries)),
Span::raw(" "),
];
header.extend(action_spans);
logical.push(Line::from(header));
@@ -751,38 +751,13 @@ fn draw_rewind_picker(
Style::default()
.bg(Color::DarkGray)
.add_modifier(Modifier::BOLD)
} else if target.eligible {
Style::default()
} else {
Style::default().fg(Color::DarkGray)
Style::default()
};
let ts = target
.timestamp_ms
.map(|ts| format!("{}", ts))
.unwrap_or_else(|| "-".into());
logical.push(Line::from(vec![
Span::styled(marker.to_owned(), base_style),
Span::styled(
format!(
" turn {} idx {} ts {} ",
target.turn_index, target.id.user_input_entry_index, ts
),
base_style,
),
Span::styled(target.preview.clone(), base_style),
]));
if let Some(warning) = target.warning.as_ref() {
logical.push(Line::from(Span::styled(
format!(" warning: {warning}"),
Style::default().fg(Color::Yellow),
)));
}
if let Some(reason) = target.disabled_reason.as_ref() {
logical.push(Line::from(Span::styled(
format!(" disabled: {reason}"),
Style::default().fg(Color::Red),
)));
}
}
}
+1 -2
View File
@@ -12,13 +12,12 @@ pub enum WorkerInputKind {
UserIfIdle,
Notify,
Compact,
ListRewindTargets,
RegisterPeer,
}
impl WorkerInputKind {
pub fn is_empty_content_allowed(&self) -> bool {
matches!(self, Self::Compact | Self::ListRewindTargets)
matches!(self, Self::Compact)
}
}
+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
// payloads must never become model- or client-visible history early.
WorkerInputKind::User | WorkerInputKind::UserIfIdle | WorkerInputKind::Notify => None,
WorkerInputKind::Compact
| WorkerInputKind::ListRewindTargets
| WorkerInputKind::RegisterPeer => Some(protocol::Event::SystemItem {
entry_id: None,
item: serde_json::json!({
"kind": "embedded_worker_command_input",
"command": input.kind,
"content": input.content.clone(),
}),
}),
WorkerInputKind::Compact | WorkerInputKind::RegisterPeer => {
Some(protocol::Event::SystemItem {
entry_id: None,
item: serde_json::json!({
"kind": "embedded_worker_command_input",
"command": input.kind,
"content": input.content.clone(),
}),
})
}
}
}
+1 -2
View File
@@ -4255,7 +4255,6 @@ where
unreachable!("Notify input is dispatched before ordinary input mapping")
}
WorkerInputKind::Compact => unreachable!("compact input is dispatched above"),
WorkerInputKind::ListRewindTargets => (Method::ListRewindTargets, None),
WorkerInputKind::RegisterPeer => (
Method::RegisterPeer {
name: input.content.trim().to_string(),
@@ -7884,7 +7883,7 @@ mod tests {
));
let old_send = backend.run_on_adapter_runtime(async move {
old_handle
.send(Method::ListRewindTargets)
.send(Method::ListWorkers)
.await
.map_err(|error| error.to_string())
});
+11 -55
View File
@@ -32,8 +32,8 @@ use protocol::{
AlertLevel, AlertSource, CommandEvent as ProtocolCommandEvent,
CommandSnapshot as ProtocolCommandSnapshot, CommandStatus as ProtocolCommandStatus,
CommandStream as ProtocolCommandStream, CommandStreamSlice as ProtocolCommandStreamSlice,
ErrorCode, Event, Method, RewindTargetId, RunResult, TurnResult, UploadedFileRef,
WorkerBusyState, WorkerCommandAcknowledgement, WorkerCommandDisposition, WorkerCommandEnvelope,
ErrorCode, Event, Method, RunResult, TurnResult, UploadedFileRef, WorkerBusyState,
WorkerCommandAcknowledgement, WorkerCommandDisposition, WorkerCommandEnvelope,
WorkerCommandKind, WorkerMaintenanceState, WorkerRunState, WorkerState, WorkerStatus,
};
use workdir::{
@@ -2971,32 +2971,9 @@ async fn controller_loop<C, St>(
}
}
Method::ListRewindTargets => match shared_state.catalog_status() {
WorkerStatus::Idle | WorkerStatus::Paused => {
emit_rewind_targets(&worker, &working_event_tx)
}
WorkerStatus::Running | WorkerStatus::Stopped => {
let _ = working_event_tx.send(Event::Error {
code: ErrorCode::AlreadyRunning,
message: "Worker is already executing a turn; rewind can only run while idle or paused"
.into(),
});
}
},
Method::RewindTo {
target,
expected_head_entries,
} => match shared_state.catalog_status() {
Method::RewindTo { entry_id } => match shared_state.catalog_status() {
WorkerStatus::Idle => {
if apply_rewind(
&mut worker,
&working_event_tx,
target,
expected_head_entries,
)
.await
{
if apply_rewind(&mut worker, &working_event_tx, entry_id).await {
worker.clear_in_flight_events();
publish_context_usage(
&shared_state,
@@ -3018,8 +2995,9 @@ async fn controller_loop<C, St>(
WorkerStatus::Running | WorkerStatus::Stopped => {
let _ = working_event_tx.send(Event::Error {
code: ErrorCode::AlreadyRunning,
message: "Worker is already executing a turn; rewind can only run while idle or paused"
.into(),
message:
"Worker is already executing a turn; rewind can only run while idle"
.into(),
});
}
},
@@ -3668,10 +3646,10 @@ where
);
}
}
Some(Method::ListRewindTargets | Method::RewindTo { .. }) => {
Some(Method::RewindTo { .. }) => {
let _ = working_event_tx.send(Event::Error {
code: ErrorCode::AlreadyRunning,
message: "Worker is already executing a turn; rewind/compact can only run while idle or paused"
message: "Worker is already executing a turn; rewind/compact can only run while idle"
.into(),
});
}
@@ -3749,38 +3727,16 @@ where
}
}
fn emit_rewind_targets<C, St>(worker: &Worker<C, St>, working_event_tx: &broadcast::Sender<Event>)
where
C: LlmClient + 'static,
St: Store,
{
match worker.list_rewind_targets() {
Ok((head_entries, targets)) => {
let _ = working_event_tx.send(Event::RewindTargets {
head_entries,
targets,
});
}
Err(err) => {
let _ = working_event_tx.send(Event::Error {
code: ErrorCode::Internal,
message: err.to_string(),
});
}
}
}
async fn apply_rewind<C, St>(
worker: &mut Worker<C, St>,
working_event_tx: &broadcast::Sender<Event>,
target: RewindTargetId,
expected_head_entries: usize,
entry_id: String,
) -> bool
where
C: LlmClient + 'static,
St: Store,
{
match worker.rewind_to(target, expected_head_entries).await {
match worker.rewind_to(entry_id).await {
Ok(applied) => {
let session =
session_store::public_snapshot::project_current_session_snapshot(&applied.entries);
+179 -140
View File
@@ -531,8 +531,8 @@ use crate::skill::{SkillActivationResponse, SkillClientError};
use async_trait::async_trait;
use protocol::{
AlertLevel, AlertSource, CompactionLifecycle, CompactionLifecycleState, CompactionPhase,
CompactionTrigger, ErrorCode, Event, InFlightCompaction, RewindSummary, RewindTarget,
RewindTargetId, RunFailureKind, RunResumeSource, RunYieldReason, Segment,
CompactionTrigger, ErrorCode, Event, InFlightCompaction, RewindSummary, RunFailureKind,
RunResumeSource, RunYieldReason, Segment,
};
use tokio::net::UnixStream;
use tokio::sync::broadcast;
@@ -3714,22 +3714,15 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
&self.store
}
/// List user-submitted turns in newest-first order for the manual rewind picker.
pub fn list_rewind_targets(&self) -> Result<(usize, Vec<RewindTarget>), RewindError> {
/// Rewind the current segment to just before the specified public user input.
pub async fn rewind_to(&mut self, entry_id: String) -> Result<RewindAppliedState, RewindError> {
// Reject invalid targets before invoking rewrite hooks or quiescing tasks.
// Resolve again under the append lock after the asynchronous barrier.
let loc = self.segment_state.location();
let entries = self.store.read_all(loc.session_id, loc.segment_id)?;
Ok((
entries.len(),
build_rewind_targets(loc.segment_id, &entries),
))
}
/// Truncate the current segment to just before a previously listed user input.
pub async fn rewind_to(
&mut self,
target: RewindTargetId,
expected_head_entries: usize,
) -> Result<RewindAppliedState, RewindError> {
resolve_rewind_input(
&self.store.read_all(loc.session_id, loc.segment_id)?,
&entry_id,
)?;
let _rewrite_guard = self
.prepare_session_rewrite(SessionRewriteKind::Rewind)
.await
@@ -3742,29 +3735,9 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
.lock()
.expect("segment append lock poisoned");
let loc = self.segment_state.location();
if target.segment_id != loc.segment_id {
return Err(RewindError::Invalid(
"rewind target belongs to a different segment".into(),
));
}
let entries = self.store.read_all(loc.session_id, loc.segment_id)?;
if entries.len() != expected_head_entries {
return Err(RewindError::Invalid(format!(
"session head changed since picker opened (expected {expected_head_entries}, current {})",
entries.len()
)));
}
let input = match entries.get(target.user_input_entry_index) {
Some(LogEntry::AnnotatedUserInput { segments, .. }) => segments.clone(),
_ => {
return Err(RewindError::Invalid(
"rewind target is no longer a user message".into(),
));
}
};
let truncate_entries = rewind_truncate_entries(&entries, target.user_input_entry_index);
let (entry_index, input) = resolve_rewind_input(&entries, &entry_id)?;
let truncate_entries = rewind_truncate_entries(&entries, entry_index);
let retained = entries[..truncate_entries].to_vec();
let tool_side_effect_warning = suffix_has_tool_side_effects(&entries[truncate_entries..]);
let state = segment_log::collect_state(&retained);
@@ -8994,37 +8967,21 @@ pub struct RewindAppliedState {
pub summary: RewindSummary,
}
fn build_rewind_targets(segment_id: uuid::Uuid, entries: &[LogEntry]) -> Vec<RewindTarget> {
let head_entries = entries.len();
let mut turn_index = 0usize;
let mut targets = Vec::new();
for (entry_index, entry) in entries.iter().enumerate() {
let (segments, ts) = match entry {
LogEntry::AnnotatedUserInput { segments, ts, .. } => (segments, ts),
_ => continue,
};
turn_index += 1;
let truncate_entries = rewind_truncate_entries(entries, entry_index);
let tool_warning = suffix_has_tool_side_effects(&entries[truncate_entries..]);
targets.push(RewindTarget {
id: RewindTargetId {
segment_id,
user_input_entry_index: entry_index,
},
expected_head_entries: head_entries,
truncate_entries,
turn_index,
timestamp_ms: Some(*ts),
preview: preview_segments(segments),
eligible: true,
disabled_reason: None,
warning: tool_warning.then(|| {
"history suffix will be discarded; tool side effects are not undone".into()
}),
});
}
targets.reverse();
targets
fn resolve_rewind_input(
entries: &[LogEntry],
entry_id: &str,
) -> Result<(usize, Vec<Segment>), RewindError> {
// Resolve only actual user submissions in this segment, never seeded
// ancestor history or a display position inferred from matching text.
entries.iter().enumerate().find_map(|(index, entry)| {
let LogEntry::AnnotatedUserInput { history, segments, .. } = entry else { return None; };
let user = history.iter().find(|entry| matches!(&entry.item,
session_store::LoggedItem::Message { role: session_store::LoggedRole::User, .. }
))?;
(user.metadata.entry_id.0 == entry_id).then(|| (index, segments.clone()))
}).ok_or_else(|| RewindError::Invalid(
"rewind entry is not a user input in the current segment; it may have been removed or belong to another history".into()
))
}
fn rewind_truncate_entries(entries: &[LogEntry], user_input_entry_index: usize) -> usize {
@@ -9052,6 +9009,7 @@ fn logged_item_is_tool_call(item: &session_store::LoggedItem) -> bool {
matches!(item, session_store::LoggedItem::ToolCall { .. })
}
#[cfg(test)]
fn preview_segments(segments: &[Segment]) -> String {
let mut preview = String::new();
for segment in segments {
@@ -12305,48 +12263,139 @@ mod build_summary_prompt_tests {
);
}
fn rewind_test_inputs<C: LlmClient + 'static, St: Store>(
worker: &Worker<C, St>,
) -> Vec<(String, usize)> {
let loc = worker.segment_state.location();
let entries = worker
.store
.read_all(loc.session_id, loc.segment_id)
.unwrap();
let public = session_store::public_snapshot::project_current_session_snapshot(&entries);
public
.entries
.iter()
.filter_map(|entry| {
if !matches!(
entry.data,
protocol::SessionSnapshotEntryData::UserInput { .. }
) {
return None;
}
let index = entries.iter().position(|log| {
matches!(log, LogEntry::AnnotatedUserInput { history, .. }
if history.iter().any(|h| h.metadata.entry_id.0 == entry.entry_id))
})?;
Some((
entry.entry_id.clone(),
rewind_truncate_entries(&entries, index),
))
})
.rev()
.collect()
}
#[tokio::test]
async fn rewind_target_listing_is_newest_first_and_warns_on_tool_suffix() {
let (_dir, worker) = rewind_test_worker().await;
append_user_turn(&worker, 10, "first message");
append_user_turn(&worker, 20, "second message");
async fn rewind_resolves_duplicate_text_by_public_entry_identity() {
let (_dir, mut worker) = rewind_test_worker().await;
append_user_turn(&worker, 10, "same input");
append_user_turn(&worker, 20, "same input");
let inputs = rewind_test_inputs(&worker);
assert_ne!(inputs[0].0, inputs[1].0);
worker.rewind_to(inputs[0].0.clone()).await.unwrap();
assert_eq!(rewind_test_inputs(&worker), vec![inputs[1].clone()]);
}
#[tokio::test]
async fn rewind_rejects_unknown_foreign_and_non_user_ids_without_changing_log() {
let (_dir, mut worker) = rewind_test_worker().await;
let (_other_dir, other) = rewind_test_worker().await;
append_user_turn(&worker, 10, "same input");
append_user_turn(&other, 10, "same input");
let assistant =
crate::session_history::test_logged_history_entry(session_store::LoggedItem::Message {
role: session_store::LoggedRole::Assistant,
content: vec![session_store::LoggedContentPart::Text {
text: "answer".into(),
}],
});
let assistant_id = assistant.metadata.entry_id.0.clone();
append_test_entry(
&worker,
LogEntry::AnnotatedToolResult {
ts: 30,
entry: crate::session_history::test_logged_history_entry(
session_store::LoggedItem::ToolResult {
call_id: "call-1".into(),
summary: "wrote a file".into(),
content: None,
attachments: Vec::new(),
disposition: Default::default(),
is_error: false,
},
),
LogEntry::AnnotatedAssistantItem {
ts: 12,
entry: assistant,
},
);
let (head_entries, targets) = worker.list_rewind_targets().unwrap();
let loc = worker.segment_state.location();
let before = worker
.store
.read_all(loc.session_id, loc.segment_id)
.unwrap();
for id in [
String::new(),
"missing-entry".into(),
rewind_test_inputs(&other)[0].0.clone(),
assistant_id,
] {
assert!(
worker
.rewind_to(id)
.await
.unwrap_err()
.to_string()
.contains("not a user input")
);
assert_eq!(
serde_json::to_value(
worker
.store
.read_all(loc.session_id, loc.segment_id)
.unwrap()
)
.unwrap(),
serde_json::to_value(&before).unwrap()
);
}
}
assert_eq!(
head_entries,
worker
.store
.read_all(loc.session_id, loc.segment_id)
.unwrap()
.len()
#[tokio::test]
async fn rewind_rejects_user_input_from_seeded_ancestor_history() {
let (_dir, mut worker) = rewind_test_worker().await;
let input = crate::session_history::test_logged_history_entry(
session_store::LoggedItem::from(agen::Item::user_message("ancestor input")),
);
assert_eq!(targets.len(), 2);
assert_eq!(targets[0].preview, "second message");
assert_eq!(targets[1].preview, "first message");
let id = input.metadata.entry_id.0.clone();
let loc = worker.segment_state.location();
let mut entries = worker
.store
.read_all(loc.session_id, loc.segment_id)
.unwrap();
let LogEntry::AnnotatedSegmentStart { history, .. } = &mut entries[0] else {
panic!("segment start");
};
history.push(input);
worker
.store
.create_segment(loc.session_id, loc.segment_id, &entries)
.unwrap();
assert!(
targets[0]
.warning
.as_ref()
.unwrap()
.contains("tool side effects")
worker
.rewind_to(id)
.await
.unwrap_err()
.to_string()
.contains("current segment")
);
assert_eq!(
serde_json::to_value(
worker
.store
.read_all(loc.session_id, loc.segment_id)
.unwrap()
)
.unwrap(),
serde_json::to_value(entries).unwrap()
);
}
@@ -12371,11 +12420,11 @@ mod build_summary_prompt_tests {
),
},
);
let (head_entries, targets) = worker.list_rewind_targets().unwrap();
let expected_truncate_entries = targets[0].truncate_entries;
let target = targets[0].id.clone();
let targets = rewind_test_inputs(&worker);
let expected_truncate_entries = targets[0].1;
let target = targets[0].0.clone();
let applied = worker.rewind_to(target, head_entries).await.unwrap();
let applied = worker.rewind_to(target).await.unwrap();
assert_eq!(preview_segments(&applied.input), "second message");
assert_eq!(
@@ -12405,10 +12454,10 @@ mod build_summary_prompt_tests {
.pending_submission_handle()
.accept_notification("notification-1".into(), "keep me".into())
.unwrap();
let (head_entries, targets) = worker.list_rewind_targets().unwrap();
let targets = rewind_test_inputs(&worker);
worker
.rewind_to(targets.last().unwrap().id.clone(), head_entries)
.rewind_to(targets.last().unwrap().0.clone())
.await
.unwrap();
@@ -12465,16 +12514,11 @@ mod build_summary_prompt_tests {
},
);
let (head_entries, targets) = worker.list_rewind_targets().unwrap();
let targets = rewind_test_inputs(&worker);
assert_eq!(targets.len(), 2);
assert_eq!(targets[0].preview, "second message");
assert!(targets[0].truncate_entries > 0);
assert!(targets[0].warning.is_some());
assert!(targets[0].1 > 0);
let applied = worker
.rewind_to(targets[0].id.clone(), head_entries)
.await
.unwrap();
let applied = worker.rewind_to(targets[0].0.clone()).await.unwrap();
assert_eq!(applied.summary.truncated_to_entries, 5);
assert!(matches!(
applied.entries.first(),
@@ -12539,19 +12583,20 @@ mod build_summary_prompt_tests {
}
#[tokio::test]
async fn rewind_apply_rejects_stale_head() {
async fn rewind_by_entry_id_includes_history_appended_after_selection() {
let (_dir, mut worker) = rewind_test_worker().await;
append_user_turn(&worker, 10, "first message");
let (head_entries, targets) = worker.list_rewind_targets().unwrap();
let targets = rewind_test_inputs(&worker);
append_user_turn(&worker, 20, "newer message");
let err = worker
.rewind_to(targets[0].id.clone(), head_entries)
.await
.unwrap_err()
.to_string();
assert!(err.contains("session head changed"));
let applied = worker.rewind_to(targets[0].0.clone()).await.unwrap();
assert_eq!(preview_segments(&applied.input), "first message");
assert!(rewind_test_inputs(&worker).is_empty());
let err = worker.rewind_to(targets[0].0.clone()).await.unwrap_err();
assert!(
err.to_string()
.contains("not a user input in the current segment")
);
}
#[tokio::test]
@@ -15421,7 +15466,7 @@ permission = "write"
assert!(pending.pending_notifications.is_empty());
assert!(pending.activating_notifications.is_empty());
}
let (head, targets) = original.list_rewind_targets().unwrap();
let targets = rewind_test_inputs(&original);
let target = targets.last().unwrap();
let old = store
.read_all(original.session_id(), original.segment_id())
@@ -15431,7 +15476,7 @@ permission = "write"
store
.invocation_failure
.store(replacement_fault, Ordering::SeqCst);
let applied = original.rewind_to(target.id.clone(), head).await;
let applied = original.rewind_to(target.0.clone()).await;
let disk = store
.read_all(original.session_id(), original.segment_id())
.unwrap();
@@ -15456,15 +15501,9 @@ permission = "write"
replacement_fault
);
let applied = applied.unwrap();
assert_eq!(
applied.summary.truncated_to_entries,
target.truncate_entries
);
assert_eq!(
applied.summary.discarded_entries,
head - target.truncate_entries
);
assert_eq!(applied.entries.len(), target.truncate_entries);
assert_eq!(applied.summary.truncated_to_entries, target.1);
assert_eq!(applied.summary.discarded_entries, old.len() - target.1);
assert_eq!(applied.entries.len(), target.1);
assert_eq!(original.segment_state.entries_written(), disk.len());
assert_eq!(
serde_json::to_value(original.sink.subscribe_with_snapshot().0)
@@ -15474,10 +15513,10 @@ permission = "write"
} else {
assert!(applied.is_err());
}
assert_eq!(disk.len(), target.truncate_entries + 1);
assert_eq!(disk.len(), target.1 + 1);
assert_eq!(
serde_json::to_value(&disk[..target.truncate_entries]).unwrap(),
serde_json::to_value(&old[..target.truncate_entries]).unwrap()
serde_json::to_value(&disk[..target.1]).unwrap(),
serde_json::to_value(&old[..target.1]).unwrap()
);
let LogEntry::Extension {
domain, payload, ..
+1 -1
View File
@@ -2730,7 +2730,7 @@ async fn shutdown_closes_method_admission_before_terminal_confirmation() {
loop {
tokio::select! {
biased;
result = handle.send(Method::ListRewindTargets) => {
result = handle.send(Method::ListWorkers) => {
if result.is_err() {
break;
}
-4
View File
@@ -705,7 +705,6 @@ pub enum WorkerInputKind {
UserIfIdle,
Notify,
Compact,
ListRewindTargets,
RegisterPeer,
}
@@ -3271,7 +3270,6 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
WorkerInputKind::UserIfIdle => EmbeddedWorkerInputKind::UserIfIdle,
WorkerInputKind::Notify => EmbeddedWorkerInputKind::Notify,
WorkerInputKind::Compact => EmbeddedWorkerInputKind::Compact,
WorkerInputKind::ListRewindTargets => EmbeddedWorkerInputKind::ListRewindTargets,
WorkerInputKind::RegisterPeer => EmbeddedWorkerInputKind::RegisterPeer,
},
content: request.content,
@@ -5310,7 +5308,6 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
WorkerInputKind::UserIfIdle => EmbeddedWorkerInputKind::UserIfIdle,
WorkerInputKind::Notify => EmbeddedWorkerInputKind::Notify,
WorkerInputKind::Compact => EmbeddedWorkerInputKind::Compact,
WorkerInputKind::ListRewindTargets => EmbeddedWorkerInputKind::ListRewindTargets,
WorkerInputKind::RegisterPeer => EmbeddedWorkerInputKind::RegisterPeer,
},
content: request.content,
@@ -6748,7 +6745,6 @@ mod tests {
EmbeddedWorkerInputKind::UserIfIdle,
EmbeddedWorkerInputKind::Notify,
EmbeddedWorkerInputKind::Compact,
EmbeddedWorkerInputKind::ListRewindTargets,
EmbeddedWorkerInputKind::RegisterPeer,
] {
let input = EmbeddedWorkerInput {
-2
View File
@@ -6930,7 +6930,6 @@ fn worker_input_request_from_api(
"user" => WorkerInputKind::User,
"notify" => WorkerInputKind::Notify,
"compact" => WorkerInputKind::Compact,
"list_rewind_targets" => WorkerInputKind::ListRewindTargets,
"register_peer" => WorkerInputKind::RegisterPeer,
value => {
return Err(Error::InvalidInput(format!(
@@ -29168,7 +29167,6 @@ pub(crate) fn authorize_browser_worker_method(
| protocol::Method::Cancel { .. }
| protocol::Method::Pause { .. }
| protocol::Method::Compact { .. }
| protocol::Method::ListRewindTargets
| protocol::Method::RewindTo { .. }
| protocol::Method::Shutdown { .. }
| protocol::Method::ListCompletions { .. }
@@ -19,6 +19,7 @@ type State = {
params?: {
input?: import("../../../web/workspace/src/lib/generated/protocol.ts").Segment[];
submission_request_id?: string;
entry_id?: 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),
);
}));
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 uploadMode: "normal" | "fail" | "hold" = "normal";
const pendingAcceptances: (() => void)[] = [];
let rewindMode: "accept" | "reject" | "hold" = "accept";
const pendingRewinds: (() => void)[] = [];
const cancelledUploads = new Set<string>();
const heldUploads = new Map<string, (response: Response) => 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();
if (["accept", "reject", "hold"].includes(control.submit)) submitMode = control.submit;
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.release_uploads) {
for (const release of [...releaseHeldUploads.values()]) release();
@@ -365,6 +373,21 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
methods.push(method);
const subscription_id = frame.message.subscription_id;
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") {
const { kind, prefix, request_id, context } = method.params;
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") {
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 accept = () =>
const accept = () => {
emit({
event: "submission_accepted",
data: {
@@ -450,6 +473,20 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
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") {
emit({
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 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 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 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
* 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.
* 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({
extensions: editorExtensions = [
history(),
Prec.highest(EditorView.domEventHandlers({ keydown: completionKeydown })),
Prec.highest(EditorView.domEventHandlers({ keydown(event) {
onkeydown?.(event);
return event.defaultPrevented || completionKeydown(event);
} })),
Prec.highest(keymap.of([
{
key: "ArrowUp",
@@ -851,8 +854,6 @@
return true;
},
keydown(event) {
onkeydown?.(event);
if (event.defaultPrevented) return true;
if (
shouldSubmitChatKey(event, {
mode: "mod-enter",
@@ -7,10 +7,11 @@
type Props = {
item: ConsoleLine;
rewindSelected?: boolean;
attachmentUrl?: (attachment: SessionToolAttachment) => string | null;
};
let { item, attachmentUrl = () => null }: Props = $props();
let { item, rewindSelected = false, attachmentUrl = () => null }: Props = $props();
let detailOpen = $state(false);
let nowMs = $state(Date.now());
@@ -83,6 +84,8 @@
<li
class={`console-line ${lineClass(item)} ${toolClass(item)}`}
class:error-line={item.error}
class:rewind-selected={rewindSelected}
aria-current={rewindSelected ? "true" : undefined}
data-console-line-id={item.id}
>
{#if item.compaction}
@@ -165,6 +168,12 @@
</li>
<style>
.rewind-selected {
outline: 2px solid var(--accent);
outline-offset: -2px;
background: color-mix(in srgb, var(--accent) 12%, transparent);
}
.console-line {
min-width: 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",
});
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"
| "notify"
| "compact"
| "list_rewind_targets"
| "register_peer";
export type WorkerConsoleInputRequest = {
@@ -15,7 +14,7 @@ export type WorkerConsoleInputRequest = {
};
export type ComposerCommandResult =
| { ok: true; request?: WorkerConsoleInputRequest; notice?: string }
| { ok: true; request?: WorkerConsoleInputRequest; action?: "rewind"; notice?: string }
| { ok: false; message: string };
type CommandSpec = {
@@ -43,7 +42,7 @@ export const COMMANDS: Record<string, CommandSpec> = {
},
rewind: {
usage: ":rewind",
description: "Ask the Worker for rewind targets.",
description: "Select a user input in Console history with ↑/↓, then Enter to rewind.",
},
rollback: {
usage: ":rollback",
@@ -200,8 +199,7 @@ function buildColonCommand(commandLine: string): ComposerCommandResult {
}
return {
ok: true,
request: { kind: "list_rewind_targets", content: "" },
notice: "rewind targets requested",
action: "rewind",
};
case "peer":
if (argv.length !== 1) {
@@ -1325,7 +1325,6 @@ export function applyProtocolEvent(
break;
case "memory_worker":
case "completions":
case "rewind_targets":
case "workers_listed":
case "worker_restored":
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("import { pushWorkspaceAlert }") &&
page.includes('title: "Worker control"') &&
page.includes('title: "Rewind targets"') &&
!page.includes('aria-label="Rewind targets"') &&
page.includes('pushWorkspaceAlert("error"') &&
!page.includes("controlNotice") &&
!page.includes("console-notice") &&
@@ -1271,7 +1271,7 @@ Deno.test("Worker Console route resolves logical Worker authority before Runtime
'const workerRunning = $derived(workerState === "running");',
) &&
consolePage.includes(
'const canSendComposerControl = $derived(protocolState === "open" && !sending);',
'const canSendComposerControl = $derived(protocolState === "open" && !sending && !rewindApplying);',
) &&
consolePage.includes('sendWorkerControl("cancel")') &&
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("disabled={!composerEditable}") &&
consolePage.includes("if (!canSendComposerControl) return;") &&
consolePage.includes("class:stop={workerRunning}") &&
consolePage.includes("class:stop={workerRunning && !rewindEntryId}") &&
consolePage.includes('"Stop Worker"') &&
consolePage.includes("disabled={composerSubmitDisabled}") &&
!consolePage.includes("disabled={!inputReady || sending}") &&
@@ -44,7 +44,6 @@
Event as ProtocolEvent,
Method as ProtocolMethod,
PendingSubmissionsSnapshot,
RewindTarget,
Segment,
SessionHistoryPage,
SessionToolAttachment,
@@ -176,8 +175,9 @@
reportComposerError(error instanceof Error ? error.message : String(error));
}
}
let rewindTargets = $state<RewindTarget[]>([]);
let rewindHeadEntries = $state(0);
let rewindEntryId = $state<string | null>(null);
let rewindApplying = $state(false);
let rewindLoadingEarlier = false;
let protocolState = $state<"connecting" | "open" | "closed" | "error">(
"connecting",
);
@@ -305,6 +305,7 @@
const turnNavigationItems = $derived(
conversationTurnPreviewsFromLines(lines),
);
const rewindEntries = $derived(lines.filter((line) => line.kind === "user" && line.entryId));
const tasks = $derived(selectedConsoleProjection.tasks);
const diagnostics = $derived(
mergeDiagnostics(worker?.diagnostics ?? [], streamDiagnostics),
@@ -314,7 +315,7 @@
);
const workerRunning = $derived(workerState === "running");
const workerPaused = $derived(workerState === "paused");
const canSendComposerControl = $derived(protocolState === "open" && !sending);
const canSendComposerControl = $derived(protocolState === "open" && !sending && !rewindApplying);
const canUploadAttachments = $derived(consoleTarget !== null);
const draftHasText = $derived(draft.content.trim().length > 0);
const draftHasAttachments = $derived(
@@ -325,7 +326,7 @@
delivery: "submit",
workerState,
protocolOpen: protocolState === "open",
sending,
sending: sending || rewindApplying,
hasText: draftHasText,
hasAttachments: draftHasAttachments,
}),
@@ -335,7 +336,7 @@
delivery: "queue",
workerState,
protocolOpen: protocolState === "open",
sending,
sending: sending || rewindApplying,
hasText: draftHasText,
hasAttachments: draftHasAttachments,
}),
@@ -345,14 +346,14 @@
delivery: "notify",
workerState,
protocolOpen: protocolState === "open",
sending,
sending: sending || rewindApplying,
hasText: draftHasText,
hasAttachments: draftHasAttachments,
}),
);
const canStopFromComposer = $derived(workerRunning && canSendComposerControl);
const composerSubmitDisabled = $derived(
workerRunning ? !canStopFromComposer : !canSubmitDraft,
rewindEntryId ? !canSendComposerControl : workerRunning ? !canStopFromComposer : !canSubmitDraft,
);
async function getJson<T>(path: string): Promise<T> {
@@ -773,6 +774,8 @@
) {
handleProtocolCommandEvent(payload);
if (payload.event === "rewind_applied") {
rewindApplying = false;
rewindEntryId = null;
if (draft.segments.length === 0 && attachments.length === 0) {
composerInputElement?.restoreSegments(payload.data.input);
} else {
@@ -824,6 +827,7 @@
}
function handleComposerKeydown(event: KeyboardEvent) {
if (handleRewindKeydown(event)) return;
if (event.key === "PageUp" || event.key === "PageDown") {
event.preventDefault();
scrollConsoleByPage(event.key === "PageDown" ? 1 : -1);
@@ -934,17 +938,88 @@
sendWorkerControl(command);
}
function rewindTo(target: RewindTarget) {
sendControl(
{
method: "rewind_to",
params: {
target: target.id,
expected_head_entries: rewindHeadEntries,
},
},
`Rewind to ${target.preview || "target"}`,
);
async function startRewindSelection(): Promise<void> {
if (rewindApplying) return;
const targetKey = activeComposerTargetKey;
await selectConsoleWorkerView(null);
if (activeComposerTargetKey !== targetKey) return;
const target = rewindEntries.at(-1);
if (!target?.entryId) {
reportComposerError("No user input is loaded. Load earlier conversation to select a rewind point.");
return;
}
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(
@@ -974,8 +1049,6 @@
if (!method) throw new Error("Worker state snapshot is not available");
return method;
}
case "list_rewind_targets":
return { method: "list_rewind_targets" };
case "register_peer":
return {
method: "register_peer",
@@ -992,6 +1065,7 @@
}
function handleComposerChange(snapshot: ComposerDraftSnapshot) {
if (snapshot.document !== draft.document) rewindEntryId = null;
draft = snapshot;
composerDrafts.set(activeComposerTargetKey, cachedComposerDraft(snapshot));
}
@@ -1057,13 +1131,14 @@
}
function handleComposerSubmit() {
if (!canSendComposerControl) return;
if (rewindEntryId) { applySelectedRewind(); return; }
const value = composerSnapshotOrReport();
if (!value) return;
if (value.document.trimStart().startsWith(":")) {
handleComposerCommand();
return;
}
if (!canSendComposerControl) return;
if (workerRunning) {
sendWorkerControl("cancel");
return;
@@ -1275,6 +1350,11 @@
reportComposerError(command.message);
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.notice) pushWorkspaceAlert("info", command.notice, { title: "Console command" });
composerInputElement?.clear();
@@ -1284,7 +1364,7 @@
delivery,
workerState,
protocolOpen: protocolState === "open",
sending,
sending: sending || rewindApplying,
hasText: value.content.trim().length > 0,
hasAttachments: attachments.length > 0 ||
value.segments.some((segment) => segment.kind === "uploaded_file"),
@@ -1622,19 +1702,11 @@
}
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 (rewindApplying) {
rewindApplying = false;
reportComposerError(event.data.message);
}
const error = new Error(event.data.message);
if (
fileCompletions.pending || featureCompletions.pending ||
@@ -1655,6 +1727,8 @@
function rejectPendingCompletion(error: Error, targetKey = activeComposerTargetKey) {
admissionDisconnected(targetKey);
rewindEntryId = null;
rewindApplying = false;
// Invalidate suggestions on disconnect without locking the draft.
composerInputElement?.closeCompletion();
fileCompletions.close(error);
@@ -1693,6 +1767,7 @@
rememberCurrent = true,
) {
if (sessionId === selectedWorkerViewSessionId) return;
rewindEntryId = null;
const generation = ++workerViewSelectionGeneration;
if (rememberCurrent) rememberConsoleWorkerViewScroll();
const target = workerViews.find((view) => view.sessionId === sessionId) ??
@@ -1799,6 +1874,8 @@
const target = consoleTarget;
const targetWorker = data.worker;
const targetWorkerError = data.workerError;
rewindEntryId = null;
rewindApplying = false;
workerViewSelectionGeneration += 1;
selectedWorkerViewSessionId = null;
consoleViewScroll.clear();
@@ -1854,7 +1931,7 @@
});
</script>
<svelte:window onkeydown={handleWorkerControlShortcut} />
<svelte:window onkeydown={handleConsoleKeydown} />
<svelte:head>
<title>Worker Console · Yoi Workspace</title>
@@ -1865,28 +1942,6 @@
</svelte:head>
<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}
<svelte:boundary onerror={(error) => console.error("Console transcript rendering failed", error)}>
<div class:with-task-pane={taskPaneOpen} class="console-history">
@@ -1932,7 +1987,7 @@
{#if (consoleDisplayState.kind === "ready" || consoleDisplayState.kind === "stale") && lines.length > 0}
<ol class="console-log">
{#each lines as item (item.id)}
<ConsoleLineItem {item} {attachmentUrl} />
<ConsoleLineItem {item} {attachmentUrl} rewindSelected={rewindEntryId !== null && item.entryId === rewindEntryId} />
{/each}
</ol>
{/if}
@@ -2151,6 +2206,12 @@
accept="text/*,application/json,application/pdf,image/png,image/jpeg,image/gif,image/webp"
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
bind:this={composerInputElement}
historyScope={workspaceId}
@@ -2268,16 +2329,18 @@
{/if}
<button
class="composer-send-button"
class:stop={workerRunning}
class:stop={workerRunning && !rewindEntryId}
type="submit"
aria-label={workerRunning
aria-label={rewindEntryId ? "Rewind to selected input" : workerRunning
? "Stop Worker"
: sending
? "Sending message"
: "Send message"}
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
class="composer-send-icon"
aria-hidden="true"
@@ -2325,6 +2388,9 @@
</div>
<style>
.rewind-status { font-size: var(--font-size-compact); color: var(--text-muted); }
.rewind-status span { display: block; }
.console-shell {
width: 100%;
max-width: 920px;
@@ -2409,28 +2475,7 @@
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 {
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 }) },
}));
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 } });
const option = await screen.findByRole("option");
await fireEvent.pointerDown(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);
});
@@ -1701,3 +1705,119 @@ test("pending groups independently follow authoritative changes, including zero
latestListener().onFrame(subscribedFrame(session));
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);
});