feat(personalization): OperatorProfileBoundEvent + bind-at-start + inject about context

Records operator profile as a snapshot event at session start (invariants #8/#9),
populates SessionState.boundProfile via reducer, and injects the about text as a
pinned L0 SYSTEM context entry in every stage. Gated by PersonalizationConfig.enabled.
This commit is contained in:
2026-06-08 10:38:47 +04:00
parent 76b19e2f63
commit 0c7fa70f8d
10 changed files with 221 additions and 1 deletions
@@ -0,0 +1,8 @@
package com.correx.core.sessions
data class BoundProfile(
val about: String,
val approvalMode: String,
val preferredModels: List<String>,
val conventions: List<String>,
)
@@ -1,5 +1,6 @@
package com.correx.core.sessions
import com.correx.core.events.events.OperatorProfileBoundEvent
import com.correx.core.events.events.SessionWorkspaceBoundEvent
import com.correx.core.events.events.StageCompletedEvent
import com.correx.core.events.events.StageFailedEvent
@@ -39,11 +40,23 @@ class DefaultSessionReducer : SessionReducer {
else -> state.boundWorkspace
}
val boundProfile = when (payload) {
is OperatorProfileBoundEvent ->
BoundProfile(
about = payload.about,
approvalMode = payload.approvalMode,
preferredModels = payload.preferredModels,
conventions = payload.conventions,
)
else -> state.boundProfile
}
return state.copy(
status = newStatus,
createdAt = createdAt,
updatedAt = event.metadata.timestamp,
boundWorkspace = boundWorkspace,
boundProfile = boundProfile,
)
}
}
@@ -8,4 +8,5 @@ data class SessionState(
val updatedAt: Instant? = null,
val invalidTransitions: Int = 0,
val boundWorkspace: BoundWorkspace? = null,
val boundProfile: BoundProfile? = null,
)
@@ -0,0 +1,82 @@
package com.correx.core.sessions
import com.correx.core.events.events.EventMetadata
import com.correx.core.events.events.OperatorProfileBoundEvent
import com.correx.core.events.events.SessionWorkspaceBoundEvent
import com.correx.core.events.events.StoredEvent
import com.correx.core.events.types.EventId
import com.correx.core.events.types.SessionId
import kotlinx.datetime.Instant
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test
class DefaultSessionReducerTest {
private val reducer = DefaultSessionReducer()
private val sessionId = SessionId("s-test")
private val initialState = SessionState(status = SessionStatus.ACTIVE)
private fun stored(payload: com.correx.core.events.events.EventPayload) = StoredEvent(
metadata = EventMetadata(
eventId = EventId("e-1"),
sessionId = sessionId,
timestamp = Instant.parse("2026-01-01T00:00:00Z"),
schemaVersion = 1,
causationId = null,
correlationId = null,
),
sequence = 1L,
sessionSequence = 1L,
payload = payload,
)
@Test
fun `OperatorProfileBoundEvent populates boundProfile`() {
val event = stored(
OperatorProfileBoundEvent(
sessionId = sessionId,
about = "Expert Kotlin engineer",
approvalMode = "auto",
preferredModels = listOf("model-x"),
conventions = listOf("no var"),
),
)
val result = reducer.reduce(initialState, event)
val profile = result.boundProfile
assertEquals("Expert Kotlin engineer", profile?.about)
assertEquals("auto", profile?.approvalMode)
assertEquals(listOf("model-x"), profile?.preferredModels)
assertEquals(listOf("no var"), profile?.conventions)
}
@Test
fun `boundProfile is null by default`() {
assertNull(initialState.boundProfile)
}
@Test
fun `unrelated event leaves boundProfile unchanged`() {
val state = initialState.copy(
boundProfile = BoundProfile(
about = "original",
approvalMode = "",
preferredModels = emptyList(),
conventions = emptyList(),
),
)
val event = stored(
SessionWorkspaceBoundEvent(
sessionId = sessionId,
workspaceRoot = "/ws",
allowedPaths = listOf("/ws"),
),
)
val result = reducer.reduce(state, event)
assertEquals("original", result.boundProfile?.about)
}
}