feat(events,sessions): ProjectProfileBoundEvent reduced into session state

This commit is contained in:
2026-06-10 21:47:30 +04:00
parent 5eda64949f
commit 4389163c5c
7 changed files with 120 additions and 0 deletions
@@ -27,3 +27,13 @@ data class OperatorProfileBoundEvent(
val preferredModels: List<String>, val preferredModels: List<String>,
val conventions: List<String>, val conventions: List<String>,
) : EventPayload ) : EventPayload
@Serializable
@SerialName("ProjectProfileBound")
data class ProjectProfileBoundEvent(
val sessionId: SessionId,
val workspaceRoot: String,
val about: String,
val conventions: List<String>,
val commands: Map<String, String>,
) : EventPayload
@@ -12,6 +12,7 @@ import com.correx.core.events.events.ChatSessionStartedEvent
import com.correx.core.events.events.ChatTurnEvent import com.correx.core.events.events.ChatTurnEvent
import com.correx.core.events.events.RouterNarrationEvent import com.correx.core.events.events.RouterNarrationEvent
import com.correx.core.events.events.OperatorProfileBoundEvent import com.correx.core.events.events.OperatorProfileBoundEvent
import com.correx.core.events.events.ProjectProfileBoundEvent
import com.correx.core.events.events.SessionWorkspaceBoundEvent import com.correx.core.events.events.SessionWorkspaceBoundEvent
import com.correx.core.events.events.ContextTruncatedEvent import com.correx.core.events.events.ContextTruncatedEvent
import com.correx.core.events.events.EventPayload import com.correx.core.events.events.EventPayload
@@ -100,6 +101,7 @@ val eventModule = SerializersModule {
subclass(RouterNarrationEvent::class) subclass(RouterNarrationEvent::class)
subclass(SessionWorkspaceBoundEvent::class) subclass(SessionWorkspaceBoundEvent::class)
subclass(OperatorProfileBoundEvent::class) subclass(OperatorProfileBoundEvent::class)
subclass(ProjectProfileBoundEvent::class)
subclass(L3MemoryRetrievedEvent::class) subclass(L3MemoryRetrievedEvent::class)
subclass(ContextTruncatedEvent::class) subclass(ContextTruncatedEvent::class)
subclass(ExecutionPlanLockedEvent::class) subclass(ExecutionPlanLockedEvent::class)
@@ -0,0 +1,40 @@
package com.correx.core.events.serialization
import com.correx.core.events.events.EventPayload
import com.correx.core.events.events.ProjectProfileBoundEvent
import com.correx.core.events.types.SessionId
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class ProjectProfileBoundEventSerializationTest {
@Test
fun `ProjectProfileBoundEvent round-trips as polymorphic EventPayload`() {
val sample: EventPayload = ProjectProfileBoundEvent(
sessionId = SessionId("s1"),
workspaceRoot = "/repo",
about = "Event-sourced orchestration kernel",
conventions = listOf("no bare try-catch", "events are the only source of truth"),
commands = mapOf("build" to "./gradlew build", "test" to "./gradlew check"),
)
val encoded = eventJson.encodeToString(EventPayload.serializer(), sample)
assertTrue(encoded.contains("\"type\":\"ProjectProfileBound\""), "SerialName must be present: $encoded")
val decoded = eventJson.decodeFromString(EventPayload.serializer(), encoded)
assertEquals(sample, decoded)
}
@Test
fun `round-trips with empty fields`() {
val sample: EventPayload = ProjectProfileBoundEvent(
sessionId = SessionId("s0"),
workspaceRoot = "/empty",
about = "",
conventions = emptyList(),
commands = emptyMap(),
)
val encoded = eventJson.encodeToString(EventPayload.serializer(), sample)
val decoded = eventJson.decodeFromString(EventPayload.serializer(), encoded)
assertEquals(sample, decoded)
}
}
@@ -0,0 +1,7 @@
package com.correx.core.sessions
data class BoundProjectProfile(
val about: String,
val conventions: List<String>,
val commands: Map<String, String>,
)
@@ -1,6 +1,7 @@
package com.correx.core.sessions package com.correx.core.sessions
import com.correx.core.events.events.OperatorProfileBoundEvent import com.correx.core.events.events.OperatorProfileBoundEvent
import com.correx.core.events.events.ProjectProfileBoundEvent
import com.correx.core.events.events.SessionWorkspaceBoundEvent import com.correx.core.events.events.SessionWorkspaceBoundEvent
import com.correx.core.events.events.StageCompletedEvent import com.correx.core.events.events.StageCompletedEvent
import com.correx.core.events.events.StageFailedEvent import com.correx.core.events.events.StageFailedEvent
@@ -51,12 +52,22 @@ class DefaultSessionReducer : SessionReducer {
else -> state.boundProfile else -> state.boundProfile
} }
val boundProjectProfile = when (payload) {
is ProjectProfileBoundEvent -> BoundProjectProfile(
about = payload.about,
conventions = payload.conventions,
commands = payload.commands,
)
else -> state.boundProjectProfile
}
return state.copy( return state.copy(
status = newStatus, status = newStatus,
createdAt = createdAt, createdAt = createdAt,
updatedAt = event.metadata.timestamp, updatedAt = event.metadata.timestamp,
boundWorkspace = boundWorkspace, boundWorkspace = boundWorkspace,
boundProfile = boundProfile, boundProfile = boundProfile,
boundProjectProfile = boundProjectProfile,
) )
} }
} }
@@ -9,4 +9,5 @@ data class SessionState(
val invalidTransitions: Int = 0, val invalidTransitions: Int = 0,
val boundWorkspace: BoundWorkspace? = null, val boundWorkspace: BoundWorkspace? = null,
val boundProfile: BoundProfile? = null, val boundProfile: BoundProfile? = null,
val boundProjectProfile: BoundProjectProfile? = null,
) )
@@ -10,6 +10,8 @@ import com.correx.core.events.events.WorkflowStartedEvent
import com.correx.core.events.types.SessionId import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId import com.correx.core.events.types.StageId
import com.correx.core.events.types.TransitionId import com.correx.core.events.types.TransitionId
import com.correx.core.events.events.ProjectProfileBoundEvent
import com.correx.core.sessions.BoundProjectProfile
import com.correx.core.sessions.BoundWorkspace import com.correx.core.sessions.BoundWorkspace
import com.correx.core.sessions.DefaultSessionReducer import com.correx.core.sessions.DefaultSessionReducer
import com.correx.core.sessions.SessionState import com.correx.core.sessions.SessionState
@@ -278,6 +280,53 @@ class DefaultSessionReducerTest {
) )
} }
@Test
fun `ProjectProfileBoundEvent reduces into boundProjectProfile`() {
val result = reducer.reduce(
state = initialState(),
event = stored(
sessionId = sessionId,
payload = ProjectProfileBoundEvent(
sessionId = sessionId,
workspaceRoot = "/repo",
about = "kernel project",
conventions = listOf("no bare try-catch"),
commands = mapOf("test" to "./gradlew check"),
),
),
)
assertEquals(
BoundProjectProfile(
about = "kernel project",
conventions = listOf("no bare try-catch"),
commands = mapOf("test" to "./gradlew check"),
),
result.boundProjectProfile,
)
}
@Test
fun `boundProjectProfile is preserved by subsequent unrelated events`() {
val withProfile = initialState().copy(
boundProjectProfile = BoundProjectProfile(
about = "kernel project",
conventions = listOf("no bare try-catch"),
commands = emptyMap(),
),
)
val result = reducer.reduce(
state = withProfile,
event = stored(
sessionId = sessionId,
payload = WorkflowStartedEvent(sessionId, workflowId = "wf", startStageId = StageId("s1")),
),
)
assertEquals("kernel project", result.boundProjectProfile?.about)
}
private fun initialState() = private fun initialState() =
SessionState( SessionState(
status = SessionStatus.CREATED status = SessionStatus.CREATED