Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions agent_session/pkg.generated.mbti
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ pub fn Session::from_json(Json, @json.JsonPath) -> Self raise @json.JsonDecodeEr
pub fn Session::id(Self) -> SessionId
pub fn Session::last_sequence(Self) -> Int
pub fn Session::not_equal(Self, Self) -> Bool
pub fn Session::remove_last(Self, count? : Int) -> Self
pub fn Session::summary_item(Self, content~ : String, from_sequence~ : Int, to_sequence~ : Int) -> SessionItem raise
pub fn Session::system_prompt(Self) -> String
pub fn Session::to_json(Self) -> Json
Expand Down
23 changes: 23 additions & 0 deletions agent_session/types.mbt
Original file line number Diff line number Diff line change
Expand Up @@ -517,6 +517,29 @@ pub fn Session::events(self : Session) -> @vector.Vector[SessionEvent] {
self.events
}

///|
/// Return a new `Session` whose last `count` events have been removed.
///
/// The receiver is unchanged. When `count` exceeds the event count, all events
/// are removed and the session is empty. This is the inverse of `append` for
/// implementing `/undo` in the TUI.
///
/// Invariant: the returned session has contiguous sequence numbers starting at
/// 1 (when non-empty), matching the `Session` constructor contract. Durable
/// callers must persist it via `SessionStore::create` (atomic rewrite), not
/// `SessionStore::append`, since `remove_last` is a mutation, not an addition.
pub fn Session::remove_last(self : Session, count? : Int = 1) -> Session {
let n = count.min(self.events.length())
let keep = self.events.length() - n
let items = self.events.to_array()
{
id: self.id,
system_prompt: self.system_prompt,
events: @vector.from_array(items[0:keep]),
last_sequence: self.last_sequence - n,
}
}

///|
/// Return the sequence number that will precede the next append.
///
Expand Down
29 changes: 29 additions & 0 deletions cmd/openseek/serve.mbt
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ priv enum ServeCommand {
Compact
Cancel
Goal(GoalAction)
Undo(Int)
}

///|
Expand Down Expand Up @@ -177,6 +178,12 @@ fn decode_serve_command(json : Json) -> Result[ServeCommand, String] {
Ok(Goal(SetGoal(text, auto~)))
}
GoalClear => Ok(Goal(ClearGoal))
Undo(count~) =>
if count <= 0 {
Err("undo count must be positive")
} else {
Ok(Undo(count))
}
}
}

Expand Down Expand Up @@ -1099,6 +1106,28 @@ async fn run_serve(
} else {
active_work = Some(ActiveCompact(start_compaction(session)))
}
Wire(Undo(count)) =>
if active_work is Some(_) || pending.length() > 0 {
@emit.emit(
CommandError(
error="cannot undo while a turn or compaction is in flight",
),
)
} else if store is None {
@emit.emit(
CommandError(
error="undo requires a durable session (--session or OPENSEEK_SESSION)",
),
)
} else if store is Some(store) {
// The durable session is the source of truth for what the
// controller sees on resume; roll it back and persist the rewrite
// in one atomic replacement (create, not append).
let modified = session.remove_last(count~)
store.create(modified)
session = modified
@emit.emit(SessionUndone(events_removed=count))
}
Wire(Goal(action)) => {
// Any new goal command supersedes the previous auto state; a set
// with auto only records a PENDING intent against the current
Expand Down
28 changes: 28 additions & 0 deletions cmd/tui/engine_client.mbt
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,10 @@ async fn[G] EngineClient::start(
}
self.messages.try_put(EngineGoalUpdated(goal)) |> ignore
}
// Session-undo confirmation. Posted untagged like a goal update,
// so an idle confirmation is never swept by the stale-run guard.
SessionUndone(count~) =>
self.messages.try_put(EngineSessionUndone(count~)) |> ignore
// Auto-continue lifecycle from a headless-armed goal: surfaced as
// plain notices — the TUI never arms auto itself, and engine-
// initiated turns are invisible to run tracking by design.
Expand Down Expand Up @@ -455,6 +459,30 @@ async fn[G] EngineClient::compact(
self.open_run(run_id, (Compact : @protocol.Command).to_json())
}

