# State Lifecycle: Undo/Redo, Reset, and State Delegation

[Top](../Readme.md) | [App Controller](guide_app_controller.md) | [Discussions](guide_discussions.md) | [GUI Main](guide_gui_2.md)

---

## Overview

Manual Slop's state lifecycle has three load-bearing concerns:

1. **Undo/Redo** via `HistoryManager` + `UISnapshot` — the non-provider history system
2. **Reset** via `_handle_reset_session` — the "throw away everything" flow
3. **State delegation** via `App.__getattr__`/`__setattr__` — the App is a thin proxy over the Controller

This guide covers the *implementation* of all three. The design philosophy is documented in `conductor/tracks/nagent_review_20260608/nagent_takeaways_20260608.md §1` (state visibility) and `§9` (edit-the-input, not the output).

> **Domain classification.** All three concerns are **Application**-domain. The Hook API (which exposes state) is also Application, but crosses into Meta-Tooling via the bridge scripts. See `guide_meta_boundary.md`.

---

## 1. Undo/Redo: `HistoryManager` + `UISnapshot`

### Why a Non-Provider History?

Manual Slop's history is **not** the same as the provider-side conversation history. The provider (`ai_client._anthropic_history`, `_deepseek_history`, etc.) tracks the *exact bytes* sent to and received from the LLM. The Manual Slop history (`app.history` + `UISnapshot`) tracks the *user's UI state* — text inputs, sliders, file lists, discussions.

This separation is intentional. It means:
- The user can `Ctrl+Z` to undo a typo in the AI input box, even if the previous LLM call's history is preserved on the provider side.
- The provider's history is the *authoritative* transcript for the LLM; the Manual Slop history is the *user's working state*.
- A reset of the Manual Slop state does not clear the provider's history (and vice versa) — see §"Reset" below.

This is exactly the kind of "edit the input, not the output" pattern nagent uses; see nagent takeaways §9.

### `UISnapshot` — The Serializable State

`src/history.py:8 UISnapshot` is a frozen-shaped dataclass capturing the 13 user-mutable fields:

| Field | Type | Source |
|---|---|---|
| `ai_input` | `str` | `self.ui_ai_input` |
| `project_system_prompt` | `str` | `self.ui_project_system_prompt` |
| `global_system_prompt` | `str` | `self.ui_global_system_prompt` |
| `base_system_prompt` | `str` | `self.ui_base_system_prompt` |
| `use_default_base_prompt` | `bool` | `self.ui_use_default_base_prompt` |
| `temperature` | `float` | `self.temperature` |
| `top_p` | `float` | `self.top_p` |
| `max_tokens` | `int` | `self.max_tokens` |
| `auto_add_history` | `bool` | `self.ui_auto_add_history` |
| `disc_entries` | `list[dict]` | `copy.deepcopy(self.disc_entries)` |
| `files` | `list[dict]` | `[f.to_dict() if hasattr(f, 'to_dict') else f for f in self.files]` |
| `context_files` | `list[dict]` | `[f.to_dict() if hasattr(f, 'to_dict') else f for f in self.context_files]` |
| `screenshots` | `list[str]` | `list(self.screenshots)` |

`to_dict()` / `from_dict()` are explicit serializers, used by the Hook API for the `/api/session` endpoint. The dataclass is **not** auto-serialized; explicit `to_dict` is required so the schema is documented.

### `HistoryManager` — The Undo/Redo Stack

`src/history.py:71 HistoryManager` is a 100-snapshot capacity stack:

```python
class HistoryManager:
    def __init__(self, max_capacity: int = 100): ...
    def push(self, state: typing.Any, description: str) -> None: ...
    def undo(self, current_state: typing.Any, current_description: str = "Current State") -> typing.Optional[HistoryEntry]: ...
    def redo(self, current_state: typing.Any, current_description: str = "Current State") -> typing.Optional[HistoryEntry]: ...
    def jump_to_undo(self, index: int, current_state: typing.Any, current_description: str = "Before Jump") -> typing.Optional[HistoryEntry]: ...
    @property
    def can_undo(self) -> bool: ...
    @property
    def can_redo(self) -> bool: ...
    def get_history(self) -> typing.List[typing.Dict[str, typing.Any]]: ...
```

