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.
354 lines
26 KiB
Markdown
354 lines
26 KiB
Markdown
# Discussions: Takes, Branching, and Per-Entry Editing
|
|
|
|
[Top](../Readme.md) | [App Controller](guide_app_controller.md) | [GUI Main](guide_gui_2.md) | [Models](guide_models.md)
|
|
|
|
---
|
|
|
|
## Overview
|
|
|
|
A **Discussion** is Manual Slop's first-class unit of conversation. Every prompt the user types, every AI response, every tool result, every per-entry edit lives in a Discussion. Discussions are persisted to the project's TOML as a typed list of entries; they can be branched into multiple **Takes**, switched between, renamed, deleted, and (most importantly) **edited at the entry level** by the user in the GUI.
|
|
|
|
The discussion system is one of the *most-edited* surfaces in Manual Slop. The user can:
|
|
|
|
- **Edit any entry's text** in place (full multi-line edit, not just inline)
|
|
- **Insert new entries** at any position
|
|
- **Delete any entry** by position
|
|
- **Change the role** of any entry
|
|
- **Branch** at any entry to create a new Take
|
|
- **Undo/redo** every edit (Ctrl+Z / Ctrl+Y)
|
|
- **Promote** a Take to a top-level discussion
|
|
|
|
This is a *deliberate* design choice. Manual Slop treats the discussion as user-editable working state, not as opaque chat history. The full operation matrix and the rationale are in `conductor/tracks/nagent_review_20260608/report.md §3`; this guide covers the *implementation*.
|
|
|
|
> **Domain classification.** The discussion system is purely **Application**-domain. It owns no Meta-Tooling concerns; it does not call into `scripts/mma_exec.py`; it is consumed by the GUI and the headless controller, and projected to the AI client. See `guide_meta_boundary.md` for the Application vs Meta-Tooling split.
|
|
|
|
---
|
|
|
|
## Data Model
|
|
|
|
### The Entry Dict
|
|
|
|
The smallest unit of a discussion is the **entry**, a `dict[str, Any]` with this shape (`src/models.py:parse_history_entries` builds it; `src/gui_2.py:render_discussion_entry` reads it):
|
|
|
|
| Field | Type | Source | Purpose |
|
|
|---|---|---|---|
|
|
| `role` | `str` | `parse_history_entries` | The speaker. Defaults to one of `["User", "AI", "Vendor API", "System"]` (set in `models.py:208`), but `disc_roles` is user-editable so this can be any string. |
|
|
| `content` | `str` | user input / LLM response | The entry's text. Fully editable in the GUI. |
|
|
| `collapsed` | `bool` | GUI render state | Whether the entry is collapsed to a 60-char preview. Defaults `True`. |
|
|
| `ts` | `str` | `project_manager.now_ts()` | ISO timestamp, prefixed with `@` in the persisted form. |
|
|
| `thinking_segments` | `list[dict]` | `src/thinking_parser.py` | AI entries with `<thinking>` blocks have the blocks parsed out into collapsible segments. |
|
|
| `usage` | `dict` | `ai_client.send()` | Token accounting: `{"input_tokens": N, "output_tokens": N, "cache_read_input_tokens": N}`. |
|
|
| `read_mode` | `bool` | GUI render state | If `True`, render as Markdown; if `False` (default), render as editable text input. |
|
|
|
|
An entry dict is *open*: extra keys are allowed and ignored by the renderer. This is intentional — the user can add custom metadata via the Hook API or by editing the project TOML directly.
|
|
|
|
### The Discussion Dict
|
|
|
|
A **Discussion** is a `dict[str, Any]` under `project.discussion.discussions[<name>]`:
|
|
|
|
```python
|
|
{
|
|
"history": [str, ...] # legacy: list of "Role: content" strings
|
|
# OR
|
|
# list of entry dicts (new format)
|
|
"git_commit": str, # git SHA at the time the discussion was last updated
|
|
"last_updated": str, # ISO timestamp
|
|
"context_snapshot": [dict, ...], # list of FileItem.to_dict() at send time
|
|
"sent_markdown": str, # the actual markdown sent to the AI on the last send
|
|
"sent_system_prompt": str, # the system prompt that was active at send time
|
|
}
|
|
```
|
|
|
|
The `project_manager.default_discussion()` factory returns a fresh dict with empty `history` and the standard keys. `app_controller._switch_discussion` reads the dict, parses `history` via `models.parse_history_entries(history_strings, self.disc_roles)`, and writes the live `disc_entries` list.
|
|
|
|
### The Take Naming Convention
|
|
|
|
Takes are encoded in the discussion name. A Take's name has the shape `<base>_take_<n>`. Example: a discussion named `refactor_auth` can have takes `refactor_auth_take_1`, `refactor_auth_take_2`, etc. The `_get_discussion_names` accessor groups by base name (`name.split("_take_")[0]`) so the GUI can render them as nested tabs.
|
|
|
|
The `_branch_discussion(index)` method (in `app_controller.py:3503`) generates a unique Take name by incrementing `<base>_take_<counter>` until it finds an unused name, then calls `project_manager.branch_discussion(self.project, self.active_discussion, new_name, index)`.
|
|
|
|
---
|
|
|
|
## Per-Entry Operations (the A1-A7 matrix)
|
|
|
|
This is the operation set the user has *per individual entry*. Renderer: `src/gui_2.py:3770 render_discussion_entry(app, entry, index)`.
|
|
|
|
| # | Operation | GUI control | Source code | What it does |
|
|
|---|---|---|---|---|
|
|
| A1 | **Edit content in place** | `imgui.input_text_multiline` on the entry body | `gui_2.py:3841` | `entry["content"]` is a fully editable multi-line text input. The user can rewrite an AI's response, fix a typo in their own prompt, paste in code from another source, etc. |
|
|
| A2 | **Toggle read/edit mode** | `[Edit]` / `[Read]` button | `gui_2.py:3799` | When in `[Read]` mode, the content is rendered as Markdown with syntax highlighting (`render_discussion_entry_read_mode` at `gui_2.py:3855`). When in `[Edit]` mode, the multi-line text input is shown. |
|
|
| A3 | **Toggle collapsed/expanded** | `+/-` button per entry | `gui_2.py:3789` | Collapsed entries show a 60-char preview (line 3822-3824). Expanded entries show full content. |
|
|
| A4 | **Change role** | Combo box from `app.disc_roles` | `gui_2.py:3793-3796` | The entry's `role` field is editable. The list `app.disc_roles` is itself user-managed (see §"Role Management" below). |
|
|
| A5 | **Insert entry before this one** | `Ins` button | `gui_2.py:3813` | `app.disc_entries.insert(index, {"role": "User", "content": "", "collapsed": True, "ts": project_manager.now_ts()})` |
|
|
| A6 | **Delete this entry** | `Del` button | `gui_2.py:3815-3816` | `if entry in app.disc_entries: app.disc_entries.remove(entry)`. The membership check matters — ImGui can re-render stale state, so the check guards against double-delete. |
|
|
| A7 | **Branch at this entry** | `Branch` button | `gui_2.py:3821` → `app._branch_discussion(index)` → `app_controller._branch_discussion:3503` → `project_manager.branch_discussion:429` | Creates a new Take named `<base>_take_<n>` and copies the history up to and including `index` into the new Take. The user is then switched to the new Take. |
|
|
|
|
**Why this matrix is load-bearing.** Every entry is independently editable. There is no "edit the whole discussion as one operation." This is the design difference vs. most chat UIs: when an AI's response is wrong, the user can *fix the response text* without losing the entry's role, timestamp, usage accounting, or thinking segments. The AI on the *next* turn sees the corrected response (because the entry's `content` is the source for `build_discussion_section` in `aggregate.py:109`).
|
|
|
|
---
|
|
|
|
## Discussion-Level Operations (the B1-B11 matrix)
|
|
|
|
These are the second-tier controls, rendered at `src/gui_2.py:4239 render_discussion_entry_controls(...)` and the discussion selector at `gui_2.py:4330 render_discussion_selector(...)`.
|
|
|
|
| # | Operation | GUI control | Source code | What it does |
|
|
|---|---|---|---|---|
|
|
| B1 | **Append new entry** | `+ Entry` button | `gui_2.py:4240` | `app.disc_entries.append({...})` with the default role from `app.disc_roles[0]`. |
|
|
| B2 | **Collapse all / Expand all** | `-All` / `+All` buttons | `gui_2.py:4242-4246` | Bulk-set `collapsed` flag on every entry. |
|
|
| B3 | **Clear all** | `Clear All` button | `gui_2.py:4248` | `app.disc_entries.clear()`. Note: this clears the *current* take, not all takes. |
|
|
| B4 | **Save (flush to project TOML)** | `Save` button | `gui_2.py:4250` | `app._flush_to_project(); app._flush_to_config(); app.save_config()`. |
|
|
| B5 | **Add/remove roles** | `Add` / `X` buttons under "Roles" | `gui_2.py:4317-4328` | `app.disc_roles.append(r)` / `app.disc_roles.pop(i)`. |
|
|
| B6 | **Switch active discussion** | Discussion combo + Take tabs | `gui_2.py:4197, 4344, 4354` | `app._switch_discussion(name)`. Takes group by base name and render as nested tabs. |
|
|
| B7 | **Rename / Delete discussion** | `Rename` / `Delete` buttons | `gui_2.py:4291, 4293` | `app._rename_discussion(...)` / `app._delete_discussion(...)`. Cannot delete the last discussion (guarded at `app_controller.py:3543`). |
|
|
| B8 | **Promote Take to top-level** | `Promote` button in takes panel | `gui_2.py:4364` | `project_manager.promote_take(app.project, app.active_discussion, new_name)` — renames a Take (e.g. `T0_take_2`) to a fresh top-level discussion name. |
|
|
| B9 | **Per-role filter** | `ui_focus_agent` selector (system-wide) | `gui_2.py:4230-4234` | `display_entries = [e for e in app.disc_entries if e.get("role") == persona_name or e.get("role") == "User"]`. The filter follows the MMA persona focus. |
|
|
| B10 | **Truncate to N pairs** | `Truncate` button + `drag_int` | `gui_2.py:4254-4260` | `truncate_entries(app.disc_entries, app.ui_disc_truncate_pairs)` keeps the last `N` User/AI pairs (per `gui_2.py:175 truncate_entries(...)`). |
|
|
| B11 | **Compress (AI summarization)** | `Compress` button | `gui_2.py:4252` → `app_controller._handle_compress_discussion:3357` | Calls `ai_client.run_discussion_compression(disc_text)` and replaces the discussion with the LLM's compressed version. |
|
|
|
|
---
|
|
|
|
## Role Management
|
|
|
|
`app.disc_roles: list[str]` is the master list of valid role strings. It's:
|
|
|
|
- **Populated from** `models.parse_history_entries`'s default `["User", "AI", "Vendor API", "System"]` (`models.py:208`)
|
|
- **Persisted as** `manual_slop.toml [discussion].disc_roles` (or a project TOML equivalent)
|
|
- **Loaded by** `app_controller.init_state` from the project dict
|
|
|
|
The user can add or remove roles at runtime via `gui_2.py:4317-4328 render_discussion_roles`. The `Add` button takes `app.ui_disc_new_role_input`, strips it, and appends if not already present. The `X` button pops by index.
|
|
|
|
A role can be any string — Manual Slop doesn't enforce a vocabulary. Typical custom roles include `Context`, `Tool`, `CodeBlock`, `Error`, `Warning`, or per-project names like `Architect` vs `Implementer`.
|
|
|
|
The **default role** for new entries is `app.disc_roles[0] if app.disc_roles else "User"`. If the role list is empty, the system falls back to `"User"`. This is intentionally permissive — empty role list is never an error.
|
|
|
|
---
|
|
|
|
## Take Lifecycle
|
|
|
|
### Branch
|
|
|
|
`app_controller._branch_discussion(index)` (`app_controller.py:3503-3519`):
|
|
|
|
1. Flush current `disc_entries` to project TOML via `_flush_disc_entries_to_project` (so we don't lose unsaved edits).
|
|
2. Compute the base name: `self.active_discussion.split("_take_")[0]`.
|
|
3. Generate a unique take name: `<base>_take_<counter>` incremented until unused.
|
|
4. Call `project_manager.branch_discussion(self.project, self.active_discussion, new_name, index)`.
|
|
5. Switch the active discussion to the new take via `_switch_discussion(new_name)`.
|
|
|
|
`project_manager.branch_discussion` (`project_manager.py:429`) does the actual copy:
|
|
- Reads the source discussion
|
|
- Creates a fresh discussion dict with `default_discussion()`
|
|
- Copies the source's `git_commit` (so the new take is anchored to the same code state)
|
|
- Copies `source_disc["history"][:message_index + 1]` — i.e. **all entries up to and including `index`**
|
|
- Sets the new take as active
|
|
|
|
**Why "up to and including"?** Branching at entry N means "the future starts from entry N's state." The user is saying "from here, what if I had asked a different follow-up?" The AI sees entries 0..N as the prior conversation; entries N+1..end are discarded (in this take — they're still in the parent take, accessible via the Take tabs).
|
|
|
|
### Promote
|
|
|
|
`project_manager.promote_take` (`project_manager.py:447`):
|
|
|
|
- Renames a take to a fresh top-level name
|
|
- Updates the `active` pointer if the renamed take was active
|
|
- Use case: a Take that turned out to be the "real" conversation gets renamed away from the `_take_<n>` suffix to become a first-class discussion
|
|
|
|
### Switch
|
|
|
|
`app_controller._switch_discussion(name)` (`app_controller.py:3199`):
|
|
|
|
1. Flush the current `disc_entries` to the project TOML.
|
|
2. Look up the new discussion in `self.project["discussion"]["discussions"]`.
|
|
3. Set `self.active_discussion = name` and `self._track_discussion_active = False`.
|
|
4. **Atomically** (under `_disc_entries_lock`) replace `self.disc_entries[:] = models.parse_history_entries(disc_data.get("history", []), self.disc_roles)`.
|
|
5. Restore the context snapshot from `disc_data["context_snapshot"]` if present.
|
|
6. Update `ai_status = f"discussion: {name}"`.
|
|
|
|
The atomic slice-replacement is critical: a renderer that reads `self.disc_entries` mid-update would see a half-empty list. The lock ensures the renderer only sees the old list (before) or the new list (after), never an in-between state.
|
|
|
|
### Rename / Delete
|
|
|
|
`_rename_discussion(old, new)` (`app_controller.py:3521`):
|
|
|
|
- `discussions[new_name] = discussions.pop(old_name)` — atomically swaps the key
|
|
- Updates `active_discussion` and the `active` pointer if the renamed discussion was active
|
|
- Rejects the rename if `new_name` is already in use (`ai_status = f"discussion '{new_name}' already exists"`)
|
|
|
|
`_delete_discussion(name)` (`app_controller.py:3537`):
|
|
|
|
- Refuses to delete the last remaining discussion (guarded at line 3543)
|
|
- Removes the discussion from the dict
|
|
- If the deleted discussion was active, switches to the first remaining sorted-by-name discussion
|
|
|
|
---
|
|
|
|
## Per-Role Filter (the MMA Link)
|
|
|
|
`gui_2.py:4227-4237 render_discussion_entries` filters the entry list when `app.ui_focus_agent` is set:
|
|
|
|
```python
|
|
if app.ui_focus_agent:
|
|
tier_usage = app.mma_tier_usage.get(app.ui_focus_agent)
|
|
if tier_usage:
|
|
persona_name = tier_usage.get("persona")
|
|
if persona_name:
|
|
display_entries = [e for e in app.disc_entries
|
|
if e.get("role") == persona_name or e.get("role") == "User"]
|
|
```
|
|
|
|
When the user clicks "Focus on Tier 3 Worker A" in the MMA dashboard, the Discussion Hub filters to only show entries whose `role` matches the focused worker's persona name plus User entries. This is a *read-only* filter — the underlying `disc_entries` is unchanged. The `app._render_message_panel` (or whoever sent the entries) is unaffected.
|
|
|
|
---
|
|
|
|
## Persistence
|
|
|
|
### `app._flush_to_project` (called from B4 Save, and from `_switch_discussion`)
|
|
|
|
`gui_2.py:1046-1047` and `app_controller.py:2558`:
|
|
|
|
```python
|
|
app._flush_to_project() # serializes self.project to <project_root>/<project_name>.toml
|
|
app._flush_to_config() # serializes self.config to <user_config>/config.toml
|
|
app.save_config() # write config.toml to disk
|
|
```
|
|
|
|
`_flush_to_project` calls `project_manager.save_project(self.project, self.active_project_path)`, which serializes the full project dict (including all discussions) to the project TOML.
|
|
|
|
### `_flush_disc_entries_to_project` (called from `_switch_discussion` and `_branch_discussion`)
|
|
|
|
`app_controller.py:3225-3240`:
|
|
|
|
```python
|
|
def _flush_disc_entries_to_project(self) -> None:
|
|
history_strings = [project_manager.entry_to_str(e) for e in self.disc_entries]
|
|
if self.active_track and self._track_discussion_active:
|
|
project_manager.save_track_history(self.active_track.id, history_strings, self.active_project_root)
|
|
return
|
|
disc_sec = self.project.setdefault("discussion", {})
|
|
discussions = disc_sec.setdefault("discussions", {})
|
|
disc_data = discussions.setdefault(self.active_discussion, project_manager.default_discussion())
|
|
disc_data["history"] = history_strings
|
|
disc_data["last_updated"] = project_manager.now_ts()
|
|
disc_data["context_snapshot"] = [f.to_dict() if hasattr(f, "to_dict") else {"path": str(f)} for f in self.context_files]
|
|
disc_data["sent_markdown"] = getattr(self, "discussion_sent_markdown", "")
|
|
disc_data["sent_system_prompt"] = getattr(self, "discussion_sent_system_prompt", "")
|
|
```
|
|
|
|
**Two paths:**
|
|
- If a track discussion is active (`self.active_track and self._track_discussion_active`): persist to `conductor/tracks/<id>/track_history` via `save_track_history`.
|
|
- Otherwise: persist to the project's `discussion.discussions[<active>]` dict.
|
|
|
|
`entry_to_str(e)` converts an entry dict to a `Role: content` string for the legacy `history` field. `parse_history_entries` (in `models.py:196`) reverses the conversion when loading.
|
|
|
|
**The `context_snapshot`** is the FileItem list at send time. Restoring a discussion restores the file list (per `_switch_discussion:3218-3222`). This is *the* mechanism for "I sent this discussion with these files in context; if I switch away and back, the files come back."
|
|
|
|
### When is the save triggered?
|
|
|
|
- **Explicit:** B4 `Save` button.
|
|
- **Implicit (and risky):** `_switch_discussion` and `_branch_discussion` both flush *before* switching. **But** the per-entry edit operations (A1-A7) do *not* flush on their own. The user is expected to either Save explicitly or rely on the next `_switch_discussion` / `_branch_discussion` to flush.
|
|
|
|
This is a known design tension. See the "Known Limitations" section below.
|
|
|
|
---
|
|
|
|
## Threading & Locking
|
|
|
|
`self._disc_entries_lock: threading.Lock` is a `threading.Lock` owned by `app_controller`. It is acquired in:
|
|
|
|
- `_switch_discussion` (`app_controller.py:3214-3215`) — to atomically replace `disc_entries[:]`
|
|
- `app._process_pending_gui_tasks` (called from render loop) — to read entries safely while a background thread appends an AI response
|
|
- `truncate_entries` (via the panel-level `Truncate` button) — to atomically replace `disc_entries` with the truncated list
|
|
- `gui_2.py:4060, 4223-4224` — the AI response callback appends a new entry under the lock
|
|
- `gui_2.py:4359` (in `render_discussion_selector` when track-discussion is toggled) — flushes under the lock
|
|
|
|
**Invariant:** the lock is *never* held across a render call. The lock is acquired, `disc_entries[:] = ...` is done, the lock is released. The ImGui renderer reads `disc_entries` lock-free; it sees either the old list or the new list but never a half-updated one.
|
|
|
|
**Cross-thread append pattern** (the AI response callback at `gui_2.py:4060`):
|
|
|
|
```python
|
|
with app._disc_entries_lock:
|
|
app.disc_entries.append({"role": "user", "content": prompt, "collapsed": False, "ts": project_manager.now_ts()})
|
|
```
|
|
|
|
The background thread (e.g. `_bg_task`) appends; the render thread reads. The lock is the *only* synchronization primitive — there is no event loop, no message queue, no signal. The render thread polls at frame rate (60 FPS nominal); if the background thread appends between frames, the next frame sees the new entry.
|
|
|
|
---
|
|
|
|
## Undo/Redo Integration
|
|
|
|
The discussion system is integrated with `HistoryManager` + `UISnapshot` for full undo/redo. See `guide_state_lifecycle.md` for the full architecture. The relevant details for discussions:
|
|
|
|
- `UISnapshot.disc_entries: list[dict]` (`src/history.py:19`) captures the full entry list via `copy.deepcopy(self.disc_entries)` (`gui_2.py:748`).
|
|
- The change-detection logic at `gui_2.py:1160, 1166-1167` checks if `disc_entries` length or last-entry content changed; if so, a new snapshot is pushed to the undo stack.
|
|
- `Ctrl+Z` restores the previous `disc_entries` via `gui_2.py:754 _apply_snapshot`.
|
|
|
|
**Per-edit granularity.** A snapshot is pushed *per render frame* that detects a change. The 100-snapshot cap means you can rewind up to ~100 edits. For a 5-second window of rapid typing, that's a lot. For long sessions with infrequent edits, the history can span hours.
|
|
|
|
---
|
|
|
|
## Reset (Destroying the Discussion)
|
|
|
|
`app_controller._handle_reset_session` (`app_controller.py:3286-3356`) is the **nuclear** reset:
|
|
|
|
- `self.disc_entries.clear()` — empties the current take
|
|
- `for d_name in discussions: discussions[d_name]["history"] = []` — empties ALL takes and ALL discussions
|
|
- Resets `discussion_sent_markdown` and `discussion_sent_system_prompt` to `""`
|
|
- Resets the entire project dict to `default_project(...)` — this is a *new* empty project, not the user's saved one
|
|
|
|
**The reset is intentionally aggressive.** The 2026-06-08 `_handle_reset_session` regression (documented in the comments at `app_controller.py:3307-3312`) was caused by an early version that *also* cleared `self.active_project_path`, leading to an infinite re-switch loop. The fix is to leave `active_project_path` alone.
|
|
|
|
**What reset does NOT touch:**
|
|
- `self.project` is replaced, but the user's *saved* project TOML on disk is untouched. Switching projects after reset reloads from disk.
|
|
- `app.history` (the `HistoryManager`) is not cleared. The undo stack survives a reset — Ctrl+Z after a reset can restore the pre-reset discussion state. This may be a bug or a feature depending on user expectation.
|
|
- `self.active_project_path` is preserved.
|
|
|
|
---
|
|
|
|
## Hook API Surface
|
|
|
|
The discussion system is exposed to the Hook API via two endpoints (per `guide_tools.md`):
|
|
|
|
| Method | Endpoint | Behavior |
|
|
|---|---|---|
|
|
| `GET /api/session` | Direct read | `{"session": {"entries": [...]}}` from `app.disc_entries` |
|
|
| `POST /api/session` | `{"session": {"entries": [...]}}` | `{"status": "updated"}` — sets `app.disc_entries` |
|
|
|
|
The POST endpoint allows external automation to *replace* the entire discussion. Per-entry inserts/deletes are not currently exposed via the Hook API (only full-replacement). This is a known gap.
|
|
|
|
`api_hook_client.py` exposes `get_session()` and `set_session(entries)` as the Python-side wrappers.
|
|
|
|
---
|
|
|
|
## Tests
|
|
|
|
- `tests/test_discussion_takes.py` — `TestDiscussionTakes` covers `branch_discussion` (creates a new Take) and `promote_take` (renames a Take to top-level).
|
|
- `tests/test_gui_discussion_tabs.py` — `test_discussion_tabs_rendered` covers the discussion selector and Take tabs.
|
|
- `tests/test_discussion_takes_gui.py` — `test_render_discussion_tabs` and `test_switching_discussion_via_tabs` cover the GUI flow.
|
|
- `tests/test_history.py` — `test_undo_redo`, `test_jump_to_undo`, `test_max_capacity`, `test_redo_cleared_on_push`, `test_push_state` cover the undo/redo integration.
|
|
- `tests/test_history_manager.py` — `TestHistoryManager` covers `snapshot_roundtrip`, `push_and_undo`, `push_clears_redo_stack`, `undo_and_redo`, `undo_no_history_returns_none`, `redo_no_history_returns_none`, `get_history_returns_descriptions`, `jump_to_undo`.
|
|
- `tests/test_session_logger_reset.py` — `test_reset_session` covers the reset path.
|
|
- `tests/test_gui_fast_render.py` — `test_render_discussion_panel_fast` covers the render path.
|
|
- `tests/test_gui_phase4.py` — `test_track_discussion_toggle` covers the track-discussion toggle.
|
|
- `tests/test_gui_symbol_navigation.py` — `test_render_discussion_panel_symbol_lookup` covers the `@Symbol` lookup integration.
|
|
|
|
---
|
|
|
|
## Known Limitations
|
|
|
|
1. **Per-edit save is implicit.** The per-entry edit operations (A1-A7) do not flush to TOML on every edit. The save happens on the next `_switch_discussion`, `_branch_discussion`, or explicit B4 Save. A crash between edit and save loses the edit. Fix: hook the change-detection logic in `gui_2.py:1160, 1166-1167` to also call `_flush_disc_entries_to_project` after a debounce.
|
|
2. **Provider-side history diverges from `disc_entries`.** When the user edits an entry's `content` via A1, the *displayed* text is corrected but the *provider-side* `ai_client._anthropic_history` (and siblings) still contains the original. The next LLM call may replay the original tool results. This is Pitfall #4 in `conductor/tracks/nagent_review_20260608/report.md` and the corresponding Decision candidate #3 (Stateless LLMClient).
|
|
3. **Hook API is full-replacement only.** No per-entry insert/delete via the API. The user could `POST /api/session` with a new list, but partial edits require the full list.
|
|
4. **Truncate is destructive.** The `Truncate` button (B10) is not undoable as a single operation — it's a list replacement, so the undo stack pushes the new (truncated) list, not the pre-truncate list. Actually, it *is* pushed (per the change-detection logic), so Ctrl+Z restores the pre-truncate list. Confirmed working in `tests/test_history.py`.
|
|
|
|
---
|
|
|
|
## Cross-References
|
|
|
|
- **Discussion data model:** `src/models.py:196 parse_history_entries`, `src/models.py:909 ContextPreset`, `src/models.py:510 FileItem`
|
|
- **Discussion persistence:** `src/project_manager.py:429 branch_discussion`, `src/project_manager.py:447 promote_take`, `src/project_manager.py:396 calculate_track_progress`
|
|
- **Discussion switching/management:** `src/app_controller.py:3199 _switch_discussion`, `src/app_controller.py:3225 _flush_disc_entries_to_project`, `src/app_controller.py:3286 _handle_reset_session`, `src/app_controller.py:3357 _handle_compress_discussion`, `src/app_controller.py:3503 _branch_discussion`, `src/app_controller.py:3521 _rename_discussion`, `src/app_controller.py:3537 _delete_discussion`
|
|
- **GUI render functions:** `src/gui_2.py:175 truncate_entries`, `src/gui_2.py:735 _take_snapshot`, `src/gui_2.py:754 _apply_snapshot`, `src/gui_2.py:3770 render_discussion_entry`, `src/gui_2.py:4227 render_discussion_entries`, `src/gui_2.py:4239 render_discussion_entry_controls`, `src/gui_2.py:4317 render_discussion_roles`, `src/gui_2.py:4330 render_discussion_selector`
|
|
- **Undo/redo integration:** `src/history.py:8 UISnapshot`, `src/history.py:71 HistoryManager`
|
|
- **Deep-dive on the design philosophy:** `conductor/tracks/nagent_review_20260608/report.md §3` (the 23-operation matrix A1-C5)
|
|
- **Actionable patterns for sub-agents in 1:1 discussions:** `conductor/tracks/nagent_review_20260608/nagent_takeaways_20260608.md` §3 and §10
|
|
- **Future-track candidate for raw-transcript persistence:** `conductor/tracks/nagent_review_20260608/decisions.md` candidate #10
|