595ec187bc
The 'router' name was misleading — it's the always-on conversational front-end
(CHAT triage + STEERING into a running workflow), not a routing layer, and it's
distinct from InferenceRouter. Rename core:router -> core:talkie, package
com.correx.core.router -> com.correx.core.talkie, and RouterFacade/Config/State/
Repository/ContextBuilder/Projector/Reducer/Response -> Talkie*. Config section
[router] -> [talkie] (legacy [router] still read as fallback).
Persisted wire formats are preserved: ChatTurnRole.ROUTER and the
RouterNarrationEvent @SerialName("RouterNarration") stay, so existing event
logs still replay. RouterNarrationEvent the class is now TalkieNarrationEvent.
1024 lines
47 KiB
Kotlin
1024 lines
47 KiB
Kotlin
import com.correx.core.context.model.ContextLayer
|
|
import com.correx.core.context.model.ContextPack
|
|
import com.correx.core.context.model.EntryRole
|
|
import com.correx.core.context.model.TokenBudget
|
|
import com.correx.core.events.events.L3RetrievedHit
|
|
import com.correx.core.events.types.SessionId
|
|
import com.correx.core.events.types.StageId
|
|
import com.correx.core.talkie.DefaultTalkieContextBuilder
|
|
import com.correx.core.talkie.model.TalkieConfig
|
|
import com.correx.core.talkie.model.WorkflowSummary
|
|
import com.correx.core.talkie.model.RouterL2Entry
|
|
import com.correx.core.talkie.model.TalkieState
|
|
import com.correx.core.talkie.model.TalkieTurn
|
|
import com.correx.core.talkie.model.StageOutcomeKind
|
|
import com.correx.core.talkie.model.TurnRole
|
|
import com.correx.core.talkie.model.WorkflowStatus
|
|
import kotlinx.coroutines.runBlocking
|
|
import kotlinx.datetime.Clock
|
|
import kotlinx.datetime.Instant
|
|
import org.junit.jupiter.api.Assertions.assertEquals
|
|
import org.junit.jupiter.api.Assertions.assertFalse
|
|
import org.junit.jupiter.api.Assertions.assertNotNull
|
|
import org.junit.jupiter.api.Assertions.assertNull
|
|
import org.junit.jupiter.api.Assertions.assertTrue
|
|
import org.junit.jupiter.api.Test
|
|
|
|
class TalkieContextBuilderTest {
|
|
|
|
private val config = TalkieConfig(conversationKeepLast = 3, tokenBudget = TokenBudget(limit = 200))
|
|
private val builder = DefaultTalkieContextBuilder(config)
|
|
|
|
private fun buildPack(state: TalkieState, budget: TokenBudget): ContextPack = runBlocking {
|
|
builder.build(state, budget)
|
|
}
|
|
|
|
private val sessionId = SessionId("test-session")
|
|
private val stageId = StageId("stage-1")
|
|
private val clock = Clock.System
|
|
|
|
// --------------------------------------------------------------------------
|
|
// Budget enforcement
|
|
// --------------------------------------------------------------------------
|
|
|
|
@Test
|
|
fun `build fits within budget when all entries are small`() {
|
|
val state = TalkieState(
|
|
sessionId = sessionId,
|
|
workflowStatus = WorkflowStatus.RUNNING,
|
|
currentStageId = stageId,
|
|
conversationHistory = listOf(
|
|
TalkieTurn(TurnRole.USER, "hi", clock.now()),
|
|
),
|
|
)
|
|
val pack = buildPack(state, TokenBudget(limit = 10000))
|
|
assertEquals(10000, pack.budgetLimit)
|
|
assertTrue(pack.budgetUsed <= pack.budgetLimit)
|
|
}
|
|
|
|
@Test
|
|
fun `build drops entries when budget is exceeded`() {
|
|
// Build a state where conversation + L2 entries together exceed the tight budget
|
|
val longContent = "x".repeat(800) // ~400 tokens each
|
|
val state = TalkieState(
|
|
sessionId = sessionId,
|
|
workflowStatus = WorkflowStatus.RUNNING,
|
|
currentStageId = stageId,
|
|
conversationHistory = listOf(
|
|
TalkieTurn(TurnRole.USER, longContent, clock.now()),
|
|
TalkieTurn(TurnRole.ROUTER, longContent, clock.now()),
|
|
TalkieTurn(TurnRole.USER, longContent, clock.now()),
|
|
),
|
|
l2Memory = listOf(
|
|
RouterL2Entry(StageId("s1"), "summary", StageOutcomeKind.SUCCESS, clock.now()),
|
|
),
|
|
)
|
|
val pack = buildPack(state, TokenBudget(limit = 100))
|
|
// L0 entries (system prompt + workflow status) always fit; L1/L2 should be dropped
|
|
assertTrue(pack.compressionMetadata.entriesDropped > 0)
|
|
}
|
|
|
|
@Test
|
|
fun `build drops entries oldest-first for L2 memory when budget forces partial drop`() {
|
|
// Each L2 entry: "Stage sN (SUCCESS): " + 200-char summary ≈ 55 tokens each.
|
|
// Use a config with conversationKeepLast=0 so only L0 + L2 compete for budget.
|
|
val configNoConv = TalkieConfig(conversationKeepLast = 0, tokenBudget = TokenBudget(limit = 10000))
|
|
val builderNoConv = DefaultTalkieContextBuilder(configNoConv)
|
|
val summary = "x".repeat(200) // ~50 tokens; with prefix "Stage sN (SUCCESS): " ~55 tokens total
|
|
val state = TalkieState(
|
|
sessionId = sessionId,
|
|
workflowStatus = WorkflowStatus.RUNNING,
|
|
currentStageId = stageId,
|
|
l2Memory = listOf(
|
|
RouterL2Entry(StageId("s1"), summary, StageOutcomeKind.SUCCESS, Instant.parse("2026-01-01T00:00:00Z")),
|
|
RouterL2Entry(StageId("s2"), summary, StageOutcomeKind.SUCCESS, Instant.parse("2026-01-02T00:00:00Z")),
|
|
RouterL2Entry(StageId("s3"), summary, StageOutcomeKind.SUCCESS, Instant.parse("2026-01-03T00:00:00Z")),
|
|
),
|
|
)
|
|
// Measure the protected frame (L0) and per-entry cost, then size the budget to fit
|
|
// exactly two of three L2 entries — robust to system-prompt length changes.
|
|
val measured = runBlocking { builderNoConv.build(state, TokenBudget(limit = 10000)) }
|
|
val l0Cost = measured.layers[ContextLayer.L0].orEmpty().sumOf { it.tokenEstimate }
|
|
val entryCost = measured.layers[ContextLayer.L2].orEmpty().first().tokenEstimate
|
|
val pack = runBlocking {
|
|
builderNoConv.build(state, TokenBudget(limit = l0Cost + entryCost * 2 + entryCost / 2))
|
|
}
|
|
val l2Entries = pack.layers[ContextLayer.L2] ?: emptyList()
|
|
val retainedIds = l2Entries.map { it.sourceId }.toSet()
|
|
// Newest two survive; oldest is dropped
|
|
assertEquals(2, l2Entries.size, "Only 2 of 3 L2 entries should fit")
|
|
assertFalse(retainedIds.contains("s1"), "Oldest L2 entry (s1) should be dropped first")
|
|
assertTrue(retainedIds.contains("s2"), "Second-newest L2 entry (s2) should be retained")
|
|
assertTrue(retainedIds.contains("s3"), "Newest L2 entry (s3) should be retained")
|
|
assertEquals(1, pack.compressionMetadata.entriesDropped)
|
|
}
|
|
|
|
@Test
|
|
fun `build drops conversation turns oldest-first when conversationKeepLast exceeded`() {
|
|
val configWide = TalkieConfig(conversationKeepLast = 2, tokenBudget = TokenBudget(limit = 10000))
|
|
val builderWide = DefaultTalkieContextBuilder(configWide)
|
|
val longContent = "x".repeat(400)
|
|
val state = TalkieState(
|
|
sessionId = sessionId,
|
|
workflowStatus = WorkflowStatus.RUNNING,
|
|
currentStageId = stageId,
|
|
conversationHistory = listOf(
|
|
TalkieTurn(TurnRole.USER, longContent, Instant.parse("2026-01-01T00:00:00Z")),
|
|
TalkieTurn(TurnRole.ROUTER, longContent, Instant.parse("2026-01-02T00:00:00Z")),
|
|
TalkieTurn(TurnRole.USER, longContent, Instant.parse("2026-01-03T00:00:00Z")),
|
|
TalkieTurn(TurnRole.ROUTER, longContent, Instant.parse("2026-01-04T00:00:00Z")),
|
|
),
|
|
)
|
|
// Budget allows L0 + 2 long conversation turns; oldest 2 turns are dropped.
|
|
// Bumped 500 → 600 → 800 as the triage system prompt grew (now carries the
|
|
// workflow-proposal directive); conversationKeepLast=2 still caps L1 at 2.
|
|
val pack = runBlocking { builderWide.build(state, TokenBudget(limit = 800)) }
|
|
val l1Entries = pack.layers[ContextLayer.L1] ?: emptyList()
|
|
// Only the last 2 turns (oldest-first drop) should remain
|
|
assertEquals(2, l1Entries.size)
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// L0 immutability
|
|
// --------------------------------------------------------------------------
|
|
|
|
@Test
|
|
fun `L0 system prompt is always present`() {
|
|
val state = TalkieState(
|
|
sessionId = sessionId,
|
|
workflowStatus = WorkflowStatus.IDLE,
|
|
currentStageId = null,
|
|
)
|
|
val pack = buildPack(state, TokenBudget(limit = 0))
|
|
val l0Entries = pack.layers[ContextLayer.L0] ?: emptyList()
|
|
assertTrue(l0Entries.any { it.sourceType == "systemPrompt" })
|
|
}
|
|
|
|
@Test
|
|
fun `L0 workflow status is always present`() {
|
|
val state = TalkieState(
|
|
sessionId = sessionId,
|
|
workflowStatus = WorkflowStatus.FAILED,
|
|
currentStageId = StageId("failed-stage"),
|
|
)
|
|
val pack = buildPack(state, TokenBudget(limit = 0))
|
|
val l0Entries = pack.layers[ContextLayer.L0] ?: emptyList()
|
|
assertTrue(l0Entries.any { it.sourceType == "workflowStatus" })
|
|
val workflowEntry = l0Entries.find { it.sourceType == "workflowStatus" }
|
|
assertNotNull(workflowEntry)
|
|
assertTrue(workflowEntry!!.content.contains("failed"))
|
|
}
|
|
|
|
@Test
|
|
fun `L0 entries survive zero budget`() {
|
|
val state = TalkieState(
|
|
sessionId = sessionId,
|
|
workflowStatus = WorkflowStatus.COMPLETED,
|
|
currentStageId = null,
|
|
conversationHistory = emptyList(),
|
|
l2Memory = emptyList(),
|
|
)
|
|
val pack = buildPack(state, TokenBudget(limit = 0))
|
|
val l0Entries = pack.layers[ContextLayer.L0] ?: emptyList()
|
|
assertEquals(2, l0Entries.size) // systemPrompt + workflowStatus
|
|
}
|
|
|
|
@Test
|
|
fun `L0 entries survive budget that cannot cover L1 or L2`() {
|
|
val state = TalkieState(
|
|
sessionId = sessionId,
|
|
workflowStatus = WorkflowStatus.RUNNING,
|
|
currentStageId = stageId,
|
|
conversationHistory = listOf(
|
|
TalkieTurn(TurnRole.USER, "x".repeat(500), clock.now()),
|
|
),
|
|
l2Memory = listOf(
|
|
RouterL2Entry(StageId("s1"), "x".repeat(500), StageOutcomeKind.SUCCESS, clock.now()),
|
|
),
|
|
)
|
|
val pack = buildPack(state, TokenBudget(limit = 50))
|
|
val l0Entries = pack.layers[ContextLayer.L0] ?: emptyList()
|
|
val l2Entries = pack.layers[ContextLayer.L2] ?: emptyList()
|
|
assertEquals(2, l0Entries.size)
|
|
// The current (last) user turn is always protected and present even at budget=50
|
|
assertEquals(1, (pack.layers[ContextLayer.L1] ?: emptyList()).size)
|
|
assertEquals(0, l2Entries.size)
|
|
assertTrue(pack.compressionMetadata.entriesDropped > 0)
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// L2 oldest-first eviction
|
|
// --------------------------------------------------------------------------
|
|
|
|
@Test
|
|
fun `L2 entries are evicted oldest-first and retained in chronological order`() {
|
|
// Each L2 entry: "Stage sN (SUCCESS): " + 100-char summary ≈ 30 tokens each.
|
|
// L0 ~30 tokens; budget 90 leaves ~60. Two entries (~30 each = ~60) fit; oldest dropped.
|
|
val summary = "x".repeat(100) // ~25 tokens; with prefix ~30 tokens total
|
|
val configTight = TalkieConfig(conversationKeepLast = 0, tokenBudget = TokenBudget(limit = 10000))
|
|
val builderTight = DefaultTalkieContextBuilder(configTight)
|
|
val baseTime = Instant.parse("2026-01-01T00:00:00Z")
|
|
val state = TalkieState(
|
|
sessionId = sessionId,
|
|
workflowStatus = WorkflowStatus.RUNNING,
|
|
currentStageId = stageId,
|
|
l2Memory = listOf(
|
|
RouterL2Entry(StageId("s1"), summary, StageOutcomeKind.SUCCESS, baseTime),
|
|
RouterL2Entry(StageId("s2"), summary, StageOutcomeKind.SUCCESS, Instant.parse("2026-01-01T01:00:00Z")),
|
|
RouterL2Entry(StageId("s3"), summary, StageOutcomeKind.SUCCESS, Instant.parse("2026-01-01T02:00:00Z")),
|
|
),
|
|
)
|
|
// Size the budget from the measured protected frame + per-entry cost to fit exactly two
|
|
// of three entries; the third (oldest, s1) is dropped. Robust to system-prompt length.
|
|
val measured = runBlocking { builderTight.build(state, TokenBudget(limit = 10000)) }
|
|
val l0Cost = measured.layers[ContextLayer.L0].orEmpty().sumOf { it.tokenEstimate }
|
|
val entryCost = measured.layers[ContextLayer.L2].orEmpty().first().tokenEstimate
|
|
val pack = runBlocking {
|
|
builderTight.build(state, TokenBudget(limit = l0Cost + entryCost * 2 + entryCost / 2))
|
|
}
|
|
val l2Entries = pack.layers[ContextLayer.L2] ?: emptyList()
|
|
val retainedIds = l2Entries.map { it.sourceId }
|
|
// Oldest entry (s1) should be dropped; s2 and s3 retained in chronological order
|
|
assertFalse(retainedIds.contains("s1"), "Oldest L2 entry (s1) should be dropped first")
|
|
assertTrue(retainedIds.contains("s2"), "Second L2 entry (s2) should be retained")
|
|
assertTrue(retainedIds.contains("s3"), "Newest L2 entry (s3) should be retained")
|
|
// Chronological ordering preserved
|
|
if (retainedIds.size == 2) {
|
|
assertEquals("s2", retainedIds[0], "s2 should appear before s3 in chronological order")
|
|
assertEquals("s3", retainedIds[1], "s3 should appear after s2 in chronological order")
|
|
}
|
|
}
|
|
|
|
@Test
|
|
fun `L2 entries fit within budget are all retained`() {
|
|
val config = TalkieConfig(conversationKeepLast = 0, tokenBudget = TokenBudget(limit = 10000))
|
|
val builder = DefaultTalkieContextBuilder(config)
|
|
val state = TalkieState(
|
|
sessionId = sessionId,
|
|
workflowStatus = WorkflowStatus.RUNNING,
|
|
currentStageId = stageId,
|
|
l2Memory = listOf(
|
|
RouterL2Entry(StageId("s1"), "summary", StageOutcomeKind.SUCCESS, clock.now()),
|
|
RouterL2Entry(StageId("s2"), "summary", StageOutcomeKind.FAILURE, clock.now()),
|
|
),
|
|
)
|
|
val pack = buildPack(state, TokenBudget(limit = 10000))
|
|
val l2Entries = pack.layers[ContextLayer.L2] ?: emptyList()
|
|
assertEquals(2, l2Entries.size)
|
|
assertEquals(0, pack.compressionMetadata.entriesDropped)
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// Layer classification
|
|
// --------------------------------------------------------------------------
|
|
|
|
@Test
|
|
fun `system prompt is classified as L0`() {
|
|
val state = TalkieState(
|
|
sessionId = sessionId,
|
|
workflowStatus = WorkflowStatus.IDLE,
|
|
)
|
|
val pack = buildPack(state, TokenBudget(limit = 10000))
|
|
val l0Entries = pack.layers[ContextLayer.L0]
|
|
assertNotNull(l0Entries)
|
|
assertTrue(l0Entries!!.any { it.sourceType == "systemPrompt" && it.layer == ContextLayer.L0 })
|
|
}
|
|
|
|
@Test
|
|
fun `workflow status is classified as L0`() {
|
|
val state = TalkieState(
|
|
sessionId = sessionId,
|
|
workflowStatus = WorkflowStatus.RUNNING,
|
|
currentStageId = stageId,
|
|
)
|
|
val pack = buildPack(state, TokenBudget(limit = 10000))
|
|
val l0Entries = pack.layers[ContextLayer.L0]
|
|
assertTrue(l0Entries!!.any { it.sourceType == "workflowStatus" && it.layer == ContextLayer.L0 })
|
|
}
|
|
|
|
@Test
|
|
fun `conversation turns are classified as L1`() {
|
|
val state = TalkieState(
|
|
sessionId = sessionId,
|
|
workflowStatus = WorkflowStatus.RUNNING,
|
|
currentStageId = stageId,
|
|
conversationHistory = listOf(
|
|
TalkieTurn(TurnRole.USER, "hello", clock.now()),
|
|
TalkieTurn(TurnRole.ROUTER, "hi", clock.now()),
|
|
),
|
|
)
|
|
val pack = buildPack(state, TokenBudget(limit = 10000))
|
|
val l1Entries = pack.layers[ContextLayer.L1]
|
|
assertNotNull(l1Entries)
|
|
assertEquals(2, l1Entries!!.size)
|
|
assertTrue(l1Entries.all { it.layer == ContextLayer.L1 })
|
|
assertTrue(l1Entries.all { it.sourceType == "conversation" })
|
|
}
|
|
|
|
@Test
|
|
fun `l2 memory entries are classified as L2`() {
|
|
val state = TalkieState(
|
|
sessionId = sessionId,
|
|
workflowStatus = WorkflowStatus.RUNNING,
|
|
currentStageId = stageId,
|
|
l2Memory = listOf(
|
|
RouterL2Entry(StageId("s1"), "summary", StageOutcomeKind.SUCCESS, clock.now()),
|
|
),
|
|
)
|
|
val pack = buildPack(state, TokenBudget(limit = 10000))
|
|
val l2Entries = pack.layers[ContextLayer.L2]
|
|
assertNotNull(l2Entries)
|
|
assertEquals(1, l2Entries!!.size)
|
|
assertTrue(l2Entries.all { it.layer == ContextLayer.L2 })
|
|
assertTrue(l2Entries.all { it.sourceType == "stageSummary" })
|
|
}
|
|
|
|
@Test
|
|
fun `no entries other than L0 L1 L2 appear in pack`() {
|
|
val state = TalkieState(
|
|
sessionId = sessionId,
|
|
workflowStatus = WorkflowStatus.RUNNING,
|
|
currentStageId = stageId,
|
|
conversationHistory = listOf(
|
|
TalkieTurn(TurnRole.USER, "hello", clock.now()),
|
|
),
|
|
l2Memory = listOf(
|
|
RouterL2Entry(StageId("s1"), "summary", StageOutcomeKind.SUCCESS, clock.now()),
|
|
),
|
|
)
|
|
val pack = buildPack(state, TokenBudget(limit = 10000))
|
|
val presentLayers = pack.layers.keys
|
|
assertTrue(presentLayers.containsAll(listOf(ContextLayer.L0, ContextLayer.L1, ContextLayer.L2)))
|
|
assertFalse(presentLayers.contains(ContextLayer.L3))
|
|
assertFalse(presentLayers.contains(ContextLayer.L4))
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// Conversation history capping (conversationKeepLast)
|
|
// --------------------------------------------------------------------------
|
|
|
|
@Test
|
|
fun `conversationHistory respects conversationKeepLast from config`() {
|
|
val config = TalkieConfig(conversationKeepLast = 2, tokenBudget = TokenBudget(limit = 10000))
|
|
val builder = DefaultTalkieContextBuilder(config)
|
|
val state = TalkieState(
|
|
sessionId = sessionId,
|
|
workflowStatus = WorkflowStatus.RUNNING,
|
|
currentStageId = stageId,
|
|
conversationHistory = listOf(
|
|
TalkieTurn(TurnRole.USER, "one", Instant.parse("2026-01-01T00:00:00Z")),
|
|
TalkieTurn(TurnRole.ROUTER, "two", Instant.parse("2026-01-02T00:00:00Z")),
|
|
TalkieTurn(TurnRole.USER, "three", Instant.parse("2026-01-03T00:00:00Z")),
|
|
TalkieTurn(TurnRole.ROUTER, "four", Instant.parse("2026-01-04T00:00:00Z")),
|
|
TalkieTurn(TurnRole.USER, "five", Instant.parse("2026-01-05T00:00:00Z")),
|
|
),
|
|
)
|
|
val pack = runBlocking { builder.build(state, TokenBudget(limit = 10000)) }
|
|
val l1Entries = pack.layers[ContextLayer.L1] ?: emptyList()
|
|
// conversationKeepLast=2, so only the last 2 turns should appear
|
|
assertEquals(2, l1Entries.size)
|
|
}
|
|
|
|
@Test
|
|
fun `conversationHistory takeLast preserves ordering`() {
|
|
val config = TalkieConfig(conversationKeepLast = 3, tokenBudget = TokenBudget(limit = 10000))
|
|
val builder = DefaultTalkieContextBuilder(config)
|
|
val state = TalkieState(
|
|
sessionId = sessionId,
|
|
workflowStatus = WorkflowStatus.RUNNING,
|
|
currentStageId = stageId,
|
|
conversationHistory = listOf(
|
|
TalkieTurn(TurnRole.USER, "first", Instant.parse("2026-01-01T00:00:00Z")),
|
|
TalkieTurn(TurnRole.ROUTER, "second", Instant.parse("2026-01-02T00:00:00Z")),
|
|
TalkieTurn(TurnRole.USER, "third", Instant.parse("2026-01-03T00:00:00Z")),
|
|
TalkieTurn(TurnRole.ROUTER, "fourth", Instant.parse("2026-01-04T00:00:00Z")),
|
|
),
|
|
)
|
|
val pack = buildPack(state, TokenBudget(limit = 10000))
|
|
val l1Entries = pack.layers[ContextLayer.L1] ?: emptyList()
|
|
assertEquals(3, l1Entries.size)
|
|
// The last 3: second, third, fourth
|
|
assertTrue(l1Entries.any { it.content.contains("second") })
|
|
assertTrue(l1Entries.any { it.content.contains("third") })
|
|
assertTrue(l1Entries.any { it.content.contains("fourth") })
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// Empty state
|
|
// --------------------------------------------------------------------------
|
|
|
|
@Test
|
|
fun `build with empty state produces L0 only`() {
|
|
val state = TalkieState()
|
|
val pack = buildPack(state, TokenBudget(limit = 10000))
|
|
val l0Entries = pack.layers[ContextLayer.L0]
|
|
assertNotNull(l0Entries)
|
|
assertEquals(2, l0Entries!!.size)
|
|
assertTrue(pack.layers[ContextLayer.L1].isNullOrEmpty())
|
|
assertTrue(pack.layers[ContextLayer.L2].isNullOrEmpty())
|
|
}
|
|
|
|
@Test
|
|
fun `pack contains correct metadata on empty state`() {
|
|
val state = TalkieState()
|
|
val pack = buildPack(state, TokenBudget(limit = 5000))
|
|
assertEquals(5000, pack.budgetLimit)
|
|
assertEquals(0, pack.compressionMetadata.entriesDropped)
|
|
assertEquals(listOf("L0Immutable", "Conversation"), pack.compressionMetadata.appliedStrategies)
|
|
assertTrue(pack.compressionMetadata.truncatedLayers.isEmpty())
|
|
}
|
|
|
|
@Test
|
|
fun `pack has correct context pack id and session info`() {
|
|
val state = TalkieState(
|
|
sessionId = sessionId,
|
|
workflowStatus = WorkflowStatus.RUNNING,
|
|
currentStageId = stageId,
|
|
)
|
|
val pack = buildPack(state, TokenBudget(limit = 1000))
|
|
assertEquals("test-session-router-pack", pack.id.value)
|
|
assertEquals(sessionId, pack.sessionId)
|
|
assertEquals(stageId, pack.stageId)
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// Content formatting
|
|
// --------------------------------------------------------------------------
|
|
|
|
@Test
|
|
fun `system prompt has expected content`() {
|
|
val state = TalkieState()
|
|
val pack = buildPack(state, TokenBudget(limit = 10000))
|
|
val systemEntry = pack.layers[ContextLayer.L0]?.find { it.sourceType == "systemPrompt" }
|
|
assertNotNull(systemEntry)
|
|
val content = systemEntry!!.content
|
|
assertTrue(content.contains("correx"), "system prompt names the product")
|
|
assertTrue(
|
|
content.contains("workflow", ignoreCase = true),
|
|
"system prompt grounds the router in workflow state",
|
|
)
|
|
}
|
|
|
|
@Test
|
|
fun `workflow status entry contains status and stage`() {
|
|
val state = TalkieState(
|
|
sessionId = sessionId,
|
|
workflowStatus = WorkflowStatus.COMPLETED,
|
|
currentStageId = null,
|
|
)
|
|
val pack = buildPack(state, TokenBudget(limit = 10000))
|
|
val workflowEntry = pack.layers[ContextLayer.L0]?.find { it.sourceType == "workflowStatus" }
|
|
assertNotNull(workflowEntry)
|
|
assertTrue(workflowEntry!!.content.contains("Status: Completed"))
|
|
assertTrue(workflowEntry.content.contains("Stage: none"))
|
|
}
|
|
|
|
@Test
|
|
fun `conversation turn entry is formatted with role prefix`() {
|
|
val state = TalkieState(
|
|
sessionId = sessionId,
|
|
workflowStatus = WorkflowStatus.RUNNING,
|
|
currentStageId = stageId,
|
|
conversationHistory = listOf(
|
|
TalkieTurn(TurnRole.USER, "user message", clock.now()),
|
|
),
|
|
)
|
|
val pack = buildPack(state, TokenBudget(limit = 10000))
|
|
val l1Entries = pack.layers[ContextLayer.L1] ?: emptyList()
|
|
assertEquals(1, l1Entries.size)
|
|
assertEquals("user message", l1Entries[0].content)
|
|
assertEquals(EntryRole.USER, l1Entries[0].role)
|
|
}
|
|
|
|
@Test
|
|
fun `l2 entry is formatted with stage outcome and summary`() {
|
|
val state = TalkieState(
|
|
sessionId = sessionId,
|
|
workflowStatus = WorkflowStatus.RUNNING,
|
|
currentStageId = stageId,
|
|
l2Memory = listOf(
|
|
RouterL2Entry(StageId("stage-x"), "completed with 3 items", StageOutcomeKind.SUCCESS, clock.now()),
|
|
),
|
|
)
|
|
val pack = buildPack(state, TokenBudget(limit = 10000))
|
|
val l2Entries = pack.layers[ContextLayer.L2] ?: emptyList()
|
|
assertEquals(1, l2Entries.size)
|
|
val entry = l2Entries[0]
|
|
assertEquals("stage-x", entry.sourceId)
|
|
assertTrue(entry.content.contains("Stage stage-x"))
|
|
assertTrue(entry.content.contains("SUCCESS"))
|
|
assertTrue(entry.content.contains("completed with 3 items"))
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// Token budget accounting
|
|
// --------------------------------------------------------------------------
|
|
|
|
@Test
|
|
fun `budgetUsed equals sum of tokenEstimate of all retained entries`() {
|
|
val state = TalkieState(
|
|
sessionId = sessionId,
|
|
workflowStatus = WorkflowStatus.RUNNING,
|
|
currentStageId = stageId,
|
|
conversationHistory = listOf(
|
|
TalkieTurn(TurnRole.USER, "hello world", clock.now()),
|
|
),
|
|
l2Memory = listOf(
|
|
RouterL2Entry(StageId("s1"), "summary", StageOutcomeKind.SUCCESS, clock.now()),
|
|
),
|
|
)
|
|
val pack = buildPack(state, TokenBudget(limit = 10000))
|
|
val allEntries = pack.layers.values.flatten()
|
|
val computedSum = allEntries.sumOf { it.tokenEstimate }
|
|
assertEquals(computedSum, pack.budgetUsed)
|
|
}
|
|
|
|
@Test
|
|
fun `compressionMetadata entriesDropped reflects dropped count`() {
|
|
val config = TalkieConfig(conversationKeepLast = 0, tokenBudget = TokenBudget(limit = 10))
|
|
val builder = DefaultTalkieContextBuilder(config)
|
|
val state = TalkieState(
|
|
sessionId = sessionId,
|
|
workflowStatus = WorkflowStatus.RUNNING,
|
|
currentStageId = stageId,
|
|
conversationHistory = listOf(
|
|
TalkieTurn(TurnRole.USER, "x".repeat(200), clock.now()),
|
|
),
|
|
l2Memory = listOf(
|
|
RouterL2Entry(StageId("s1"), "x".repeat(500), StageOutcomeKind.SUCCESS, clock.now()),
|
|
RouterL2Entry(StageId("s2"), "x".repeat(500), StageOutcomeKind.SUCCESS, clock.now()),
|
|
),
|
|
)
|
|
val pack = runBlocking { builder.build(state, TokenBudget(limit = 65)) }
|
|
// L0 consumes ~30 tokens, leaving 35 — both L2 entries (each ~130 tokens) dropped
|
|
// conversationKeepLast=0 but the single user turn is the protected current turn; it fits and is not dropped
|
|
assertEquals(2, pack.compressionMetadata.entriesDropped)
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// Integration: full lifecycle
|
|
// --------------------------------------------------------------------------
|
|
|
|
@Test
|
|
fun `build with full state including all layers`() {
|
|
val config = TalkieConfig(conversationKeepLast = 2, tokenBudget = TokenBudget(limit = 10000))
|
|
val builder = DefaultTalkieContextBuilder(config)
|
|
val state = TalkieState(
|
|
sessionId = sessionId,
|
|
workflowStatus = WorkflowStatus.RUNNING,
|
|
currentStageId = stageId,
|
|
conversationHistory = listOf(
|
|
TalkieTurn(TurnRole.USER, "first turn", Instant.parse("2026-01-01T00:00:00Z")),
|
|
TalkieTurn(TurnRole.ROUTER, "first reply", Instant.parse("2026-01-02T00:00:00Z")),
|
|
TalkieTurn(TurnRole.USER, "second turn", Instant.parse("2026-01-03T00:00:00Z")),
|
|
TalkieTurn(TurnRole.ROUTER, "second reply", Instant.parse("2026-01-04T00:00:00Z")),
|
|
),
|
|
l2Memory = listOf(
|
|
RouterL2Entry(
|
|
StageId("s1"),
|
|
"stage one done",
|
|
StageOutcomeKind.SUCCESS,
|
|
Instant.parse("2026-01-01T10:00:00Z"),
|
|
),
|
|
RouterL2Entry(
|
|
StageId("s2"),
|
|
"stage two failed",
|
|
StageOutcomeKind.FAILURE,
|
|
Instant.parse("2026-01-02T10:00:00Z"),
|
|
),
|
|
),
|
|
)
|
|
val pack = runBlocking { builder.build(state, TokenBudget(limit = 10000)) }
|
|
|
|
// L0: system prompt + workflow status
|
|
val l0 = pack.layers[ContextLayer.L0]!!
|
|
assertEquals(2, l0.size)
|
|
assertEquals("systemPrompt", l0[0].sourceType)
|
|
assertEquals("workflowStatus", l0[1].sourceType)
|
|
|
|
// L1: last 2 conversation turns
|
|
val l1 = pack.layers[ContextLayer.L1]!!
|
|
assertEquals(2, l1.size)
|
|
assertEquals("conversation", l1[0].sourceType)
|
|
|
|
// L2: both stage summaries fit
|
|
val l2 = pack.layers[ContextLayer.L2]!!
|
|
assertEquals(2, l2.size)
|
|
assertEquals("stageSummary", l2[0].sourceType)
|
|
|
|
// Budget and metadata
|
|
assertTrue(pack.budgetUsed > 0)
|
|
assertTrue(pack.budgetUsed <= pack.budgetLimit)
|
|
assertEquals(0, pack.compressionMetadata.entriesDropped)
|
|
}
|
|
|
|
@Test
|
|
fun `build with null sessionId produces unknown pack id`() {
|
|
val state = TalkieState(
|
|
workflowStatus = WorkflowStatus.IDLE,
|
|
)
|
|
val pack = buildPack(state, TokenBudget(limit = 1000))
|
|
assertTrue(pack.id.value.contains("unknown"))
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// L3 memory injection (slice 2b)
|
|
// --------------------------------------------------------------------------
|
|
|
|
@Test
|
|
fun `L3 hits injected into pack ordered by score descending`() {
|
|
val state = TalkieState(
|
|
sessionId = sessionId,
|
|
workflowStatus = WorkflowStatus.RUNNING,
|
|
currentStageId = stageId,
|
|
lastRetrievedMemory = listOf(
|
|
L3RetrievedHit(
|
|
entryId = "entry-low",
|
|
sourceSessionId = SessionId("other"),
|
|
sourceTurnId = "turn-low",
|
|
text = "low relevance memory",
|
|
score = 0.3f,
|
|
),
|
|
L3RetrievedHit(
|
|
entryId = "entry-high",
|
|
sourceSessionId = SessionId("other"),
|
|
sourceTurnId = "turn-high",
|
|
text = "high relevance memory",
|
|
score = 0.9f,
|
|
),
|
|
L3RetrievedHit(
|
|
entryId = "entry-mid",
|
|
sourceSessionId = SessionId("other"),
|
|
sourceTurnId = "turn-mid",
|
|
text = "mid relevance memory",
|
|
score = 0.6f,
|
|
),
|
|
),
|
|
)
|
|
val pack = buildPack(state, TokenBudget(limit = 10000))
|
|
val l3Entries = pack.layers[ContextLayer.L3]
|
|
assertNotNull(l3Entries)
|
|
assertEquals(3, l3Entries!!.size)
|
|
// Verify ordering: highest score first
|
|
assertEquals("entry-high", l3Entries[0].sourceId)
|
|
assertEquals("entry-mid", l3Entries[1].sourceId)
|
|
assertEquals("entry-low", l3Entries[2].sourceId)
|
|
}
|
|
|
|
@Test
|
|
fun `L3 entries carry recalled memory label in content`() {
|
|
val state = TalkieState(
|
|
sessionId = sessionId,
|
|
workflowStatus = WorkflowStatus.RUNNING,
|
|
lastRetrievedMemory = listOf(
|
|
L3RetrievedHit(
|
|
entryId = "e1",
|
|
sourceSessionId = SessionId("other"),
|
|
sourceTurnId = "t1",
|
|
text = "some past knowledge",
|
|
score = 0.8f,
|
|
),
|
|
),
|
|
)
|
|
val pack = buildPack(state, TokenBudget(limit = 10000))
|
|
val l3Entry = pack.layers[ContextLayer.L3]?.first()
|
|
assertNotNull(l3Entry)
|
|
assertTrue(l3Entry!!.content.contains("[recalled memory]"))
|
|
assertTrue(l3Entry.content.contains("some past knowledge"))
|
|
assertEquals(EntryRole.SYSTEM, l3Entry.role)
|
|
assertEquals(ContextLayer.L3, l3Entry.layer)
|
|
assertEquals("recalledMemory", l3Entry.sourceType)
|
|
}
|
|
|
|
@Test
|
|
fun `L3 hits dropped when budget exhausted and counted in entriesDropped`() {
|
|
// Budget tight enough for L0 but not L3 entries with long text
|
|
val longText = "x".repeat(800) // ~200 tokens
|
|
val state = TalkieState(
|
|
sessionId = sessionId,
|
|
workflowStatus = WorkflowStatus.RUNNING,
|
|
lastRetrievedMemory = listOf(
|
|
L3RetrievedHit(
|
|
entryId = "big-entry",
|
|
sourceSessionId = SessionId("other"),
|
|
sourceTurnId = "t1",
|
|
text = longText,
|
|
score = 0.9f,
|
|
),
|
|
),
|
|
)
|
|
// Budget 50: L0 frame ~30 tokens leaves ~20; L3 entry ~200 tokens -> dropped
|
|
val pack = buildPack(state, TokenBudget(limit = 50))
|
|
assertTrue(pack.layers[ContextLayer.L3].isNullOrEmpty())
|
|
assertEquals(1, pack.compressionMetadata.entriesDropped)
|
|
assertTrue(pack.compressionMetadata.truncatedLayers.contains(ContextLayer.L3))
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// Budget priority: protect system + current user turn, drop oldest first
|
|
// --------------------------------------------------------------------------
|
|
|
|
@Test
|
|
fun `system prompt and current user turn survive tight budget`() {
|
|
val longContent = "x".repeat(800) // ~200 tokens each
|
|
val state = TalkieState(
|
|
sessionId = sessionId,
|
|
workflowStatus = WorkflowStatus.RUNNING,
|
|
currentStageId = stageId,
|
|
conversationHistory = listOf(
|
|
TalkieTurn(TurnRole.USER, longContent, Instant.parse("2026-01-01T00:00:00Z"), turnId = "old-turn"),
|
|
TalkieTurn(TurnRole.ROUTER, longContent, Instant.parse("2026-01-02T00:00:00Z"), turnId = "mid-turn"),
|
|
TalkieTurn(TurnRole.USER, "current user input", Instant.parse("2026-01-03T00:00:00Z"), turnId = "current-turn"),
|
|
),
|
|
)
|
|
// Budget 60: L0 frame ~30 tokens -> remaining ~30; current user turn ~4 tokens -> protected.
|
|
// Older turns (each ~200 tokens) cannot fit -> dropped.
|
|
val pack = buildPack(state, TokenBudget(limit = 60))
|
|
val l0 = pack.layers[ContextLayer.L0]
|
|
assertNotNull(l0)
|
|
assertEquals(2, l0!!.size) // system prompt + workflow status always present
|
|
|
|
val l1 = pack.layers[ContextLayer.L1] ?: emptyList()
|
|
// Current user turn ("current user input") must be present
|
|
assertTrue(l1.any { it.content == "current user input" })
|
|
// Older turns dropped due to budget
|
|
assertFalse(l1.any { it.content == longContent })
|
|
assertTrue(pack.compressionMetadata.entriesDropped > 0)
|
|
}
|
|
|
|
@Test
|
|
fun `oldest conversation turn is dropped before newest when budget is tight`() {
|
|
val config3 = TalkieConfig(conversationKeepLast = 3, tokenBudget = TokenBudget(limit = 10000))
|
|
val builder3 = DefaultTalkieContextBuilder(config3)
|
|
val longContent = "x".repeat(800) // ~200 tokens each
|
|
val state = TalkieState(
|
|
sessionId = sessionId,
|
|
workflowStatus = WorkflowStatus.RUNNING,
|
|
currentStageId = stageId,
|
|
conversationHistory = listOf(
|
|
TalkieTurn(TurnRole.USER, "oldest turn $longContent", Instant.parse("2026-01-01T00:00:00Z"), turnId = "t1"),
|
|
TalkieTurn(TurnRole.ROUTER, "middle turn $longContent", Instant.parse("2026-01-02T00:00:00Z"), turnId = "t2"),
|
|
TalkieTurn(TurnRole.USER, "newest turn", Instant.parse("2026-01-03T00:00:00Z"), turnId = "t3"),
|
|
),
|
|
)
|
|
// Budget 100: L0 frame ~30 tokens -> remaining ~70; newest turn ~3 tokens -> protected.
|
|
// Middle turn (~200+) doesn't fit -> dropped. Oldest turn also dropped.
|
|
val pack = runBlocking { builder3.build(state, TokenBudget(limit = 100)) }
|
|
val l1 = pack.layers[ContextLayer.L1] ?: emptyList()
|
|
// Newest turn must survive
|
|
assertTrue(l1.any { it.content == "newest turn" })
|
|
// Oldest turn should be dropped (too large)
|
|
assertFalse(l1.any { it.content.contains("oldest turn") && it.content.contains(longContent) })
|
|
assertTrue(pack.compressionMetadata.entriesDropped > 0)
|
|
assertTrue(pack.compressionMetadata.truncatedLayers.contains(ContextLayer.L1))
|
|
}
|
|
|
|
@Test
|
|
fun `L3 and L2 dropped before recent conversation when budget is tight`() {
|
|
val longContent = "x".repeat(200) // ~50 tokens each
|
|
val state = TalkieState(
|
|
sessionId = sessionId,
|
|
workflowStatus = WorkflowStatus.RUNNING,
|
|
currentStageId = stageId,
|
|
conversationHistory = listOf(
|
|
TalkieTurn(TurnRole.USER, "current user message", clock.now(), turnId = "curr"),
|
|
),
|
|
lastRetrievedMemory = listOf(
|
|
L3RetrievedHit(
|
|
entryId = "l3-entry",
|
|
sourceSessionId = SessionId("other"),
|
|
sourceTurnId = "l3-turn",
|
|
text = longContent,
|
|
score = 0.9f,
|
|
),
|
|
),
|
|
l2Memory = listOf(
|
|
RouterL2Entry(StageId("s1"), longContent, StageOutcomeKind.SUCCESS, clock.now()),
|
|
),
|
|
)
|
|
// Budget 80: L0 frame ~30 -> remaining ~50; current user turn ~5 -> protected, remaining ~45
|
|
// L3 entry ~50 tokens -> just barely doesn't fit (50 > 45) -> dropped
|
|
// L2 entry ~50 tokens -> dropped
|
|
val pack = buildPack(state, TokenBudget(limit = 80))
|
|
val l1 = pack.layers[ContextLayer.L1] ?: emptyList()
|
|
// Current user message must survive
|
|
assertTrue(l1.any { it.content == "current user message" })
|
|
// L3 and L2 must be absent
|
|
assertTrue(pack.layers[ContextLayer.L3].isNullOrEmpty())
|
|
assertTrue(pack.layers[ContextLayer.L2].isNullOrEmpty())
|
|
assertTrue(pack.compressionMetadata.entriesDropped >= 2)
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// A2: availableWorkflows and projectProfileText L0 injection
|
|
// --------------------------------------------------------------------------
|
|
|
|
@Test
|
|
fun `available workflows entry is injected into L0 when non-empty`() {
|
|
val workflows = listOf(WorkflowSummary("healthcheck", "Runs the health check pipeline", listOf("collect", "report")))
|
|
val pack = runBlocking { builder.build(TalkieState(), TokenBudget(limit = 10000), availableWorkflows = workflows) }
|
|
val entry = (pack.layers[ContextLayer.L0] ?: emptyList()).find { it.sourceType == "availableWorkflows" }
|
|
assertNotNull(entry)
|
|
assertTrue(entry!!.content.contains("## Available workflows"))
|
|
assertTrue(entry.content.contains("healthcheck: Runs the health check pipeline (stages: collect → report)"))
|
|
}
|
|
|
|
@Test
|
|
fun `available workflows entry is omitted when list is empty`() {
|
|
val pack = runBlocking { builder.build(TalkieState(), TokenBudget(limit = 10000)) }
|
|
assertNull((pack.layers[ContextLayer.L0] ?: emptyList()).find { it.sourceType == "availableWorkflows" })
|
|
}
|
|
|
|
@Test
|
|
fun `project profile text is injected into L0 when non-null`() {
|
|
val text = "## Project profile\nA Kotlin service.\n### Conventions\n- Use runCatching"
|
|
val pack = runBlocking { builder.build(TalkieState(), TokenBudget(limit = 10000), projectProfileText = text) }
|
|
val entry = (pack.layers[ContextLayer.L0] ?: emptyList()).find { it.sourceType == "projectProfile" }
|
|
assertEquals(text, entry!!.content)
|
|
}
|
|
|
|
@Test
|
|
fun `project profile text is omitted when null or blank`() {
|
|
val pack = runBlocking { builder.build(TalkieState(), TokenBudget(limit = 10000), projectProfileText = " ") }
|
|
assertNull((pack.layers[ContextLayer.L0] ?: emptyList()).find { it.sourceType == "projectProfile" })
|
|
}
|
|
|
|
@Test
|
|
fun `system prompt contains triage directive not bare refusal`() {
|
|
val pack = buildPack(TalkieState(), TokenBudget(limit = 10000))
|
|
val content = pack.layers[ContextLayer.L0]?.find { it.sourceType == "systemPrompt" }!!.content
|
|
assertFalse(content.contains("if something is not in context, say you do not have it"))
|
|
assertTrue(content.contains("Available workflows", ignoreCase = true))
|
|
assertTrue(content.contains("project profile", ignoreCase = true))
|
|
}
|
|
|
|
@Test
|
|
fun `truncatedLayers populated with affected layers when drops occur`() {
|
|
val longText = "x".repeat(800)
|
|
val state = TalkieState(
|
|
sessionId = sessionId,
|
|
workflowStatus = WorkflowStatus.RUNNING,
|
|
currentStageId = stageId,
|
|
conversationHistory = listOf(
|
|
TalkieTurn(TurnRole.USER, longText, clock.now(), turnId = "old"),
|
|
TalkieTurn(TurnRole.USER, "current", clock.now(), turnId = "curr"),
|
|
),
|
|
lastRetrievedMemory = listOf(
|
|
L3RetrievedHit(
|
|
entryId = "l3e",
|
|
sourceSessionId = SessionId("other"),
|
|
sourceTurnId = "lt",
|
|
text = longText,
|
|
score = 0.8f,
|
|
),
|
|
),
|
|
)
|
|
val pack = buildPack(state, TokenBudget(limit = 60))
|
|
val truncated = pack.compressionMetadata.truncatedLayers
|
|
// At least L1 and L3 should be truncated
|
|
assertTrue(truncated.isNotEmpty())
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// N2/B2: protected-frame-overflow — budget smaller than system + current user turn
|
|
// --------------------------------------------------------------------------
|
|
|
|
@Test
|
|
fun `budget overflow - system prompt and current user turn present even when they exceed budgetLimit`() {
|
|
// Budget so tight that the protected frame alone (system prompt + workflow status) exceeds it.
|
|
// The current user turn must still be included; budgetUsed must report the true total;
|
|
// appliedStrategies must contain "BudgetExceeded".
|
|
val longUserTurn = "x".repeat(200) // ~50 tokens
|
|
val state = TalkieState(
|
|
sessionId = sessionId,
|
|
workflowStatus = WorkflowStatus.RUNNING,
|
|
currentStageId = stageId,
|
|
conversationHistory = listOf(
|
|
TalkieTurn(TurnRole.USER, longUserTurn, clock.now(), turnId = "current"),
|
|
),
|
|
)
|
|
// Budget 1: system prompt + workflow status alone exceed this.
|
|
val pack = buildPack(state, TokenBudget(limit = 1))
|
|
|
|
// System prompt must be present
|
|
val l0 = pack.layers[ContextLayer.L0] ?: emptyList()
|
|
assertTrue(l0.any { it.sourceType == "systemPrompt" }, "System prompt must always be present")
|
|
assertTrue(l0.any { it.sourceType == "workflowStatus" }, "Workflow status must always be present")
|
|
|
|
// Current user turn must be present
|
|
val l1 = pack.layers[ContextLayer.L1] ?: emptyList()
|
|
assertTrue(l1.any { it.content == longUserTurn }, "Current user turn must be present even when over budget")
|
|
|
|
// budgetUsed must be the true total (exceeds budgetLimit)
|
|
val allEntries = pack.layers.values.flatten()
|
|
val trueTotal = allEntries.sumOf { it.tokenEstimate }
|
|
assertEquals(trueTotal, pack.budgetUsed, "budgetUsed must report the true token total, not clamped")
|
|
assertTrue(pack.budgetUsed > pack.budgetLimit, "budgetUsed must exceed budgetLimit in overflow scenario")
|
|
|
|
// BudgetExceeded must be signalled in appliedStrategies
|
|
assertTrue(
|
|
pack.compressionMetadata.appliedStrategies.contains("BudgetExceeded"),
|
|
"appliedStrategies must contain 'BudgetExceeded' when frame exceeds budget",
|
|
)
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// S1: conversationKeepLast = 0 must still include the current user turn
|
|
// --------------------------------------------------------------------------
|
|
|
|
@Test
|
|
fun `conversationKeepLast zero still includes the current user turn`() {
|
|
val configZero = TalkieConfig(conversationKeepLast = 0, tokenBudget = TokenBudget(limit = 10000))
|
|
val builderZero = DefaultTalkieContextBuilder(configZero)
|
|
val state = TalkieState(
|
|
sessionId = sessionId,
|
|
workflowStatus = WorkflowStatus.RUNNING,
|
|
currentStageId = stageId,
|
|
conversationHistory = listOf(
|
|
TalkieTurn(TurnRole.USER, "old turn 1", Instant.parse("2026-01-01T00:00:00Z"), turnId = "t1"),
|
|
TalkieTurn(TurnRole.ROUTER, "old reply", Instant.parse("2026-01-02T00:00:00Z"), turnId = "t2"),
|
|
TalkieTurn(TurnRole.USER, "current user input", Instant.parse("2026-01-03T00:00:00Z"), turnId = "t3"),
|
|
),
|
|
)
|
|
val pack = runBlocking { builderZero.build(state, TokenBudget(limit = 10000)) }
|
|
val l1 = pack.layers[ContextLayer.L1] ?: emptyList()
|
|
// conversationKeepLast=0 caps the window to 0 turns, but the protected current turn must appear
|
|
assertTrue(l1.any { it.content == "current user input" }, "Current user turn must be present even with conversationKeepLast=0")
|
|
// The older turns (capped out) must NOT be present
|
|
assertFalse(l1.any { it.content == "old turn 1" }, "Older turns must not appear with conversationKeepLast=0")
|
|
assertFalse(l1.any { it.content == "old reply" }, "Older turns must not appear with conversationKeepLast=0")
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// S4: appliedStrategies includes L3Recall when L3 memory is injected
|
|
// --------------------------------------------------------------------------
|
|
|
|
@Test
|
|
fun `appliedStrategies includes L3Recall when lastRetrievedMemory is non-empty`() {
|
|
val state = TalkieState(
|
|
sessionId = sessionId,
|
|
workflowStatus = WorkflowStatus.RUNNING,
|
|
currentStageId = stageId,
|
|
lastRetrievedMemory = listOf(
|
|
L3RetrievedHit(
|
|
entryId = "e1",
|
|
sourceSessionId = SessionId("other"),
|
|
sourceTurnId = "t1",
|
|
text = "recalled fact",
|
|
score = 0.7f,
|
|
),
|
|
),
|
|
)
|
|
val pack = buildPack(state, TokenBudget(limit = 10000))
|
|
assertTrue(
|
|
pack.compressionMetadata.appliedStrategies.contains("L3Recall"),
|
|
"appliedStrategies must include 'L3Recall' when L3 memory is injected",
|
|
)
|
|
}
|
|
|
|
@Test
|
|
fun `appliedStrategies does not include L3Recall when lastRetrievedMemory is empty`() {
|
|
val state = TalkieState(
|
|
sessionId = sessionId,
|
|
workflowStatus = WorkflowStatus.RUNNING,
|
|
)
|
|
val pack = buildPack(state, TokenBudget(limit = 10000))
|
|
assertFalse(
|
|
pack.compressionMetadata.appliedStrategies.contains("L3Recall"),
|
|
"appliedStrategies must not include 'L3Recall' when no L3 memory is present",
|
|
)
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// N3: ContextPack.layers is sorted by ContextLayer ordinal
|
|
// --------------------------------------------------------------------------
|
|
|
|
@Test
|
|
fun `layers map is sorted by ContextLayer ordinal`() {
|
|
val state = TalkieState(
|
|
sessionId = sessionId,
|
|
workflowStatus = WorkflowStatus.RUNNING,
|
|
currentStageId = stageId,
|
|
conversationHistory = listOf(
|
|
TalkieTurn(TurnRole.USER, "hello", clock.now()),
|
|
),
|
|
l2Memory = listOf(
|
|
RouterL2Entry(StageId("s1"), "summary", StageOutcomeKind.SUCCESS, clock.now()),
|
|
),
|
|
lastRetrievedMemory = listOf(
|
|
L3RetrievedHit(
|
|
entryId = "e1",
|
|
sourceSessionId = SessionId("other"),
|
|
sourceTurnId = "t1",
|
|
text = "recalled",
|
|
score = 0.5f,
|
|
),
|
|
),
|
|
)
|
|
val pack = buildPack(state, TokenBudget(limit = 10000))
|
|
val layerKeys = pack.layers.keys.toList()
|
|
val ordinals = layerKeys.map { it.ordinal }
|
|
assertEquals(ordinals.sorted(), ordinals, "ContextPack.layers must be sorted by ContextLayer ordinal")
|
|
}
|
|
}
|