Compare commits
8 Commits
ea84168ada
...
4933a007c3
| Author | SHA1 | Date | |
|---|---|---|---|
| 4933a007c3 | |||
| b2e900e77d | |||
| 7c44948f33 | |||
| 09df57df2b | |||
| a6c9093961 | |||
| 754fbe5c30 | |||
| 7bed5efe61 | |||
| ba02c8ed12 |
41
aggregate.py
41
aggregate.py
@@ -16,6 +16,7 @@ import re
|
|||||||
import glob
|
import glob
|
||||||
from pathlib import Path, PureWindowsPath
|
from pathlib import Path, PureWindowsPath
|
||||||
import summarize
|
import summarize
|
||||||
|
import project_manager
|
||||||
|
|
||||||
def find_next_increment(output_dir: Path, namespace: str) -> int:
|
def find_next_increment(output_dir: Path, namespace: str) -> int:
|
||||||
pattern = re.compile(rf"^{re.escape(namespace)}_(\d+)\.md$")
|
pattern = re.compile(rf"^{re.escape(namespace)}_(\d+)\.md$")
|
||||||
@@ -37,14 +38,24 @@ def is_absolute_with_drive(entry: str) -> bool:
|
|||||||
def resolve_paths(base_dir: Path, entry: str) -> list[Path]:
|
def resolve_paths(base_dir: Path, entry: str) -> list[Path]:
|
||||||
has_drive = is_absolute_with_drive(entry)
|
has_drive = is_absolute_with_drive(entry)
|
||||||
is_wildcard = "*" in entry
|
is_wildcard = "*" in entry
|
||||||
|
|
||||||
|
matches = []
|
||||||
if is_wildcard:
|
if is_wildcard:
|
||||||
root = Path(entry) if has_drive else base_dir / entry
|
root = Path(entry) if has_drive else base_dir / entry
|
||||||
matches = [Path(p) for p in glob.glob(str(root), recursive=True) if Path(p).is_file()]
|
matches = [Path(p) for p in glob.glob(str(root), recursive=True) if Path(p).is_file()]
|
||||||
return sorted(matches)
|
|
||||||
else:
|
else:
|
||||||
if has_drive:
|
p = Path(entry) if has_drive else (base_dir / entry).resolve()
|
||||||
return [Path(entry)]
|
matches = [p]
|
||||||
return [(base_dir / entry).resolve()]
|
|
||||||
|
# Blacklist filter
|
||||||
|
filtered = []
|
||||||
|
for p in matches:
|
||||||
|
name = p.name.lower()
|
||||||
|
if name == "history.toml" or name.endswith("_history.toml"):
|
||||||
|
continue
|
||||||
|
filtered.append(p)
|
||||||
|
|
||||||
|
return sorted(filtered)
|
||||||
|
|
||||||
def build_discussion_section(history: list[str]) -> str:
|
def build_discussion_section(history: list[str]) -> str:
|
||||||
sections = []
|
sections = []
|
||||||
@@ -214,9 +225,25 @@ def run(config: dict) -> tuple[str, Path, list[dict]]:
|
|||||||
return markdown, output_file, file_items
|
return markdown, output_file, file_items
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
with open("config.toml", "rb") as f:
|
# Load global config to find active project
|
||||||
import tomllib
|
config_path = Path("config.toml")
|
||||||
config = tomllib.load(f)
|
if not config_path.exists():
|
||||||
|
print("config.toml not found.")
|
||||||
|
return
|
||||||
|
|
||||||
|
with open(config_path, "rb") as f:
|
||||||
|
global_cfg = tomllib.load(f)
|
||||||
|
|
||||||
|
active_path = global_cfg.get("projects", {}).get("active")
|
||||||
|
if not active_path:
|
||||||
|
print("No active project found in config.toml.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Use project_manager to load project (handles history segregation)
|
||||||
|
proj = project_manager.load_project(active_path)
|
||||||
|
# Use flat_config to make it compatible with aggregate.run()
|
||||||
|
config = project_manager.flat_config(proj)
|
||||||
|
|
||||||
markdown, output_file, _ = run(config)
|
markdown, output_file, _ = run(config)
|
||||||
print(f"Written: {output_file}")
|
print(f"Written: {output_file}")
|
||||||
|
|
||||||
|
|||||||
33
conductor/archive/history_segregation_20260224/plan.md
Normal file
33
conductor/archive/history_segregation_20260224/plan.md
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# Implementation Plan: Discussion History Segregation and Blacklisting
|
||||||
|
|
||||||
|
This plan follows the Test-Driven Development (TDD) workflow to move discussion history into a dedicated sibling TOML file and enforce a strict blacklist against AI agent tool access.
|
||||||
|
|
||||||
|
## Phase 1: Foundation and Migration Logic
|
||||||
|
This phase focuses on the structural changes needed to handle dual-file project configurations and the automatic migration of legacy history.
|
||||||
|
|
||||||
|
- [x] Task: Research existing `ProjectManager` serialization and tool access points in `mcp_client.py`. (f400799)
|
||||||
|
- [x] Task: Write TDD tests for migrating the `discussion` key from `manual_slop.toml` to a new sibling file. (7c18e11)
|
||||||
|
- [x] Task: Implement automatic migration in `ProjectManager.load_project()`. (7c18e11)
|
||||||
|
- [x] Task: Update `ProjectManager.save_project()` to persist history separately. (7c18e11)
|
||||||
|
- [x] Task: Verify that existing history is correctly migrated and remains visible in the GUI. (ba02c8e)
|
||||||
|
- [x] Task: Conductor - User Manual Verification 'Foundation and Migration' (Protocol in workflow.md)
|
||||||
|
|
||||||
|
## Phase 2: Blacklist Enforcement
|
||||||
|
This phase ensures the AI agent is strictly prevented from reading the history source files through its tools.
|
||||||
|
|
||||||
|
- [x] Task: Write failing tests that attempt to read a known history file via the `mcp_client.py` and `aggregate.py` logic. (77f3e22)
|
||||||
|
- [x] Task: Implement hardcoded exclusion for `*_history.toml` and `history.toml` in `mcp_client.py`. (77f3e22)
|
||||||
|
- [x] Task: Implement hardcoded exclusion in `aggregate.py` to prevent history from being added as a raw file context. (77f3e22)
|
||||||
|
- [x] Task: Verify that tool-based file reads for the history file return a "Permission Denied" or "Blacklisted" error. (77f3e22)
|
||||||
|
- [x] Task: Conductor - User Manual Verification 'Blacklist Enforcement' (Protocol in workflow.md)
|
||||||
|
|
||||||
|
## Phase 3: Integration and Final Validation
|
||||||
|
This phase validates the full lifecycle, ensuring the application remains functional and secure.
|
||||||
|
|
||||||
|
- [x] Task: Conduct a full walkthrough using the simulation scripts to verify history persistence across turns. (754fbe5)
|
||||||
|
- [x] Task: Verify that the AI can still use the *curated* history provided in the prompt context but cannot access the raw file. (754fbe5)
|
||||||
|
- [x] Task: Run full suite of automated GUI and API hook tests. (754fbe5)
|
||||||
|
- [x] Task: Conductor - User Manual Verification 'Integration and Final Validation' (Protocol in workflow.md) [checkpoint: 754fbe5]
|
||||||
|
|
||||||
|
## Phase: Review Fixes
|
||||||
|
- [x] Task: Apply review suggestions (docstrings, annotations, import placement) (09df57d)
|
||||||
@@ -20,11 +20,6 @@ This file tracks all major tracks for the project. Each track has its own detail
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
- [ ] **Track: Move discussion histories to their own toml to prevent the ai agent from reading it (will be on a blacklist).**
|
|
||||||
*Link: [./tracks/history_segregation_20260224/](./tracks/history_segregation_20260224/)*
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
- [ ] **Track: Update ./docs/* & ./Readme.md, review ./MainContext.md significance (should we keep it..).**
|
- [ ] **Track: Update ./docs/* & ./Readme.md, review ./MainContext.md significance (should we keep it..).**
|
||||||
*Link: [./tracks/documentation_refresh_20260224/](./tracks/documentation_refresh_20260224/)*
|
*Link: [./tracks/documentation_refresh_20260224/](./tracks/documentation_refresh_20260224/)*
|
||||||
|
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
# Implementation Plan: Discussion History Segregation and Blacklisting
|
|
||||||
|
|
||||||
This plan follows the Test-Driven Development (TDD) workflow to move discussion history into a dedicated sibling TOML file and enforce a strict blacklist against AI agent tool access.
|
|
||||||
|
|
||||||
## Phase 1: Foundation and Migration Logic
|
|
||||||
This phase focuses on the structural changes needed to handle dual-file project configurations and the automatic migration of legacy history.
|
|
||||||
|
|
||||||
- [ ] Task: Research existing `ProjectManager` serialization and tool access points in `mcp_client.py`.
|
|
||||||
- [ ] Task: Write TDD tests for migrating the `discussion` key from `manual_slop.toml` to a new sibling file.
|
|
||||||
- [ ] Task: Implement automatic migration in `ProjectManager.load_project()`.
|
|
||||||
- [ ] Task: Update `ProjectManager.save_project()` to persist history separately.
|
|
||||||
- [ ] Task: Verify that existing history is correctly migrated and remains visible in the GUI.
|
|
||||||
- [ ] Task: Conductor - User Manual Verification 'Foundation and Migration' (Protocol in workflow.md)
|
|
||||||
|
|
||||||
## Phase 2: Blacklist Enforcement
|
|
||||||
This phase ensures the AI agent is strictly prevented from reading the history source files through its tools.
|
|
||||||
|
|
||||||
- [ ] Task: Write failing tests that attempt to read a known history file via the `mcp_client.py` and `aggregate.py` logic.
|
|
||||||
- [ ] Task: Implement hardcoded exclusion for `*_history.toml` and `history.toml` in `mcp_client.py`.
|
|
||||||
- [ ] Task: Implement hardcoded exclusion in `aggregate.py` to prevent history from being added as a raw file context.
|
|
||||||
- [ ] Task: Verify that tool-based file reads for the history file return a "Permission Denied" or "Blacklisted" error.
|
|
||||||
- [ ] Task: Conductor - User Manual Verification 'Blacklist Enforcement' (Protocol in workflow.md)
|
|
||||||
|
|
||||||
## Phase 3: Integration and Final Validation
|
|
||||||
This phase validates the full lifecycle, ensuring the application remains functional and secure.
|
|
||||||
|
|
||||||
- [ ] Task: Conduct a full walkthrough using the simulation scripts to verify history persistence across turns.
|
|
||||||
- [ ] Task: Verify that the AI can still use the *curated* history provided in the prompt context but cannot access the raw file.
|
|
||||||
- [ ] Task: Run full suite of automated GUI and API hook tests.
|
|
||||||
- [ ] Task: Conductor - User Manual Verification 'Integration and Final Validation' (Protocol in workflow.md)
|
|
||||||
File diff suppressed because one or more lines are too long
89
manual_slop_history.toml
Normal file
89
manual_slop_history.toml
Normal file
File diff suppressed because one or more lines are too long
@@ -87,7 +87,14 @@ def _is_allowed(path: Path) -> bool:
|
|||||||
- it is contained within (or equal to) one of the _base_dirs
|
- it is contained within (or equal to) one of the _base_dirs
|
||||||
All paths are resolved (follows symlinks) before comparison to prevent
|
All paths are resolved (follows symlinks) before comparison to prevent
|
||||||
symlink-based path traversal.
|
symlink-based path traversal.
|
||||||
|
|
||||||
|
CRITICAL: Blacklisted files (history) are NEVER allowed.
|
||||||
"""
|
"""
|
||||||
|
# Blacklist check
|
||||||
|
name = path.name.lower()
|
||||||
|
if name == "history.toml" or name.endswith("_history.toml"):
|
||||||
|
return False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
rp = path.resolve(strict=True)
|
rp = path.resolve(strict=True)
|
||||||
except (OSError, ValueError):
|
except (OSError, ValueError):
|
||||||
@@ -153,11 +160,18 @@ def list_directory(path: str) -> str:
|
|||||||
try:
|
try:
|
||||||
entries = sorted(p.iterdir(), key=lambda e: (e.is_file(), e.name.lower()))
|
entries = sorted(p.iterdir(), key=lambda e: (e.is_file(), e.name.lower()))
|
||||||
lines = [f"Directory: {p}", ""]
|
lines = [f"Directory: {p}", ""]
|
||||||
|
count = 0
|
||||||
for entry in entries:
|
for entry in entries:
|
||||||
|
# Blacklist check
|
||||||
|
name = entry.name.lower()
|
||||||
|
if name == "history.toml" or name.endswith("_history.toml"):
|
||||||
|
continue
|
||||||
|
|
||||||
kind = "file" if entry.is_file() else "dir "
|
kind = "file" if entry.is_file() else "dir "
|
||||||
size = f"{entry.stat().st_size:>10,} bytes" if entry.is_file() else ""
|
size = f"{entry.stat().st_size:>10,} bytes" if entry.is_file() else ""
|
||||||
lines.append(f" [{kind}] {entry.name:<40} {size}")
|
lines.append(f" [{kind}] {entry.name:<40} {size}")
|
||||||
lines.append(f" ({len(entries)} entries)")
|
count += 1
|
||||||
|
lines.append(f" ({count} entries)")
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return f"ERROR listing '{path}': {e}"
|
return f"ERROR listing '{path}': {e}"
|
||||||
@@ -178,11 +192,18 @@ def search_files(path: str, pattern: str) -> str:
|
|||||||
if not matches:
|
if not matches:
|
||||||
return f"No files matched '{pattern}' in {path}"
|
return f"No files matched '{pattern}' in {path}"
|
||||||
lines = [f"Search '{pattern}' in {p}:", ""]
|
lines = [f"Search '{pattern}' in {p}:", ""]
|
||||||
|
count = 0
|
||||||
for m in matches:
|
for m in matches:
|
||||||
|
# Blacklist check
|
||||||
|
name = m.name.lower()
|
||||||
|
if name == "history.toml" or name.endswith("_history.toml"):
|
||||||
|
continue
|
||||||
|
|
||||||
rel = m.relative_to(p)
|
rel = m.relative_to(p)
|
||||||
kind = "file" if m.is_file() else "dir "
|
kind = "file" if m.is_file() else "dir "
|
||||||
lines.append(f" [{kind}] {rel}")
|
lines.append(f" [{kind}] {rel}")
|
||||||
lines.append(f" ({len(matches)} match(es))")
|
count += 1
|
||||||
|
lines.append(f" ({count} match(es))")
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return f"ERROR searching '{path}': {e}"
|
return f"ERROR searching '{path}': {e}"
|
||||||
|
|||||||
14
project.toml
14
project.toml
@@ -23,17 +23,3 @@ search_files = true
|
|||||||
get_file_summary = true
|
get_file_summary = true
|
||||||
web_search = true
|
web_search = true
|
||||||
fetch_url = true
|
fetch_url = true
|
||||||
|
|
||||||
[discussion]
|
|
||||||
roles = [
|
|
||||||
"User",
|
|
||||||
"AI",
|
|
||||||
"Vendor API",
|
|
||||||
"System",
|
|
||||||
]
|
|
||||||
active = "main"
|
|
||||||
|
|
||||||
[discussion.discussions.main]
|
|
||||||
git_commit = ""
|
|
||||||
last_updated = "2026-02-24T21:55:23"
|
|
||||||
history = []
|
|
||||||
|
|||||||
12
project_history.toml
Normal file
12
project_history.toml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
roles = [
|
||||||
|
"User",
|
||||||
|
"AI",
|
||||||
|
"Vendor API",
|
||||||
|
"System",
|
||||||
|
]
|
||||||
|
active = "main"
|
||||||
|
|
||||||
|
[discussions.main]
|
||||||
|
git_commit = ""
|
||||||
|
last_updated = "2026-02-24T22:13:24"
|
||||||
|
history = []
|
||||||
@@ -121,14 +121,69 @@ def default_project(name: str = "unnamed") -> dict:
|
|||||||
|
|
||||||
# ── load / save ──────────────────────────────────────────────────────────────
|
# ── load / save ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def load_project(path) -> dict:
|
def get_history_path(project_path: str | Path) -> Path:
|
||||||
|
"""Return the Path to the sibling history TOML file for a given project."""
|
||||||
|
p = Path(project_path)
|
||||||
|
return p.parent / f"{p.stem}_history.toml"
|
||||||
|
|
||||||
|
|
||||||
|
def load_project(path: str | Path) -> dict:
|
||||||
|
"""
|
||||||
|
Load a project TOML file.
|
||||||
|
Automatically migrates legacy 'discussion' keys to a sibling history file.
|
||||||
|
"""
|
||||||
with open(path, "rb") as f:
|
with open(path, "rb") as f:
|
||||||
return tomllib.load(f)
|
proj = tomllib.load(f)
|
||||||
|
|
||||||
|
# Automatic Migration: move legacy 'discussion' to sibling file
|
||||||
|
hist_path = get_history_path(path)
|
||||||
|
if "discussion" in proj:
|
||||||
|
disc = proj.pop("discussion")
|
||||||
|
# Save to history file if it doesn't exist yet (or overwrite to migrate)
|
||||||
|
with open(hist_path, "wb") as f:
|
||||||
|
tomli_w.dump(disc, f)
|
||||||
|
# Save the stripped project file
|
||||||
|
save_project(proj, path)
|
||||||
|
# Restore for the returned dict so GUI works as before
|
||||||
|
proj["discussion"] = disc
|
||||||
|
else:
|
||||||
|
# Load from sibling if it exists
|
||||||
|
if hist_path.exists():
|
||||||
|
proj["discussion"] = load_history(path)
|
||||||
|
|
||||||
|
return proj
|
||||||
|
|
||||||
|
|
||||||
def save_project(proj: dict, path):
|
def load_history(project_path: str | Path) -> dict:
|
||||||
|
"""Load the segregated discussion history from its dedicated TOML file."""
|
||||||
|
hist_path = get_history_path(project_path)
|
||||||
|
if hist_path.exists():
|
||||||
|
with open(hist_path, "rb") as f:
|
||||||
|
return tomllib.load(f)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def save_project(proj: dict, path: str | Path, disc_data: dict | None = None):
|
||||||
|
"""
|
||||||
|
Save the project TOML.
|
||||||
|
If 'discussion' is present in proj, it is moved to the sibling history file.
|
||||||
|
"""
|
||||||
|
# Ensure 'discussion' is NOT in the main project dict
|
||||||
|
if "discussion" in proj:
|
||||||
|
# If disc_data wasn't provided, use the one from proj
|
||||||
|
if disc_data is None:
|
||||||
|
disc_data = proj["discussion"]
|
||||||
|
# Remove it so it doesn't get saved to the main file
|
||||||
|
proj = dict(proj) # shallow copy to avoid mutating caller's dict
|
||||||
|
del proj["discussion"]
|
||||||
|
|
||||||
with open(path, "wb") as f:
|
with open(path, "wb") as f:
|
||||||
tomli_w.dump(proj, f)
|
tomli_w.dump(proj, f)
|
||||||
|
|
||||||
|
if disc_data:
|
||||||
|
hist_path = get_history_path(path)
|
||||||
|
with open(hist_path, "wb") as f:
|
||||||
|
tomli_w.dump(disc_data, f)
|
||||||
|
|
||||||
|
|
||||||
# ── migration helper ─────────────────────────────────────────────────────────
|
# ── migration helper ─────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -25,18 +25,3 @@ search_files = true
|
|||||||
get_file_summary = true
|
get_file_summary = true
|
||||||
web_search = true
|
web_search = true
|
||||||
fetch_url = true
|
fetch_url = true
|
||||||
|
|
||||||
[discussion]
|
|
||||||
roles = [
|
|
||||||
"User",
|
|
||||||
"AI",
|
|
||||||
"Vendor API",
|
|
||||||
"System",
|
|
||||||
]
|
|
||||||
active = "main"
|
|
||||||
auto_add = true
|
|
||||||
|
|
||||||
[discussion.discussions.main]
|
|
||||||
git_commit = ""
|
|
||||||
last_updated = "2026-02-24T21:55:18"
|
|
||||||
history = []
|
|
||||||
|
|||||||
13
tests/temp_project_history.toml
Normal file
13
tests/temp_project_history.toml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
roles = [
|
||||||
|
"User",
|
||||||
|
"AI",
|
||||||
|
"Vendor API",
|
||||||
|
"System",
|
||||||
|
]
|
||||||
|
active = "main"
|
||||||
|
auto_add = true
|
||||||
|
|
||||||
|
[discussions.main]
|
||||||
|
git_commit = ""
|
||||||
|
last_updated = "2026-02-24T22:13:19"
|
||||||
|
history = []
|
||||||
25
tests/test_ai_context_history.py
Normal file
25
tests/test_ai_context_history.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import pytest
|
||||||
|
import tomli_w
|
||||||
|
from pathlib import Path
|
||||||
|
import aggregate
|
||||||
|
import project_manager
|
||||||
|
|
||||||
|
def test_aggregate_includes_segregated_history(tmp_path):
|
||||||
|
proj_path = tmp_path / "manual_slop.toml"
|
||||||
|
hist_path = tmp_path / "manual_slop_history.toml"
|
||||||
|
|
||||||
|
# Setup segregated project
|
||||||
|
proj_data = project_manager.default_project("test-aggregate")
|
||||||
|
proj_data["discussion"]["discussions"]["main"]["history"] = ["@2026-02-24T14:00:00\nUser:\nShow me history"]
|
||||||
|
|
||||||
|
# Save (will segregate)
|
||||||
|
project_manager.save_project(proj_data, proj_path)
|
||||||
|
|
||||||
|
# Run aggregate
|
||||||
|
loaded_proj = project_manager.load_project(proj_path)
|
||||||
|
config = project_manager.flat_config(loaded_proj)
|
||||||
|
|
||||||
|
markdown, output_file, file_items = aggregate.run(config)
|
||||||
|
|
||||||
|
assert "## Discussion History" in markdown
|
||||||
|
assert "Show me history" in markdown
|
||||||
32
tests/test_history_blacklist.py
Normal file
32
tests/test_history_blacklist.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import pytest
|
||||||
|
from pathlib import Path
|
||||||
|
import mcp_client
|
||||||
|
import aggregate
|
||||||
|
|
||||||
|
def test_mcp_blacklist(tmp_path):
|
||||||
|
# Setup a "history" file
|
||||||
|
hist_file = tmp_path / "my_project_history.toml"
|
||||||
|
hist_file.write_text("secret history", encoding="utf-8")
|
||||||
|
|
||||||
|
# Configure MCP client with the tmp_path as allowed
|
||||||
|
mcp_client.configure([{"path": str(hist_file)}], extra_base_dirs=[str(tmp_path)])
|
||||||
|
|
||||||
|
# Try to read it - should fail
|
||||||
|
result = mcp_client.read_file(str(hist_file))
|
||||||
|
assert "ACCESS DENIED" in result or "BLACKLISTED" in result
|
||||||
|
|
||||||
|
# Try to list it
|
||||||
|
result = mcp_client.list_directory(str(tmp_path))
|
||||||
|
assert "my_project_history.toml" not in result
|
||||||
|
|
||||||
|
def test_aggregate_blacklist(tmp_path):
|
||||||
|
# Setup a "history" file
|
||||||
|
hist_file = tmp_path / "my_project_history.toml"
|
||||||
|
hist_file.write_text("secret history", encoding="utf-8")
|
||||||
|
|
||||||
|
# Try to resolve paths including the history file
|
||||||
|
paths = aggregate.resolve_paths(tmp_path, "*_history.toml")
|
||||||
|
assert hist_file not in paths
|
||||||
|
|
||||||
|
paths = aggregate.resolve_paths(tmp_path, "*")
|
||||||
|
assert hist_file not in paths
|
||||||
56
tests/test_history_migration.py
Normal file
56
tests/test_history_migration.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import pytest
|
||||||
|
import tomli_w
|
||||||
|
import tomllib
|
||||||
|
from pathlib import Path
|
||||||
|
from project_manager import load_project, save_project, default_project
|
||||||
|
|
||||||
|
def test_migration_on_load(tmp_path):
|
||||||
|
# Setup legacy project file with discussion
|
||||||
|
proj_path = tmp_path / "manual_slop.toml"
|
||||||
|
hist_path = tmp_path / "manual_slop_history.toml"
|
||||||
|
|
||||||
|
legacy_data = default_project("test-project")
|
||||||
|
legacy_data["discussion"]["discussions"]["main"]["history"] = ["Hello", "World"]
|
||||||
|
|
||||||
|
with open(proj_path, "wb") as f:
|
||||||
|
tomli_w.dump(legacy_data, f)
|
||||||
|
|
||||||
|
# Load project - should trigger migration
|
||||||
|
loaded_data = load_project(proj_path)
|
||||||
|
|
||||||
|
# Assertions
|
||||||
|
assert "discussion" in loaded_data
|
||||||
|
assert loaded_data["discussion"]["discussions"]["main"]["history"] == ["Hello", "World"]
|
||||||
|
|
||||||
|
# Check that it's NOT in the main file on disk anymore
|
||||||
|
with open(proj_path, "rb") as f:
|
||||||
|
on_disk = tomllib.load(f)
|
||||||
|
assert "discussion" not in on_disk
|
||||||
|
|
||||||
|
# Check history file
|
||||||
|
assert hist_path.exists()
|
||||||
|
with open(hist_path, "rb") as f:
|
||||||
|
hist_data = tomllib.load(f)
|
||||||
|
assert hist_data["discussions"]["main"]["history"] == ["Hello", "World"]
|
||||||
|
|
||||||
|
def test_save_separation(tmp_path):
|
||||||
|
# Setup fresh project data
|
||||||
|
proj_path = tmp_path / "manual_slop.toml"
|
||||||
|
hist_path = tmp_path / "manual_slop_history.toml"
|
||||||
|
|
||||||
|
proj_data = default_project("test-project")
|
||||||
|
proj_data["discussion"]["discussions"]["main"]["history"] = ["Saved", "Separately"]
|
||||||
|
|
||||||
|
# Save project - should save both files
|
||||||
|
save_project(proj_data, proj_path)
|
||||||
|
|
||||||
|
assert proj_path.exists()
|
||||||
|
assert hist_path.exists()
|
||||||
|
|
||||||
|
with open(proj_path, "rb") as f:
|
||||||
|
p = tomllib.load(f)
|
||||||
|
assert "discussion" not in p
|
||||||
|
|
||||||
|
with open(hist_path, "rb") as f:
|
||||||
|
h = tomllib.load(f)
|
||||||
|
assert h["discussions"]["main"]["history"] == ["Saved", "Separately"]
|
||||||
44
tests/test_history_persistence.py
Normal file
44
tests/test_history_persistence.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import pytest
|
||||||
|
import tomli_w
|
||||||
|
import tomllib
|
||||||
|
from pathlib import Path
|
||||||
|
from project_manager import load_project, save_project, default_project, entry_to_str
|
||||||
|
|
||||||
|
def test_history_persistence_across_turns(tmp_path):
|
||||||
|
proj_path = tmp_path / "manual_slop.toml"
|
||||||
|
hist_path = tmp_path / "manual_slop_history.toml"
|
||||||
|
|
||||||
|
# 1. Start project
|
||||||
|
proj = default_project("test-persistence")
|
||||||
|
save_project(proj, proj_path)
|
||||||
|
|
||||||
|
# 2. Add a turn
|
||||||
|
proj = load_project(proj_path)
|
||||||
|
entry1 = {"role": "User", "content": "Hello", "ts": "2026-02-24T13:00:00"}
|
||||||
|
proj["discussion"]["discussions"]["main"]["history"].append(entry_to_str(entry1))
|
||||||
|
save_project(proj, proj_path)
|
||||||
|
|
||||||
|
# Verify separation
|
||||||
|
with open(proj_path, "rb") as f:
|
||||||
|
p_disk = tomllib.load(f)
|
||||||
|
assert "discussion" not in p_disk
|
||||||
|
|
||||||
|
with open(hist_path, "rb") as f:
|
||||||
|
h_disk = tomllib.load(f)
|
||||||
|
assert h_disk["discussions"]["main"]["history"] == ["@2026-02-24T13:00:00\nUser:\nHello"]
|
||||||
|
|
||||||
|
# 3. Add another turn
|
||||||
|
proj = load_project(proj_path)
|
||||||
|
entry2 = {"role": "AI", "content": "Hi there!", "ts": "2026-02-24T13:01:00"}
|
||||||
|
proj["discussion"]["discussions"]["main"]["history"].append(entry_to_str(entry2))
|
||||||
|
save_project(proj, proj_path)
|
||||||
|
|
||||||
|
# Verify persistence
|
||||||
|
with open(hist_path, "rb") as f:
|
||||||
|
h_disk = tomllib.load(f)
|
||||||
|
assert len(h_disk["discussions"]["main"]["history"]) == 2
|
||||||
|
assert h_disk["discussions"]["main"]["history"][1] == "@2026-02-24T13:01:00\nAI:\nHi there!"
|
||||||
|
|
||||||
|
# 4. Reload and check
|
||||||
|
proj_final = load_project(proj_path)
|
||||||
|
assert len(proj_final["discussion"]["discussions"]["main"]["history"]) == 2
|
||||||
Reference in New Issue
Block a user