fix(kernel): llm-emitted artifacts require stored content — close phantom path

emitLlmArtifacts previously emitted ArtifactCreated/Validating/Validated for every
llm-emitted slot unconditionally, fabricating "validated" artifacts when the model
produced no content. Gate now mirrors the F-015 file_written check: slots with null
or blank cached content are skipped with a warn log. Regression test added in
NeedsArtifactInjectionTest verifies no artifact events appear for a blank-response stage.
This commit is contained in:
2026-06-10 21:20:28 +04:00
parent 113f403f5a
commit fba2bbb17e
2 changed files with 73 additions and 0 deletions
@@ -48,6 +48,8 @@ import com.correx.testing.fixtures.cyclePolicyMissingValidator
import com.correx.testing.fixtures.inference.MockInferenceProvider
import com.correx.testing.fixtures.transitions.TransitionFixtures
import com.correx.testing.kernel.MockSessionEventReplayer
import com.correx.core.events.events.ArtifactCreatedEvent
import com.correx.core.events.events.ArtifactValidatedEvent
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
@@ -231,4 +233,64 @@ class NeedsArtifactInjectionTest {
val needsEntries = capturedEntries.filter { it.sourceType == "neededArtifact" }
assertTrue(needsEntries.isEmpty(), "Missing cache content should produce no neededArtifact entries")
}
/**
* Regression test for the phantom-artifact bug: when an llm-emitted slot's inference
* response is blank, emitLlmArtifacts must NOT emit ArtifactCreatedEvent or
* ArtifactValidatedEvent. Only a slot with non-blank cached content should produce events.
*/
@Test
fun `blank inference output does not emit artifact events for llm-emitted slot`(): Unit = runBlocking {
val sessionId = SessionId("phantom-artifact-test-1")
val stageA = StageId("A")
val stageB = StageId("B")
val blankArtifactId = ArtifactId("blank_output")
val blankKind = ConfigArtifactKind(
id = "blank_output",
schema = JsonSchema(type = "object", properties = emptyMap()),
llmEmitted = true,
)
val graph = WorkflowGraph(
id = "phantom-artifact-graph",
stages = mapOf(
stageA to StageConfig(
produces = listOf(TypedArtifactSlot(name = blankArtifactId, kind = blankKind)),
),
stageB to StageConfig(),
),
transitions = setOf(
TransitionEdge(id = TransitionId("t1"), from = stageA, to = stageB, condition = { true }),
),
start = stageA,
)
// Stage A returns blank/whitespace — content cache will not be populated
val router = object : com.correx.core.inference.InferenceRouter {
override suspend fun route(
stageId: StageId,
requiredCapabilities: Set<ModelCapability>,
) = MockInferenceProvider(fixedResponse = " ")
}
buildOrchestrator(router).run(sessionId, graph, defaultConfig)
val events = eventStore.read(sessionId)
val createdForBlank = events.filter { e ->
(e.payload as? ArtifactCreatedEvent)?.artifactId == blankArtifactId
}
val validatedForBlank = events.filter { e ->
(e.payload as? ArtifactValidatedEvent)?.artifactId == blankArtifactId
}
assertTrue(
createdForBlank.isEmpty(),
"ArtifactCreatedEvent must not be emitted when inference output is blank",
)
assertTrue(
validatedForBlank.isEmpty(),
"ArtifactValidatedEvent must not be emitted when inference output is blank",
)
}
}