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:
2026-06-14 14:25:49 +04:00
parent 3ac0a140ec
commit 400ae5729a
8 changed files with 258 additions and 5 deletions
@@ -33,6 +33,7 @@ import com.correx.core.router.ChatMode
import com.correx.core.router.DefaultRouterContextBuilder
import com.correx.core.router.DefaultRouterFacade
import com.correx.core.router.DefaultRouterReducer
import com.correx.core.router.IdeaReader
import com.correx.core.router.RouterContextBuilder
import com.correx.core.router.RouterFacade
import com.correx.core.router.RouterProjector
@@ -664,6 +665,50 @@ class RouterFacadeTest {
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
// --------------------------------------------------------------------------