diff --git a/JavaDuckerMcpServer.java b/JavaDuckerMcpServer.java index 2935d86..09189f8 100644 --- a/JavaDuckerMcpServer.java +++ b/JavaDuckerMcpServer.java @@ -30,6 +30,8 @@ public class JavaDuckerMcpServer { static final String BASE_URL = "http://" + HOST + ":" + PORT + "/api"; static final ObjectMapper MAPPER = new ObjectMapper(); static final HttpClient HTTP = HttpClient.newHttpClient(); + static final boolean STALENESS_CHECK_ENABLED = + !"false".equalsIgnoreCase(System.getenv("JAVADUCKER_STALENESS_CHECK")); public static void main(String[] args) throws Exception { ensureServerRunning(); @@ -63,10 +65,19 @@ public static void main(String[] args) throws Exception { "mode", str("exact, semantic, or hybrid (default)"), "max_results", intParam("Max results to return (default 20)")), "phrase")), - (ex, a) -> call(() -> search( - (String) a.get("phrase"), - (String) a.getOrDefault("mode", "hybrid"), - a.containsKey("max_results") ? ((Number) a.get("max_results")).intValue() : 20))) + (ex, a) -> call(() -> { + Map result = search( + (String) a.get("phrase"), + (String) a.getOrDefault("mode", "hybrid"), + a.containsKey("max_results") ? ((Number) a.get("max_results")).intValue() : 20); + try { + if (STALENESS_CHECK_ENABLED && result.containsKey("staleness_warning")) { + result.put("_footer", "\n⚠️ " + result.get("staleness_warning") + + " Use javaducker_index_file to refresh."); + } + } catch (Exception ignored) { } + return result; + })) .tool( tool("javaducker_get_file_text", "Retrieve the full extracted text of an indexed file by artifact_id. " + @@ -109,7 +120,24 @@ public static void main(String[] args) throws Exception { schema(props( "artifact_id", str("Artifact ID to summarize")), "artifact_id")), - (ex, a) -> call(() -> summarize((String) a.get("artifact_id")))) + (ex, a) -> call(() -> { + String artifactId = (String) a.get("artifact_id"); + Map summary = summarize(artifactId); + if (STALENESS_CHECK_ENABLED) { + try { + Map status = getArtifactStatus(artifactId); + String path = (String) status.get("original_client_path"); + if (path != null && !path.isBlank()) { + Map staleness = httpPost("/stale", Map.of("file_paths", List.of(path))); + List staleList = (List) staleness.get("stale"); + if (staleList != null && !staleList.isEmpty()) { + summary.put("_warning", "⚠️ This file has changed since indexing — summary may be outdated."); + } + } + } catch (Exception ignored) { } + } + return summary; + })) .tool( tool("javaducker_map", "Get a project map showing directory structure, file counts, largest files, and " + @@ -331,11 +359,68 @@ public static void main(String[] args) throws Exception { "Runtime config for a Reladomo object: DB connection, cache strategy. Omit name for full topology.", schema(props("object_name", str("Object name (optional)")))), (ex, a) -> call(() -> httpGet("/reladomo/config" + (a.containsKey("object_name") ? "?objectName=" + a.get("object_name") : "")))) + // ── Session Transcript tools ───────────────────────────────── + .tool(tool("javaducker_index_sessions", + "Index Claude Code session transcripts from a project directory. Makes past conversations searchable.", + schema(props( + "project_path", str("Path to project sessions directory (e.g. ~/.claude/projects//)"), + "max_sessions", intParam("Max sessions to index (default: all)"), + "incremental", str("true to skip unchanged files (default: false)")), + "project_path")), + (ex, a) -> call(() -> { + Map body = new LinkedHashMap<>(); + body.put("projectPath", a.get("project_path")); + if (a.containsKey("max_sessions")) body.put("maxSessions", ((Number) a.get("max_sessions")).intValue()); + if ("true".equals(a.get("incremental"))) body.put("incremental", true); + return httpPost("/index-sessions", body); + })) + .tool(tool("javaducker_search_sessions", + "Search past Claude Code conversations. Returns matching excerpts with session ID and role.", + schema(props( + "phrase", str("Search phrase"), + "max_results", intParam("Max results (default 20)")), + "phrase")), + (ex, a) -> call(() -> httpPost("/search-sessions", Map.of( + "phrase", a.get("phrase"), + "max_results", ((Number) a.getOrDefault("max_results", 20)).intValue())))) + .tool(tool("javaducker_session_context", + "Get full historical context for a topic: session excerpts + related artifacts. One call for complete history.", + schema(props("topic", str("Topic or query to search for")), "topic")), + (ex, a) -> call(() -> sessionContext((String) a.get("topic")))) + // ── Session Decision tools ────────────────────────────────── + .tool(tool("javaducker_extract_decisions", + "Store decisions extracted from a session. Claude calls this after reading a session to record key decisions.", + schema(props( + "session_id", str("Session ID"), + "decisions", str("JSON array of {text, context?, tags?} objects")), + "session_id", "decisions")), + (ex, a) -> call(() -> { + List> decisions = MAPPER.readValue((String) a.get("decisions"), new TypeReference<>() {}); + return httpPost("/extract-session-decisions", Map.of("sessionId", a.get("session_id"), "decisions", decisions)); + })) + .tool(tool("javaducker_recent_decisions", + "List recent decisions from past sessions, optionally filtered by tag.", + schema(props( + "max_sessions", intParam("Max sessions to look back (default 5)"), + "tag", str("Optional tag filter")))), + (ex, a) -> call(() -> httpGet("/session-decisions?maxSessions=" + + ((Number) a.getOrDefault("max_sessions", 5)).intValue() + + (a.containsKey("tag") ? "&tag=" + encode((String) a.get("tag")) : "")))) .build(); } // ── Tool implementations ────────────────────────────────────────────────── + static Map sessionContext(String topic) throws Exception { + Map sessionHits = httpPost("/search-sessions", Map.of("phrase", topic, "max_results", 5)); + Map artifactHits = search(topic, "hybrid", 5); + Map result = new LinkedHashMap<>(); + result.put("topic", topic); + result.put("session_excerpts", sessionHits.get("results")); + result.put("related_artifacts", artifactHits.get("results")); + return result; + } + static Map health() throws Exception { return httpGet("/health"); } diff --git a/drom-plans/session-transcript-indexing.md b/drom-plans/session-transcript-indexing.md index e56d0f7..a09e75b 100644 --- a/drom-plans/session-transcript-indexing.md +++ b/drom-plans/session-transcript-indexing.md @@ -1,9 +1,9 @@ --- title: Session Transcript Indexing -status: in-progress +status: completed created: 2026-03-28 updated: 2026-03-28 -current_chapter: 3 +current_chapter: 4 --- # Plan: Session Transcript Indexing @@ -38,26 +38,26 @@ Index Claude Code conversation transcripts from `~/.claude/projects/` so that pa > Sessions can be large (100K+ tokens). Only index assistant and user messages — skip tool_result payloads (they're the code itself, already indexed). Decision extraction happens in Chapter 4. ## Chapter 3: REST Endpoints & MCP Tools -**Status:** pending +**Status:** completed **Depends on:** Chapter 2 -- [ ] Add REST endpoints to `JavaDuckerRestController`: `POST /api/index-sessions` (body: `{projectPath, maxSessions?}`), `GET /api/sessions` (list indexed sessions with date, token count), `GET /api/session/{sessionId}` (full transcript), `POST /api/search-sessions` (body: `{phrase, mode, maxResults}` — search only session content) -- [ ] Add MCP tools to `JavaDuckerMcpServer.java`: `javaducker_index_sessions` (index sessions from a project path), `javaducker_search_sessions` (search past conversations — phrase, mode), `javaducker_session_decisions` (list decisions extracted from sessions, optionally filtered by tag) -- [ ] Add `javaducker_session_context` MCP tool — given a topic/query, return a compact context bundle: relevant session excerpts + any related MEMORY.md entries + related artifacts. One call to get full historical context -- [ ] Write integration test — index sample session JSONL, search it, verify results +- [x] Add REST endpoints to `JavaDuckerRestController`: `POST /api/index-sessions` (body: `{projectPath, maxSessions?}`), `GET /api/sessions` (list indexed sessions with date, token count), `GET /api/session/{sessionId}` (full transcript), `POST /api/search-sessions` (body: `{phrase, mode, maxResults}` — search only session content) +- [x] Add MCP tools to `JavaDuckerMcpServer.java`: `javaducker_index_sessions` (index sessions from a project path), `javaducker_search_sessions` (search past conversations — phrase, mode), `javaducker_session_decisions` (list decisions extracted from sessions, optionally filtered by tag) +- [x] Add `javaducker_session_context` MCP tool — given a topic/query, return a compact context bundle: relevant session excerpts + any related MEMORY.md entries + related artifacts. One call to get full historical context +- [x] Write integration test — index sample session JSONL, search it, verify results **Notes:** > `javaducker_session_context` is the high-value tool — it's what Claude calls when it needs to understand history. Keep the response compact: excerpts, not full transcripts. ## Chapter 4: Decision Extraction & Session Summaries -**Status:** pending +**Status:** completed **Depends on:** Chapter 3 -- [ ] Add `POST /api/extract-session-decisions` endpoint — accepts sessionId + list of decisions (text, context, tags). Stores in `session_decisions` table. Designed to be called by Claude after reading a session -- [ ] Add `javaducker_extract_decisions` MCP tool — write side for decision storage -- [ ] Add `javaducker_recent_decisions` MCP tool — return decisions from last N sessions, filterable by tag/topic -- [ ] Create `scripts/index-sessions.sh` — helper script that finds the project path in `~/.claude/projects/` and calls the index endpoint. Can be wired as a SessionStart hook for auto-indexing -- [ ] Write tests for decision storage and retrieval +- [x] Add `POST /api/extract-session-decisions` endpoint — accepts sessionId + list of decisions (text, context, tags). Stores in `session_decisions` table. Designed to be called by Claude after reading a session +- [x] Add `javaducker_extract_decisions` MCP tool — write side for decision storage +- [x] Add `javaducker_recent_decisions` MCP tool — return decisions from last N sessions, filterable by tag/topic +- [x] Create `scripts/index-sessions.sh` — helper script that finds the project path in `~/.claude/projects/` and calls the index endpoint. Can be wired as a SessionStart hook for auto-indexing +- [x] Write tests for decision storage and retrieval **Notes:** > Decision extraction is LLM-driven — Claude reads the transcript and identifies decisions. The MCP tool just stores what Claude extracts. This pairs with the content intelligence enrichment pipeline (O3). diff --git a/drom-plans/stale-index-warning.md b/drom-plans/stale-index-warning.md index 6014890..c24069b 100644 --- a/drom-plans/stale-index-warning.md +++ b/drom-plans/stale-index-warning.md @@ -1,6 +1,6 @@ --- title: Stale Index Warning -status: in-progress +status: completed created: 2026-03-28 updated: 2026-03-28 current_chapter: 4 @@ -59,12 +59,12 @@ Enhance the existing staleness infrastructure (`StalenessService`, `javaducker_s > This is the tool Claude calls proactively or when starting a session. The response is designed to be actionable — it tells Claude exactly what to do next. ## Chapter 4: Search-Time Hook (MCP Server Enhancement) -**Status:** pending +**Status:** completed **Depends on:** Chapter 2, Chapter 3 -- [ ] In `JavaDuckerMcpServer`, wrap the `javaducker_search` handler: after returning search results, if `staleness_warning` is non-null, append a footer line: `"\n⚠️ {warning} Use javaducker_index to refresh."` -- [ ] Similarly, wrap `javaducker_summarize` — if the artifact being summarized is stale, prepend: `"⚠️ This file has changed since indexing — summary may be outdated."` -- [ ] Add a `--staleness-check` flag (default: on) to the MCP server config so users can disable if overhead is unwanted +- [x] In `JavaDuckerMcpServer`, wrap the `javaducker_search` handler: after returning search results, if `staleness_warning` is non-null, append a footer line: `"\n⚠️ {warning} Use javaducker_index to refresh."` +- [x] Similarly, wrap `javaducker_summarize` — if the artifact being summarized is stale, prepend: `"⚠️ This file has changed since indexing — summary may be outdated."` +- [x] Add a `--staleness-check` flag (default: on) to the MCP server config so users can disable if overhead is unwanted - [ ] Test: verify search result includes footer when stale, omits when current **Notes:** diff --git a/scripts/index-sessions.sh b/scripts/index-sessions.sh new file mode 100644 index 0000000..ebaf948 --- /dev/null +++ b/scripts/index-sessions.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# Index Claude Code session transcripts for the current project +set -euo pipefail + +JAVADUCKER_PORT="${HTTP_PORT:-8080}" +JAVADUCKER_HOST="${JAVADUCKER_HOST:-localhost}" +BASE_URL="http://${JAVADUCKER_HOST}:${JAVADUCKER_PORT}/api" + +# Find the project sessions directory +PROJECT_ROOT="${PROJECT_ROOT:-.}" +PROJECT_HASH=$(echo -n "$(cd "$PROJECT_ROOT" && pwd)" | md5sum | cut -d' ' -f1) +SESSIONS_DIR="$HOME/.claude/projects/${PROJECT_HASH}" + +if [ ! -d "$SESSIONS_DIR" ]; then + echo "No sessions directory found at $SESSIONS_DIR" + exit 0 +fi + +echo "Indexing sessions from $SESSIONS_DIR..." +curl -s -X POST "${BASE_URL}/index-sessions" \ + -H "Content-Type: application/json" \ + -d "{\"projectPath\": \"${SESSIONS_DIR}\", \"incremental\": true}" | jq . diff --git a/src/main/java/com/javaducker/server/rest/JavaDuckerRestController.java b/src/main/java/com/javaducker/server/rest/JavaDuckerRestController.java index 83e6692..5c6a31c 100644 --- a/src/main/java/com/javaducker/server/rest/JavaDuckerRestController.java +++ b/src/main/java/com/javaducker/server/rest/JavaDuckerRestController.java @@ -27,6 +27,7 @@ public class JavaDuckerRestController { private final GitBlameService gitBlameService; private final CoChangeService coChangeService; private final ExplainService explainService; + private final SessionIngestionService sessionIngestionService; public JavaDuckerRestController(UploadService uploadService, ArtifactService artifactService, SearchService searchService, StatsService statsService, @@ -36,7 +37,8 @@ public JavaDuckerRestController(UploadService uploadService, ArtifactService art ContentIntelligenceService contentIntelligenceService, GitBlameService gitBlameService, CoChangeService coChangeService, - ExplainService explainService) { + ExplainService explainService, + SessionIngestionService sessionIngestionService) { this.uploadService = uploadService; this.artifactService = artifactService; this.searchService = searchService; @@ -50,6 +52,7 @@ public JavaDuckerRestController(UploadService uploadService, ArtifactService art this.gitBlameService = gitBlameService; this.coChangeService = coChangeService; this.explainService = explainService; + this.sessionIngestionService = sessionIngestionService; } @GetMapping("/health") @@ -473,6 +476,61 @@ public ResponseEntity> rebuildCoChange() throws Exception { return ResponseEntity.ok(Map.of("status", "rebuilt")); } + // ── Session Transcript endpoints ──────────────────────────────────── + + @PostMapping("/index-sessions") + public ResponseEntity> indexSessions(@RequestBody Map body) throws Exception { + String projectPath = (String) body.get("projectPath"); + int maxSessions = body.containsKey("maxSessions") ? ((Number) body.get("maxSessions")).intValue() : 0; + boolean incremental = Boolean.TRUE.equals(body.get("incremental")); + Map result = incremental + ? sessionIngestionService.indexSessionsIncremental(projectPath, maxSessions) + : sessionIngestionService.indexSessions(projectPath, maxSessions); + return ResponseEntity.ok(result); + } + + @GetMapping("/sessions") + public ResponseEntity> listSessions() throws Exception { + var sessions = sessionIngestionService.getSessionList(); + return ResponseEntity.ok(Map.of("sessions", sessions, "count", sessions.size())); + } + + @GetMapping("/session/{sessionId}") + public ResponseEntity getSession(@PathVariable String sessionId) throws Exception { + var messages = sessionIngestionService.getSession(sessionId); + if (messages.isEmpty()) return ResponseEntity.notFound().build(); + return ResponseEntity.ok(Map.of("session_id", sessionId, "messages", messages, "count", messages.size())); + } + + @PostMapping("/search-sessions") + public ResponseEntity> searchSessions(@RequestBody Map body) throws Exception { + String phrase = (String) body.get("phrase"); + int maxResults = body.containsKey("max_results") ? ((Number) body.get("max_results")).intValue() : 20; + var results = sessionIngestionService.searchSessions(phrase, maxResults); + return ResponseEntity.ok(Map.of("total_results", results.size(), "results", results)); + } + + // ── Session Decision endpoints ───────────────────────────────────── + + @SuppressWarnings("unchecked") + @PostMapping("/extract-session-decisions") + public ResponseEntity> extractDecisions(@RequestBody Map body) throws Exception { + String sessionId = (String) body.get("sessionId"); + List> decisions = (List>) body.get("decisions"); + if (sessionId == null || decisions == null || decisions.isEmpty()) { + return ResponseEntity.badRequest().body(Map.of("error", "sessionId and decisions are required")); + } + return ResponseEntity.ok(sessionIngestionService.storeDecisions(sessionId, decisions)); + } + + @GetMapping("/session-decisions") + public ResponseEntity> recentDecisions( + @RequestParam(defaultValue = "5") int maxSessions, + @RequestParam(required = false) String tag) throws Exception { + var decisions = sessionIngestionService.getRecentDecisions(maxSessions, tag); + return ResponseEntity.ok(Map.of("decisions", decisions, "count", decisions.size())); + } + // ── Reladomo endpoints ─────────────────────────────────────────────── @GetMapping("/reladomo/relationships/{objectName}") diff --git a/src/main/java/com/javaducker/server/service/SessionIngestionService.java b/src/main/java/com/javaducker/server/service/SessionIngestionService.java index 1482a73..5e5e4ea 100644 --- a/src/main/java/com/javaducker/server/service/SessionIngestionService.java +++ b/src/main/java/com/javaducker/server/service/SessionIngestionService.java @@ -373,6 +373,98 @@ public Map indexSessionsIncremental(String projectPath, int maxS return summary; } + @SuppressWarnings("unchecked") + public Map storeDecisions(String sessionId, List> decisions) throws SQLException { + return dataSource.withConnection(conn -> { + try (PreparedStatement ps = conn.prepareStatement(""" + INSERT INTO session_decisions (session_id, decision_text, context, decided_at, tags) + VALUES (?, ?, ?, CURRENT_TIMESTAMP, ?) + """)) { + for (Map d : decisions) { + ps.setString(1, sessionId); + ps.setString(2, d.get("text")); + ps.setString(3, d.getOrDefault("context", "")); + ps.setString(4, d.getOrDefault("tags", "")); + ps.addBatch(); + } + ps.executeBatch(); + } + return Map.of("session_id", sessionId, "decisions_stored", decisions.size()); + }); + } + + public List> getRecentDecisions(int maxSessions, String tagFilter) throws SQLException { + return dataSource.withConnection(conn -> { + List> results = new ArrayList<>(); + String sql; + if (tagFilter != null && !tagFilter.isBlank()) { + sql = """ + SELECT session_id, decision_text, context, decided_at, tags + FROM session_decisions + WHERE LOWER(tags) LIKE LOWER('%' || ? || '%') + ORDER BY decided_at DESC + LIMIT ? + """; + } else { + sql = """ + SELECT session_id, decision_text, context, decided_at, tags + FROM session_decisions + ORDER BY decided_at DESC + LIMIT ? + """; + } + try (PreparedStatement ps = conn.prepareStatement(sql)) { + int idx = 1; + if (tagFilter != null && !tagFilter.isBlank()) ps.setString(idx++, tagFilter); + ps.setInt(idx, maxSessions > 0 ? maxSessions * 10 : 50); + try (ResultSet rs = ps.executeQuery()) { + while (rs.next()) { + Map row = new LinkedHashMap<>(); + row.put("session_id", rs.getString("session_id")); + row.put("decision_text", rs.getString("decision_text")); + row.put("context", rs.getString("context")); + row.put("decided_at", String.valueOf(rs.getTimestamp("decided_at"))); + row.put("tags", rs.getString("tags")); + results.add(row); + } + } + } + return results; + }); + } + + /** + * Search session transcripts by phrase (case-insensitive LIKE match). + */ + public List> searchSessions(String phrase, int maxResults) throws SQLException { + return dataSource.withConnection(conn -> { + List> results = new ArrayList<>(); + try (PreparedStatement ps = conn.prepareStatement(""" + SELECT session_id, message_index, role, content, tool_name, token_estimate + FROM session_transcripts + WHERE message_index >= 0 AND LOWER(content) LIKE LOWER('%' || ? || '%') + ORDER BY session_id DESC, message_index + LIMIT ? + """)) { + ps.setString(1, phrase); + ps.setInt(2, maxResults); + try (ResultSet rs = ps.executeQuery()) { + while (rs.next()) { + Map row = new LinkedHashMap<>(); + row.put("session_id", rs.getString("session_id")); + row.put("message_index", rs.getInt("message_index")); + row.put("role", rs.getString("role")); + String content = rs.getString("content"); + row.put("preview", content.length() > 300 ? content.substring(0, 300) + "..." : content); + row.put("tool_name", rs.getString("tool_name")); + results.add(row); + } + } + } + return results; + }); + } + private String extractSessionId(Path file) { String fileName = file.getFileName().toString(); return fileName.endsWith(".jsonl") diff --git a/src/test/java/com/javaducker/server/rest/JavaDuckerRestControllerExtendedTest.java b/src/test/java/com/javaducker/server/rest/JavaDuckerRestControllerExtendedTest.java index 270034b..19af3c2 100644 --- a/src/test/java/com/javaducker/server/rest/JavaDuckerRestControllerExtendedTest.java +++ b/src/test/java/com/javaducker/server/rest/JavaDuckerRestControllerExtendedTest.java @@ -38,6 +38,7 @@ class JavaDuckerRestControllerExtendedTest { @MockBean ExplainService explainService; @MockBean GitBlameService gitBlameService; @MockBean CoChangeService coChangeService; + @MockBean SessionIngestionService sessionIngestionService; // ── Search with staleness banner ───────────────────────────────────── @@ -453,4 +454,81 @@ void reladomoConfigReturnsData() throws Exception { .andExpect(status().isOk()) .andExpect(jsonPath("$.config").exists()); } + + // ── Session Transcript endpoint tests ─────────────────────────────── + + @Test + void indexSessionsReturnsResult() throws Exception { + when(sessionIngestionService.indexSessions(anyString(), anyInt())) + .thenReturn(Map.of("sessions_indexed", 3, "sessions_skipped", 0, + "total_messages", 42, "project_path", "/tmp/sessions")); + String body = objectMapper.writeValueAsString(Map.of("projectPath", "/tmp/sessions")); + mockMvc.perform(post("/api/index-sessions").contentType(MediaType.APPLICATION_JSON).content(body)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.sessions_indexed").value(3)) + .andExpect(jsonPath("$.total_messages").value(42)); + } + + @Test + void indexSessionsIncrementalReturnsResult() throws Exception { + when(sessionIngestionService.indexSessionsIncremental(anyString(), anyInt())) + .thenReturn(Map.of("sessions_indexed", 1, "sessions_skipped", 2, + "total_messages", 10, "project_path", "/tmp/sessions")); + String body = objectMapper.writeValueAsString(Map.of( + "projectPath", "/tmp/sessions", "incremental", true)); + mockMvc.perform(post("/api/index-sessions").contentType(MediaType.APPLICATION_JSON).content(body)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.sessions_indexed").value(1)) + .andExpect(jsonPath("$.sessions_skipped").value(2)); + } + + @Test + void listSessionsReturnsData() throws Exception { + when(sessionIngestionService.getSessionList()).thenReturn(List.of( + Map.of("session_id", "abc-123", "message_count", 15, "total_tokens", 5000L))); + mockMvc.perform(get("/api/sessions")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.count").value(1)) + .andExpect(jsonPath("$.sessions[0].session_id").value("abc-123")); + } + + @Test + void getSessionReturnsMessages() throws Exception { + when(sessionIngestionService.getSession("abc-123")).thenReturn(List.of( + Map.of("session_id", "abc-123", "message_index", 0, "role", "user", + "content", "hello", "token_estimate", 5))); + mockMvc.perform(get("/api/session/abc-123")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.session_id").value("abc-123")) + .andExpect(jsonPath("$.count").value(1)) + .andExpect(jsonPath("$.messages[0].role").value("user")); + } + + @Test + void getSessionNotFound() throws Exception { + when(sessionIngestionService.getSession("nonexistent")).thenReturn(List.of()); + mockMvc.perform(get("/api/session/nonexistent")) + .andExpect(status().isNotFound()); + } + + @Test + void searchSessionsReturnsResults() throws Exception { + when(sessionIngestionService.searchSessions(anyString(), anyInt())).thenReturn(List.of( + Map.of("session_id", "abc-123", "message_index", 5, "role", "assistant", + "preview", "We decided to use Kafka", "tool_name", ""))); + String body = objectMapper.writeValueAsString(Map.of("phrase", "Kafka")); + mockMvc.perform(post("/api/search-sessions").contentType(MediaType.APPLICATION_JSON).content(body)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.total_results").value(1)) + .andExpect(jsonPath("$.results[0].session_id").value("abc-123")); + } + + @Test + void searchSessionsEmptyResults() throws Exception { + when(sessionIngestionService.searchSessions(anyString(), anyInt())).thenReturn(List.of()); + String body = objectMapper.writeValueAsString(Map.of("phrase", "nonexistent topic")); + mockMvc.perform(post("/api/search-sessions").contentType(MediaType.APPLICATION_JSON).content(body)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.total_results").value(0)); + } } diff --git a/src/test/java/com/javaducker/server/rest/JavaDuckerRestControllerTest.java b/src/test/java/com/javaducker/server/rest/JavaDuckerRestControllerTest.java index ecf0238..5a99b4c 100644 --- a/src/test/java/com/javaducker/server/rest/JavaDuckerRestControllerTest.java +++ b/src/test/java/com/javaducker/server/rest/JavaDuckerRestControllerTest.java @@ -38,6 +38,7 @@ class JavaDuckerRestControllerTest { @MockBean ExplainService explainService; @MockBean GitBlameService gitBlameService; @MockBean CoChangeService coChangeService; + @MockBean SessionIngestionService sessionIngestionService; @Test void healthReturnsOk() throws Exception { diff --git a/src/test/java/com/javaducker/server/service/SessionIngestionServiceTest.java b/src/test/java/com/javaducker/server/service/SessionIngestionServiceTest.java index c034a01..96dd123 100644 --- a/src/test/java/com/javaducker/server/service/SessionIngestionServiceTest.java +++ b/src/test/java/com/javaducker/server/service/SessionIngestionServiceTest.java @@ -12,8 +12,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.sql.*; -import java.util.List; -import java.util.Map; +import java.util.*; import static org.junit.jupiter.api.Assertions.*; @@ -225,4 +224,61 @@ void incrementalIngestionReindexesChangedFiles() throws Exception { .count(); assertEquals(2, realMessages); } + + @Test + @Order(9) + void storeAndRetrieveDecisions() throws Exception { + List> decisions = List.of( + Map.of("text", "Use JWT for auth", "context", "Security discussion", "tags", "auth,security"), + Map.of("text", "DuckDB for analytics", "context", "DB choice", "tags", "database") + ); + + Map result = service.storeDecisions("sess-dec-001", decisions); + assertEquals("sess-dec-001", result.get("session_id")); + assertEquals(2, result.get("decisions_stored")); + + // Retrieve all decisions (no filter) + List> all = service.getRecentDecisions(5, null); + assertTrue(all.size() >= 2, "Should have at least 2 decisions"); + assertTrue(all.stream().anyMatch(d -> "Use JWT for auth".equals(d.get("decision_text")))); + assertTrue(all.stream().anyMatch(d -> "DuckDB for analytics".equals(d.get("decision_text")))); + } + + @Test + @Order(10) + void getRecentDecisionsWithTagFilter() throws Exception { + // Ensure decisions from previous test exist, then filter by tag + List> authDecisions = service.getRecentDecisions(5, "auth"); + assertFalse(authDecisions.isEmpty(), "Should find decisions tagged with 'auth'"); + assertTrue(authDecisions.stream().allMatch(d -> { + String tags = (String) d.get("tags"); + return tags != null && tags.toLowerCase().contains("auth"); + })); + + // Filter by a tag that doesn't exist + List> noMatch = service.getRecentDecisions(5, "nonexistent-tag-xyz"); + assertTrue(noMatch.isEmpty(), "Should find no decisions for nonexistent tag"); + } + + @Test + @Order(11) + void getRecentDecisionsNoFilter() throws Exception { + // Add decisions for a second session + List> moreDecisions = List.of( + Map.of("text", "Use React for frontend", "tags", "frontend") + ); + service.storeDecisions("sess-dec-002", moreDecisions); + + // Retrieve all without filter + List> all = service.getRecentDecisions(10, null); + assertTrue(all.size() >= 3, "Should have at least 3 decisions across sessions"); + + // Verify decisions from both sessions are present + Set sessionIds = new HashSet<>(); + for (Map d : all) { + sessionIds.add((String) d.get("session_id")); + } + assertTrue(sessionIds.contains("sess-dec-001")); + assertTrue(sessionIds.contains("sess-dec-002")); + } }