feat(kernel): brief echo-back gate (C-A1)
- BriefEcho model + BriefEchoMismatchEvent (registered + serialization test) - BriefEchoExtractor: parses the planner's brief_echo block and the original brief's referenced-files/symbols/acceptance-criteria dimensions - BriefEchoComparator: conservative diff — a dropped acceptance criterion or an invented file path halts; dropping a file/symbol alone does not - checkBriefEcho gate chained into the post-stage flow after groundBriefReferences; opt-in via stageConfig.metadata["briefEcho"], fails the stage (retryable) on divergence with re-grounding feedback - prompt/workflow activation (planner.md emitting brief_echo; role_pipeline metadata) left as live-QA follow-up Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,38 @@
|
|||||||
|
package com.correx.core.events.events
|
||||||
|
|
||||||
|
import com.correx.core.events.types.SessionId
|
||||||
|
import com.correx.core.events.types.StageId
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A planner's structured restatement of the brief it was handed: the files it must touch, the
|
||||||
|
* symbols it must reach, and the acceptance criteria it must satisfy. The harness diffs this echo
|
||||||
|
* against the original brief before any plan is generated, so a divergence halts at the cheapest
|
||||||
|
* point in the pipeline. Also reused as the diff input shape for both sides of the comparison.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class BriefEcho(
|
||||||
|
val referencedFiles: Set<String> = emptySet(),
|
||||||
|
val symbols: Set<String> = emptySet(),
|
||||||
|
val acceptanceCriteria: Set<String> = emptySet(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A stage echoed the brief but the echo diverged from the original, so the plan is rejected
|
||||||
|
* before generation ("halt before plan generation"). Recorded so replay explains the halt.
|
||||||
|
*
|
||||||
|
* - [droppedFiles] / [droppedSymbols] / [droppedAcceptanceCriteria]: present in the original brief,
|
||||||
|
* missing from the echo — the planner failed to carry them forward.
|
||||||
|
* - [inventedFiles]: file paths in the echo that are not in the original brief — hallucinated paths.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
@SerialName("BriefEchoMismatch")
|
||||||
|
data class BriefEchoMismatchEvent(
|
||||||
|
val sessionId: SessionId,
|
||||||
|
val stageId: StageId,
|
||||||
|
val droppedFiles: Set<String>,
|
||||||
|
val droppedSymbols: Set<String>,
|
||||||
|
val droppedAcceptanceCriteria: Set<String>,
|
||||||
|
val inventedFiles: Set<String>,
|
||||||
|
) : EventPayload
|
||||||
@@ -8,6 +8,7 @@ import com.correx.core.events.events.ArtifactContentStoredEvent
|
|||||||
import com.correx.core.events.events.ArtifactCreatedEvent
|
import com.correx.core.events.events.ArtifactCreatedEvent
|
||||||
import com.correx.core.events.events.ArtifactValidatedEvent
|
import com.correx.core.events.events.ArtifactValidatedEvent
|
||||||
import com.correx.core.events.events.ArtifactValidatingEvent
|
import com.correx.core.events.events.ArtifactValidatingEvent
|
||||||
|
import com.correx.core.events.events.BriefEchoMismatchEvent
|
||||||
import com.correx.core.events.events.BriefGroundingCheckedEvent
|
import com.correx.core.events.events.BriefGroundingCheckedEvent
|
||||||
import com.correx.core.events.events.ChatSessionStartedEvent
|
import com.correx.core.events.events.ChatSessionStartedEvent
|
||||||
import com.correx.core.events.events.ClarificationAnsweredEvent
|
import com.correx.core.events.events.ClarificationAnsweredEvent
|
||||||
@@ -109,6 +110,7 @@ val eventModule = SerializersModule {
|
|||||||
subclass(WorkspaceStateObservedEvent::class)
|
subclass(WorkspaceStateObservedEvent::class)
|
||||||
subclass(RepoKnowledgeRetrievedEvent::class)
|
subclass(RepoKnowledgeRetrievedEvent::class)
|
||||||
subclass(BriefGroundingCheckedEvent::class)
|
subclass(BriefGroundingCheckedEvent::class)
|
||||||
|
subclass(BriefEchoMismatchEvent::class)
|
||||||
subclass(RiskAssessedEvent::class)
|
subclass(RiskAssessedEvent::class)
|
||||||
subclass(ChatSessionStartedEvent::class)
|
subclass(ChatSessionStartedEvent::class)
|
||||||
subclass(ChatTurnEvent::class)
|
subclass(ChatTurnEvent::class)
|
||||||
|
|||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
package com.correx.core.events.serialization
|
||||||
|
|
||||||
|
import com.correx.core.events.events.BriefEchoMismatchEvent
|
||||||
|
import com.correx.core.events.events.EventPayload
|
||||||
|
import com.correx.core.events.types.SessionId
|
||||||
|
import com.correx.core.events.types.StageId
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class BriefEchoEventSerializationTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `BriefEchoMismatchEvent round-trips as polymorphic EventPayload`() {
|
||||||
|
val sample: EventPayload = BriefEchoMismatchEvent(
|
||||||
|
sessionId = SessionId("s"),
|
||||||
|
stageId = StageId("plan"),
|
||||||
|
droppedFiles = setOf("core/kernel/Foo.kt"),
|
||||||
|
droppedSymbols = setOf("Foo"),
|
||||||
|
droppedAcceptanceCriteria = setOf("must compile"),
|
||||||
|
inventedFiles = setOf("core/kernel/Bar.kt"),
|
||||||
|
)
|
||||||
|
val encoded = eventJson.encodeToString(EventPayload.serializer(), sample)
|
||||||
|
assertTrue(encoded.contains("\"type\":\"BriefEchoMismatch\""), "SerialName must be present: $encoded")
|
||||||
|
val decoded = eventJson.decodeFromString(EventPayload.serializer(), encoded)
|
||||||
|
assertEquals(sample, decoded)
|
||||||
|
}
|
||||||
|
}
|
||||||
+59
@@ -0,0 +1,59 @@
|
|||||||
|
package com.correx.core.kernel.orchestration
|
||||||
|
|
||||||
|
import com.correx.core.events.events.BriefEcho
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The divergence between an original brief and a planner's echo of it.
|
||||||
|
*
|
||||||
|
* - [droppedFiles] / [droppedSymbols] / [droppedAcceptanceCriteria]: present in the original brief,
|
||||||
|
* missing from the echo — the planner failed to carry them forward.
|
||||||
|
* - [inventedFiles]: file paths in the echo not in the original — hallucinated paths.
|
||||||
|
*
|
||||||
|
* [hasMismatch] is conservative: see [BriefEchoComparator.compare].
|
||||||
|
*/
|
||||||
|
internal data class BriefEchoDiff(
|
||||||
|
val droppedFiles: Set<String>,
|
||||||
|
val droppedSymbols: Set<String>,
|
||||||
|
val droppedAcceptanceCriteria: Set<String>,
|
||||||
|
val inventedFiles: Set<String>,
|
||||||
|
) {
|
||||||
|
/**
|
||||||
|
* A mismatch that should halt the stage. Only dropping a *required acceptance criterion* or
|
||||||
|
* *inventing a file path* qualifies: those are correctness-bearing. Merely dropping a file or
|
||||||
|
* symbol the plan may legitimately not echo is tolerated and does NOT halt.
|
||||||
|
*/
|
||||||
|
val hasMismatch: Boolean
|
||||||
|
get() = droppedAcceptanceCriteria.isNotEmpty() || inventedFiles.isNotEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Diffs a planner's [BriefEcho] against the original brief's. Pure and deterministic.
|
||||||
|
*
|
||||||
|
* Diff semantics:
|
||||||
|
* - `dropped* = original.X - echo.X` (the planner failed to carry something forward).
|
||||||
|
* - `inventedFiles = echo.referencedFiles - original.referencedFiles` (hallucinated paths).
|
||||||
|
*
|
||||||
|
* Comparison is case-insensitive and trimmed for acceptance criteria and symbols (free-text the
|
||||||
|
* model may rephrase in casing/whitespace), and exact for file paths (a path is a path).
|
||||||
|
*/
|
||||||
|
internal object BriefEchoComparator {
|
||||||
|
|
||||||
|
fun compare(original: BriefEcho, echo: BriefEcho): BriefEchoDiff {
|
||||||
|
val droppedFiles = original.referencedFiles - echo.referencedFiles
|
||||||
|
val inventedFiles = echo.referencedFiles - original.referencedFiles
|
||||||
|
return BriefEchoDiff(
|
||||||
|
droppedFiles = droppedFiles,
|
||||||
|
droppedSymbols = normalizedDrop(original.symbols, echo.symbols),
|
||||||
|
droppedAcceptanceCriteria = normalizedDrop(original.acceptanceCriteria, echo.acceptanceCriteria),
|
||||||
|
inventedFiles = inventedFiles,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Original entries whose normalized form is absent from the echo, returned in original form. */
|
||||||
|
private fun normalizedDrop(original: Set<String>, echo: Set<String>): Set<String> {
|
||||||
|
val echoKeys = echo.mapTo(HashSet()) { it.normalize() }
|
||||||
|
return original.filterTo(LinkedHashSet()) { it.normalize() !in echoKeys }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.normalize(): String = trim().lowercase()
|
||||||
|
}
|
||||||
+61
@@ -0,0 +1,61 @@
|
|||||||
|
package com.correx.core.kernel.orchestration
|
||||||
|
|
||||||
|
import com.correx.core.events.events.BriefEcho
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonArray
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts the structured [BriefEcho] from two artifact shapes the echo gate compares:
|
||||||
|
*
|
||||||
|
* - [fromEcho]: a planner's produced artifact, which restates the brief under a `brief_echo` key:
|
||||||
|
* `{"brief_echo": {"referenced_files": [...], "symbols": [...], "acceptance_criteria": [...]}}`.
|
||||||
|
* - [fromOriginalBrief]: the original (analyst) brief artifact, whose three dimensions are pulled
|
||||||
|
* best-effort — referenced files via [BriefReferenceExtractor], plus optional `acceptance_criteria`
|
||||||
|
* and `symbols` arrays.
|
||||||
|
*
|
||||||
|
* Both are deterministic and tolerant of missing keys, parsing with kotlinx-serialization Json
|
||||||
|
* like [BriefReferenceExtractor].
|
||||||
|
*/
|
||||||
|
internal object BriefEchoExtractor {
|
||||||
|
|
||||||
|
private const val BRIEF_ECHO_KEY = "brief_echo"
|
||||||
|
private const val REFERENCED_FILES_KEY = "referenced_files"
|
||||||
|
private const val SYMBOLS_KEY = "symbols"
|
||||||
|
private const val ACCEPTANCE_CRITERIA_KEY = "acceptance_criteria"
|
||||||
|
|
||||||
|
/** Returns the `brief_echo` block as a [BriefEcho], or null if the artifact has no such key. */
|
||||||
|
fun fromEcho(artifactJson: String): BriefEcho? {
|
||||||
|
val root = artifactJson.parseObjectOrNull() ?: return null
|
||||||
|
val echo = root[BRIEF_ECHO_KEY] as? JsonObject ?: return null
|
||||||
|
return BriefEcho(
|
||||||
|
referencedFiles = echo.stringSet(REFERENCED_FILES_KEY),
|
||||||
|
symbols = echo.stringSet(SYMBOLS_KEY),
|
||||||
|
acceptanceCriteria = echo.stringSet(ACCEPTANCE_CRITERIA_KEY),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The same three dimensions pulled from the original brief artifact, best-effort. */
|
||||||
|
fun fromOriginalBrief(artifactJson: String): BriefEcho {
|
||||||
|
val root = artifactJson.parseObjectOrNull()
|
||||||
|
return BriefEcho(
|
||||||
|
referencedFiles = BriefReferenceExtractor.fileReferences(artifactJson).toSet(),
|
||||||
|
symbols = root?.stringSet(SYMBOLS_KEY) ?: emptySet(),
|
||||||
|
acceptanceCriteria = root?.stringSet(ACCEPTANCE_CRITERIA_KEY) ?: emptySet(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.parseObjectOrNull(): JsonObject? =
|
||||||
|
runCatching { Json.parseToJsonElement(this) }.getOrNull() as? JsonObject
|
||||||
|
|
||||||
|
/** Non-blank string leaves of the named array, in order; empty when the key is absent/non-array. */
|
||||||
|
private fun JsonObject.stringSet(key: String): Set<String> {
|
||||||
|
val array = this[key] as? JsonArray ?: return emptySet()
|
||||||
|
return array.mapNotNullTo(LinkedHashSet()) { it.stringOrNull()?.takeIf(String::isNotBlank) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JsonElement.stringOrNull(): String? =
|
||||||
|
(this as? JsonPrimitive)?.takeIf { it.isString }?.content
|
||||||
|
}
|
||||||
+70
-1
@@ -28,6 +28,7 @@ import com.correx.core.events.events.RefinementIterationEvent
|
|||||||
import com.correx.core.events.events.ApprovalRequestedEvent
|
import com.correx.core.events.events.ApprovalRequestedEvent
|
||||||
import com.correx.core.events.events.ArtifactContentStoredEvent
|
import com.correx.core.events.events.ArtifactContentStoredEvent
|
||||||
import com.correx.core.events.events.ArtifactCreatedEvent
|
import com.correx.core.events.events.ArtifactCreatedEvent
|
||||||
|
import com.correx.core.events.events.BriefEchoMismatchEvent
|
||||||
import com.correx.core.events.events.BriefGroundingCheckedEvent
|
import com.correx.core.events.events.BriefGroundingCheckedEvent
|
||||||
import com.correx.core.events.events.ContextTruncatedEvent
|
import com.correx.core.events.events.ContextTruncatedEvent
|
||||||
import com.correx.core.events.events.ClarificationAnswer
|
import com.correx.core.events.events.ClarificationAnswer
|
||||||
@@ -550,7 +551,11 @@ abstract class SessionOrchestrator(
|
|||||||
emitLlmArtifacts(sessionId, stageId, stageConfig)
|
emitLlmArtifacts(sessionId, stageId, stageConfig)
|
||||||
when (val produced = verifyProduces(sessionId, stageId, stageConfig)) {
|
when (val produced = verifyProduces(sessionId, stageId, stageConfig)) {
|
||||||
is StageExecutionResult.Success ->
|
is StageExecutionResult.Success ->
|
||||||
groundBriefReferences(sessionId, stageId, stageConfig, effectives)
|
when (val grounded = groundBriefReferences(sessionId, stageId, stageConfig, effectives)) {
|
||||||
|
is StageExecutionResult.Success ->
|
||||||
|
checkBriefEcho(sessionId, stageId, stageConfig)
|
||||||
|
is StageExecutionResult.Failure -> grounded
|
||||||
|
}
|
||||||
is StageExecutionResult.Failure -> produced
|
is StageExecutionResult.Failure -> produced
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1399,6 +1404,70 @@ abstract class SessionOrchestrator(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Brief echo-back gate (BACKLOG §C-A1): for a stage that opted in (`briefEcho`), the planner must
|
||||||
|
* restate the brief as a structured `brief_echo` block before any plan is generated. The echo is
|
||||||
|
* diffed against the original brief; on divergence a [BriefEchoMismatchEvent] is emitted and the
|
||||||
|
* stage fails (retryable), so the plan is never accepted — the cheapest failure point in the
|
||||||
|
* pipeline. Mismatch is conservative ([BriefEchoComparator]): a dropped acceptance criterion or an
|
||||||
|
* invented file path halts; merely dropping a file/symbol does not.
|
||||||
|
*/
|
||||||
|
private suspend fun checkBriefEcho(
|
||||||
|
sessionId: SessionId,
|
||||||
|
stageId: StageId,
|
||||||
|
stageConfig: StageConfig,
|
||||||
|
): StageExecutionResult {
|
||||||
|
if (stageConfig.metadata["briefEcho"] != "true") return StageExecutionResult.Success(emptyList())
|
||||||
|
val sourceSlot = stageConfig.metadata["briefEchoSource"] ?: "brief"
|
||||||
|
val original = artifactContentCache["${sessionId.value}:$sourceSlot"]
|
||||||
|
?: return StageExecutionResult.Success(emptyList())
|
||||||
|
|
||||||
|
val produced = stageConfig.produces
|
||||||
|
.filter { it.kind.llmEmitted }
|
||||||
|
.firstNotNullOfOrNull { artifactContentCache["${sessionId.value}:${it.name.value}"] }
|
||||||
|
?: return StageExecutionResult.Success(emptyList())
|
||||||
|
|
||||||
|
val echo = BriefEchoExtractor.fromEcho(produced)
|
||||||
|
?: return StageExecutionResult.Failure(
|
||||||
|
"stage ${stageId.value} must restate the brief as a brief_echo block before planning",
|
||||||
|
retryable = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
val diff = BriefEchoComparator.compare(BriefEchoExtractor.fromOriginalBrief(original), echo)
|
||||||
|
if (!diff.hasMismatch) return StageExecutionResult.Success(emptyList())
|
||||||
|
|
||||||
|
emit(
|
||||||
|
sessionId,
|
||||||
|
BriefEchoMismatchEvent(
|
||||||
|
sessionId = sessionId,
|
||||||
|
stageId = stageId,
|
||||||
|
droppedFiles = diff.droppedFiles,
|
||||||
|
droppedSymbols = diff.droppedSymbols,
|
||||||
|
droppedAcceptanceCriteria = diff.droppedAcceptanceCriteria,
|
||||||
|
inventedFiles = diff.inventedFiles,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
log.warn(
|
||||||
|
"[Orchestrator] brief echo diverged session={} stage={} droppedCriteria={} invented={}",
|
||||||
|
sessionId.value, stageId.value,
|
||||||
|
diff.droppedAcceptanceCriteria.joinToString(", "),
|
||||||
|
diff.inventedFiles.joinToString(", "),
|
||||||
|
)
|
||||||
|
return StageExecutionResult.Failure(
|
||||||
|
buildString {
|
||||||
|
append("stage ${stageId.value} brief_echo diverged from the brief.")
|
||||||
|
if (diff.droppedAcceptanceCriteria.isNotEmpty()) {
|
||||||
|
append(" Restate these acceptance criteria: ${diff.droppedAcceptanceCriteria.joinToString(", ")}.")
|
||||||
|
}
|
||||||
|
if (diff.inventedFiles.isNotEmpty()) {
|
||||||
|
append(" These files are not in the brief — do not invent paths: " +
|
||||||
|
"${diff.inventedFiles.joinToString(", ")}.")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
retryable = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun emitProcessResultEvents(
|
private suspend fun emitProcessResultEvents(
|
||||||
sessionId: SessionId,
|
sessionId: SessionId,
|
||||||
stageId: StageId,
|
stageId: StageId,
|
||||||
|
|||||||
+68
@@ -0,0 +1,68 @@
|
|||||||
|
package com.correx.core.kernel.orchestration
|
||||||
|
|
||||||
|
import com.correx.core.events.events.BriefEcho
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertFalse
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
class BriefEchoComparatorTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `identical echo has no mismatch`() {
|
||||||
|
val brief = BriefEcho(
|
||||||
|
referencedFiles = setOf("core/kernel/Foo.kt"),
|
||||||
|
symbols = setOf("Foo"),
|
||||||
|
acceptanceCriteria = setOf("must compile"),
|
||||||
|
)
|
||||||
|
val diff = BriefEchoComparator.compare(brief, brief)
|
||||||
|
assertFalse(diff.hasMismatch)
|
||||||
|
assertTrue(diff.droppedFiles.isEmpty())
|
||||||
|
assertTrue(diff.inventedFiles.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `dropped acceptance criterion is a mismatch`() {
|
||||||
|
val original = BriefEcho(acceptanceCriteria = setOf("must compile", "must pass tests"))
|
||||||
|
val echo = BriefEcho(acceptanceCriteria = setOf("must compile"))
|
||||||
|
val diff = BriefEchoComparator.compare(original, echo)
|
||||||
|
assertTrue(diff.hasMismatch)
|
||||||
|
assertEquals(setOf("must pass tests"), diff.droppedAcceptanceCriteria)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `invented file is a mismatch`() {
|
||||||
|
val original = BriefEcho(referencedFiles = setOf("core/kernel/Foo.kt"))
|
||||||
|
val echo = BriefEcho(referencedFiles = setOf("core/kernel/Foo.kt", "core/kernel/Bar.kt"))
|
||||||
|
val diff = BriefEchoComparator.compare(original, echo)
|
||||||
|
assertTrue(diff.hasMismatch)
|
||||||
|
assertEquals(setOf("core/kernel/Bar.kt"), diff.inventedFiles)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `dropped file only is not a mismatch`() {
|
||||||
|
val original = BriefEcho(referencedFiles = setOf("core/kernel/Foo.kt", "core/kernel/Bar.kt"))
|
||||||
|
val echo = BriefEcho(referencedFiles = setOf("core/kernel/Foo.kt"))
|
||||||
|
val diff = BriefEchoComparator.compare(original, echo)
|
||||||
|
assertFalse(diff.hasMismatch)
|
||||||
|
assertEquals(setOf("core/kernel/Bar.kt"), diff.droppedFiles)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `acceptance criterion match is case-insensitive and trimmed`() {
|
||||||
|
val original = BriefEcho(acceptanceCriteria = setOf("Must Compile"))
|
||||||
|
val echo = BriefEcho(acceptanceCriteria = setOf(" must compile "))
|
||||||
|
val diff = BriefEchoComparator.compare(original, echo)
|
||||||
|
assertFalse(diff.hasMismatch)
|
||||||
|
assertTrue(diff.droppedAcceptanceCriteria.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `dropped symbol is not a mismatch but is reported`() {
|
||||||
|
val original = BriefEcho(symbols = setOf("Foo", "Bar"))
|
||||||
|
val echo = BriefEcho(symbols = setOf("foo"))
|
||||||
|
val diff = BriefEchoComparator.compare(original, echo)
|
||||||
|
assertFalse(diff.hasMismatch)
|
||||||
|
assertEquals(setOf("Bar"), diff.droppedSymbols)
|
||||||
|
}
|
||||||
|
}
|
||||||
+62
@@ -0,0 +1,62 @@
|
|||||||
|
package com.correx.core.kernel.orchestration
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertNull
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
class BriefEchoExtractorTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parses a well-formed brief_echo block`() {
|
||||||
|
val json = """
|
||||||
|
{
|
||||||
|
"brief_echo": {
|
||||||
|
"referenced_files": ["core/kernel/Foo.kt", "core/kernel/Bar.kt"],
|
||||||
|
"symbols": ["Foo"],
|
||||||
|
"acceptance_criteria": ["must compile", "must pass tests"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""".trimIndent()
|
||||||
|
val echo = BriefEchoExtractor.fromEcho(json)!!
|
||||||
|
assertEquals(setOf("core/kernel/Foo.kt", "core/kernel/Bar.kt"), echo.referencedFiles)
|
||||||
|
assertEquals(setOf("Foo"), echo.symbols)
|
||||||
|
assertEquals(setOf("must compile", "must pass tests"), echo.acceptanceCriteria)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `returns null when brief_echo key is absent`() {
|
||||||
|
assertNull(BriefEchoExtractor.fromEcho("""{"summary": "do the thing"}"""))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `tolerates missing dimensions in brief_echo`() {
|
||||||
|
val echo = BriefEchoExtractor.fromEcho("""{"brief_echo": {"referenced_files": ["a/b/C.kt"]}}""")!!
|
||||||
|
assertEquals(setOf("a/b/C.kt"), echo.referencedFiles)
|
||||||
|
assertTrue(echo.symbols.isEmpty())
|
||||||
|
assertTrue(echo.acceptanceCriteria.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `original-brief extraction pulls referenced files and acceptance criteria`() {
|
||||||
|
val json = """
|
||||||
|
{
|
||||||
|
"summary": "touch core/kernel/Foo.kt to satisfy the gate",
|
||||||
|
"acceptance_criteria": ["must compile", "must pass tests"],
|
||||||
|
"symbols": ["Foo"]
|
||||||
|
}
|
||||||
|
""".trimIndent()
|
||||||
|
val original = BriefEchoExtractor.fromOriginalBrief(json)
|
||||||
|
assertTrue(original.referencedFiles.contains("core/kernel/Foo.kt"))
|
||||||
|
assertEquals(setOf("must compile", "must pass tests"), original.acceptanceCriteria)
|
||||||
|
assertEquals(setOf("Foo"), original.symbols)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `original-brief extraction tolerates missing acceptance_criteria and symbols`() {
|
||||||
|
val original = BriefEchoExtractor.fromOriginalBrief("""{"affected": ["core/kernel/Foo.kt"]}""")
|
||||||
|
assertTrue(original.referencedFiles.contains("core/kernel/Foo.kt"))
|
||||||
|
assertTrue(original.acceptanceCriteria.isEmpty())
|
||||||
|
assertTrue(original.symbols.isEmpty())
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user