Implement closed-loop workspace follow-ups
This commit is contained in:
@@ -19,6 +19,8 @@ All sources under `apps/server/src/`.
|
||||
- `GET /health` — health report (probes: event-store, llama-server, disk watermark)
|
||||
- `GET /stats` — metrics report (MetricsProjection)
|
||||
- `GET /metrics/tool-reliability` — per-model tool-call validity across the event log (`ToolReliabilityInspectionService`); groundwork for capability-aware routing
|
||||
- Optional `[git]` transport creates `run/<sessionId>` from a server-local checkout and pushes it at terminal state; clients review with ordinary Git and never supply a remote URL as `cwd`.
|
||||
- Repo-map L3 embeddings use bounded, recorded source descriptors (module/package, imports, leading purpose comment, symbols); raw file bodies are never embedded. Their versioned `repomap:v2` namespace forces a one-time re-embed when the semantic document format changes.
|
||||
|
||||
### WebSocket protocol (`/ws`)
|
||||
- **ServerMessage** (server → client): sealed hierarchy — `SessionMessage` (event-derived, carries `sequence` + `sessionSequence`) and `NonEventMessage` (control/infra). Variants include session lifecycle, approval requests, clarification requests, narration, proposed workflows, health/metrics pushes.
|
||||
|
||||
@@ -654,6 +654,11 @@ fun main() {
|
||||
taskArtifactResolver = taskArtifactResolver,
|
||||
taskSessionResolver = taskSessionResolver,
|
||||
gitCommitReader = gitCommitReader,
|
||||
gitRunBranchTransport = if (correxConfig.git.enabled) {
|
||||
com.correx.apps.server.git.GitRunBranchTransport(eventStore, correxConfig.git)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
// Wire live config editing: persist to TOML, swap the holder, and rebuild config-derived
|
||||
// services. Built after the module so the rebuild hook can swap them in place.
|
||||
|
||||
@@ -133,6 +133,9 @@ class ServerModule(
|
||||
// Reads recent commits for POST /tasks/sync-git (git-driven status). Null disables the repo read
|
||||
// (the endpoint then only acts on commits supplied in the request body).
|
||||
val gitCommitReader: com.correx.core.tasks.GitCommitReader? = null,
|
||||
// Optional plain-Git transport for a server-owned checkout. It creates and pushes a per-run
|
||||
// branch; null preserves the ordinary local-workspace lifecycle.
|
||||
private val gitRunBranchTransport: com.correx.apps.server.git.GitRunBranchTransport? = null,
|
||||
) {
|
||||
val approvalCoordinator: ApprovalCoordinator = approvalCoordinator ?: ApprovalCoordinator(
|
||||
orchestrator = orchestrator,
|
||||
@@ -409,17 +412,25 @@ class ServerModule(
|
||||
bindProjectProfile(sessionId)
|
||||
bindAgentInstructions(sessionId)
|
||||
runCatching {
|
||||
val result = orchestrator.run(sessionId, graph, sessionConfig)
|
||||
freestyleHandoff(sessionId, graph, result)
|
||||
// Distil this run's decisions into durable project memory on completion.
|
||||
projectMemory?.let { pm -> pm.persist(sessionId, pm.repoRoot()) }
|
||||
// Propose learned profile adaptations based on session journal (opt-in, never auto-applied).
|
||||
operatorProfile?.let { profile ->
|
||||
profileAdaptationService?.let { svc ->
|
||||
runCatching { svc.proposeAdaptation(sessionId, profile) }
|
||||
.onFailure { log.warn("Profile adaptation failed: {}", it.message) }
|
||||
suspend fun runAndFinalize() {
|
||||
val result = orchestrator.run(sessionId, graph, sessionConfig)
|
||||
freestyleHandoff(sessionId, graph, result)
|
||||
// Distil this run's decisions into durable project memory on completion.
|
||||
projectMemory?.let { pm -> pm.persist(sessionId, pm.repoRoot()) }
|
||||
// Propose learned profile adaptations based on session journal (opt-in, never auto-applied).
|
||||
operatorProfile?.let { profile ->
|
||||
profileAdaptationService?.let { svc ->
|
||||
runCatching { svc.proposeAdaptation(sessionId, profile) }
|
||||
.onFailure { log.warn("Profile adaptation failed: {}", it.message) }
|
||||
}
|
||||
}
|
||||
}
|
||||
val workspaceRoot = sessionConfig.workspace?.workspaceRoot
|
||||
if (gitRunBranchTransport != null && workspaceRoot != null) {
|
||||
gitRunBranchTransport.onRunBranch(sessionId, workspaceRoot) { runAndFinalize() }
|
||||
} else {
|
||||
runAndFinalize()
|
||||
}
|
||||
}.onFailure { ex -> recordUnhandledFailure(sessionId, graph, sessionConfig, ex) }
|
||||
activeSessionJobs.remove(sessionId)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
package com.correx.apps.server.git
|
||||
|
||||
import com.correx.core.config.GitConfig
|
||||
import com.correx.core.events.events.EventMetadata
|
||||
import com.correx.core.events.events.NewEvent
|
||||
import com.correx.core.events.events.RunBranchPushedEvent
|
||||
import com.correx.core.events.events.WorkflowCompletedEvent
|
||||
import com.correx.core.events.events.WorkflowFailedEvent
|
||||
import com.correx.core.events.stores.EventStore
|
||||
import com.correx.core.events.types.EventId
|
||||
import com.correx.core.events.types.SessionId
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.datetime.Clock
|
||||
import java.nio.file.Path
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
private const val GIT_TIMEOUT_SECONDS = 30L
|
||||
|
||||
/**
|
||||
* Plain-Git transport for a server-owned checkout. The mutex intentionally spans a whole run:
|
||||
* checkout is process-global state, so parallel sessions cannot safely share one working tree.
|
||||
*/
|
||||
class GitRunBranchTransport(
|
||||
private val eventStore: EventStore,
|
||||
private val config: GitConfig,
|
||||
) {
|
||||
private val checkoutLock = Mutex()
|
||||
|
||||
suspend fun <T> onRunBranch(
|
||||
sessionId: SessionId,
|
||||
workspaceRoot: Path,
|
||||
block: suspend () -> T,
|
||||
): T = checkoutLock.withLock {
|
||||
val prepared = prepare(sessionId, workspaceRoot)
|
||||
try {
|
||||
block()
|
||||
} finally {
|
||||
val terminalStage = eventStore.read(sessionId).lastOrNull { event ->
|
||||
event.payload is WorkflowCompletedEvent || event.payload is WorkflowFailedEvent
|
||||
}?.payload?.let { payload ->
|
||||
when (payload) {
|
||||
is WorkflowCompletedEvent -> payload.terminalStageId.value
|
||||
is WorkflowFailedEvent -> payload.stageId.value
|
||||
else -> null
|
||||
}
|
||||
} ?: "unknown"
|
||||
pushTerminalBranch(sessionId, workspaceRoot, prepared, terminalStage)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun prepare(sessionId: SessionId, workspaceRoot: Path): PreparedBranch = withContext(Dispatchers.IO) {
|
||||
val branch = "run/${sessionId.value}"
|
||||
runGit(workspaceRoot, "fetch", config.remote, config.baseBranch)
|
||||
val baseRef = "${config.remote}/${config.baseBranch}"
|
||||
val baseSha = runGit(workspaceRoot, "rev-parse", baseRef).trim()
|
||||
runGit(workspaceRoot, "checkout", "-B", branch, baseRef)
|
||||
PreparedBranch(branch, baseSha)
|
||||
}
|
||||
|
||||
private suspend fun pushTerminalBranch(
|
||||
sessionId: SessionId,
|
||||
workspaceRoot: Path,
|
||||
prepared: PreparedBranch,
|
||||
terminalStage: String,
|
||||
) =
|
||||
withContext(Dispatchers.IO) {
|
||||
runGit(workspaceRoot, "add", "-A")
|
||||
if (runGitExitCode(workspaceRoot, "diff", "--cached", "--quiet") != 0) {
|
||||
val args = mutableListOf("commit", "-m", "correx run ${sessionId.value} terminal $terminalStage")
|
||||
if (config.author.isNotBlank()) args += "--author=${config.author}"
|
||||
runGit(workspaceRoot, *args.toTypedArray())
|
||||
}
|
||||
val headSha = runGit(workspaceRoot, "rev-parse", "HEAD").trim()
|
||||
runGit(workspaceRoot, "push", config.remote, "${prepared.branch}:${prepared.branch}")
|
||||
eventStore.append(
|
||||
NewEvent(
|
||||
metadata = EventMetadata(
|
||||
eventId = EventId(UUID.randomUUID().toString()),
|
||||
sessionId = sessionId,
|
||||
timestamp = Clock.System.now(),
|
||||
schemaVersion = 1,
|
||||
causationId = null,
|
||||
correlationId = null,
|
||||
),
|
||||
payload = RunBranchPushedEvent(sessionId, prepared.branch, prepared.baseSha, headSha),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun runGit(root: Path, vararg args: String): String {
|
||||
val process = ProcessBuilder(listOf("git", "-C", root.toString()) + args)
|
||||
.redirectErrorStream(true)
|
||||
.start()
|
||||
val output = process.inputStream.bufferedReader().use { it.readText() }
|
||||
if (!process.waitFor(GIT_TIMEOUT_SECONDS, TimeUnit.SECONDS)) {
|
||||
process.destroyForcibly()
|
||||
error("git ${args.joinToString(" ")} timed out")
|
||||
}
|
||||
check(process.exitValue() == 0) { "git ${args.joinToString(" ")} failed: ${output.trim()}" }
|
||||
return output
|
||||
}
|
||||
|
||||
private fun runGitExitCode(root: Path, vararg args: String): Int {
|
||||
val process = ProcessBuilder(listOf("git", "-C", root.toString()) + args)
|
||||
.redirectErrorStream(true)
|
||||
.start()
|
||||
process.inputStream.bufferedReader().use { it.readText() }
|
||||
if (!process.waitFor(GIT_TIMEOUT_SECONDS, TimeUnit.SECONDS)) {
|
||||
process.destroyForcibly()
|
||||
error("git ${args.joinToString(" ")} timed out")
|
||||
}
|
||||
return process.exitValue()
|
||||
}
|
||||
|
||||
private data class PreparedBranch(val branch: String, val baseSha: String)
|
||||
}
|
||||
+1
-1
@@ -19,7 +19,7 @@ import com.correx.core.talkie.l3.L3Query
|
||||
* [ProjectMemoryService] under `turnId = "project:<repoRoot>"` (trailing-`:` delimiter). This is
|
||||
* the only decision-bearing L3 namespace that exists today, so [decisionNamespacePrefix] defaults
|
||||
* to `"project:"` — a `startsWith` prefix, matching the trailing-`:` delimiter convention used by
|
||||
* [L3RepoKnowledgeRetriever]'s `"repomap:<repoRoot>:"` filter. Hits are also constrained to PRIOR
|
||||
* [L3RepoKnowledgeRetriever]'s versioned `"repomap:v2:<repoRoot>:"` filter. Hits are also constrained to PRIOR
|
||||
* sessions (`entry.sessionId != sessionId`) so the architect never flags its own in-flight run.
|
||||
*/
|
||||
class ArchitectContradictionChecker(
|
||||
|
||||
+3
-2
@@ -35,8 +35,9 @@ class L3RepoKnowledgeRetriever(
|
||||
override suspend fun retrieve(sessionId: SessionId, query: String, k: Int): List<RepoKnowledgeHit> {
|
||||
val vector = embedder.embed(query)
|
||||
val candidates = l3MemoryStore.query(L3Query(vector = vector, k = k * RETRIEVAL_OVERSAMPLE_FACTOR))
|
||||
// Trailing ':' so "/repo" does not also match "/repo2" turnIds (prefix collision).
|
||||
.filter { it.entry.turnId.startsWith("repomap:$repoRoot:") }
|
||||
// Versioned trailing delimiter keeps sibling repo roots isolated and stale semantic
|
||||
// documents out after a repo-map descriptor format change.
|
||||
.filter { it.entry.turnId.startsWith(repoMapEmbeddingPrefix(repoRoot)) }
|
||||
val (kept, dropped) = candidates.partition { it.score >= MIN_SIMILARITY_SCORE }
|
||||
if (dropped.isNotEmpty()) recordDropped(sessionId, query, dropped.map { it.toHit() })
|
||||
return kept.take(k).map { it.toHit() }
|
||||
|
||||
@@ -110,13 +110,13 @@ class ProjectMemoryService(
|
||||
stateKey: String?,
|
||||
entries: List<RepoMapEntry>,
|
||||
) {
|
||||
if (stateKey != null && l3MemoryStore.existsByTurnIdPrefix("repomap:$repoRoot:$stateKey")) {
|
||||
val tag = repoMapEmbeddingTag(repoRoot, stateKey)
|
||||
if (stateKey != null && l3MemoryStore.existsByTurnIdPrefix(tag)) {
|
||||
log.debug("repo-map already embedded for {}@{} — skipping L3 store", repoRoot, stateKey)
|
||||
return
|
||||
}
|
||||
// Trailing ':' delimiter so a repoRoot prefix can't collide with a longer sibling
|
||||
// (/repo vs /repo2) under the retriever's startsWith filter.
|
||||
val tag = if (stateKey != null) "repomap:$repoRoot:$stateKey" else "repomap:$repoRoot:"
|
||||
// Docs are already surfaced via the "Docs available" catalog built straight from
|
||||
// RepoMapComputedEvent (SessionOrchestrator, 2026-07-07 doc-injection rework) — embedding
|
||||
// them into the same L3 namespace as code let generic textual similarity (e.g. "analyst",
|
||||
@@ -126,7 +126,11 @@ class ProjectMemoryService(
|
||||
// rather than the repo-map floor that was fixed then).
|
||||
entries.filterNot { it.path.endsWith(".md", ignoreCase = true) }.forEach { entry ->
|
||||
runCatching {
|
||||
val text = entry.path + if (entry.symbols.isEmpty()) "" else ": ${entry.symbols.joinToString(", ")}"
|
||||
val text = buildString {
|
||||
append(entry.path)
|
||||
if (entry.descriptor.isNotBlank()) append(": ").append(entry.descriptor)
|
||||
if (entry.symbols.isNotEmpty()) append("; symbols: ").append(entry.symbols.joinToString(", "))
|
||||
}
|
||||
l3MemoryStore.store(
|
||||
L3MemoryEntry(
|
||||
id = UUID.randomUUID().toString(),
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.correx.apps.server.memory
|
||||
|
||||
/**
|
||||
* Version the serialized repo-map embedding namespace. A descriptor change alters the semantic
|
||||
* document, so a new version deliberately ignores stale vectors and causes one re-embed per
|
||||
* recorded workspace state. The recorded repo-map event remains backwards compatible.
|
||||
*/
|
||||
internal const val REPO_MAP_EMBEDDING_VERSION = "v2"
|
||||
|
||||
internal fun repoMapEmbeddingPrefix(repoRoot: String): String =
|
||||
"repomap:$REPO_MAP_EMBEDDING_VERSION:$repoRoot:"
|
||||
|
||||
internal fun repoMapEmbeddingTag(repoRoot: String, stateKey: String?): String =
|
||||
stateKey?.let { repoMapEmbeddingPrefix(repoRoot) + it } ?: repoMapEmbeddingPrefix(repoRoot)
|
||||
@@ -20,7 +20,8 @@ interface RepoMapIndexerPort {
|
||||
* Walks a repo and produces a ranked file/symbol index. Reads the filesystem (a
|
||||
* nondeterministic environment observation) — the caller records the result as a
|
||||
* [com.correx.core.events.events.RepoMapComputedEvent] so replay reads recorded facts and
|
||||
* never re-scans (invariant #9). Paths + top-level symbol names only, never file bodies.
|
||||
* never re-scans (invariant #9). Entries retain paths, top-level symbols, and a bounded,
|
||||
* deterministic purpose descriptor; full file bodies are never stored in the repo map.
|
||||
*
|
||||
* Scoring is recency-based: the most-recently-modified source file scores 1.0, the oldest
|
||||
* ~0.0, linearly between. Recency is a cheap centrality proxy — files under active work rank
|
||||
@@ -53,6 +54,7 @@ class RepoMapIndexer(
|
||||
path = path.relativeTo(repoRoot).toString(),
|
||||
score = (mtimes.getValue(path) - min).toDouble() / span,
|
||||
symbols = extractSymbols(path),
|
||||
descriptor = sourceDescriptor(path),
|
||||
)
|
||||
}
|
||||
.sortedByDescending { it.score }
|
||||
@@ -104,6 +106,35 @@ class RepoMapIndexer(
|
||||
} ?: emptyList()
|
||||
}
|
||||
|
||||
/**
|
||||
* A small semantic bridge between an intent ("context packing") and code whose class name
|
||||
* alone lacks those words. Package/module identity, leading KDoc/comments, and imports are
|
||||
* stable source facts; they are capped before the event and L3 embedding are recorded.
|
||||
*/
|
||||
private fun sourceDescriptor(path: Path): String {
|
||||
if (path.extension.equals("md", ignoreCase = true)) return docDescriptor(path).orEmpty()
|
||||
val lines = runCatching { path.readText().lineSequence().take(DESCRIPTOR_SCAN_LINES).toList() }
|
||||
.getOrDefault(emptyList())
|
||||
if (lines.isEmpty()) return ""
|
||||
val packageOrModule = lines.firstNotNullOfOrNull { line ->
|
||||
PACKAGE_OR_MODULE.find(line.trim())?.groupValues?.get(1)
|
||||
}
|
||||
val imports = lines.asSequence()
|
||||
.mapNotNull { IMPORT.find(it.trim())?.groupValues?.get(1) }
|
||||
.take(MAX_DESCRIPTOR_IMPORTS)
|
||||
.toList()
|
||||
val comment = lines.asSequence()
|
||||
.map { raw -> raw.trim() }
|
||||
.filter(::isCommentLine)
|
||||
.map { it.removePrefix("/**").removePrefix("*").removePrefix("//").removePrefix("#").trim() }
|
||||
.firstOrNull { it.length >= MIN_COMMENT_CHARS }
|
||||
return buildList {
|
||||
packageOrModule?.let { add("module $it") }
|
||||
if (imports.isNotEmpty()) add("uses ${imports.joinToString(", ")}")
|
||||
comment?.let { add(it) }
|
||||
}.joinToString("; ").truncateDescriptor()
|
||||
}
|
||||
|
||||
/**
|
||||
* One-line "what is this doc about" descriptor, preferred in order: YAML frontmatter
|
||||
* `description`/`summary`/`title`, then the first `# H1` heading, then the first prose line.
|
||||
@@ -133,13 +164,21 @@ class RepoMapIndexer(
|
||||
private fun String.truncateDescriptor(): String =
|
||||
if (length <= MAX_DESCRIPTOR_CHARS) this else take(MAX_DESCRIPTOR_CHARS).trimEnd() + "…"
|
||||
|
||||
private fun isCommentLine(line: String): Boolean =
|
||||
line.startsWith("/**") || line.startsWith("*") || line.startsWith("//") || line.startsWith("#")
|
||||
|
||||
companion object {
|
||||
/** File extensions considered for indexing — reused by [WorkspaceStateProbe] for fingerprinting. */
|
||||
val INDEXED_EXTENSIONS: Set<String> get() = SYMBOL_PATTERNS.keys
|
||||
|
||||
private const val MAX_SYMBOLS_PER_FILE = 40
|
||||
private const val MAX_DESCRIPTOR_CHARS = 120
|
||||
private const val DESCRIPTOR_SCAN_LINES = 80
|
||||
private const val MAX_DESCRIPTOR_IMPORTS = 6
|
||||
private const val MIN_COMMENT_CHARS = 16
|
||||
private val FRONTMATTER_KEYS = listOf("description", "summary", "title")
|
||||
private val PACKAGE_OR_MODULE = Regex("""(?:package|module|namespace)\s+([A-Za-z0-9_.$/-]+)""")
|
||||
private val IMPORT = Regex("""(?:import|using)\s+([A-Za-z0-9_.$/*-]+)""")
|
||||
|
||||
// Top-level declarations only — best-effort per language. Bodies/locals are never matched.
|
||||
val SYMBOL_PATTERNS: Map<String, Regex> = mapOf(
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.correx.apps.server.git
|
||||
|
||||
import com.correx.core.config.GitConfig
|
||||
import com.correx.core.events.events.RunBranchPushedEvent
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.infrastructure.persistence.InMemoryEventStore
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.io.path.writeText
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class GitRunBranchTransportTest {
|
||||
|
||||
@Test
|
||||
fun `creates a run branch, pushes it, and records the pushed ref`(): Unit = runBlocking {
|
||||
val root = Files.createTempDirectory("correx-git-transport")
|
||||
val remote = Files.createTempDirectory("correx-git-remote")
|
||||
git(root, "init", "-b", "main")
|
||||
git(root, "config", "user.name", "Test User")
|
||||
git(root, "config", "user.email", "test@example.test")
|
||||
root.resolve("README.md").writeText("base\n")
|
||||
git(root, "add", "README.md")
|
||||
git(root, "commit", "-m", "base")
|
||||
git(remote, "init", "--bare")
|
||||
git(root, "remote", "add", "origin", remote.toString())
|
||||
git(root, "push", "origin", "main")
|
||||
|
||||
val store = InMemoryEventStore()
|
||||
val sessionId = SessionId("git-transport")
|
||||
val transport = GitRunBranchTransport(
|
||||
store,
|
||||
GitConfig(enabled = true, remote = "origin", baseBranch = "main"),
|
||||
)
|
||||
|
||||
transport.onRunBranch(sessionId, root) {
|
||||
root.resolve("agent-output.txt").writeText("generated\n")
|
||||
}
|
||||
|
||||
assertEquals("run/git-transport", git(root, "branch", "--show-current").trim())
|
||||
assertTrue(git(root, "ls-remote", "--heads", "origin", "run/git-transport").isNotBlank())
|
||||
val pushed = store.read(sessionId).single().payload as RunBranchPushedEvent
|
||||
assertEquals("run/git-transport", pushed.branch)
|
||||
assertTrue(pushed.baseSha.isNotBlank())
|
||||
assertEquals(git(root, "rev-parse", "HEAD").trim(), pushed.headSha)
|
||||
}
|
||||
|
||||
private fun git(root: Path, vararg args: String): String {
|
||||
val process = ProcessBuilder(listOf("git", "-C", root.toString()) + args)
|
||||
.redirectErrorStream(true)
|
||||
.start()
|
||||
val output = process.inputStream.bufferedReader().use { it.readText() }
|
||||
check(process.waitFor(20, TimeUnit.SECONDS)) { "git ${args.joinToString(" ")} timed out" }
|
||||
check(process.exitValue() == 0) { "git ${args.joinToString(" ")} failed: $output" }
|
||||
return output
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -87,7 +87,7 @@ class ArchitectContradictionCheckerTest {
|
||||
val store = CannedL3MemoryStore(
|
||||
listOf(
|
||||
// Above threshold but a repo-map entry, not a decision — must be excluded.
|
||||
hit("Foo.kt: ClassA, funcB", score = 0.95f, turnId = "repomap:/repo:abc"),
|
||||
hit("Foo.kt: ClassA, funcB", score = 0.95f, turnId = "repomap:v2:/repo:abc"),
|
||||
),
|
||||
)
|
||||
val checker = ArchitectContradictionChecker(ContradictionOnesEmbedder(), store)
|
||||
|
||||
+4
-4
@@ -19,8 +19,8 @@ class L3RepoKnowledgeRetrieverTest {
|
||||
@Test
|
||||
fun `retriever for a repoRoot does not match a sibling whose path it prefixes`(): Unit = runBlocking {
|
||||
val l3 = InMemoryL3MemoryStore()
|
||||
l3.store(L3MemoryEntry("1", SessionId("s"), "repomap:/repo:git:h", "repo/A.kt: Foo", vec, 0L))
|
||||
l3.store(L3MemoryEntry("2", SessionId("s"), "repomap:/repo2:git:h", "repo2/B.kt: Bar", vec, 0L))
|
||||
l3.store(L3MemoryEntry("1", SessionId("s"), "repomap:v2:/repo:git:h", "repo/A.kt: Foo", vec, 0L))
|
||||
l3.store(L3MemoryEntry("2", SessionId("s"), "repomap:v2:/repo2:git:h", "repo2/B.kt: Bar", vec, 0L))
|
||||
|
||||
val hits = L3RepoKnowledgeRetriever(RetrieverConstantEmbedder(), l3, "/repo")
|
||||
.retrieve(SessionId("s"), "anything", 10)
|
||||
@@ -31,8 +31,8 @@ class L3RepoKnowledgeRetrieverTest {
|
||||
@Test
|
||||
fun `retriever matches its own repoRoot entries with or without a stateKey suffix`(): Unit = runBlocking {
|
||||
val l3 = InMemoryL3MemoryStore()
|
||||
l3.store(L3MemoryEntry("1", SessionId("s"), "repomap:/repo:git:h1", "repo/A.kt", vec, 0L))
|
||||
l3.store(L3MemoryEntry("2", SessionId("s"), "repomap:/repo:", "repo/B.kt", vec, 0L))
|
||||
l3.store(L3MemoryEntry("1", SessionId("s"), "repomap:v2:/repo:git:h1", "repo/A.kt", vec, 0L))
|
||||
l3.store(L3MemoryEntry("2", SessionId("s"), "repomap:v2:/repo:", "repo/B.kt", vec, 0L))
|
||||
|
||||
val hits = L3RepoKnowledgeRetriever(RetrieverConstantEmbedder(), l3, "/repo")
|
||||
.retrieve(SessionId("s"), "anything", 10)
|
||||
|
||||
+4
-4
@@ -118,7 +118,7 @@ class ProjectMemoryServiceReuseTest {
|
||||
svc.indexAndRecord(sessionId, "/repo")
|
||||
|
||||
assertTrue(
|
||||
l3.existsByTurnIdPrefix("repomap:/repo:git:hash1"),
|
||||
l3.existsByTurnIdPrefix("repomap:v2:/repo:git:hash1"),
|
||||
"entries should be embedded with stateKey tag",
|
||||
)
|
||||
}
|
||||
@@ -131,7 +131,7 @@ class ProjectMemoryServiceReuseTest {
|
||||
val embedder = ConstantEmbedderReuse()
|
||||
|
||||
// Index /repo and /repo2 into the same shared L3 store with the same stateKey.
|
||||
// The trailing-':' delimiter on the repomap: tag must keep their entries from bleeding
|
||||
// The versioned trailing-':' delimiter on the repomap tag must keep their entries from bleeding
|
||||
// across roots: "/repo" must NOT match tags written for "/repo2".
|
||||
val repoEntries = listOf(RepoMapEntry(path = "src/Repo.kt", score = 1.0, symbols = listOf("RepoClass")))
|
||||
val repo2Entries = listOf(RepoMapEntry(path = "src/Repo2.kt", score = 1.0, symbols = listOf("Repo2Class")))
|
||||
@@ -142,8 +142,8 @@ class ProjectMemoryServiceReuseTest {
|
||||
.indexAndRecord(SessionId("session-collide-repo2"), "/repo2")
|
||||
|
||||
// existsByTurnIdPrefix with the delimiter must not match the other root.
|
||||
assertTrue(l3.existsByTurnIdPrefix("repomap:/repo:"), "tag for /repo should exist")
|
||||
assertTrue(l3.existsByTurnIdPrefix("repomap:/repo2:"), "tag for /repo2 should exist")
|
||||
assertTrue(l3.existsByTurnIdPrefix("repomap:v2:/repo:"), "tag for /repo should exist")
|
||||
assertTrue(l3.existsByTurnIdPrefix("repomap:v2:/repo2:"), "tag for /repo2 should exist")
|
||||
|
||||
// The retriever scoped to /repo must return only /repo's entry, never /repo2's.
|
||||
val hits = L3RepoKnowledgeRetriever(embedder = embedder, l3MemoryStore = l3, repoRoot = "/repo")
|
||||
|
||||
@@ -41,6 +41,40 @@ class RepoMapIndexerTest {
|
||||
assertTrue(entries.first { it.path == "Old.kt" }.symbols.contains("Old"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `source descriptor retains package imports and leading purpose comment`(@TempDir root: Path) {
|
||||
root.resolve("src").createDirectories()
|
||||
root.resolve("src/Context.kt").writeText(
|
||||
"""
|
||||
/** Builds the context packing pipeline for stage inference. */
|
||||
package com.correx.context
|
||||
import com.correx.events.EventStore
|
||||
class DefaultContextPackBuilder
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
val entry = RepoMapIndexer().index(root).single()
|
||||
|
||||
assertTrue(entry.descriptor.contains("module com.correx.context"))
|
||||
assertTrue(entry.descriptor.contains("EventStore"))
|
||||
assertTrue(entry.descriptor.contains("context packing pipeline"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `source descriptor never uses a declaration as a purpose comment`(@TempDir root: Path) {
|
||||
root.resolve("Plain.kt").writeText(
|
||||
"""
|
||||
package com.correx.example
|
||||
import com.correx.events.EventStore
|
||||
class ALongEnoughDeclarationToHaveLookedLikeAComment
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
val entry = RepoMapIndexer().index(root).single()
|
||||
|
||||
assertFalse(entry.descriptor.contains("class ALongEnough"), entry.descriptor)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `extracts GDScript top-level symbols`(@TempDir root: Path) {
|
||||
root.resolve("player.gd").writeText(
|
||||
|
||||
Reference in New Issue
Block a user