From c78d1d82ace46c304937db9b45be77c04776012d Mon Sep 17 00:00:00 2001 From: Bill Date: Tue, 17 Feb 2026 21:21:20 -0700 Subject: [PATCH] Add step 630 chat session persistence --- editor/CMakeLists.txt | 9 ++ editor/src/AgentChatPanelModel.h | 4 + editor/src/AgentChatSessionPersistence.h | 167 ++++++++++++++++++++ editor/src/panels/AgentChatPanel.h | 33 ++++ editor/tests/step630_test.cpp | 184 +++++++++++++++++++++++ progress.md | 37 +++++ 6 files changed, 434 insertions(+) create mode 100644 editor/src/AgentChatSessionPersistence.h create mode 100644 editor/tests/step630_test.cpp diff --git a/editor/CMakeLists.txt b/editor/CMakeLists.txt index 6c00eba..3401747 100644 --- a/editor/CMakeLists.txt +++ b/editor/CMakeLists.txt @@ -4549,4 +4549,13 @@ target_link_libraries(step629_test PRIVATE tree_sitter_javascript tree_sitter_typescript tree_sitter_java tree_sitter_rust tree_sitter_go) +add_executable(step630_test tests/step630_test.cpp) +target_include_directories(step630_test PRIVATE src) +target_link_libraries(step630_test PRIVATE + nlohmann_json::nlohmann_json + unofficial::tree-sitter::tree-sitter + tree_sitter_python tree_sitter_cpp tree_sitter_elisp + tree_sitter_javascript tree_sitter_typescript + tree_sitter_java tree_sitter_rust tree_sitter_go) + # Step 12: Dear ImGui shell scaffolding created (main.cpp exists but not built due to dependencies) diff --git a/editor/src/AgentChatPanelModel.h b/editor/src/AgentChatPanelModel.h index de29fb3..9ba47a6 100644 --- a/editor/src/AgentChatPanelModel.h +++ b/editor/src/AgentChatPanelModel.h @@ -5,6 +5,7 @@ #include "AgentMutationPreview.h" #include "AgentMutationApproval.h" +#include #include #include #include @@ -28,6 +29,9 @@ struct AgentChatState { std::vector mutationApprovals; std::string systemContext; std::string draftInput; + std::string loadedProjectFile; + std::size_t persistedMessageCount = 0; + bool sessionLoaded = false; bool autoScroll = true; bool open = false; }; diff --git a/editor/src/AgentChatSessionPersistence.h b/editor/src/AgentChatSessionPersistence.h new file mode 100644 index 0000000..e4eca3b --- /dev/null +++ b/editor/src/AgentChatSessionPersistence.h @@ -0,0 +1,167 @@ +#pragma once +// Step 630: Chat session persistence + +#include "AgentChatPanelModel.h" + +#include +#include +#include +#include + +#include + +using json = nlohmann::json; +namespace fs = std::filesystem; + +struct AgentChatSavedSession { + std::string timestamp; + AgentChatState chat; +}; + +class AgentChatSessionPersistence { +public: + static std::string sessionStorePath(const std::string& workspaceRoot) { + fs::path root = workspaceRoot.empty() ? fs::path(".") : fs::path(workspaceRoot); + return (root / ".whetstone.chat_sessions.json").string(); + } + + static bool saveSession(const std::string& workspaceRoot, + const std::string& projectFile, + const std::string& timestamp, + const AgentChatState& chat, + std::string* error) { + if (!error) return false; + error->clear(); + if (projectFile.empty()) return fail(error, "project_file_missing"); + if (timestamp.empty()) return fail(error, "timestamp_missing"); + + json store = readStore(workspaceRoot, error); + if (!error->empty()) return false; + if (!store.contains("projects")) store["projects"] = json::object(); + if (!store["projects"].contains(projectFile)) store["projects"][projectFile] = json::array(); + + store["projects"][projectFile].push_back({ + {"timestamp", timestamp}, + {"chat", chatToJson(chat)} + }); + return writeStore(workspaceRoot, store, error); + } + + static bool loadLatestSession(const std::string& workspaceRoot, + const std::string& projectFile, + AgentChatSavedSession* out, + std::string* error) { + if (!out || !error) return false; + error->clear(); + out->timestamp.clear(); + out->chat = AgentChatState{}; + if (projectFile.empty()) return fail(error, "project_file_missing"); + + json store = readStore(workspaceRoot, error); + if (!error->empty()) return false; + if (!store.contains("projects") || + !store["projects"].contains(projectFile) || + !store["projects"][projectFile].is_array() || + store["projects"][projectFile].empty()) { + return fail(error, "session_not_found"); + } + + const auto& latest = store["projects"][projectFile].back(); + out->timestamp = latest.value("timestamp", ""); + if (!latest.contains("chat") || !latest["chat"].is_object()) { + return fail(error, "chat_payload_invalid"); + } + out->chat = jsonToChat(latest["chat"]); + return true; + } + + static std::vector listSessionTimestamps(const std::string& workspaceRoot, + const std::string& projectFile, + std::string* error) { + std::vector out; + if (!error) return out; + error->clear(); + if (projectFile.empty()) { + *error = "project_file_missing"; + return out; + } + json store = readStore(workspaceRoot, error); + if (!error->empty()) return out; + if (!store.contains("projects") || !store["projects"].contains(projectFile)) return out; + for (const auto& session : store["projects"][projectFile]) { + out.push_back(session.value("timestamp", "")); + } + return out; + } + +private: + static bool fail(std::string* error, const char* code) { + *error = code; + return false; + } + + static json chatToJson(const AgentChatState& chat) { + json messages = json::array(); + for (const auto& message : chat.messages) { + messages.push_back({ + {"role", AgentChatPanelModel::roleLabel(message.role)}, + {"content", message.content}, + {"timestamp", message.timestamp} + }); + } + return { + {"systemContext", chat.systemContext}, + {"messages", messages} + }; + } + + static AgentChatState jsonToChat(const json& value) { + AgentChatState out; + out.systemContext = value.value("systemContext", ""); + if (!value.contains("messages") || !value["messages"].is_array()) return out; + for (const auto& message : value["messages"]) { + AgentChatRole role = AgentChatRole::Tool; + const std::string roleText = message.value("role", "tool"); + if (roleText == "user") role = AgentChatRole::User; + else if (roleText == "assistant") role = AgentChatRole::Assistant; + out.messages.push_back({ + role, + message.value("content", ""), + message.value("timestamp", "") + }); + } + return out; + } + + static json readStore(const std::string& workspaceRoot, std::string* error) { + const std::string path = sessionStorePath(workspaceRoot); + if (!fs::exists(path)) return json::object(); + std::ifstream in(path); + if (!in.is_open()) { + *error = "session_store_open_failed"; + return json::object(); + } + try { + json parsed = json::parse(in); + if (!parsed.is_object()) { + *error = "session_store_invalid"; + return json::object(); + } + return parsed; + } catch (...) { + *error = "session_store_parse_failed"; + return json::object(); + } + } + + static bool writeStore(const std::string& workspaceRoot, + const json& store, + std::string* error) { + fs::path path = sessionStorePath(workspaceRoot); + if (path.has_parent_path()) fs::create_directories(path.parent_path()); + std::ofstream out(path); + if (!out.is_open()) return fail(error, "session_store_write_failed"); + out << store.dump(2); + return true; + } +}; diff --git a/editor/src/panels/AgentChatPanel.h b/editor/src/panels/AgentChatPanel.h index 980e246..6e1ce81 100644 --- a/editor/src/panels/AgentChatPanel.h +++ b/editor/src/panels/AgentChatPanel.h @@ -2,6 +2,7 @@ #include "../AgentChatPanelModel.h" #include "../AgentChatContextInjector.h" +#include "../AgentChatSessionPersistence.h" #include "../EditorState.h" #include "../EditorUtils.h" @@ -20,6 +21,25 @@ static void renderAgentChatPanel(EditorState& state) { } AgentChatState& chat = state.agent.chat; + const std::string projectFile = state.activeBuffer ? state.activeBuffer->path : ""; + if (!projectFile.empty() && + (!chat.sessionLoaded || chat.loadedProjectFile != projectFile)) { + AgentChatSavedSession loaded; + std::string error; + if (AgentChatSessionPersistence::loadLatestSession(state.workspaceRoot, + projectFile, + &loaded, + &error)) { + chat = loaded.chat; + chat.loadedProjectFile = projectFile; + chat.sessionLoaded = true; + chat.persistedMessageCount = chat.messages.size(); + } else { + chat.loadedProjectFile = projectFile; + chat.sessionLoaded = true; + } + } + if (chat.systemContext.empty()) { AgentChatRuntimeSnapshot snapshot; if (state.activeBuffer) { @@ -122,5 +142,18 @@ static void renderAgentChatPanel(EditorState& state) { AgentChatPanelModel::sendUserMessage(&chat, "now"); } + if (!projectFile.empty() && + chat.sessionLoaded && + chat.messages.size() != chat.persistedMessageCount) { + std::string error; + if (AgentChatSessionPersistence::saveSession(state.workspaceRoot, + projectFile, + "autosave", + chat, + &error)) { + chat.persistedMessageCount = chat.messages.size(); + } + } + ImGui::End(); } diff --git a/editor/tests/step630_test.cpp b/editor/tests/step630_test.cpp new file mode 100644 index 0000000..23f843d --- /dev/null +++ b/editor/tests/step630_test.cpp @@ -0,0 +1,184 @@ +// Step 630: Chat session persistence (12 tests) + +#include "AgentChatSessionPersistence.h" + +#include +#include + +namespace fs = std::filesystem; + +static int passed = 0, failed = 0; +#define TEST(name) { std::cout << " " << #name << "... "; } +#define PASS() { std::cout << "PASS\n"; ++passed; } +#define FAIL(msg) { std::cout << "FAIL: " << msg << "\n"; ++failed; } +#define CHECK(cond, msg) if (!(cond)) { FAIL(msg); return; } else {} + +static fs::path tempWorkspace() { + fs::path root = fs::temp_directory_path() / "whetstone_step630"; + fs::remove_all(root); + fs::create_directories(root); + return root; +} + +static AgentChatState sampleChat(const std::string& content) { + AgentChatState chat; + chat.systemContext = "Context"; + chat.messages.push_back({AgentChatRole::User, content, "13:00"}); + return chat; +} + +void test_session_store_path_uses_workspace_root() { + TEST(session_store_path_uses_workspace_root); + std::string path = AgentChatSessionPersistence::sessionStorePath("/tmp/ws"); + CHECK(path.find("/tmp/ws") != std::string::npos, "workspace root missing in path"); + CHECK(path.find(".whetstone.chat_sessions.json") != std::string::npos, "filename mismatch"); + PASS(); +} + +void test_save_session_requires_project_file() { + TEST(save_session_requires_project_file); + std::string error; + CHECK(!AgentChatSessionPersistence::saveSession("/tmp/ws", "", "ts", sampleChat("x"), &error), + "save should fail"); + CHECK(error == "project_file_missing", "wrong error"); + PASS(); +} + +void test_save_session_requires_timestamp() { + TEST(save_session_requires_timestamp); + std::string error; + CHECK(!AgentChatSessionPersistence::saveSession("/tmp/ws", "src/main.cpp", "", sampleChat("x"), &error), + "save should fail"); + CHECK(error == "timestamp_missing", "wrong error"); + PASS(); +} + +void test_save_session_creates_store_file() { + TEST(save_session_creates_store_file); + auto ws = tempWorkspace(); + std::string error; + CHECK(AgentChatSessionPersistence::saveSession(ws.string(), "src/main.cpp", "t1", sampleChat("hello"), &error), + "save should succeed"); + CHECK(fs::exists(AgentChatSessionPersistence::sessionStorePath(ws.string())), "store file should exist"); + PASS(); +} + +void test_load_latest_returns_saved_session() { + TEST(load_latest_returns_saved_session); + auto ws = tempWorkspace(); + std::string error; + (void)AgentChatSessionPersistence::saveSession(ws.string(), "src/main.cpp", "t1", sampleChat("hello"), &error); + AgentChatSavedSession loaded; + CHECK(AgentChatSessionPersistence::loadLatestSession(ws.string(), "src/main.cpp", &loaded, &error), + "load should succeed"); + CHECK(loaded.timestamp == "t1", "timestamp mismatch"); + CHECK(loaded.chat.messages.size() == 1, "message count mismatch"); + CHECK(loaded.chat.messages[0].content == "hello", "message content mismatch"); + PASS(); +} + +void test_load_latest_uses_most_recent_timestamp_entry() { + TEST(load_latest_uses_most_recent_timestamp_entry); + auto ws = tempWorkspace(); + std::string error; + (void)AgentChatSessionPersistence::saveSession(ws.string(), "src/main.cpp", "t1", sampleChat("first"), &error); + (void)AgentChatSessionPersistence::saveSession(ws.string(), "src/main.cpp", "t2", sampleChat("second"), &error); + AgentChatSavedSession loaded; + CHECK(AgentChatSessionPersistence::loadLatestSession(ws.string(), "src/main.cpp", &loaded, &error), + "load should succeed"); + CHECK(loaded.timestamp == "t2", "latest timestamp mismatch"); + CHECK(loaded.chat.messages[0].content == "second", "latest content mismatch"); + PASS(); +} + +void test_load_latest_fails_when_project_missing() { + TEST(load_latest_fails_when_project_missing); + auto ws = tempWorkspace(); + std::string error; + AgentChatSavedSession loaded; + CHECK(!AgentChatSessionPersistence::loadLatestSession(ws.string(), "src/main.cpp", &loaded, &error), + "load should fail"); + CHECK(error == "session_not_found", "wrong error"); + PASS(); +} + +void test_load_latest_requires_project_file() { + TEST(load_latest_requires_project_file); + auto ws = tempWorkspace(); + std::string error; + AgentChatSavedSession loaded; + CHECK(!AgentChatSessionPersistence::loadLatestSession(ws.string(), "", &loaded, &error), + "load should fail"); + CHECK(error == "project_file_missing", "wrong error"); + PASS(); +} + +void test_list_session_timestamps_returns_all_entries() { + TEST(list_session_timestamps_returns_all_entries); + auto ws = tempWorkspace(); + std::string error; + (void)AgentChatSessionPersistence::saveSession(ws.string(), "src/main.cpp", "t1", sampleChat("a"), &error); + (void)AgentChatSessionPersistence::saveSession(ws.string(), "src/main.cpp", "t2", sampleChat("b"), &error); + auto stamps = AgentChatSessionPersistence::listSessionTimestamps(ws.string(), "src/main.cpp", &error); + CHECK(stamps.size() == 2, "timestamp count mismatch"); + CHECK(stamps[0] == "t1" && stamps[1] == "t2", "timestamp order mismatch"); + PASS(); +} + +void test_list_session_timestamps_requires_project_file() { + TEST(list_session_timestamps_requires_project_file); + auto ws = tempWorkspace(); + std::string error; + auto stamps = AgentChatSessionPersistence::listSessionTimestamps(ws.string(), "", &error); + CHECK(stamps.empty(), "stamps should be empty"); + CHECK(error == "project_file_missing", "wrong error"); + PASS(); +} + +void test_save_sessions_are_scoped_per_project_file() { + TEST(save_sessions_are_scoped_per_project_file); + auto ws = tempWorkspace(); + std::string error; + (void)AgentChatSessionPersistence::saveSession(ws.string(), "src/a.cpp", "t1", sampleChat("A"), &error); + (void)AgentChatSessionPersistence::saveSession(ws.string(), "src/b.cpp", "t2", sampleChat("B"), &error); + AgentChatSavedSession loadedA; + AgentChatSavedSession loadedB; + CHECK(AgentChatSessionPersistence::loadLatestSession(ws.string(), "src/a.cpp", &loadedA, &error), "load A failed"); + CHECK(AgentChatSessionPersistence::loadLatestSession(ws.string(), "src/b.cpp", &loadedB, &error), "load B failed"); + CHECK(loadedA.chat.messages[0].content == "A", "A content mismatch"); + CHECK(loadedB.chat.messages[0].content == "B", "B content mismatch"); + PASS(); +} + +void test_saved_system_context_roundtrips() { + TEST(saved_system_context_roundtrips); + auto ws = tempWorkspace(); + std::string error; + AgentChatState chat = sampleChat("hello"); + chat.systemContext = "Context: activeFile src/main.cpp"; + (void)AgentChatSessionPersistence::saveSession(ws.string(), "src/main.cpp", "t3", chat, &error); + AgentChatSavedSession loaded; + CHECK(AgentChatSessionPersistence::loadLatestSession(ws.string(), "src/main.cpp", &loaded, &error), "load failed"); + CHECK(loaded.chat.systemContext == "Context: activeFile src/main.cpp", "system context mismatch"); + PASS(); +} + +int main() { + std::cout << "Step 630: Chat session persistence\n"; + + test_session_store_path_uses_workspace_root(); // 1 + test_save_session_requires_project_file(); // 2 + test_save_session_requires_timestamp(); // 3 + test_save_session_creates_store_file(); // 4 + test_load_latest_returns_saved_session(); // 5 + test_load_latest_uses_most_recent_timestamp_entry(); // 6 + test_load_latest_fails_when_project_missing(); // 7 + test_load_latest_requires_project_file(); // 8 + test_list_session_timestamps_returns_all_entries(); // 9 + test_list_session_timestamps_requires_project_file(); // 10 + test_save_sessions_are_scoped_per_project_file(); // 11 + test_saved_system_context_roundtrips(); // 12 + + std::cout << "\nResults: " << passed << "/" << (passed + failed) << " passed\n"; + return failed == 0 ? 0 : 1; +} diff --git a/progress.md b/progress.md index fe83111..540bffe 100644 --- a/progress.md +++ b/progress.md @@ -12629,3 +12629,40 @@ active file, language, AST node count, open annotation count, and workspace path - `editor/src/panels/AgentChatPanel.h` (`126` <= `600`) - `editor/tests/step629_test.cpp` within test-file size guidance (`146` lines) - Header-only architecture and naming conventions remain aligned with `ARCHITECTURE.md` + +### Step 630: Chat session persistence +**Status:** PASS (12/12 tests) + +Implements per-project-file chat session persistence under workspace storage, +with latest-session restore and session timestamp indexing. + +**Files added:** +- `editor/src/AgentChatSessionPersistence.h` - persistence module: + - store path resolution under workspace + - append-save and latest-load APIs by project file + - session timestamp listing and JSON encode/decode for chat state +- `editor/tests/step630_test.cpp` - 12 tests covering: + - path derivation and save/load validation + - latest-session retrieval and ordering + - per-project-file isolation + - session-context round-trip behavior + +**Files modified:** +- `editor/src/AgentChatPanelModel.h` - session bookkeeping fields for load/save state +- `editor/src/panels/AgentChatPanel.h` - panel wiring: + - auto-load latest session on project switch/open + - auto-save session snapshots when message count changes +- `editor/CMakeLists.txt` - `step630_test` target + +**Verification run:** +- `cmake -S editor -B editor/build-native` - PASS +- `cmake --build editor/build-native --target step630_test step629_test` - PASS +- `./editor/build-native/step630_test` - PASS (12/12) +- `./editor/build-native/step629_test` - PASS (12/12) regression coverage + +**Architecture gate check:** +- `editor/src/AgentChatSessionPersistence.h` (`167` <= `600`) +- `editor/src/AgentChatPanelModel.h` (`116` <= `600`) +- `editor/src/panels/AgentChatPanel.h` (`159` <= `600`) +- `editor/tests/step630_test.cpp` within test-file size guidance (`184` lines) +- Header-only architecture and naming conventions remain aligned with `ARCHITECTURE.md`