feat(router): rubber-duck idea capture + feed
The rubber-duck path emits a fenced {"ideas":[…]} block; Ideas.parse strips it
from the chat turn and onUserInput records an IdeaCapturedEvent each (CHAT only).
IdeaReader folds eventStore.allEvents() into the active cross-session board, and
DefaultRouterContextBuilder injects a capped "## Ideas" entry — router-only, so
workflow stages never see it. Extended SYSTEM_PROMPT option (d) to capture ideas.
This commit is contained in:
@@ -0,0 +1,37 @@
|
|||||||
|
package com.correx.core.router
|
||||||
|
|
||||||
|
import com.correx.core.events.events.IdeaCapturedEvent
|
||||||
|
import com.correx.core.events.events.IdeaDiscardedEvent
|
||||||
|
import com.correx.core.events.stores.EventStore
|
||||||
|
import com.correx.core.events.types.SessionId
|
||||||
|
import com.correx.core.router.model.Idea
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rebuilds the operator's idea board from the event log. Cross-session by design: it folds over
|
||||||
|
* [EventStore.allEvents] rather than a single session's replay, so an idea captured in one session
|
||||||
|
* shows on the board (and feeds the router) in every session. A captured idea is dropped once a
|
||||||
|
* matching [IdeaDiscardedEvent] tombstones it (invariant #1 — the capture stays in the log).
|
||||||
|
*/
|
||||||
|
class IdeaReader(private val eventStore: EventStore) {
|
||||||
|
|
||||||
|
/** Active ideas (captured, not later discarded) across all sessions, newest first. */
|
||||||
|
fun activeIdeas(): List<Idea> {
|
||||||
|
val discarded = mutableSetOf<String>()
|
||||||
|
val captured = mutableListOf<Idea>()
|
||||||
|
eventStore.allEvents().forEach { stored ->
|
||||||
|
when (val payload = stored.payload) {
|
||||||
|
is IdeaDiscardedEvent -> discarded += payload.ideaId
|
||||||
|
is IdeaCapturedEvent -> captured += Idea(payload.ideaId, payload.text, payload.timestampMs)
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return captured.filterNot { it.id in discarded }.sortedByDescending { it.capturedAtMs }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The session that captured [ideaId], so a discard tombstone lands alongside its capture. */
|
||||||
|
fun sessionOf(ideaId: String): SessionId? =
|
||||||
|
eventStore.allEvents()
|
||||||
|
.mapNotNull { it.payload as? IdeaCapturedEvent }
|
||||||
|
.firstOrNull { it.ideaId == ideaId }
|
||||||
|
?.sessionId
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package com.correx.core.router
|
||||||
|
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import kotlinx.serialization.json.contentOrNull
|
||||||
|
import kotlinx.serialization.json.jsonArray
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses an optional `ideas` block out of a router chat reply: the rubber-duck path may end its prose
|
||||||
|
* with a fenced ```` ```json {"ideas":["…","…"]} ``` ```` block to save ideas that crystallized.
|
||||||
|
* Lenient and fence-tolerant — scans every fenced block for the one carrying an `ideas` array, accepts
|
||||||
|
* string items or `{text|idea}` objects, and any parse failure or empty array yields no ideas. Returns
|
||||||
|
* the reply with that block stripped (so the operator reads only prose), mirroring [WorkflowProposals].
|
||||||
|
*/
|
||||||
|
object Ideas {
|
||||||
|
|
||||||
|
data class Parsed(val cleanText: String, val ideas: List<String>)
|
||||||
|
|
||||||
|
private val json = Json { ignoreUnknownKeys = true; isLenient = true }
|
||||||
|
private val fence = Regex("```(?:json)?\\s*(.*?)```", RegexOption.DOT_MATCHES_ALL)
|
||||||
|
|
||||||
|
fun parse(content: String): Parsed = runCatching {
|
||||||
|
fence.findAll(content).firstNotNullOfOrNull { match ->
|
||||||
|
val obj = runCatching { json.parseToJsonElement(match.groupValues[1].trim()).jsonObject }.getOrNull()
|
||||||
|
val array = obj?.get("ideas")?.jsonArray ?: return@firstNotNullOfOrNull null
|
||||||
|
val ideas = array.mapNotNull { element ->
|
||||||
|
when (element) {
|
||||||
|
is JsonObject -> (element["text"] ?: element["idea"])?.jsonPrimitive?.contentOrNull
|
||||||
|
is JsonPrimitive -> element.contentOrNull
|
||||||
|
else -> null
|
||||||
|
}?.trim()?.ifBlank { null }
|
||||||
|
}
|
||||||
|
ideas.takeIf { it.isNotEmpty() }?.let { Parsed(content.removeRange(match.range).trim(), it) }
|
||||||
|
} ?: Parsed(cleanText = content, ideas = emptyList())
|
||||||
|
}.getOrElse { Parsed(cleanText = content, ideas = emptyList()) }
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import com.correx.core.events.types.ContextPackId
|
|||||||
import com.correx.core.events.types.SessionId
|
import com.correx.core.events.types.SessionId
|
||||||
import com.correx.core.events.types.StageId
|
import com.correx.core.events.types.StageId
|
||||||
import com.correx.core.inference.Tokenizer
|
import com.correx.core.inference.Tokenizer
|
||||||
|
import com.correx.core.router.model.Idea
|
||||||
import com.correx.core.router.model.NarrationTrigger
|
import com.correx.core.router.model.NarrationTrigger
|
||||||
import com.correx.core.router.model.RouterConfig
|
import com.correx.core.router.model.RouterConfig
|
||||||
import com.correx.core.router.model.RouterL2Entry
|
import com.correx.core.router.model.RouterL2Entry
|
||||||
@@ -44,6 +45,10 @@ interface RouterContextBuilder {
|
|||||||
class DefaultRouterContextBuilder(
|
class DefaultRouterContextBuilder(
|
||||||
private val config: RouterConfig,
|
private val config: RouterConfig,
|
||||||
private val tokenizer: Tokenizer? = null,
|
private val tokenizer: Tokenizer? = null,
|
||||||
|
// Cross-session ideas to surface to the router (router-only context). Read fresh each build so
|
||||||
|
// newly-captured ideas appear on the next turn; injected via the constructor so the build()
|
||||||
|
// interface — and its many test doubles — stay unchanged.
|
||||||
|
private val ideaProvider: () -> List<Idea> = { emptyList() },
|
||||||
) : RouterContextBuilder {
|
) : RouterContextBuilder {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@@ -73,6 +78,15 @@ class DefaultRouterContextBuilder(
|
|||||||
point at the relevant conventions or commands;
|
point at the relevant conventions or commands;
|
||||||
(c) if recalled cross-session memory shows similar prior work, say so and reference it;
|
(c) if recalled cross-session memory shows similar prior work, say so and reference it;
|
||||||
(d) otherwise offer to think the problem through together in chat.
|
(d) otherwise offer to think the problem through together in chat.
|
||||||
|
|
||||||
|
Rubber-duck capture: while thinking a problem through in chat, when a concrete idea or
|
||||||
|
decision crystallizes, capture it by ending your reply with a fenced json block:
|
||||||
|
```json
|
||||||
|
{"ideas":["<one idea per item, in the operator's framing>"]}
|
||||||
|
```
|
||||||
|
These are saved to the operator's idea board (also shown back to you under "## Ideas").
|
||||||
|
Capture only genuinely new, concrete ideas — omit the block when nothing crystallized,
|
||||||
|
and never re-capture an idea already under "## Ideas".
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
|
|
||||||
private val NARRATION_SYSTEM_PROMPT =
|
private val NARRATION_SYSTEM_PROMPT =
|
||||||
@@ -86,6 +100,9 @@ class DefaultRouterContextBuilder(
|
|||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
|
|
||||||
private const val RECALLED_MEMORY_PREFIX = "[recalled memory]"
|
private const val RECALLED_MEMORY_PREFIX = "[recalled memory]"
|
||||||
|
|
||||||
|
/** Cap on ideas folded into router context, newest-first, so the board can't balloon L0. */
|
||||||
|
private const val MAX_IDEAS_IN_CONTEXT = 10
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun build(
|
override suspend fun build(
|
||||||
@@ -131,10 +148,22 @@ class DefaultRouterContextBuilder(
|
|||||||
role = EntryRole.SYSTEM,
|
role = EntryRole.SYSTEM,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
val ideasEntry: ContextEntry? = ideaProvider()
|
||||||
|
.takeIf { it.isNotEmpty() }
|
||||||
|
?.let {
|
||||||
|
buildContextEntry(
|
||||||
|
sourceType = "ideas",
|
||||||
|
sourceId = "operator-ideas",
|
||||||
|
content = buildIdeasContent(it),
|
||||||
|
layer = ContextLayer.L0,
|
||||||
|
role = EntryRole.SYSTEM,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Compute remaining budget after protected frame.
|
// Compute remaining budget after protected frame.
|
||||||
val protectedTokens = systemPrompt.tokenEstimate + workflowStatusEntry.tokenEstimate +
|
val protectedTokens = systemPrompt.tokenEstimate + workflowStatusEntry.tokenEstimate +
|
||||||
(availableWorkflowsEntry?.tokenEstimate ?: 0) + (projectProfileEntry?.tokenEstimate ?: 0)
|
(availableWorkflowsEntry?.tokenEstimate ?: 0) + (projectProfileEntry?.tokenEstimate ?: 0) +
|
||||||
|
(ideasEntry?.tokenEstimate ?: 0)
|
||||||
var remainingBudget = (budget.limit - protectedTokens).coerceAtLeast(0)
|
var remainingBudget = (budget.limit - protectedTokens).coerceAtLeast(0)
|
||||||
|
|
||||||
val allEntries = mutableListOf<ContextEntry>()
|
val allEntries = mutableListOf<ContextEntry>()
|
||||||
@@ -142,6 +171,7 @@ class DefaultRouterContextBuilder(
|
|||||||
allEntries += workflowStatusEntry
|
allEntries += workflowStatusEntry
|
||||||
availableWorkflowsEntry?.let { allEntries += it }
|
availableWorkflowsEntry?.let { allEntries += it }
|
||||||
projectProfileEntry?.let { allEntries += it }
|
projectProfileEntry?.let { allEntries += it }
|
||||||
|
ideasEntry?.let { allEntries += it }
|
||||||
|
|
||||||
var droppedCount = 0
|
var droppedCount = 0
|
||||||
val truncatedLayers = mutableSetOf<ContextLayer>()
|
val truncatedLayers = mutableSetOf<ContextLayer>()
|
||||||
@@ -316,6 +346,14 @@ class DefaultRouterContextBuilder(
|
|||||||
}
|
}
|
||||||
}.trimEnd()
|
}.trimEnd()
|
||||||
|
|
||||||
|
private fun buildIdeasContent(ideas: List<Idea>): String =
|
||||||
|
buildString {
|
||||||
|
append("## Ideas\n")
|
||||||
|
append("Ideas the operator captured while thinking out loud. Reference them when relevant; ")
|
||||||
|
append("they are notes, not instructions.\n")
|
||||||
|
ideas.take(MAX_IDEAS_IN_CONTEXT).forEach { append("- ${it.text}\n") }
|
||||||
|
}.trimEnd()
|
||||||
|
|
||||||
private suspend fun buildContextEntry(
|
private suspend fun buildContextEntry(
|
||||||
sourceType: String,
|
sourceType: String,
|
||||||
sourceId: String,
|
sourceId: String,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import com.correx.core.events.events.ChatTurnEvent
|
|||||||
import com.correx.core.events.events.ChatTurnRole
|
import com.correx.core.events.events.ChatTurnRole
|
||||||
import com.correx.core.events.events.ContextTruncatedEvent
|
import com.correx.core.events.events.ContextTruncatedEvent
|
||||||
import com.correx.core.events.events.EventMetadata
|
import com.correx.core.events.events.EventMetadata
|
||||||
|
import com.correx.core.events.events.IdeaCapturedEvent
|
||||||
import com.correx.core.events.events.L3MemoryRetrievedEvent
|
import com.correx.core.events.events.L3MemoryRetrievedEvent
|
||||||
import com.correx.core.events.events.L3RetrievedHit
|
import com.correx.core.events.events.L3RetrievedHit
|
||||||
import com.correx.core.events.events.NewEvent
|
import com.correx.core.events.events.NewEvent
|
||||||
@@ -156,12 +157,14 @@ class DefaultRouterFacade(
|
|||||||
)
|
)
|
||||||
val inferenceResponse = provider.infer(inferenceRequest)
|
val inferenceResponse = provider.infer(inferenceRequest)
|
||||||
val rawContent = inferenceResponse.text
|
val rawContent = inferenceResponse.text
|
||||||
// The triage path may append a structured workflow proposal; strip it from the visible turn
|
// The triage path may append a structured workflow proposal, and the rubber-duck path an
|
||||||
// so the operator reads prose, then record the candidates as a WorkflowProposedEvent below.
|
// `ideas` block; strip both from the visible turn so the operator reads only prose, then
|
||||||
|
// record them as events below.
|
||||||
val proposal = WorkflowProposals.parse(rawContent)
|
val proposal = WorkflowProposals.parse(rawContent)
|
||||||
|
val ideas = Ideas.parse(proposal.cleanText)
|
||||||
// A length-finish with no visible text means the model burned the whole completion
|
// A length-finish with no visible text means the model burned the whole completion
|
||||||
// budget on hidden reasoning — surface that instead of rendering a silent blank turn.
|
// budget on hidden reasoning — surface that instead of rendering a silent blank turn.
|
||||||
val content = proposal.cleanText.ifBlank {
|
val content = ideas.cleanText.ifBlank {
|
||||||
if (inferenceResponse.finishReason is FinishReason.Length) {
|
if (inferenceResponse.finishReason is FinishReason.Length) {
|
||||||
"The model used its entire ${config.generationConfig.maxTokens}-token response budget " +
|
"The model used its entire ${config.generationConfig.maxTokens}-token response budget " +
|
||||||
"without producing an answer (likely spent on hidden reasoning). " +
|
"without producing an answer (likely spent on hidden reasoning). " +
|
||||||
@@ -180,6 +183,7 @@ class DefaultRouterFacade(
|
|||||||
|
|
||||||
if (mode == ChatMode.CHAT) {
|
if (mode == ChatMode.CHAT) {
|
||||||
emitWorkflowProposalIfAny(sessionId, input, proposal)
|
emitWorkflowProposalIfAny(sessionId, input, proposal)
|
||||||
|
emitIdeasCaptured(sessionId, ideas.ideas)
|
||||||
}
|
}
|
||||||
|
|
||||||
val steeringEmitted = mode == ChatMode.STEERING && rawContent.isNotBlank()
|
val steeringEmitted = mode == ChatMode.STEERING && rawContent.isNotBlank()
|
||||||
@@ -329,6 +333,31 @@ class DefaultRouterFacade(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Records one [IdeaCapturedEvent] per idea the operator surfaced while rubber-ducking. */
|
||||||
|
private suspend fun emitIdeasCaptured(sessionId: SessionId, ideas: List<String>) {
|
||||||
|
ideas.forEach { text ->
|
||||||
|
val now = Clock.System.now()
|
||||||
|
eventStore.append(
|
||||||
|
NewEvent(
|
||||||
|
metadata = EventMetadata(
|
||||||
|
eventId = EventId(UUID.randomUUID().toString()),
|
||||||
|
sessionId = sessionId,
|
||||||
|
timestamp = now,
|
||||||
|
schemaVersion = 1,
|
||||||
|
causationId = null,
|
||||||
|
correlationId = null,
|
||||||
|
),
|
||||||
|
payload = IdeaCapturedEvent(
|
||||||
|
ideaId = UUID.randomUUID().toString(),
|
||||||
|
sessionId = sessionId,
|
||||||
|
text = text,
|
||||||
|
timestampMs = now.toEpochMilliseconds(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun emitSteeringNote(sessionId: SessionId, content: String, effectiveStageId: StageId) {
|
private suspend fun emitSteeringNote(sessionId: SessionId, content: String, effectiveStageId: StageId) {
|
||||||
val now = Clock.System.now()
|
val now = Clock.System.now()
|
||||||
eventStore.append(
|
eventStore.append(
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package com.correx.core.router.model
|
||||||
|
|
||||||
|
/** An active idea on the operator's cross-session scratchpad (a captured, not-yet-discarded note). */
|
||||||
|
data class Idea(
|
||||||
|
val id: String,
|
||||||
|
val text: String,
|
||||||
|
val capturedAtMs: Long,
|
||||||
|
)
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package com.correx.core.router
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
class IdeasTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parses a fenced ideas block and strips it from the prose`() {
|
||||||
|
val content = """
|
||||||
|
Good point — two things worth remembering.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"ideas":["cache the repo map across sessions","add a --dry-run flag"]}
|
||||||
|
```
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
val parsed = Ideas.parse(content)
|
||||||
|
|
||||||
|
assertEquals(listOf("cache the repo map across sessions", "add a --dry-run flag"), parsed.ideas)
|
||||||
|
assertTrue(parsed.cleanText.startsWith("Good point"))
|
||||||
|
assertTrue("```" !in parsed.cleanText, "fence must be stripped from the visible turn")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `accepts object items with a text field`() {
|
||||||
|
val content = "prose\n```json\n{\"ideas\":[{\"text\":\"idea one\"},{\"idea\":\"idea two\"}]}\n```"
|
||||||
|
val parsed = Ideas.parse(content)
|
||||||
|
assertEquals(listOf("idea one", "idea two"), parsed.ideas)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `finds the ideas block even when it is not the first fence`() {
|
||||||
|
val content = "```json\n{\"propose_workflows\":[{\"id\":\"x\"}]}\n```\n" +
|
||||||
|
"and\n```json\n{\"ideas\":[\"keep this\"]}\n```"
|
||||||
|
val parsed = Ideas.parse(content)
|
||||||
|
assertEquals(listOf("keep this"), parsed.ideas)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `no block yields no ideas and untouched text`() {
|
||||||
|
val content = "Let's just talk it through."
|
||||||
|
val parsed = Ideas.parse(content)
|
||||||
|
assertTrue(parsed.ideas.isEmpty())
|
||||||
|
assertEquals(content, parsed.cleanText)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `blank and empty items are dropped`() {
|
||||||
|
val content = "x\n```json\n{\"ideas\":[\" \",\"real\",\"\"]}\n```"
|
||||||
|
val parsed = Ideas.parse(content)
|
||||||
|
assertEquals(listOf("real"), parsed.ideas)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ import com.correx.core.inference.NoopEmbedder
|
|||||||
import com.correx.core.inference.Tokenizer
|
import com.correx.core.inference.Tokenizer
|
||||||
import com.correx.core.router.DefaultRouterContextBuilder
|
import com.correx.core.router.DefaultRouterContextBuilder
|
||||||
import com.correx.core.router.DefaultRouterFacade
|
import com.correx.core.router.DefaultRouterFacade
|
||||||
|
import com.correx.core.router.IdeaReader
|
||||||
import com.correx.core.router.DefaultRouterReducer
|
import com.correx.core.router.DefaultRouterReducer
|
||||||
import com.correx.core.router.DefaultRouterRepository
|
import com.correx.core.router.DefaultRouterRepository
|
||||||
import com.correx.core.router.RouterFacade
|
import com.correx.core.router.RouterFacade
|
||||||
@@ -300,7 +301,8 @@ object InfrastructureModule {
|
|||||||
val projector = RouterProjector(reducer)
|
val projector = RouterProjector(reducer)
|
||||||
val replayer = DefaultEventReplayer(eventStore, projector)
|
val replayer = DefaultEventReplayer(eventStore, projector)
|
||||||
val repository = DefaultRouterRepository(replayer)
|
val repository = DefaultRouterRepository(replayer)
|
||||||
val contextBuilder = DefaultRouterContextBuilder(config, tokenizer)
|
val ideaReader = IdeaReader(eventStore)
|
||||||
|
val contextBuilder = DefaultRouterContextBuilder(config, tokenizer, ideaProvider = { ideaReader.activeIdeas() })
|
||||||
return DefaultRouterFacade(repository, contextBuilder, inferenceRouter, eventStore, config, null, embedder, l3MemoryStore, workflowSummaryProvider, sessionProfileProvider)
|
return DefaultRouterFacade(repository, contextBuilder, inferenceRouter, eventStore, config, null, embedder, l3MemoryStore, workflowSummaryProvider, sessionProfileProvider)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import com.correx.core.router.ChatMode
|
|||||||
import com.correx.core.router.DefaultRouterContextBuilder
|
import com.correx.core.router.DefaultRouterContextBuilder
|
||||||
import com.correx.core.router.DefaultRouterFacade
|
import com.correx.core.router.DefaultRouterFacade
|
||||||
import com.correx.core.router.DefaultRouterReducer
|
import com.correx.core.router.DefaultRouterReducer
|
||||||
|
import com.correx.core.router.IdeaReader
|
||||||
import com.correx.core.router.RouterContextBuilder
|
import com.correx.core.router.RouterContextBuilder
|
||||||
import com.correx.core.router.RouterFacade
|
import com.correx.core.router.RouterFacade
|
||||||
import com.correx.core.router.RouterProjector
|
import com.correx.core.router.RouterProjector
|
||||||
@@ -664,6 +665,50 @@ class RouterFacadeTest {
|
|||||||
assertTrue(store.appendedEvents.map { it.payload }.filterIsInstance<WorkflowProposedEvent>().isEmpty())
|
assertTrue(store.appendedEvents.map { it.payload }.filterIsInstance<WorkflowProposedEvent>().isEmpty())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `rubber-duck ideas are captured, readable cross-session, and removed by a discard`(): Unit = runBlocking {
|
||||||
|
val store = mockEventStore()
|
||||||
|
val reply = "Right — worth keeping.\n```json\n{\"ideas\":[\"cache the repo map\",\"add --dry-run\"]}\n```"
|
||||||
|
val facade = facadeProposing(store, reply, emptyList())
|
||||||
|
|
||||||
|
facade.onUserInput(SessionId("chat-1"), "how do we speed this up?")
|
||||||
|
|
||||||
|
val reader = IdeaReader(store)
|
||||||
|
val ideas = reader.activeIdeas()
|
||||||
|
assertEquals(2, ideas.size)
|
||||||
|
assertTrue(ideas.any { it.text == "cache the repo map" } && ideas.any { it.text == "add --dry-run" })
|
||||||
|
|
||||||
|
// The visible chat turn must not leak the json block.
|
||||||
|
val routerTurn = store.appendedEvents.map { it.payload }
|
||||||
|
.filterIsInstance<ChatTurnEvent>().first { it.role == ChatTurnRole.ROUTER }
|
||||||
|
assertFalse(routerTurn.content.contains("```"))
|
||||||
|
|
||||||
|
// Discard one (from a *different* session) → it drops off the board, the other stays.
|
||||||
|
val drop = ideas.first { it.text == "add --dry-run" }
|
||||||
|
store.append(
|
||||||
|
NewEvent(
|
||||||
|
metadata = EventMetadata(
|
||||||
|
eventId = EventId("disc-1"), sessionId = SessionId("chat-2"),
|
||||||
|
timestamp = Instant.parse("2026-01-01T00:00:00Z"), schemaVersion = 1, causationId = null, correlationId = null,
|
||||||
|
),
|
||||||
|
payload = com.correx.core.events.events.IdeaDiscardedEvent(ideaId = drop.id, sessionId = SessionId("chat-2")),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val after = reader.activeIdeas()
|
||||||
|
assertEquals(listOf("cache the repo map"), after.map { it.text })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a steering turn never captures ideas`(): Unit = runBlocking {
|
||||||
|
val store = mockEventStore()
|
||||||
|
val reply = "x\n```json\n{\"ideas\":[\"nope\"]}\n```"
|
||||||
|
val facade = facadeProposing(store, reply, emptyList())
|
||||||
|
|
||||||
|
facade.onUserInput(SessionId("s"), "steer", ChatMode.STEERING)
|
||||||
|
|
||||||
|
assertTrue(IdeaReader(store).activeIdeas().isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
// --------------------------------------------------------------------------
|
// --------------------------------------------------------------------------
|
||||||
// Helpers
|
// Helpers
|
||||||
// --------------------------------------------------------------------------
|
// --------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user