diff --git a/core/events/build.gradle b/core/events/build.gradle index d1429deb..37f5b943 100644 --- a/core/events/build.gradle +++ b/core/events/build.gradle @@ -7,5 +7,6 @@ plugins { dependencies { implementation "org.jetbrains.kotlinx:kotlinx-datetime" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlinx_coroutines_version" + testImplementation "org.jetbrains.kotlin:kotlin-test" } tasks.named("koverVerify").configure { enabled = false } diff --git a/core/events/src/main/kotlin/com/correx/core/events/events/ToolCallAssessmentEvents.kt b/core/events/src/main/kotlin/com/correx/core/events/events/ToolCallAssessmentEvents.kt new file mode 100644 index 00000000..278a249c --- /dev/null +++ b/core/events/src/main/kotlin/com/correx/core/events/events/ToolCallAssessmentEvents.kt @@ -0,0 +1,34 @@ +package com.correx.core.events.events + +import com.correx.core.events.risk.RiskAction +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import com.correx.core.events.types.ToolInvocationId +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class AssessedIssue( + val code: String, + val message: String, + val severity: String, +) + +@Serializable +data class ToolCallObservation( + val ruleCode: String, + val facts: Map = emptyMap(), +) + +@Serializable +@SerialName("ToolCallAssessed") +data class ToolCallAssessedEvent( + val invocationId: ToolInvocationId, + val sessionId: SessionId, + val stageId: StageId, + val toolName: String, + val issues: List = emptyList(), + val observations: List = emptyList(), + val disposition: RiskAction, + val timestampMs: Long, +) : EventPayload diff --git a/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt b/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt index 62d82958..5a455d79 100644 --- a/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt +++ b/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt @@ -25,6 +25,7 @@ import com.correx.core.events.events.RiskAssessedEvent import com.correx.core.events.events.StageCompletedEvent import com.correx.core.events.events.StageFailedEvent import com.correx.core.events.events.SteeringNoteAddedEvent +import com.correx.core.events.events.ToolCallAssessedEvent import com.correx.core.events.events.ToolExecutionCompletedEvent import com.correx.core.events.events.ToolExecutionFailedEvent import com.correx.core.events.events.ToolExecutionRejectedEvent @@ -49,6 +50,7 @@ val eventModule = SerializersModule { subclass(ToolExecutionCompletedEvent::class) subclass(ToolExecutionFailedEvent::class) subclass(ToolExecutionRejectedEvent::class) + subclass(ToolCallAssessedEvent::class) subclass(SteeringNoteAddedEvent::class) subclass(StageFailedEvent::class) subclass(StageCompletedEvent::class) diff --git a/core/events/src/test/kotlin/com/correx/core/events/serialization/ToolCallAssessedEventSerializationTest.kt b/core/events/src/test/kotlin/com/correx/core/events/serialization/ToolCallAssessedEventSerializationTest.kt new file mode 100644 index 00000000..8e469e1c --- /dev/null +++ b/core/events/src/test/kotlin/com/correx/core/events/serialization/ToolCallAssessedEventSerializationTest.kt @@ -0,0 +1,51 @@ +package com.correx.core.events.serialization + +import com.correx.core.events.events.AssessedIssue +import com.correx.core.events.events.EventPayload +import com.correx.core.events.events.ToolCallAssessedEvent +import com.correx.core.events.events.ToolCallObservation +import com.correx.core.events.risk.RiskAction +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import com.correx.core.events.types.ToolInvocationId +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class ToolCallAssessedEventSerializationTest { + + private val sample = ToolCallAssessedEvent( + invocationId = ToolInvocationId("inv-1"), + sessionId = SessionId("sess-1"), + stageId = StageId("stage-1"), + toolName = "file_write", + issues = listOf(AssessedIssue("PATH_OUTSIDE_WORKSPACE", "outside: /tmp/x", "WARNING")), + observations = listOf( + ToolCallObservation( + ruleCode = "PATH_CONTAINMENT", + facts = mapOf("path" to "/tmp/x", "exists" to "false", "inWorkspace" to "false", "privileged" to "false"), + ), + ), + disposition = RiskAction.PROMPT_USER, + timestampMs = 42L, + ) + + @Test + fun `round-trips as polymorphic EventPayload`() { + val encoded = eventJson.encodeToString(EventPayload.serializer(), sample) + assertTrue(encoded.contains("\"type\":\"ToolCallAssessed\""), "SerialName must be present: $encoded") + val decoded = eventJson.decodeFromString(EventPayload.serializer(), encoded) + assertEquals(sample, decoded) + } + + @Test + fun `decodes hand-written ToolCallAssessed JSON`() { + val json = """ + {"type":"ToolCallAssessed","invocationId":"inv-9","sessionId":"s","stageId":"st", + "toolName":"shell","issues":[],"observations":[],"disposition":"PROCEED","timestampMs":1} + """.trimIndent() + val decoded = eventJson.decodeFromString(EventPayload.serializer(), json) + assertTrue(decoded is ToolCallAssessedEvent) + assertEquals(RiskAction.PROCEED, (decoded as ToolCallAssessedEvent).disposition) + } +}