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
@@ -17,3 +17,13 @@ data class SessionWorkspaceBoundEvent(
val workspaceRoot: String,
val allowedPaths: List<String>,
) : EventPayload
@Serializable
@SerialName("OperatorProfileBound")
data class OperatorProfileBoundEvent(
val sessionId: SessionId,
val about: String,
val approvalMode: String,
val preferredModels: List<String>,
val conventions: List<String>,
) : EventPayload
@@ -10,6 +10,7 @@ import com.correx.core.events.events.ArtifactValidatingEvent
import com.correx.core.events.events.ChatSessionStartedEvent
import com.correx.core.events.events.ChatTurnEvent
import com.correx.core.events.events.RouterNarrationEvent
import com.correx.core.events.events.OperatorProfileBoundEvent
import com.correx.core.events.events.SessionWorkspaceBoundEvent
import com.correx.core.events.events.ContextTruncatedEvent
import com.correx.core.events.events.EventPayload
@@ -91,6 +92,7 @@ val eventModule = SerializersModule {
subclass(ChatTurnEvent::class)
subclass(RouterNarrationEvent::class)
subclass(SessionWorkspaceBoundEvent::class)
subclass(OperatorProfileBoundEvent::class)
subclass(L3MemoryRetrievedEvent::class)
subclass(ContextTruncatedEvent::class)
subclass(ExecutionPlanLockedEvent::class)
@@ -0,0 +1,56 @@
package com.correx.core.events.serialization
import com.correx.core.events.events.EventPayload
import com.correx.core.events.events.OperatorProfileBoundEvent
import com.correx.core.events.types.SessionId
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class OperatorProfileBoundEventSerializationTest {
private val sample = OperatorProfileBoundEvent(
sessionId = SessionId("sess-1"),
about = "A seasoned backend engineer who prefers concise, idiomatic Kotlin.",
approvalMode = "auto",
preferredModels = listOf("llama-3-70b", "qwen2.5-72b"),
conventions = listOf("no var", "no mutable state"),
)
@Test
fun `round-trips as polymorphic EventPayload`() {
val encoded = eventJson.encodeToString(EventPayload.serializer(), sample)
assertTrue(encoded.contains("\"type\":\"OperatorProfileBound\""), "SerialName must be present: $encoded")
val decoded = eventJson.decodeFromString(EventPayload.serializer(), encoded)
assertEquals(sample, decoded)
}
@Test
fun `decodes hand-written OperatorProfileBound JSON`() {
val json = """
{"type":"OperatorProfileBound","sessionId":"s-99","about":"senior dev","approvalMode":"suggest","preferredModels":["model-a"],"conventions":["no null"]}
""".trimIndent()
val decoded = eventJson.decodeFromString(EventPayload.serializer(), json)
assertTrue(decoded is OperatorProfileBoundEvent)
decoded as OperatorProfileBoundEvent
assertEquals("s-99", decoded.sessionId.value)
assertEquals("senior dev", decoded.about)
assertEquals("suggest", decoded.approvalMode)
assertEquals(listOf("model-a"), decoded.preferredModels)
assertEquals(listOf("no null"), decoded.conventions)
}
@Test
fun `round-trips with empty lists`() {
val empty = OperatorProfileBoundEvent(
sessionId = SessionId("s-0"),
about = "",
approvalMode = "",
preferredModels = emptyList(),
conventions = emptyList(),
)
val encoded = eventJson.encodeToString(EventPayload.serializer(), empty)
val decoded = eventJson.decodeFromString(EventPayload.serializer(), encoded)
assertEquals(empty, decoded)
}
}