///|
/// Send an `undo` command to the serve engine so it removes the last `count`
/// events from the durable session. Like a goal update, this is an explicit
/// user operation on the durable session, so it may start the long-lived
/// engine to perform the rewrite without opening a model turn. The engine
/// answers with `session_undone`, which is the only signal that triggers the
/// TUI's session-reload path.
async fn[G] EngineClient::undo(
self : EngineClient,
group : @async.TaskGroup[G],
count~ : Int,
) -> Unit {
guard self.live_for(group, "undo") is Some(live) else { return }
let command : Json = (Undo(count~) : @protocol.Command).to_json()
live.writer.write("\{command.stringify()}\n") catch {
error if @async.is_being_cancelled() || @async.is_cancellation_error(error) =>
raise error
error => {
self.live = None
self.messages.put(EngineSessionError("failed to send undo: \{error}"))
}
}
}

///|
/// Cancel the running turn. With no live engine or open run this is a
/// silent no-op — the turn the user wanted dead is already gone.
Expand Down
13 changes: 9 additions & 4 deletions cmd/tui/internal/event/decode.mbt
Original file line number Diff line number Diff line change
Expand Up @@ -73,12 +73,17 @@ pub fn decode_event(object : Json) -> AgentEvent? {
GoalUpdated(goal~) => Some(GoalUpdated(goal))
GoalContinue(remaining~) => Some(GoalContinue(remaining))
GoalBudgetExhausted(_) => Some(GoalBudgetExhausted)
// A turn that ended at the context ceiling: run-terminal, not success.
SessionUndone(events_removed~) => Some(SessionUndone(count=events_removed))
// A turn that ended at the model's context ceiling: checkpointed and
// terminal for the run, but NOT task success — the work continues in a
// new turn the user (or a controller) starts.
ContextYield(answer~, ..) => Some(ContextYield(answer))
// A sub-run's lifecycle brackets. The id pairs starts with finishes on
// the wire; the UI shows them as ordered transcript notices, so only
// the display fields survive decoding.
// A nested sub-run (a review, an explore) began inside the active run.
// `label` is a short display string, never the raw query.
SubrunStarted(kind~, label~, ..) => Some(SubrunStarted(kind~, label~))
// The paired completion: the child's terminal status ("captured",
// "failed", …) and how many model steps it spent. Non-terminal for the
// outer run — the parent turn continues.
SubrunFinished(status~, steps~, ..) => Some(SubrunFinished(status~, steps~))
// MCP lifecycle diagnostics, surfaced as notices so a typo'd config or a
// dead server is visible in the UI instead of silently yielding no tools.
Expand Down
4 changes: 4 additions & 0 deletions cmd/tui/internal/event/event.mbt
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,10 @@ pub(all) enum AgentEvent {
// The auto-continue budget ran out with the goal still standing; the
// engine paused for input. Posted untagged for the same reason.
GoalBudgetExhausted
// The engine durably removed `count` events from the session in response
// to an `undo` command. Non-terminal, untagged — always arrives between
// turns or at an idle boundary.
SessionUndone(count~ : Int)
// The turn ended at the model's context ceiling: checkpointed and
// terminal for the run, but NOT task success — the work continues in a
// new turn the user (or a controller) starts.
Expand Down
1 change: 1 addition & 0 deletions cmd/tui/internal/event/pkg.generated.mbti
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ pub(all) enum AgentEvent {
GoalUpdated(String?)
GoalContinue(Int)
GoalBudgetExhausted
SessionUndone(count~ : Int)
ContextYield(String)
SubrunStarted(kind~ : String, label~ : String)
SubrunFinished(status~ : String, steps~ : Int)
Expand Down
44 changes: 44 additions & 0 deletions cmd/tui/loop.mbt
Original file line number Diff line number Diff line change
Expand Up @@ -504,6 +504,12 @@ fn handle_agent_event(
// mirror must follow (a goal(met) mid-run would otherwise leave /goal
// reporting a cleared goal until restart).
GoalUpdated(goal) => apply_goal_update(state, goal, cmds)
// Like goal updates, the serve client routes session undo confirmations
// untagged (EngineSessionUndone), but ONESHOT mode forwards engine events
// run-tagged, so this path is live there: the confirmation notice must
// follow however the event arrives.
SessionUndone(count~) =>
cmds.push(AppendItem(Input(Notice("undo: removed \{count} event(s)"))))
// Likewise routed untagged by the client (as background notices).
GoalContinue(_) | GoalBudgetExhausted => ()
// Terminal for the run but not task success: surface the continuation
Expand Down Expand Up @@ -579,6 +585,36 @@ fn handle_compact_command(
cmds.push(CompactSession(run_id~))
}

///|
fn handle_undo_command(
state : State,
arguments : String,
cmds : Array[Cmd],
) -> Unit {
// Undo is a rewrite command on the durable session of the persistent serve
// engine; a oneshot engine is spawned per prompt and keeps no session to
// rewrite.
if state.config.engine_mode is OneShot {
cmds.push(
AppendItem(
Error("The undo command is not supported by a oneshot engine."),
),
)
return
}
let count : Int = match arguments.trim().to_owned() {
"" => 1
input =>
@cli.parse_positive_int(input, "/undo count") catch {
error => {
cmds.push(AppendItem(Error("usage: /undo [count] — \{error}")))
return
}
}
}
cmds.push(UndoRun(count~))
}

///|
fn handle_slash_command(
state : State,
Expand All @@ -596,6 +632,7 @@ fn handle_slash_command(
"compact" => handle_compact_command(state, arguments, cmds)
"loop" => handle_loop_command(state, arguments, cmds)
"goal" => handle_goal_command(state, arguments, cmds)
"undo" => handle_undo_command(state, arguments, cmds)
other => cmds.push(AppendItem(Error("Unknown slash command: /\{other}")))
}
}
Expand Down Expand Up @@ -786,6 +823,12 @@ fn update(state : State, message : Msg) -> Array[Cmd] {
// The engine's durable-append confirmation is the only writer of the
// mirrored goal state.
EngineGoalUpdated(goal) => apply_goal_update(state, goal, cmds)
// The engine confirmed it removed trailing events from the durable
// session. The session mutation is the engine's; the transcript is the
// display of that session, so a confirmation notice is all that changes
// here — no run lifecycle is involved.
EngineSessionUndone(count~) =>
cmds.push(AppendItem(Input(Notice("undo: removed \{count} event(s)"))))
EngineExited => ()
// Tick is a 1s heartbeat: it keeps the message loop turning so the trailing
// refresh_ui re-runs (the elapsed-time activity line keeps advancing during
Expand Down Expand Up @@ -896,6 +939,7 @@ async fn[G] run_message_loop(
}
SteerAgent(input) => engine.steer(group, input)
UpdateGoal(goal) => engine.goal(group, goal)
UndoRun(count~) => engine.undo(group, count~)
// Compaction is an engine operation with no local task. Clear any stale
// command handle (as a queued prompt would) so a Ctrl-C cancels this
// compaction turn rather than a finished command task.
Expand Down
4 changes: 4 additions & 0 deletions cmd/tui/main.mbt
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,10 @@ async fn run_tui(config : AppConfig, initial : String?) -> Unit {
name="goal",
description="standing goal: /goal | /goal set <text> | /goal clear",
),
@ui.SlashCommandSpec::new(
name="undo",
description="undo last turn: /undo [count]",
),
]),
ui => run_app(config, initial, ui),
)
Expand Down
9 changes: 9 additions & 0 deletions cmd/tui/state.mbt
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,10 @@ priv enum Msg {
// The engine durably recorded a goal update. Untagged like a background
// notice, so an idle confirmation is never swept by the stale-run guard.
EngineGoalUpdated(String?)
// The engine confirmed that it removed events from the durable session
// in response to an `undo` command. Untagged like a goal update, so an
// idle confirmation is never swept by the stale-run guard.
EngineSessionUndone(count~ : Int)
// The persistent engine process exited (for any reason): its background
// watchers died with it, so the loop can observe the engine boundary.
EngineExited
Expand All @@ -246,6 +250,7 @@ fn Msg::run_id(self : Msg) -> Int? {
UiInput(_)
| SteerDropped(_)
| EngineGoalUpdated(_)
| EngineSessionUndone(_)
| EngineSteerDropped(_)
| EngineSessionError(_)
| EngineBackgroundNotice(_)
Expand Down Expand Up @@ -276,6 +281,10 @@ priv enum Cmd {
// Set (Some) or clear (None) the standing goal on the serve engine. The
// engine's goal_updated event — not this command — mutates local state.
UpdateGoal(String?)
// Ask the serve engine to remove `count` trailing events from the durable
// session. The engine's session_undone event — not this command — mutates
// the transcript's understanding of the conversation.
UndoRun(count~ : Int)
CancelAgent
// Second Ctrl-C while already interrupting: the cancel command was not
// enough, so kill the engine process outright.
Expand Down
5 changes: 4 additions & 1 deletion desktop/frontend/transcript/engine_event.mbt
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,9 @@ pub fn decode_engine_event(
// error, not as stream events; `auto_compaction_started/failed` are internal
// to an open turn, unlike the finish this view previews;
// `session_started`/`workspace_created`/`fleet_started` describe a headless
// run's setup; the MCP events are engine-side registry diagnostics.
// run's setup; `session_undone` is a durable-session rollback acknowledgment,
// which this view does not act on; the MCP events are engine-side registry
// diagnostics.
GoalUpdated(..)
| GoalBlocked(..)
| GoalUnblocked
Expand All @@ -142,6 +144,7 @@ pub fn decode_engine_event(
| AutoCompactionStarted(..)
| AutoCompactionFailed(..)
| SessionStarted(..)
| SessionUndone(..)
| WorkspaceCreated(..)
| FleetStarted(..)
| McpConfigIgnored(..)
Expand Down
3 changes: 3 additions & 0 deletions desktop/internal/event/decode.mbt
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,9 @@ pub fn decode_event(object : Json) -> AgentEvent? {
| ReasoningMessage(..)
| BackgroundNotice(..)
| SessionError(..)
// The host never mutates the durable session itself; a session-undo
// confirmation is between the webview and the engine.
| SessionUndone(..)
| GoalUpdated(..)
| GoalBlocked(..)
| GoalUnblocked
Expand Down
23 changes: 23 additions & 0 deletions protocol/command.mbt
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ pub(all) enum Command {
/// protocol, not a private channel, and an external controller drives this.
GoalSet(text~ : String, auto~ : Bool)
GoalClear
Undo(count~ : Int)
} derive(Eq, Debug)

///|
Expand Down Expand Up @@ -77,6 +78,12 @@ pub fn Command::to_json(self : Command) -> Json {
{ "command": "goal", "action": "set", "text": text }
}
GoalClear => { "command": "goal", "action": "clear" }
Undo(count~) =>
if count != 1 {
{ "command": "undo", "count": count }
} else {
{ "command": "undo" }
}
}
}

Expand Down Expand Up @@ -145,6 +152,22 @@ pub fn Command::parse(line : Json) -> Result[Command, String] {
// words, where the policy lives.
{ "command": "goal", "action": "set", "text": String(text), .. } =>
Ok(GoalSet(text~, auto=line is { "auto": True, .. }))
// An absent `count` means 1 — `to_json` omits the field for the common
// single-undo command, so omission is what a one-event undo has always
// looked like on this wire.
{ "command": "undo", "count": Number(value, ..), .. } => {
let count = value.to_int()
// JSON has one number type: reject a fractional count rather than
// silently truncating it (matching `parse`'s `int` helper).
if count.to_double() == value {
Ok(Undo(count~))
} else {
Err("expected an integer \"count\" field")
}
}
{ "command": "undo", "count": _, .. } =>
Err("expected a number \"count\" field")
{ "command": "undo", .. } => Ok(Undo(count=1))
{ "command": "goal", "action": "clear", .. } => Ok(GoalClear)
{ "command": "goal", .. } =>
Err(
Expand Down
1 change: 1 addition & 0 deletions protocol/emit/emit.mbt
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ fn level(event : @protocol.Event) -> @xlog.Level {
| ToolCallDecodeError(..)
| CompactionFailed(_)
| SessionError(_)
| SessionUndone(_)
| CommandError(_) => Error
AgentAborted(_)
| MaxStepsExhausted
Expand Down
1 change: 1 addition & 0 deletions protocol/emit/emit_test.mbt
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ fn all_events() -> Array[@protocol.Event] {
workspace_root=None,
),
SessionError(error="failed to append user"),
SessionUndone(events_removed=5),
WorkspaceCreated(dir="/w"),
CommandError(error="command stream: bad json"),
FleetStarted(runs=3, task="port it"),
Expand Down
1 change: 1 addition & 0 deletions protocol/event.mbt
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ pub(all) enum Event {
workspace_root~ : String?
)
SessionError(error~ : String)
SessionUndone(events_removed~ : Int)
WorkspaceCreated(dir~ : String)
CommandError(error~ : String)
FleetStarted(runs~ : Int, task~ : String)
Expand Down
4 changes: 4 additions & 0 deletions protocol/parse.mbt
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,10 @@ pub fn parse(line : Json) -> Event? {
)
}
"session_error" => text(fields, "error").map(error => SessionError(error~))
"session_undone" =>
int(fields, "events_removed").map(events_removed => {
SessionUndone(events_removed~)
})
"workspace_created" =>
text(fields, "dir").map(dir => WorkspaceCreated(dir~))
"command_error" => text(fields, "error").map(error => CommandError(error~))
Expand Down
Loading
Loading