feat: FileWrittenEvent + reduce file mutations onto ToolState

This commit is contained in:
2026-05-31 10:28:01 +04:00
parent 7ad29c3e01
commit 0e688f3daf
7 changed files with 202 additions and 0 deletions
@@ -1,5 +1,6 @@
package com.correx.core.tools
import com.correx.core.events.events.FileWrittenEvent
import com.correx.core.events.events.StoredEvent
import com.correx.core.events.events.ToolCallAssessedEvent
import com.correx.core.events.events.ToolExecutionCompletedEvent
@@ -8,6 +9,7 @@ import com.correx.core.events.events.ToolExecutionRejectedEvent
import com.correx.core.events.events.ToolExecutionStartedEvent
import com.correx.core.events.events.ToolInvocationRequestedEvent
import com.correx.core.events.types.ToolInvocationId
import com.correx.core.tools.state.FileMutationRecord
import com.correx.core.tools.state.ToolCallAssessmentRecord
import com.correx.core.tools.state.ToolInvocationRecord
import com.correx.core.tools.state.ToolInvocationStatus
@@ -51,6 +53,17 @@ class DefaultToolReducer : ToolReducer {
),
)
}
is FileWrittenEvent -> state.updateRecord(p.invocationId) {
it.copy(
mutations = it.mutations + FileMutationRecord(
path = p.path,
preImageHash = p.preImageHash,
postImageHash = p.postImageHash,
preExisted = p.preExisted,
mode = p.mode,
),
)
}
else -> state
}
}
@@ -0,0 +1,16 @@
package com.correx.core.tools.state
import kotlinx.serialization.Serializable
/**
* Projection of a [com.correx.core.events.events.FileWrittenEvent] — one file
* mutation by a tool invocation, with the CAS hashes needed to reverse it.
*/
@Serializable
data class FileMutationRecord(
val path: String,
val preImageHash: String? = null,
val postImageHash: String? = null,
val preExisted: Boolean,
val mode: String = "0644",
)
@@ -18,4 +18,5 @@ data class ToolInvocationRecord(
val requestedAt: Instant,
val completedAt: Instant? = null,
val assessment: ToolCallAssessmentRecord? = null,
val mutations: List<FileMutationRecord> = emptyList(),
)
@@ -0,0 +1,98 @@
package com.correx.core.tools
import com.correx.core.approvals.Tier
import com.correx.core.events.events.EventMetadata
import com.correx.core.events.events.EventPayload
import com.correx.core.events.events.FileWrittenEvent
import com.correx.core.events.events.StoredEvent
import com.correx.core.events.events.ToolInvocationRequestedEvent
import com.correx.core.events.events.ToolRequest
import com.correx.core.events.types.EventId
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.core.events.types.ToolInvocationId
import com.correx.core.tools.state.ToolState
import kotlinx.datetime.Clock
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class FileMutationReducerTest {
private val reducer = DefaultToolReducer()
private val inv = ToolInvocationId("inv-1")
private val session = SessionId("s-1")
private val stage = StageId("st-1")
private fun stored(payload: EventPayload, seq: Long) = StoredEvent(
metadata = EventMetadata(
eventId = EventId("e-$seq"),
sessionId = session,
timestamp = Clock.System.now(),
schemaVersion = 1,
causationId = null,
correlationId = null,
),
sequence = seq,
sessionSequence = seq,
payload = payload,
)
private val requested = ToolInvocationRequestedEvent(
invocationId = inv,
sessionId = session,
stageId = stage,
toolName = "file_write",
tier = Tier.T2,
request = ToolRequest(inv, session, stage, "file_write", mapOf("path" to "src/A.kt")),
)
@Test
fun `file mutations accumulate onto the matching invocation record`() {
var state = ToolState()
state = reducer.reduce(state, stored(requested, 0))
state = reducer.reduce(
state,
stored(
FileWrittenEvent(
invocationId = inv, sessionId = session, path = "src/A.kt",
preImageHash = "old", postImageHash = "new", preExisted = true, mode = "0644", timestampMs = 1L,
),
1,
),
)
state = reducer.reduce(
state,
stored(
FileWrittenEvent(
invocationId = inv, sessionId = session, path = "src/B.kt",
preImageHash = null, postImageHash = "fresh", preExisted = false, mode = "0644", timestampMs = 2L,
),
2,
),
)
val mutations = state.invocations.single().mutations
assertEquals(2, mutations.size)
assertEquals("src/A.kt", mutations[0].path)
assertEquals("old", mutations[0].preImageHash)
assertTrue(mutations[0].preExisted)
assertEquals("src/B.kt", mutations[1].path)
assertEquals(null, mutations[1].preImageHash)
assertEquals(false, mutations[1].preExisted)
}
@Test
fun `file mutation for unknown invocation is a no-op`() {
val state = reducer.reduce(
ToolState(),
stored(
FileWrittenEvent(
invocationId = inv, sessionId = session, path = "x", preExisted = false, timestampMs = 0L,
),
0,
),
)
assertEquals(0, state.invocations.size)
}
}