diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/DefaultSessionOrchestrator.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/DefaultSessionOrchestrator.kt index b1689614..83c7f9cb 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/DefaultSessionOrchestrator.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/DefaultSessionOrchestrator.kt @@ -204,13 +204,12 @@ class DefaultSessionOrchestrator( ) } - return when (val result = enterStage(ctx, nextStageId, effectives)) { - is StepResult.Continue -> StepResult.Continue( - result.ctx.copy( - currentStageId = advanceStage(ctx.sessionId, ctx.currentStageId, decision), - ), - ) - + // Emit the transition before executing the next stage. Otherwise the next stage's + // events (InferenceStarted, artifacts, …) are recorded ahead of the TransitionExecuted + // that marks entering it, so the log shows the stage running before it was entered. + val advancedTo = advanceStage(ctx.sessionId, ctx.currentStageId, decision) + return when (val result = enterStage(ctx.copy(currentStageId = advancedTo), nextStageId, effectives)) { + is StepResult.Continue -> StepResult.Continue(result.ctx) is StepResult.Terminal -> result } } diff --git a/core/router/src/main/kotlin/com/correx/core/router/RouterContextBuilder.kt b/core/router/src/main/kotlin/com/correx/core/router/RouterContextBuilder.kt index 8918544c..b4d60875 100644 --- a/core/router/src/main/kotlin/com/correx/core/router/RouterContextBuilder.kt +++ b/core/router/src/main/kotlin/com/correx/core/router/RouterContextBuilder.kt @@ -300,11 +300,14 @@ class DefaultRouterContextBuilder( layer = ContextLayer.L0, role = EntryRole.SYSTEM, ) + // L1, not L0: PromptRenderer folds every L0 entry into the single system message + // regardless of role, so an L0 user turn would vanish into the system block and the + // request would go out with no user message (the chat template then rejects it). val triggerEntry = buildContextEntry( sourceType = "narrationTrigger", sourceId = trigger.kind, content = trigger.instruction, - layer = ContextLayer.L0, + layer = ContextLayer.L1, role = EntryRole.USER, ) diff --git a/testing/deterministic/src/test/kotlin/RouterNarrationTest.kt b/testing/deterministic/src/test/kotlin/RouterNarrationTest.kt index ae2e6489..b7f893e4 100644 --- a/testing/deterministic/src/test/kotlin/RouterNarrationTest.kt +++ b/testing/deterministic/src/test/kotlin/RouterNarrationTest.kt @@ -15,6 +15,7 @@ import com.correx.core.events.types.StageId import com.correx.core.inference.CapabilityScore import com.correx.core.inference.Embedder import com.correx.core.inference.FinishReason +import com.correx.core.inference.PromptRenderer import com.correx.core.inference.InferenceProvider import com.correx.core.inference.InferenceRequest import com.correx.core.inference.InferenceResponse @@ -207,6 +208,26 @@ class RouterNarrationTest { ) } + @Test + fun `rendered narration prompt carries a user message`(): Unit = runBlocking { + // Regression: the trigger instruction must render as a user turn. When it was filed + // under L0 it folded into the system message, leaving zero user messages and the chat + // template rejected the request ("No user query found in messages"). + val builder = DefaultRouterContextBuilder(RouterConfig()) + val state = RouterState(sessionId = SessionId("s"), workflowStatus = WorkflowStatus.RUNNING) + val instruction = "Tell the user the stage just completed." + val trigger = NarrationTrigger(kind = "stage_completed", instruction = instruction) + val pack = builder.buildNarrationContext(state, trigger, TokenBudget(limit = 5000)) + + val messages = PromptRenderer.render(pack) + val userMessages = messages.filter { it.role == "user" } + assertTrue(userMessages.isNotEmpty(), "narration prompt must contain a user message") + assertTrue( + userMessages.any { it.content.contains(instruction) }, + "the trigger instruction must be the user turn", + ) + } + @Test fun `buildNarrationContext stays within budget`(): Unit = runBlocking { val builder = DefaultRouterContextBuilder(RouterConfig())