feat(talkie): ground narration in the stage's actual tool actions

Narration was generic ("Stage X completed. Summarise.") because nothing about
what the stage actually did reached the narrator — the L2 summary is a
placeholder and StageCompletedEvent carries no output. narrate() now reads the
stage's ToolInvocationRequested events from the log and feeds the narrator a
concrete 'Actions taken this stage: file_read(path), ...' entry, and the system
prompt asks it to prefer those concrete actions over restating the stage name.
This commit is contained in:
2026-07-03 13:40:00 +04:00
parent 0c8a7e88f6
commit 1c027e8189
3 changed files with 115 additions and 9 deletions
@@ -6,6 +6,10 @@ import com.correx.core.events.events.EventMetadata
import com.correx.core.events.events.NewEvent
import com.correx.core.events.events.TalkieNarrationEvent
import com.correx.core.events.events.StoredEvent
import com.correx.core.events.events.ToolInvocationRequestedEvent
import com.correx.core.events.events.ToolRequest
import com.correx.core.events.types.ToolInvocationId
import com.correx.core.approvals.Tier
import com.correx.core.events.stores.EventStore
import com.correx.core.events.types.ContextPackId
import com.correx.core.events.types.EventId
@@ -78,6 +82,62 @@ class TalkieNarrationTest {
assertEquals("stage_completed", event.trigger)
}
@Test
fun `narrate context carries the stage's concrete tool actions`(): Unit = runBlocking {
val eventStore = MapBackedEventStore()
val stage = StageId("s1") // matches buildFacade's currentStageId
val session = SessionId("sess-activity")
eventStore.append(
NewEvent(
metadata = EventMetadata(
eventId = EventId("e1"), sessionId = session,
timestamp = kotlinx.datetime.Clock.System.now(), schemaVersion = 1,
causationId = null, correlationId = null,
),
payload = ToolInvocationRequestedEvent(
invocationId = ToolInvocationId("inv-1"), sessionId = session, stageId = stage,
toolName = "file_read", tier = Tier.T1,
request = ToolRequest(
invocationId = ToolInvocationId("inv-1"), sessionId = session, stageId = stage,
toolName = "file_read", parameters = mapOf("path" to "apps/server/ServerModule.kt"),
),
),
),
)
var captured: InferenceRequest? = null
val facade = DefaultTalkieFacade(
routerRepository = object : TalkieRepository {
override suspend fun getTalkieState(sessionId: SessionId) =
TalkieState(sessionId = sessionId, workflowStatus = WorkflowStatus.RUNNING, currentStageId = stage)
},
routerContextBuilder = DefaultTalkieContextBuilder(TalkieConfig()),
inferenceRouter = object : InferenceRouter {
override suspend fun route(stageId: StageId, requiredCapabilities: Set<ModelCapability>) =
object : InferenceProvider {
override val id = ProviderId("mock"); override val name = "Mock"
override val tokenizer = MockTokenizer()
override suspend fun infer(request: InferenceRequest): InferenceResponse {
captured = request
return InferenceResponse(request.requestId, "line", FinishReason.Stop, TokenUsage(1, 1), 1L)
}
override suspend fun healthCheck() = ProviderHealth.Healthy
override fun capabilities() = setOf(CapabilityScore(ModelCapability.General, 1.0))
}
},
eventStore = eventStore,
config = TalkieConfig(tokenBudget = TokenBudget(limit = 5000)),
embedder = NoopEmbedder(dimension = 8),
l3MemoryStore = InMemoryL3MemoryStore(),
)
facade.narrate(session, NarrationTrigger(kind = "stage_completed", instruction = "Summarise.", stageId = "s1"))
val contents = captured!!.contextPack.layers.values.flatten().map { it.content }
val activity = contents.firstOrNull { "file_read" in it }
assertNotNull(activity, "narration context must include the stage's tool actions; got: $contents")
assertTrue("apps/server/ServerModule.kt" in activity!!, "activity must name the file: $activity")
}
@Test
fun `narrate does not emit ChatTurnEvent`(): Unit = runBlocking {
val eventStore = MapBackedEventStore()
@@ -143,7 +203,7 @@ class TalkieNarrationTest {
},
routerContextBuilder = object : TalkieContextBuilder {
override suspend fun build(state: TalkieState, budget: TokenBudget, availableWorkflows: List<WorkflowSummary>, projectProfileText: String?): ContextPack = emptyContextPack()
override suspend fun buildNarrationContext(state: TalkieState, trigger: NarrationTrigger, budget: TokenBudget): ContextPack = emptyContextPack()
override suspend fun buildNarrationContext(state: TalkieState, trigger: NarrationTrigger, budget: TokenBudget, recentActivity: String?): ContextPack = emptyContextPack()
},
inferenceRouter = mockInferenceRouter("stage narration", latencyMs = 10L, tokensUsed = TokenUsage(1, 1)),
eventStore = eventStore,