- `push` appends a new entry; clears the redo stack; pops the oldest if capacity exceeded.
- `undo` moves the current state to the redo stack and returns the top of the undo stack.
- `redo` is the inverse.
- `jump_to_undo` allows time-traveling to any past snapshot, moving subsequent states to the redo stack.
- `get_history` returns `[{description, timestamp}, ...]` for the History List view in the GUI.

The `max_capacity=100` is the default and is sufficient for a 5-second window of rapid typing or a longer session of infrequent edits.

### The Push Trigger — `gui_2.py:1140-1170`

The undo stack is *not* pushed on every keystroke. It's pushed via debounced change-detection at the start of every render frame:

```python
current = self._take_snapshot()
if self._last_ui_snapshot is None:
    self._last_ui_snapshot = current
    return

changed = (
    current.ai_input != self._last_ui_snapshot.ai_input or
    current.project_system_prompt != self._last_ui_snapshot.project_system_prompt or
    # ... 10 more field comparisons ...
    len(current.disc_entries) != len(self._last_ui_snapshot.disc_entries) or
    len(current.files) != len(self._last_ui_snapshot.files) or
    len(current.context_files) != len(self._last_ui_snapshot.context_files) or
    len(current.screenshots) != len(self._last_ui_snapshot.screenshots)
)

if not changed and len(current.disc_entries) > 0:
    if current.disc_entries[-1].get('content') != self._last_ui_snapshot.disc_entries[-1].get('content'):
        changed = True

if changed:
    self.history.push(current, description="<auto>")
    self._last_ui_snapshot = current
```

