161ebb0da6
Gitea (and any case-sensitive filesystem) was rendering the [Top]
nav links in /docs as broken because of two bugs:
1. Case-sensitivity: 22 links used '../README.md' (all-uppercase)
but the actual file is 'docs/Readme.md' (capital R, lowercase
rest). 21 guide_*.md nav bars were affected, plus 1 internal
cross-link in Readme.md itself. Works on Windows (case-
insensitive) but broken on Linux/Gitea.
Fix: 22 occurrences across 22 files changed
'../README.md' -> '../Readme.md'
2. Wrong relative-path level: 16 links used '../../conductor/...'
from 'docs/guide_*.md' to reach 'conductor/'. This goes up 2
levels to 'projects/', which doesn't exist. The correct path
from 'docs/guide_*.md' to 'conductor/' is 1 level up
('../conductor/...'). 12 unique patterns across 10 files
affected.
Fix: 16 occurrences across 10 files changed
'../../conductor/' -> '../conductor/'
3. Bonus: 1 planned-guide link in guide_context_curation.md
referenced a never-written 'guide_context_presets.md'. The
ContextPreset schema is now fully covered in the new
'guide_context_aggregation.md' (per the 2026-06-08 docs
refresh). Fix: link target updated.
No content was changed, only link paths. 24 files, 37 link
replacements, 37 deletions.
Verification:
- All .md links in docs/ now resolve to existing files
(validated by path-resolution check from each file's directory)
- The 3 new guides from the previous docs refresh commit
(guide_discussions.md, guide_state_lifecycle.md,
guide_context_aggregation.md) had the case bug inherited from
guide_architecture.md's existing nav pattern; their top-of-file
nav bars are now correct
- The 21 pre-existing guide nav bars that had the same bug
(all 21 of them, except the 3 that used the correct case:
guide_mma.md, guide_simulations.md, guide_tools.md) are now
also fixed
- Inter-guide links (e.g. [Discussions](guide_discussions.md))
were not affected; they were always correct because both the
link text and the actual filename are lowercase
This is a docs-only fix. No code modified.
376 lines
24 KiB
Markdown
376 lines
24 KiB
Markdown
# 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
|