Add step 630 chat session persistence
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
#include "AgentMutationPreview.h"
|
||||
#include "AgentMutationApproval.h"
|
||||
|
||||
#include <cstddef>
|
||||
#include <cctype>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
@@ -28,6 +29,9 @@ struct AgentChatState {
|
||||
std::vector<MutationApprovalRecord> mutationApprovals;
|
||||
std::string systemContext;
|
||||
std::string draftInput;
|
||||
std::string loadedProjectFile;
|
||||
std::size_t persistedMessageCount = 0;
|
||||
bool sessionLoaded = false;
|
||||
bool autoScroll = true;
|
||||
bool open = false;
|
||||
};
|
||||
|
||||
167
editor/src/AgentChatSessionPersistence.h
Normal file
167
editor/src/AgentChatSessionPersistence.h
Normal file
@@ -0,0 +1,167 @@
|
||||
#pragma once
|
||||
// Step 630: Chat session persistence
|
||||
|
||||
#include "AgentChatPanelModel.h"
|
||||
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
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<std::string> listSessionTimestamps(const std::string& workspaceRoot,
|
||||
const std::string& projectFile,
|
||||
std::string* error) {
|
||||
std::vector<std::string> 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;
|
||||
}
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
184
editor/tests/step630_test.cpp
Normal file
184
editor/tests/step630_test.cpp
Normal file
@@ -0,0 +1,184 @@
|
||||
// Step 630: Chat session persistence (12 tests)
|
||||
|
||||
#include "AgentChatSessionPersistence.h"
|
||||
|
||||
#include <filesystem>
|
||||
#include <iostream>
|
||||
|
||||
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;
|
||||
}
|
||||
37
progress.md
37
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`
|
||||
|
||||
Reference in New Issue
Block a user