The change detector compares:
- 7 scalar fields directly (`!=`)
- 2 float fields with epsilon (`abs(...) > 1e-5`)
- 4 list fields by length
- The `disc_entries[-1]["content"]` separately (because streaming AI responses can change the last entry's content without changing the length)

**Performance:** The check is at the start of every render frame. `copy.deepcopy(self.disc_entries)` (line 748) is the most expensive part — O(N) where N is the entry count. For a 100-entry discussion, this is microseconds. The full snapshot push only happens when a change is detected.

### The Apply Trigger — `gui_2.py:819 _apply_undo` / `_apply_redo` / `_apply_jump`

Three wrapper methods invoke `_apply_snapshot(entry.state)` with the right description:

- `_apply_undo(...)` — pops from undo, pushes current to redo, applies popped
- `_apply_redo(...)` — pops from redo, pushes current to undo, applies popped
- `_apply_jump(index, ...)` — invokes `jump_to_undo(index, current_state)`, applies the result

`_apply_snapshot` is the *single* restore function (`gui_2.py:754-789`):

```python
def _apply_snapshot(self, snapshot: history.UISnapshot) -> None:
    self._is_applying_snapshot = True
    try:
        self.ui_ai_input                = snapshot.ai_input
        self.ui_project_system_prompt   = snapshot.project_system_prompt
        # ... 10 more assignments ...
        self.disc_entries               = snapshot.disc_entries
        # Restore files as FileItem objects
        from src import models
        self.files = []
        for f in snapshot.files:
            if isinstance(f, dict):
                self.files.append(models.FileItem.from_dict(f))
            else:
                self.files.append(models.FileItem(path=str(f)))
        # ... similar for context_files, screenshots ...
    finally:
        self._is_applying_snapshot = False
```

The `_is_applying_snapshot` flag is set during the restore to prevent re-pushing a new snapshot from the changes made *by* the restore itself. (Without this, pressing Ctrl+Z would push a new snapshot identical to the restored one, which would *clear the redo stack* — making Ctrl+Y a no-op.)

---

## 2. State Delegation: `App.__getattr__` / `__setattr__`

### The Pattern

`App` (`src/gui_2.py:264+`) is a thin wrapper around `AppController` (`src/app_controller.py:772+`). The wrapper exists because:
- The Controller is the *headless* orchestrator (no ImGui dependencies).
- The App is the *render* side (ImGui calls).
- Render functions take `app: App` and read state via `app.<field>`.

The simplest design would be: copy every Controller field to the App. But that's brittle (every new Controller field requires an App copy). The actual pattern uses Python's `__getattr__`/`__setattr__` for transparent delegation.

### `App.__getattr__` — Read Fallthrough

`gui_2.py:666-669`:

```python
def __getattr__(self, name: str) -> Any:
    if name == 'controller':
        raise AttributeError(name)
    return getattr(self.controller, name)
```

When `app.foo` is accessed and `foo` is not an instance attribute of `App`, Python falls through to `__getattr__`, which reads from `self.controller`. This means `app.disc_entries`, `app.history`, `app.temperature` all read from the Controller transparently.

The `'controller'` exception prevents infinite recursion if the Controller is not yet set (during early `__init__`).

### `App.__setattr__` — Write Fallthrough

`gui_2.py:671-675`:

```python
def __setattr__(self, name: str, value: Any) -> None:
    if name != 'controller' and hasattr(self, 'controller') and hasattr(self.controller, name):
        setattr(self.controller, name, value)
    else:
        object.__setattr__(self, name, value)
```

When `app.foo = bar` is assigned and the Controller has a `foo` attribute, the write goes *through* to the Controller. Otherwise it stores on the App instance.

### Why This Matters

- **No data duplication.** There is one source of truth (the Controller). The App never has its own copy of `disc_entries`, `temperature`, etc.
- **No boilerplate.** New Controller fields are automatically available via the App without code changes.
- **Backward-compatible.** Existing App fields (e.g. `app._is_applying_snapshot`) work because `object.__setattr__` is the fallback when the Controller doesn't have the field.

### The 4 Edge Cases (per the `guide_gui_2.md` Known Issues)

1. **`app.controller` is itself an App attribute** — it must not be delegated, hence the explicit `if name == 'controller'` guard.
2. **App instance attributes shadow Controller attributes** — the `object.__setattr__` fallback stores on the App; the next `__getattr__` call returns the App's value (because Python's normal attribute lookup finds the instance attribute first). This is sometimes intentional (e.g. `app._is_applying_snapshot` is App-local).
3. **Fields in `app._app` (not on the Controller)** are stored on the App. The `_app` field is the Controller's back-reference to the App (set at `gui_2.py:264`).
4. **Underscore-prefixed App-specific fields** like `app._mma_approval_open`, `app._pending_ask_dialog` are stored on the App because the Controller doesn't have them. They appear in the Controller's `__getattr__` mirror via `hasattr` check.

### Known Fragility

`guide_gui_2.md` notes: `ui_separate_context_preview`, `ui_separate_message_panel`, `ui_separate_response_panel`, `ui_separate_tool_calls_panel`, `ui_separate_external_tools`, `ui_discussion_split_h` are NOT in the Controller's `_settable_fields`, so `__setattr__` falls through to `object.__setattr__` and stores them on the App. This is intentional for window-separator flags (they're render-only and shouldn't pollute the Controller), but it does mean they don't survive a hot-reload of `App`.

---

## 3. Reset: `_handle_reset_session`

`src/app_controller.py:3286-3356 _handle_reset_session` is the **nuclear** reset, called from the "Reset Session" button in the message panel.

### What It Clears

