From ad3ec1965d1126e8226653b283877c17590ed911 Mon Sep 17 00:00:00 2001 From: kami Date: Wed, 3 Jun 2026 22:38:12 +0400 Subject: [PATCH] fix(router,kernel): narration user turn + stage transition ordering Two issues surfaced during live QA once workflows began progressing past the first stage: 1. Narration prompts went out with no user message, so the chat template rejected them ("No user query found in messages"). buildNarrationContext filed the trigger instruction (a user turn) under ContextLayer.L0, and PromptRenderer folds every L0 entry into the system message regardless of role. Move the trigger to L1 so it renders as the user turn. 2. TransitionExecutedEvent was emitted after the next stage had already run: executeMove called enterStage (which executes the stage) before advanceStage (which emits the transition), so the event log showed a stage running before the event marking entry into it. Emit the transition before entering. Adds a regression test asserting a rendered narration prompt carries a user message. --- .../DefaultSessionOrchestrator.kt | 13 ++++++------ .../core/router/RouterContextBuilder.kt | 5 ++++- .../src/test/kotlin/RouterNarrationTest.kt | 21 +++++++++++++++++++ 3 files changed, 31 insertions(+), 8 deletions(-) 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())