Files
correx/testing/deterministic/src/test/kotlin/PromptRendererOrderingTest.kt
T
kami 07819ec82d fix(inference): fold non-L0 SYSTEM entries into the leading system message
Strict chat templates (Qwen3.5) reject any system message after index 0.
Recalled L3 memory, L2 stage summaries, and retrieval entries carry
EntryRole.SYSTEM and rendered as standalone mid-conversation system turns,
making llama-server 400 on every router turn once L3 recall returned hits.
2026-06-11 21:55:28 +04:00

96 lines
4.3 KiB
Kotlin

import com.correx.core.context.builder.DefaultContextPackBuilder
import com.correx.core.context.compression.DefaultContextCompressor
import com.correx.core.context.model.ContextEntry
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.types.ContextEntryId
import com.correx.core.events.types.ContextPackId
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.core.inference.PromptRenderer
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class PromptRendererOrderingTest {
private val sessionId = SessionId("s1")
private val stageId = StageId("stage-1")
private fun entry(id: String, layer: ContextLayer, role: EntryRole, sourceType: String) = ContextEntry(
id = ContextEntryId(id),
layer = layer,
content = id,
sourceType = sourceType,
sourceId = id,
tokenEstimate = 10,
role = role,
)
@Test
fun `stage tool loop renders chronologically with the task before tool turns`() {
// The builder stamps ordinals from input order; the renderer must honour them so the
// model sees: system, task, assistant call, tool result, assistant call, tool result.
val builder = DefaultContextPackBuilder(DefaultContextCompressor())
val entries = listOf(
entry("sys", ContextLayer.L0, EntryRole.SYSTEM, "systemPrompt"),
entry("task", ContextLayer.L1, EntryRole.USER, "agentPrompt"),
entry("call1", ContextLayer.L2, EntryRole.ASSISTANT, "assistantToolCall"),
entry("result1", ContextLayer.L2, EntryRole.TOOL, "toolResult"),
entry("call2", ContextLayer.L2, EntryRole.ASSISTANT, "assistantToolCall"),
entry("result2", ContextLayer.L2, EntryRole.TOOL, "toolResult"),
)
val pack = builder.build(ContextPackId("p"), sessionId, stageId, entries, TokenBudget(limit = 4000))
val messages = PromptRenderer.render(pack)
assertEquals(
listOf("system" to "sys", "user" to "task", "assistant" to "call1",
"tool" to "result1", "assistant" to "call2", "tool" to "result2"),
messages.map { it.role to it.content },
)
}
@Test
fun `without ordinals the live user turn still renders last (router chat fallback)`() {
// Packs built outside DefaultContextPackBuilder (router chat) leave ordinal at 0;
// the renderer falls back to layer priority so L1 (the user query) lands last.
val pack = ContextPack(
id = ContextPackId("p"),
sessionId = sessionId,
stageId = stageId,
layers = mapOf(
ContextLayer.L1 to listOf(entry("question", ContextLayer.L1, EntryRole.USER, "chat")),
ContextLayer.L2 to listOf(entry("memory", ContextLayer.L2, EntryRole.SYSTEM, "l2")),
),
budgetUsed = 20,
budgetLimit = 4000,
)
val messages = PromptRenderer.render(pack)
assertEquals(listOf("system" to "memory", "user" to "question"), messages.map { it.role to it.content })
}
@Test
fun `non-L0 SYSTEM entries fold into the single leading system message`() {
// Strict chat templates (Qwen) 400 on any system message after index 0. Recalled L3
// memory and L2 summaries carry EntryRole.SYSTEM and must merge into the system block.
val pack = ContextPack(
id = ContextPackId("p"),
sessionId = sessionId,
stageId = stageId,
layers = mapOf(
ContextLayer.L0 to listOf(entry("sys", ContextLayer.L0, EntryRole.SYSTEM, "systemPrompt")),
ContextLayer.L1 to listOf(entry("question", ContextLayer.L1, EntryRole.USER, "chat")),
ContextLayer.L3 to listOf(entry("recalled", ContextLayer.L3, EntryRole.SYSTEM, "recalledMemory")),
),
budgetUsed = 30,
budgetLimit = 4000,
)
val messages = PromptRenderer.render(pack)
assertEquals(
listOf("system" to "sys\n\nrecalled", "user" to "question"),
messages.map { it.role to it.content },
)
}
}