| Group | What | Why |
|---|---|---|
| AI client | `ai_client.reset_session()` + `clear_comms_log()` | Clears provider-side history. |
| Tool stats | `_tool_log.clear()`, `_tool_stats.clear()`, `_comms_log.clear()` | Clears the in-memory activity logs. |
| Discussion | `self.disc_entries.clear()`; for each discussion in project: `discussions[d_name]["history"] = []` | Empties the *current* take and all takes across all discussions. |
| Files | `self.files.clear()`, `self.context_files.clear()` | Drops the FileItem lists. |
| Tracks | `self.tracks.clear()` | Drops the loaded tracks. |
| **Project dict (full replacement)** | `self.project = project_manager.default_project(...)` | The project is *replaced* with a fresh default, not mutated. |
| Project paths | `self.project_paths = []` | Clears the recent-projects list. |
| Project switch state | `_project_switch_in_progress = False` etc. | Resets the in-flight switch state machine. |
| AI status | `ai_status = "session reset"`, `ai_response = ""` | Status bar update. |
| UI inputs | `ui_ai_input = ""`, `ui_manual_approve = False`, `ui_auto_add_history = False` | Empties the message box. |
| MMA | `active_track = None`, `active_tier = None`, `mma_status = "idle"`, `proposed_tracks = []`, `active_tickets = []`, `engines.clear()`, `mma_streams.clear()`, `_worker_status.clear()` | Drops all MMA state. |
| Provider/model | `_current_provider = "gemini"`, `_current_model = "gemini-2.5-flash-lite"`, `ai_client.set_provider(...)` | Resets to defaults. |
| Locks + queues | `_pending_history_adds.clear()`, `_api_event_queue.clear()`, `_pending_gui_tasks.clear()` | Drains all queues under their locks. |
| Prompts | `ui_use_default_base_prompt = True`, all 3 system prompts = `''` | Resets to default base prompt. |
| Persona/tool settings | `ui_active_persona = ''`, `ui_active_tool_preset = None`, `ui_active_bias_profile = None` | Drops active persona. |
| Generation params | `temperature = 0.0`, `top_p = 1.0`, `max_tokens = 8192` | Defaults. |

### What It Does NOT Touch

| Field | Why preserved |
|---|---|
| `self.active_project_path` | `_do_project_switch` writes to this path; clearing it would cause OSError on next switch and an infinite re-switch loop. The 2026-06-08 regression test `test_context_sim_live` documents this. |
| `self.history` (the `HistoryManager`) | The undo stack survives a reset. Ctrl+Z after a reset can restore the pre-reset state. This may be a bug or a feature. |
| The on-disk `manual_slop.toml` | The saved project TOML is not deleted or rewritten. Switching projects after reset reloads from disk. |
| `self.discussion_sent_markdown` / `discussion_sent_system_prompt` | These *are* cleared (set to `""`). They're not in the preserve list. |

### The `_is_applying_snapshot` Guard During Reset

`_handle_reset_session` does *not* set `self._is_applying_snapshot`. This means the change-detection logic *will* push a new snapshot to the undo stack after the reset. The snapshot will contain the *post-reset* state. To get the pre-reset state, the user must Ctrl+Z *twice* (once to push the post-reset snapshot, once to restore the pre-reset snapshot).

This is a known papercut. The fix is to set `self._is_applying_snapshot = True` during the reset and clear it at the end. See `tests/test_session_logger_reset.py:test_reset_session` for the current behavior.

### The 2026-06-08 Regression

`app_controller.py:3307-3312` documents a regression that was caught by `test_context_sim_live`:

> The test's `client.click("btn_reset")` resets the AI session but does not reset the project (see `_handle_reset_session` at line 3244 — it clears files, context_files, disc_entries, etc. but not self.project or self.active_project_path).

The fix was to *not* clear `self.active_project_path`. The project dict *is* replaced (`self.project = project_manager.default_project(...)`), but the path is preserved. This is the right behavior: the user is *resetting the session*, not *abandoning the project*.

---

## 4. State Synchronization Across Threads

The state lifecycle has 4 distinct threads of access:

1. **Render thread** (60 FPS) — reads `app.<field>` to render ImGui widgets
2. **AI response callback thread** (background) — appends to `app.disc_entries` under `_disc_entries_lock`
3. **Hook API thread** (HTTP server on `127.0.0.1:8999`) — reads via `_gettable_fields` and writes via `_predefined_callbacks`
4. **MMA worker thread(s)** — write to `_api_event_queue`, `_pending_gui_tasks`, `_pending_history_adds`

The synchronization primitives:

