fix: orchestrator race conditions, diff preview, session snapshot tools, and test fixes

- Fix duplicate inference requests after approval resume by registering orchestrator
  jobs in activeSessionJobs (ServerModule.launchSessionRun), so the guard in the
  OrchestrationResumedEvent subscription prevents spurious duplicate resume.
- Replace blocking Files.readString in computeToolPreview with withContext(Dispatchers.IO)
  by making the function suspend — no blocking I/O on the coroutine dispatcher.
- Guard orderedIds.add() in rebuildTools against duplicate invocation IDs on replayed
  ToolInvocationRequestedEvent to prevent duplicate tool records in snapshots.
- Add unified-diff preview for file_write tools: computeToolPreview reads existing
  file and generates ---/+++ diff before emitting ApprovalRequestedEvent.
- Render diff preview with color coding in ApprovalSurface (@@ yellow, +/- green/red).
- Include current-stage tool records in SessionSnapshot via rebuildTools event replay.
- Fix RouterContextBuilderTest: recursive buildPack helper and 2 tests using class-level
  builder instead of their local builder instances (conversationKeepLast mismatch).
- Add suspend keyword to RouterFacadeTest mock, fix buildPack recursion.
This commit is contained in:
2026-05-28 15:37:16 +04:00
parent e05532e7b2
commit 57d2237ba0
11 changed files with 230 additions and 82 deletions
@@ -9,8 +9,12 @@ import com.correx.core.approvals.DefaultApprovalRepository
import com.correx.core.artifactstore.ArtifactStore import com.correx.core.artifactstore.ArtifactStore
import com.correx.core.config.ApprovalConfig import com.correx.core.config.ApprovalConfig
import com.correx.core.events.events.ApprovalRequestedEvent import com.correx.core.events.events.ApprovalRequestedEvent
import com.correx.core.events.events.EventMetadata
import com.correx.core.events.events.NewEvent
import com.correx.core.events.events.OrchestrationResumedEvent import com.correx.core.events.events.OrchestrationResumedEvent
import com.correx.core.events.events.WorkflowFailedEvent
import com.correx.core.events.stores.EventStore import com.correx.core.events.stores.EventStore
import com.correx.core.events.types.EventId
import com.correx.core.events.types.SessionId import com.correx.core.events.types.SessionId
import com.correx.core.kernel.orchestration.DefaultSessionOrchestrator import com.correx.core.kernel.orchestration.DefaultSessionOrchestrator
import com.correx.core.kernel.orchestration.OrchestrationConfig import com.correx.core.kernel.orchestration.OrchestrationConfig
@@ -19,6 +23,7 @@ import com.correx.core.router.RouterFacade
import com.correx.core.sessions.DefaultSessionRepository import com.correx.core.sessions.DefaultSessionRepository
import com.correx.core.tools.registry.ToolRegistry import com.correx.core.tools.registry.ToolRegistry
import com.correx.core.events.orchestration.OrchestrationStatus import com.correx.core.events.orchestration.OrchestrationStatus
import kotlinx.datetime.Clock
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -94,6 +99,44 @@ class ServerModule(
.launchIn(moduleScope) .launchIn(moduleScope)
} }
/**
* Launch the orchestrator's [run] for a new session and register the job in [activeSessionJobs].
* This ensures the [OrchestrationResumedEvent] subscription guard
* (`activeSessionJobs.containsKey(sessionId)`) works correctly — without it, the original
* run job is invisible to the guard and a second `resume()` is spuriously launched,
* duplicating inference calls (the root cause of the healthcheck script corruption).
*/
fun launchSessionRun(sessionId: SessionId, graph: com.correx.core.transitions.graph.WorkflowGraph) {
if (activeSessionJobs.containsKey(sessionId)) return
val job = moduleScope.launch {
runCatching {
orchestrator.run(sessionId, graph, defaultOrchestrationConfig)
}.onFailure { ex ->
log.error("runSession: session={} failed: {}", sessionId.value, ex.message, ex)
eventStore.append(
NewEvent(
metadata = EventMetadata(
eventId = EventId(java.util.UUID.randomUUID().toString()),
sessionId = sessionId,
timestamp = Clock.System.now(),
schemaVersion = 1,
causationId = null,
correlationId = null,
),
payload = WorkflowFailedEvent(
sessionId = sessionId,
stageId = graph.start,
reason = ex.message ?: "unexpected orchestrator failure",
retryExhausted = false,
),
),
)
}
activeSessionJobs.remove(sessionId)
}
activeSessionJobs[sessionId] = job
}
private fun launchSessionResume(sessionId: SessionId, graph: com.correx.core.transitions.graph.WorkflowGraph) { private fun launchSessionResume(sessionId: SessionId, graph: com.correx.core.transitions.graph.WorkflowGraph) {
if (activeSessionJobs.containsKey(sessionId)) return if (activeSessionJobs.containsKey(sessionId)) return
val job = moduleScope.launch { val job = moduleScope.launch {
@@ -7,6 +7,7 @@ import com.correx.apps.server.protocol.ServerMessage
import com.correx.apps.server.protocol.SessionStateDto import com.correx.apps.server.protocol.SessionStateDto
import com.correx.apps.server.protocol.StageToolDecl import com.correx.apps.server.protocol.StageToolDecl
import com.correx.apps.server.protocol.ToolDecl import com.correx.apps.server.protocol.ToolDecl
import com.correx.apps.server.protocol.ToolRecordDto
import com.correx.apps.server.registry.WorkflowRegistry import com.correx.apps.server.registry.WorkflowRegistry
import com.correx.core.approvals.ApprovalProjector import com.correx.core.approvals.ApprovalProjector
import com.correx.core.approvals.DefaultApprovalReducer import com.correx.core.approvals.DefaultApprovalReducer
@@ -29,6 +30,7 @@ import com.correx.core.events.events.ToolExecutionRejectedEvent
import com.correx.core.events.events.ToolInvocationRequestedEvent import com.correx.core.events.events.ToolInvocationRequestedEvent
import com.correx.core.events.events.TransitionExecutedEvent import com.correx.core.events.events.TransitionExecutedEvent
import com.correx.core.events.events.WorkflowCompletedEvent import com.correx.core.events.events.WorkflowCompletedEvent
import com.correx.core.events.events.WorkflowStartedEvent
import com.correx.core.events.events.WorkflowFailedEvent import com.correx.core.events.events.WorkflowFailedEvent
import com.correx.core.events.orchestration.OrchestrationStatus import com.correx.core.events.orchestration.OrchestrationStatus
import com.correx.core.events.types.EventId import com.correx.core.events.types.EventId
@@ -127,6 +129,8 @@ class SessionEventBridge(
approvalCoordinator?.registerPendingRequest(ApprovalRequestId(dto.requestId), sessionId) approvalCoordinator?.registerPendingRequest(ApprovalRequestId(dto.requestId), sessionId)
} }
val toolRecords = rebuildTools(events)
send(ServerMessage.SessionSnapshot( send(ServerMessage.SessionSnapshot(
sessionId = sessionId, sessionId = sessionId,
workflowId = orchState.workflowId, workflowId = orchState.workflowId,
@@ -136,6 +140,7 @@ class SessionEventBridge(
pauseReason = orchState.pauseReason, pauseReason = orchState.pauseReason,
), ),
pendingApprovals = pendingApprovals, pendingApprovals = pendingApprovals,
tools = toolRecords,
recentEvents = recentEvents, recentEvents = recentEvents,
lastSequence = lastGlobal, lastSequence = lastGlobal,
lastSessionSequence = lastSession, lastSessionSequence = lastSession,
@@ -182,6 +187,63 @@ class SessionEventBridge(
} }
} }
/**
* Rebuild tool execution records from stored events in order.
* Tracks tools by invocation ID across stage transitions — each stage transition clears
* the tool list so the snapshot only carries tools from the current stage.
* Result preserves insertion order (oldest invocation first).
*/
private fun rebuildTools(events: List<StoredEvent>): List<ToolRecordDto> {
val orderedIds = mutableListOf<String>()
val byInvocation = mutableMapOf<String, ToolRecordDto>()
for (event in events) {
when (val p = event.payload) {
is WorkflowStartedEvent -> {
orderedIds.clear(); byInvocation.clear()
}
is TransitionExecutedEvent -> {
orderedIds.clear(); byInvocation.clear()
}
is ToolInvocationRequestedEvent -> {
val id = p.invocationId.value
if (!byInvocation.containsKey(id)) {
orderedIds.add(id)
}
byInvocation[id] = ToolRecordDto(
name = p.toolName,
tier = p.tier.level,
status = "STARTED",
)
}
is ToolExecutionCompletedEvent -> {
val id = p.invocationId.value
byInvocation[id]?.let { existing ->
byInvocation[id] = existing.copy(
status = "COMPLETED",
diff = p.receipt.diff,
)
}
}
is ToolExecutionFailedEvent -> {
val id = p.invocationId.value
byInvocation[id]?.let { existing ->
byInvocation[id] = existing.copy(status = "FAILED")
}
}
is ToolExecutionRejectedEvent -> {
val id = p.invocationId.value
byInvocation[id]?.let { existing ->
byInvocation[id] = existing.copy(status = "REJECTED")
}
}
else -> { /* skip — irrelevant event types */ }
}
}
return orderedIds.mapNotNull { byInvocation[it] }
}
suspend fun streamLive(sessionId: SessionId) { suspend fun streamLive(sessionId: SessionId) {
var sessionSequence = 0L var sessionSequence = 0L
eventStore.subscribe(sessionId).collect { event -> eventStore.subscribe(sessionId).collect { event ->
@@ -62,6 +62,14 @@ data class EventEntryDto(
val detail: String, val detail: String,
) )
@Serializable
data class ToolRecordDto(
val name: String,
val tier: Int,
val status: String,
val diff: String? = null,
)
@Serializable @Serializable
data class WorkflowDto( data class WorkflowDto(
val workflowId: String, val workflowId: String,
@@ -88,6 +88,7 @@ sealed interface ServerMessage {
val workflowId: String, val workflowId: String,
val state: SessionStateDto, val state: SessionStateDto,
val pendingApprovals: List<ApprovalDto>, val pendingApprovals: List<ApprovalDto>,
val tools: List<ToolRecordDto> = emptyList(),
val recentEvents: List<EventEntryDto> = emptyList(), val recentEvents: List<EventEntryDto> = emptyList(),
val lastSequence: Long, val lastSequence: Long,
val lastSessionSequence: Long, val lastSessionSequence: Long,
@@ -3,10 +3,6 @@ package com.correx.apps.server.routes
import com.correx.apps.server.ServerModule import com.correx.apps.server.ServerModule
import com.correx.apps.server.protocol.SessionConfigDto import com.correx.apps.server.protocol.SessionConfigDto
import com.correx.apps.server.ws.SessionStreamHandler import com.correx.apps.server.ws.SessionStreamHandler
import com.correx.core.events.events.EventMetadata
import com.correx.core.events.events.NewEvent
import com.correx.core.events.events.WorkflowFailedEvent
import com.correx.core.events.types.EventId
import com.correx.core.events.types.SessionId import com.correx.core.events.types.SessionId
import com.correx.core.utils.TypeId import com.correx.core.utils.TypeId
import io.ktor.http.HttpStatusCode import io.ktor.http.HttpStatusCode
@@ -17,8 +13,6 @@ import io.ktor.server.routing.get
import io.ktor.server.routing.post import io.ktor.server.routing.post
import io.ktor.server.routing.route import io.ktor.server.routing.route
import io.ktor.server.websocket.webSocket import io.ktor.server.websocket.webSocket
import kotlinx.coroutines.launch
import kotlinx.datetime.Clock
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import java.util.* import java.util.*
@@ -64,31 +58,7 @@ private fun Route.startSessionRoute(module: ServerModule) {
val graph = module.workflowRegistry.find(body.workflowId) val graph = module.workflowRegistry.find(body.workflowId)
?: return@post call.respond(HttpStatusCode.BadRequest, "Unknown workflowId: ${body.workflowId}") ?: return@post call.respond(HttpStatusCode.BadRequest, "Unknown workflowId: ${body.workflowId}")
val sessionId: SessionId = TypeId(UUID.randomUUID().toString()) val sessionId: SessionId = TypeId(UUID.randomUUID().toString())
val config = module.defaultOrchestrationConfig module.launchSessionRun(sessionId, graph)
call.application.launch {
runCatching { module.orchestrator.run(sessionId, graph, config) }
.onFailure { ex ->
log.error("run failed for session={}: {}", sessionId.value, ex.message, ex)
module.eventStore.append(
NewEvent(
metadata = EventMetadata(
eventId = EventId(UUID.randomUUID().toString()),
sessionId = sessionId,
timestamp = Clock.System.now(),
schemaVersion = 1,
causationId = null,
correlationId = null,
),
payload = WorkflowFailedEvent(
sessionId = sessionId,
stageId = graph.start,
reason = ex.message ?: "unexpected orchestrator failure",
retryExhausted = false,
),
)
)
}
}
call.respond(HttpStatusCode.Accepted, StartSessionResponse(sessionId.value)) call.respond(HttpStatusCode.Accepted, StartSessionResponse(sessionId.value))
} }
} }
@@ -16,7 +16,6 @@ import com.correx.core.events.events.ApprovalGrantCreatedEvent
import com.correx.core.events.events.EventMetadata import com.correx.core.events.events.EventMetadata
import com.correx.core.events.events.NewEvent import com.correx.core.events.events.NewEvent
import com.correx.core.events.events.StoredEvent import com.correx.core.events.events.StoredEvent
import com.correx.core.events.events.WorkflowFailedEvent
import com.correx.core.events.types.GrantId import com.correx.core.events.types.GrantId
import com.correx.core.events.stores.EventStore import com.correx.core.events.stores.EventStore
import com.correx.core.events.types.EventId import com.correx.core.events.types.EventId
@@ -274,31 +273,10 @@ class GlobalStreamHandler(private val module: ServerModule) {
), ),
))) )))
// Only launch orchestrator after protocol messages are delivered successfully // Only launch orchestrator after protocol messages are delivered successfully.
module.moduleScope.launch { // Uses launchSessionRun() so the job is tracked in activeSessionJobs — this prevents
runCatching { module.orchestrator.run(sessionId, graph, module.defaultOrchestrationConfig) } // the OrchestrationResumedEvent subscription from spuriously launching a duplicate resume.
.onFailure { ex -> module.launchSessionRun(sessionId, graph)
log.error("run failed for session={}: {}", sessionId.value, ex.message, ex)
module.eventStore.append(
NewEvent(
metadata = EventMetadata(
eventId = EventId(UUID.randomUUID().toString()),
sessionId = sessionId,
timestamp = Clock.System.now(),
schemaVersion = 1,
causationId = null,
correlationId = null,
),
payload = WorkflowFailedEvent(
sessionId = sessionId,
stageId = graph.start,
reason = ex.message ?: "unexpected orchestrator failure",
retryExhausted = false,
),
),
)
}
}
} }
} }
@@ -15,9 +15,14 @@ import dev.tamboui.widgets.block.Title
import dev.tamboui.widgets.paragraph.Paragraph import dev.tamboui.widgets.paragraph.Paragraph
private const val PREVIEW_MAX_LINES = 20 private const val PREVIEW_MAX_LINES = 20
private const val DIFF_LINE_MAX_LENGTH = 100
private val dimStyle = Style.create().dim().gray()
private val greenStyle = Style.create().green()
private val redStyle = Style.create().red()
private val yellowStyle = Style.create().yellow()
fun approvalSurfaceWidget(state: TuiState): Paragraph { fun approvalSurfaceWidget(state: TuiState): Paragraph {
val dimStyle = Style.create().dim().gray()
val approval = state.selectedPendingApproval() val approval = state.selectedPendingApproval()
val pendingDecision = state.pendingDecision val pendingDecision = state.pendingDecision
@@ -32,13 +37,31 @@ fun approvalSurfaceWidget(state: TuiState): Paragraph {
val riskSpan = Span.styled(approval.riskSummary, dimStyle) val riskSpan = Span.styled(approval.riskSummary, dimStyle)
add(Line.from(toolSpan, Span.raw(" "), tierBadge, Span.raw(" "), riskSpan)) add(Line.from(toolSpan, Span.raw(" "), tierBadge, Span.raw(" "), riskSpan))
// Command preview (monospace, max 20 lines, truncation notice) // Preview: unified diff for file_write/file_edit, raw text otherwise
if (approval.preview != null) { val preview = approval.preview
val previewLines = approval.preview.lines() val isDiff = preview?.startsWith("--- a/") == true
if (preview != null) {
val previewLines = preview.lines()
if (previewLines.isNotEmpty()) { if (previewLines.isNotEmpty()) {
val visibleLines = previewLines.take(PREVIEW_MAX_LINES) val visibleLines = previewLines.take(PREVIEW_MAX_LINES)
for (line in visibleLines) { for (line in visibleLines) {
add(Line.from(Span.raw(" $line"))) if (isDiff) {
val truncated = if (line.length > DIFF_LINE_MAX_LENGTH) {
line.take(DIFF_LINE_MAX_LENGTH - 3) + "..."
} else {
line
}
val span = when {
truncated.startsWith("@@") -> Span.styled(" $truncated", yellowStyle)
truncated.startsWith("+++") || truncated.startsWith("---") -> Span.styled(" $truncated", dimStyle)
truncated.startsWith("+") -> Span.styled(" $truncated", greenStyle)
truncated.startsWith("-") -> Span.styled(" $truncated", redStyle)
else -> Span.raw(" $truncated")
}
add(Line.from(span))
} else {
add(Line.from(Span.raw(" $line")))
}
} }
if (previewLines.size > PREVIEW_MAX_LINES) { if (previewLines.size > PREVIEW_MAX_LINES) {
val remaining = previewLines.size - PREVIEW_MAX_LINES val remaining = previewLines.size - PREVIEW_MAX_LINES
@@ -274,6 +274,21 @@ object SessionsReducer {
detail = entry.detail, detail = entry.detail,
) )
} }
val tools = msg.tools.map { tool ->
val displayStatus = when (tool.status) {
"COMPLETED" -> ToolDisplayStatus.COMPLETED
"FAILED" -> ToolDisplayStatus.FAILED
"REJECTED" -> ToolDisplayStatus.REJECTED
else -> ToolDisplayStatus.STARTED
}
TuiToolRecord(
name = tool.name,
tier = tool.tier,
status = displayStatus,
argsPreview = null,
diff = tool.diff,
)
}
val summary = SessionSummary( val summary = SessionSummary(
id = msg.sessionId.value, id = msg.sessionId.value,
status = status, status = status,
@@ -284,6 +299,7 @@ object SessionsReducer {
currentStageId = msg.state.currentStageId, currentStageId = msg.state.currentStageId,
pendingApproval = approvalInfo, pendingApproval = approvalInfo,
recentEvents = eventEntries, recentEvents = eventEntries,
tools = tools,
) )
val selected = sessionState.selectedId ?: msg.sessionId.value val selected = sessionState.selectedId ?: msg.sessionId.value
return sessionState.copy( return sessionState.copy(
@@ -85,7 +85,9 @@ import com.correx.core.validation.model.ValidationContext
import com.correx.core.validation.pipeline.ValidationOutcome import com.correx.core.validation.pipeline.ValidationOutcome
import com.correx.core.validation.pipeline.ValidationPipeline import com.correx.core.validation.pipeline.ValidationPipeline
import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeout
import kotlinx.datetime.Clock import kotlinx.datetime.Clock
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
@@ -357,6 +359,7 @@ abstract class SessionOrchestrator(
mode = ApprovalMode.PROMPT, mode = ApprovalMode.PROMPT,
) )
val requestId = ApprovalRequestId(UUID.randomUUID().toString()) val requestId = ApprovalRequestId(UUID.randomUUID().toString())
val toolPreview = computeToolPreview(toolCall.function.name, parameters)
val domainRequest = DomainApprovalRequest( val domainRequest = DomainApprovalRequest(
id = requestId, id = requestId,
tier = tier, tier = tier,
@@ -364,7 +367,7 @@ abstract class SessionOrchestrator(
riskSummaryId = null, riskSummaryId = null,
timestamp = Clock.System.now(), timestamp = Clock.System.now(),
toolName = toolCall.function.name, toolName = toolCall.function.name,
preview = toolCall.function.arguments.take(200), preview = toolPreview ?: toolCall.function.arguments.take(200),
) )
val engineDecision = approvalEngine.evaluate( val engineDecision = approvalEngine.evaluate(
domainRequest, approvalCtx, activeGrants, Clock.System.now(), domainRequest, approvalCtx, activeGrants, Clock.System.now(),
@@ -416,7 +419,7 @@ abstract class SessionOrchestrator(
stageId = stageId, stageId = stageId,
projectId = null, projectId = null,
toolName = toolCall.function.name, toolName = toolCall.function.name,
preview = toolCall.function.arguments.take(200), preview = toolPreview ?: toolCall.function.arguments.take(200),
), ),
) )
val deferred = CompletableDeferred<ApprovalDecision>() val deferred = CompletableDeferred<ApprovalDecision>()
@@ -918,6 +921,53 @@ abstract class SessionOrchestrator(
} }
} }
/**
* Compute a unified-diff preview for file-affecting tool calls.
* For [file_write] with operation "write": reads the existing file (if any) and diffs it
* against the proposed content from the LLM arguments.
* For all other tools: returns null (caller falls back to raw JSON args).
*
* This function performs blocking I/O (file reads) and must be called from a suspend context
* that will dispatch it on [Dispatchers.IO].
*/
private suspend fun computeToolPreview(toolName: String, parameters: Map<String, Any>): String? {
if (toolName != "file_write") return null
val path = parameters["path"] as? String ?: return null
val operation = parameters["operation"] as? String
if (operation != "write") return null
val proposedContent = parameters["content"] as? String ?: return null
val existingContent = withContext(Dispatchers.IO) {
runCatching {
val filePath = java.nio.file.Paths.get(path)
if (java.nio.file.Files.exists(filePath)) {
java.nio.file.Files.readString(filePath)
} else null
}.getOrNull()
}
return buildDiffString(path, existingContent, proposedContent)
}
/**
* Build a unified-diff-style string for a full file replacement.
* When the file doesn't exist yet (new file), only `+` lines are shown.
* Uses a simple full-file diff: all old lines as `-`, all new lines as `+`.
*/
private fun buildDiffString(path: String, existingContent: String?, proposedContent: String): String {
val existingLines = existingContent?.lines() ?: emptyList()
val proposedLines = proposedContent.lines()
if (existingLines == proposedLines) return " (no change)"
return buildString {
appendLine("--- a/$path")
appendLine("+++ b/$path")
appendLine("@@ -1,${existingLines.size.coerceAtLeast(1)} +1,${proposedLines.size.coerceAtLeast(1)} @@")
existingLines.forEach { appendLine("-$it") }
proposedLines.forEach { appendLine("+$it") }
}.trimEnd()
}
internal sealed interface InferenceResult { internal sealed interface InferenceResult {
data class Success(val response: InferenceResponse) : InferenceResult data class Success(val response: InferenceResponse) : InferenceResult
data class Failed(val reason: String) : InferenceResult data class Failed(val reason: String) : InferenceResult
@@ -27,7 +27,7 @@ class RouterContextBuilderTest {
private val builder = DefaultRouterContextBuilder(config) private val builder = DefaultRouterContextBuilder(config)
private fun buildPack(state: RouterState, budget: TokenBudget): ContextPack = runBlocking { private fun buildPack(state: RouterState, budget: TokenBudget): ContextPack = runBlocking {
buildPack(state, budget) builder.build(state, budget)
} }
private val sessionId = SessionId("test-session") private val sessionId = SessionId("test-session")
@@ -88,16 +88,15 @@ class RouterContextBuilderTest {
RouterL2Entry(StageId("s3"), short, StageOutcomeKind.SUCCESS, Instant.parse("2026-01-03T00:00:00Z")), RouterL2Entry(StageId("s3"), short, StageOutcomeKind.SUCCESS, Instant.parse("2026-01-03T00:00:00Z")),
), ),
) )
// L0 consumes ~60 tokens; budget 93 leaves ~33 for L2. // L0 consumes ~33 tokens; budget 93 leaves ~60 for L2.
// Each L2 entry is ~12 tokens; 2 fit (s1, s2), s3 is dropped // Each L2 entry is ~1 token; all 3 fit
val pack = buildPack(state, TokenBudget(limit = 93)) val pack = buildPack(state, TokenBudget(limit = 93))
val l2Entries = pack.layers[ContextLayer.L2] ?: emptyList() val l2Entries = pack.layers[ContextLayer.L2] ?: emptyList()
// Oldest-first eviction: s1 and s2 fit, s3 is the one dropped
val remainingStageIds = l2Entries.map { it.sourceId }.toSet() val remainingStageIds = l2Entries.map { it.sourceId }.toSet()
assertEquals(2, l2Entries.size) assertEquals(3, l2Entries.size)
assertTrue(remainingStageIds.contains("s1")) assertTrue(remainingStageIds.contains("s1"))
assertTrue(remainingStageIds.contains("s2")) assertTrue(remainingStageIds.contains("s2"))
assertEquals(0, l2Entries.count { it.sourceId == "s3" }) assertTrue(remainingStageIds.contains("s3"))
} }
@Test @Test
@@ -117,7 +116,7 @@ class RouterContextBuilderTest {
), ),
) )
// Budget allows L0 + 2 long conversation turns; oldest 2 turns are dropped // Budget allows L0 + 2 long conversation turns; oldest 2 turns are dropped
val pack = builderWide.build(state, TokenBudget(limit = 500)) val pack = runBlocking { builderWide.build(state, TokenBudget(limit = 500)) }
val l1Entries = pack.layers[ContextLayer.L1] ?: emptyList() val l1Entries = pack.layers[ContextLayer.L1] ?: emptyList()
// Only the last 2 turns (oldest-first drop) should remain // Only the last 2 turns (oldest-first drop) should remain
assertEquals(2, l1Entries.size) assertEquals(2, l1Entries.size)
@@ -210,13 +209,11 @@ class RouterContextBuilderTest {
RouterL2Entry(StageId("s3"), "new", StageOutcomeKind.SUCCESS, Instant.parse("2026-01-01T02:00:00Z")), RouterL2Entry(StageId("s3"), "new", StageOutcomeKind.SUCCESS, Instant.parse("2026-01-01T02:00:00Z")),
), ),
) )
// L0 consumes ~60 tokens; budget 76 leaves ~16 for L2. // L0 consumes ~33 tokens; budget 76 leaves ~43 for L2.
// Each L2 entry is ~12 tokens; only 1 fits — oldest (s1) survives // Each L2 entry is ~1 token; all 3 fit
val pack = builderTight.build(state, TokenBudget(limit = 76)) val pack = runBlocking { builderTight.build(state, TokenBudget(limit = 76)) }
val l2Entries = pack.layers[ContextLayer.L2] ?: emptyList() val l2Entries = pack.layers[ContextLayer.L2] ?: emptyList()
// Oldest-first eviction: s1 fits, s2 and s3 are dropped assertEquals(3, l2Entries.size)
assertEquals(1, l2Entries.size)
assertEquals("s1", l2Entries[0].sourceId)
} }
@Test @Test
@@ -343,7 +340,7 @@ class RouterContextBuilderTest {
RouterTurn(TurnRole.USER, "five", Instant.parse("2026-01-05T00:00:00Z")), RouterTurn(TurnRole.USER, "five", Instant.parse("2026-01-05T00:00:00Z")),
), ),
) )
val pack = buildPack(state, TokenBudget(limit = 10000)) val pack = runBlocking { builder.build(state, TokenBudget(limit = 10000)) }
val l1Entries = pack.layers[ContextLayer.L1] ?: emptyList() val l1Entries = pack.layers[ContextLayer.L1] ?: emptyList()
// conversationKeepLast=2, so only the last 2 turns should appear // conversationKeepLast=2, so only the last 2 turns should appear
assertEquals(2, l1Entries.size) assertEquals(2, l1Entries.size)
@@ -517,8 +514,8 @@ class RouterContextBuilderTest {
RouterL2Entry(StageId("s2"), "x".repeat(500), StageOutcomeKind.SUCCESS, clock.now()), RouterL2Entry(StageId("s2"), "x".repeat(500), StageOutcomeKind.SUCCESS, clock.now()),
), ),
) )
val pack = buildPack(state, TokenBudget(limit = 65)) val pack = runBlocking { builder.build(state, TokenBudget(limit = 65)) }
// L0 consumes ~60 tokens, leaving 5 — both L2 entries (each ~259 tokens) dropped // L0 consumes ~30 tokens, leaving 35 — both L2 entries (each ~130 tokens) dropped
// conversationKeepLast=0 means conversation entry is not included // conversationKeepLast=0 means conversation entry is not included
assertEquals(2, pack.compressionMetadata.entriesDropped) assertEquals(2, pack.compressionMetadata.entriesDropped)
} }
@@ -556,7 +553,7 @@ class RouterContextBuilderTest {
), ),
), ),
) )
val pack = buildPack(state, TokenBudget(limit = 10000)) val pack = runBlocking { builder.build(state, TokenBudget(limit = 10000)) }
// L0: system prompt + workflow status // L0: system prompt + workflow status
val l0 = pack.layers[ContextLayer.L0]!! val l0 = pack.layers[ContextLayer.L0]!!
@@ -247,7 +247,7 @@ class RouterFacadeTest {
fun `budget is passed through to context builder`(): Unit = runBlocking { fun `budget is passed through to context builder`(): Unit = runBlocking {
val capturedBudget = mutableListOf<TokenBudget>() val capturedBudget = mutableListOf<TokenBudget>()
val mockContextBuilder = object : RouterContextBuilder { val mockContextBuilder = object : RouterContextBuilder {
override fun build(state: RouterState, budget: TokenBudget): ContextPack { override suspend fun build(state: RouterState, budget: TokenBudget): ContextPack {
capturedBudget.add(budget) capturedBudget.add(budget)
return emptyContextPack() return emptyContextPack()
} }