- `_disc_entries_lock: threading.Lock` — protects `disc_entries` (read by render, written by AI callback and Truncate)
- `_pending_history_adds_lock` — protects the queue of pending discussion entries
- `_api_event_queue_lock` — protects the Hook API event stream
- `_pending_gui_tasks_lock` — protects the queue of GUI tasks scheduled from background threads
- `_project_switch_lock` — protects the project-switch state machine
- `ai_client._send_lock: threading.Lock` — serializes all `ai_client.send()` calls (the global lock per `guide_ai_client.md`)
- `ai_client._<provider>_history_lock` — per-provider history lock (one per provider)

**Invariant:** the render thread *never* blocks. It reads lock-free. All locks are acquired by the writer (AI callback, Truncate button, Hook API), held for the minimum critical section, and released before the writer returns.

See `docs/reports/MUTATION_MATRIX_PHASE5.md` for the full matrix of state mutations × lock × thread.

---

## 5. State Persistence

Three layers of state persistence:

1. **In-memory only** (lost on crash): `ai_client._<provider>_history`, `app.disc_entries`, `app.history` (undo stack)
2. **Project TOML** (persisted on Save / switch discussion): `project.discussion.discussions[*].history`, `project.discussion.discussions[*].context_snapshot`, `project.discussion.discussions[*].sent_markdown`, `project.discussion.discussions[*].sent_system_prompt`
3. **Config TOML** (persisted on `save_config()`): `config.disc_roles`, `config.use_default_base_system_prompt`, `config.ui_global_system_prompt`, etc.

The auto-save flow is:
- `_flush_to_project()` → writes project TOML
- `_flush_to_config()` → writes in-memory config to `AppController.config`
- `save_config()` → calls `models.save_config(config)` to write to disk

These three calls are *manual* (not automatic). The user must click Save, or trigger a state change that flushes (switch discussion, branch, etc.). The reset path explicitly does *not* save (per the "What reset does NOT touch" section above).

**The comms log** is its own persistence layer. `ai_client._comms_log` and `app._comms_log` are in-memory, but every entry is also written to `logs/sessions/<session_id>/comms.log` (JSON-L) via the `_on_comms_entry` callback. The reset clears the in-memory log; the on-disk log survives. This is intentional — the comms log is an *audit trail*, not a working state.

---

## 6. Hot Reload Integration

`src/gui_2.py` is hot-reloadable. The `HotReloader` (covered in `guide_hot_reload.md`) swaps module references at runtime. The state lifecycle interacts with hot-reload in 3 ways:

1. **`HistoryManager` survives hot-reload** because it lives on the Controller, not the App. The `gui_2.py` module's functions can be reloaded without losing the undo stack.
2. **`UISnapshot` schema is the contract** — if a hot-reload changes the fields captured by `UISnapshot`, the old snapshots in the undo stack may have different shapes. The `from_dict` method handles missing fields via `.get(..., default)`, so old snapshots degrade gracefully.
3. **`_app` back-reference** is set in `App.__init__` (line 264). After a hot-reload of `gui_2.py`, the Controller's `self._app` still points to the (now reloaded) App instance. Render functions that captured `app` in closures may still hold the old App — but the App is a thin wrapper, so the new App is functionally equivalent.

See `guide_hot_reload.md §"What can/cannot be safely reloaded"` for the full list.

---

## 7. Hook API Surface

The state lifecycle is exposed to the Hook API via two registries on the Controller (`src/app_controller.py:296-326` in `App.__init__`):

- **`_predefined_callbacks: dict[str, Callable]`** — name → function. The Hook API exposes each as a `custom_callback` action. Includes:
  - `save_context_preset`, `load_context_preset`, `delete_context_preset`
  - `set_ui_file_paths`, `set_ui_screenshot_paths`
  - `set_context_files_for_test`, `set_screenshots_for_test`
  - `_toggle_command_palette`
  - `get_app_debug_info`, `save_context_preset_force`
  - `set_ui_attr(k, v)`, `set_context_files`
  - `simulate_save_preset`
- **`_gettable_fields: dict[str, str]`** — public name → internal field name. The Hook API exposes each as a readable state field. Includes `show_command_palette`, `app_debug_info`, and 50+ other UI/state fields.

The Hook API does *not* directly expose `disc_entries` mutation; it goes through `/api/session` POST which replaces the full list.

`/api/session` is the most state-relevant endpoint:
- `GET /api/session` → `{"session": {"entries": [...]}}`
- `POST /api/session` with `{"session": {"entries": [...]}}` → `{"status": "updated"}`

See `guide_tools.md §"Hook API"` and `guide_api_hooks.md` for the full surface.

---

## 8. Tests

- `tests/test_history.py` — `test_undo_redo`, `test_jump_to_undo`, `test_max_capacity`, `test_redo_cleared_on_push`, `test_push_state`, `test_initial_state`
- `tests/test_history_manager.py` — `TestHistoryManager` class with: `test_snapshot_roundtrip`, `test_push_and_undo`, `test_push_clears_redo_stack`, `test_undo_and_redo`, `test_undo_no_history_returns_none`, `test_redo_no_history_returns_none`, `test_get_history_returns_descriptions`, `test_jump_to_undo`
- `tests/test_session_logger_reset.py` — `test_reset_session`
- `tests/test_state_inventory.py` — validates the state inventory is up-to-date
- `tests/test_state_delegation.py` — validates `App.__getattr__`/`__setattr__` behavior
- `tests/test_live_gui_state_sync.py` — validates that Hook API state reads are consistent with the live GUI state
- `tests/test_gui_fast_render.py` — `test_render_discussion_panel_fast` (the change-detection path)
- `tests/conftest.py:app_instance`, `tests/conftest.py:mock_app` — the App fixtures used by these tests

---

## 9. Known Limitations

1. **Per-edit save is not debounced to disk.** See `guide_discussions.md §"Known Limitations"` for the related issue. The fix is to hook the change-detection in `gui_2.py:1140-1170` to also call `_flush_disc_entries_to_project` after a debounce.
2. **`_handle_reset_session` pushes a new snapshot to the undo stack.** The pre-reset state is two Ctrl+Z presses away, not one. Fix: set `self._is_applying_snapshot = True` during the reset.
3. **Provider-side history and Manual Slop state can diverge.** When the user edits an entry's `content` via the discussion UI, the provider's `ai_client._<provider>_history` still has the original. This is Pitfall #4 in `conductor/tracks/nagent_review_20260608/report.md` and Decision candidate #3.
4. **Undo stack capacity is 100.** For long sessions with infrequent edits, this is plenty. For a 5-second window of rapid typing, you can fill it. Capacity is set in `app.history = HistoryManager(max_capacity=100)` in `App.__init__` and is not configurable.
5. **Hook API state writes are not undoable.** A `POST /api/session` that replaces the discussion list is a *single change*; if the change-detection logic pushes a snapshot, it's pushed as one entry. The user can Ctrl+Z to revert it, but the API caller has no way to know the change is undoable.

---

## Cross-References

- **Undo/redo core:** `src/history.py:8 UISnapshot`, `src/history.py:71 HistoryManager`
- **App-side wiring:** `src/gui_2.py:735 _take_snapshot`, `src/gui_2.py:754 _apply_snapshot`, `src/gui_2.py:819 _apply_undo`, `src/gui_2.py:825 _apply_redo`, `src/gui_2.py:832 _apply_jump`, `src/gui_2.py:1140-1170 change_detection`, `src/gui_2.py:666 __getattr__`, `src/gui_2.py:671 __setattr__`
- **Controller-side reset:** `src/app_controller.py:3286 _handle_reset_session`, `src/app_controller.py:3357 _handle_compress_discussion`
- **Hook API registries:** `src/gui_2.py:296-326` (the `_predefined_callbacks` / `_gettable_fields` assignments in `App.__init__`)
- **State inventory:** `docs/reports/STATE_INVENTORY_PHASE5.md`, `docs/reports/MUTATION_MATRIX_PHASE5.md`
- **Discussion integration:** `guide_discussions.md`
- **Actionable patterns:** `conductor/tracks/nagent_review_20260608/nagent_takeaways_20260608.md §1` (state visibility), §9 (edit-the-input)
- **Future-track candidate for stateless LLMClient:** `conductor/tracks/nagent_review_20260608/decisions.md` candidate #3
