595ec187bc
The 'router' name was misleading — it's the always-on conversational front-end
(CHAT triage + STEERING into a running workflow), not a routing layer, and it's
distinct from InferenceRouter. Rename core:router -> core:talkie, package
com.correx.core.router -> com.correx.core.talkie, and RouterFacade/Config/State/
Repository/ContextBuilder/Projector/Reducer/Response -> Talkie*. Config section
[router] -> [talkie] (legacy [router] still read as fallback).
Persisted wire formats are preserved: ChatTurnRole.ROUTER and the
RouterNarrationEvent @SerialName("RouterNarration") stay, so existing event
logs still replay. RouterNarrationEvent the class is now TalkieNarrationEvent.
1514 lines
71 KiB
Kotlin
1514 lines
71 KiB
Kotlin
import com.correx.core.context.model.ContextLayer
|
|
import com.correx.core.context.model.ContextPack
|
|
import com.correx.core.context.model.TokenBudget
|
|
import com.correx.core.events.events.ChatTurnEvent
|
|
import com.correx.core.events.events.ChatTurnRole
|
|
import com.correx.core.events.events.ContextTruncatedEvent
|
|
import com.correx.core.events.events.EventMetadata
|
|
import com.correx.core.events.events.L3MemoryRetrievedEvent
|
|
import com.correx.core.events.events.L3RetrievedHit
|
|
import com.correx.core.events.events.NewEvent
|
|
import com.correx.core.events.events.StoredEvent
|
|
import com.correx.core.events.events.WorkflowProposedEvent
|
|
import com.correx.core.events.stores.EventStore
|
|
import com.correx.core.events.types.ContextPackId
|
|
import com.correx.core.events.types.EventId
|
|
import com.correx.core.events.types.InferenceRequestId
|
|
import com.correx.core.events.types.ProviderId
|
|
import com.correx.core.events.types.SessionId
|
|
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.InferenceProvider
|
|
import com.correx.core.inference.InferenceRequest
|
|
import com.correx.core.inference.InferenceResponse
|
|
import com.correx.core.inference.InferenceRouter
|
|
import com.correx.core.inference.ModelCapability
|
|
import com.correx.core.inference.NoopEmbedder
|
|
import com.correx.core.inference.ProviderHealth
|
|
import com.correx.core.inference.ResponseFormat
|
|
import com.correx.core.inference.TokenUsage
|
|
import com.correx.core.talkie.ChatMode
|
|
import com.correx.core.talkie.DefaultTalkieContextBuilder
|
|
import com.correx.core.talkie.DefaultTalkieFacade
|
|
import com.correx.core.talkie.DefaultTalkieReducer
|
|
import com.correx.core.talkie.IdeaReader
|
|
import com.correx.core.talkie.TalkieContextBuilder
|
|
import com.correx.core.talkie.TalkieFacade
|
|
import com.correx.core.talkie.TalkieProjector
|
|
import com.correx.core.talkie.TalkieRepository
|
|
import com.correx.core.talkie.l3.InMemoryL3MemoryStore
|
|
import com.correx.core.talkie.l3.L3MemoryEntry
|
|
import com.correx.core.talkie.l3.L3MemoryStore
|
|
import com.correx.core.talkie.l3.L3Query
|
|
import com.correx.core.talkie.model.TalkieConfig
|
|
import com.correx.core.talkie.model.TalkieResponse
|
|
import com.correx.core.talkie.model.TalkieState
|
|
import com.correx.core.talkie.model.TalkieTurn
|
|
import com.correx.core.talkie.model.TurnRole
|
|
import com.correx.core.talkie.model.WorkflowSummary
|
|
import com.correx.core.talkie.model.WorkflowStatus
|
|
import com.correx.core.sessions.projections.replay.DefaultEventReplayer
|
|
import com.correx.testing.fixtures.EventFixtures
|
|
import com.correx.testing.fixtures.inference.MockTokenizer
|
|
import kotlinx.coroutines.flow.Flow
|
|
import kotlinx.coroutines.runBlocking
|
|
import kotlinx.datetime.Instant
|
|
import kotlinx.serialization.decodeFromString
|
|
import org.junit.jupiter.api.Assertions.assertEquals
|
|
import org.junit.jupiter.api.Assertions.assertFalse
|
|
import org.junit.jupiter.api.Assertions.assertNotNull
|
|
import org.junit.jupiter.api.Assertions.assertNull
|
|
import org.junit.jupiter.api.Assertions.assertTrue
|
|
import org.junit.jupiter.api.Test
|
|
|
|
class TalkieFacadeTest {
|
|
|
|
// --------------------------------------------------------------------------
|
|
// CHAT mode
|
|
// --------------------------------------------------------------------------
|
|
|
|
@Test
|
|
fun `CHAT mode returns inference response content`(): Unit = runBlocking {
|
|
val mockStore = mockEventStore()
|
|
val facade = facadeWithMocks(eventStore = mockStore, chatMode = ChatMode.CHAT)
|
|
val response = facade.onUserInput(sessionId = SessionId("test-session"), input = "Hello, world!")
|
|
assertEquals("inference response", response.content)
|
|
}
|
|
|
|
@Test
|
|
fun `CHAT mode sets steeringEmitted to false`(): Unit = runBlocking {
|
|
val mockStore = mockEventStore()
|
|
val facade = facadeWithMocks(eventStore = mockStore, chatMode = ChatMode.CHAT)
|
|
val response = facade.onUserInput(sessionId = SessionId("test-session"), input = "Hello!")
|
|
assertFalse(response.steeringEmitted)
|
|
}
|
|
|
|
@Test
|
|
fun `CHAT mode appends USER and ROUTER ChatTurnEvent to EventStore`(): Unit = runBlocking {
|
|
val mockStore = mockEventStore()
|
|
val facade = facadeWithMocks(eventStore = mockStore, chatMode = ChatMode.CHAT)
|
|
facade.onUserInput(sessionId = SessionId("test-session"), input = "Hello!")
|
|
val chatEvents = mockStore.appendedEvents
|
|
.map { it.payload }
|
|
.filterIsInstance<com.correx.core.events.events.ChatTurnEvent>()
|
|
assertEquals(2, chatEvents.size)
|
|
val userEvent = chatEvents[0]
|
|
val routerEvent = chatEvents[1]
|
|
assertEquals("Hello!", userEvent.content)
|
|
assertEquals(com.correx.core.events.events.ChatTurnRole.USER, userEvent.role)
|
|
assertEquals("inference response", routerEvent.content)
|
|
assertEquals(com.correx.core.events.events.ChatTurnRole.ROUTER, routerEvent.role)
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// STEERING mode
|
|
// --------------------------------------------------------------------------
|
|
|
|
@Test
|
|
fun `STEERING mode sets steeringEmitted to true`(): Unit = runBlocking {
|
|
val mockStore = mockEventStore()
|
|
val facade = facadeWithMocks(eventStore = mockStore, chatMode = ChatMode.STEERING)
|
|
val response = facade.onUserInput(sessionId = SessionId("test-session"), input = "Hello!")
|
|
assertTrue(response.steeringEmitted)
|
|
}
|
|
|
|
@Test
|
|
fun `STEERING mode appends USER ChatTurnEvent, ROUTER ChatTurnEvent, and SteeringNoteAddedEvent to store`(): Unit = runBlocking {
|
|
val mockStore = mockEventStore()
|
|
val facade = facadeWithMocks(eventStore = mockStore, chatMode = ChatMode.STEERING)
|
|
facade.onUserInput(sessionId = SessionId("session-xyz"), input = "steer this way")
|
|
val payloads = mockStore.appendedEvents.map { it.payload }
|
|
val chatEvents = payloads.filterIsInstance<com.correx.core.events.events.ChatTurnEvent>()
|
|
val steeringEvents = payloads.filterIsInstance<com.correx.core.events.events.SteeringNoteAddedEvent>()
|
|
assertEquals(2, chatEvents.size)
|
|
assertEquals(1, steeringEvents.size)
|
|
assertEquals("steer this way", chatEvents[0].content)
|
|
assertEquals(com.correx.core.events.events.ChatTurnRole.USER, chatEvents[0].role)
|
|
assertEquals("inference response", chatEvents[1].content)
|
|
assertEquals(com.correx.core.events.events.ChatTurnRole.ROUTER, chatEvents[1].role)
|
|
assertEquals("inference response", steeringEvents[0].content)
|
|
}
|
|
|
|
@Test
|
|
fun `STEERING mode appends SteeringNoteAddedEvent with current stageId`(): Unit = runBlocking {
|
|
val mockStore = mockEventStore()
|
|
val stageId = StageId("stage-A")
|
|
val facade = DefaultTalkieFacade(
|
|
routerRepository = object : TalkieRepository {
|
|
override suspend fun getTalkieState(sessionId: SessionId): TalkieState =
|
|
TalkieState(
|
|
sessionId = sessionId,
|
|
workflowStatus = WorkflowStatus.RUNNING,
|
|
currentStageId = stageId,
|
|
)
|
|
},
|
|
routerContextBuilder = object : TalkieContextBuilder {
|
|
override suspend fun build(state: TalkieState, budget: TokenBudget, availableWorkflows: List<WorkflowSummary>, projectProfileText: String?): ContextPack = emptyContextPack()
|
|
},
|
|
inferenceRouter = mockInferenceRouter("steering response"),
|
|
eventStore = mockStore,
|
|
config = TalkieConfig(tokenBudget = TokenBudget(limit = 5000)),
|
|
embedder = NoopEmbedder(dimension = 8),
|
|
l3MemoryStore = InMemoryL3MemoryStore(),
|
|
)
|
|
facade.onUserInput(sessionId = SessionId("test-session"), input = "Hello!", mode = ChatMode.STEERING)
|
|
val payloads = mockStore.appendedEvents.map { it.payload }
|
|
val steeringEvents = payloads.filterIsInstance<com.correx.core.events.events.SteeringNoteAddedEvent>()
|
|
assertEquals(1, steeringEvents.size)
|
|
assertEquals("steering response", steeringEvents[0].content)
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// In-memory conversation history
|
|
// --------------------------------------------------------------------------
|
|
|
|
@Test
|
|
fun `conversation history grows per call - user and router turns appended`(): Unit = runBlocking {
|
|
val capturedStates = mutableListOf<TalkieState>()
|
|
val eventStore = mockEventStore()
|
|
val replayer = DefaultEventReplayer<TalkieState>(
|
|
store = eventStore,
|
|
projection = TalkieProjector(DefaultTalkieReducer()),
|
|
)
|
|
val facade = DefaultTalkieFacade(
|
|
routerRepository = object : TalkieRepository {
|
|
override suspend fun getTalkieState(sessionId: SessionId): TalkieState =
|
|
replayer.rebuild(sessionId)
|
|
},
|
|
routerContextBuilder = object : TalkieContextBuilder {
|
|
override suspend fun build(state: TalkieState, budget: TokenBudget, availableWorkflows: List<WorkflowSummary>, projectProfileText: String?): ContextPack {
|
|
capturedStates.add(state)
|
|
return emptyContextPack()
|
|
}
|
|
},
|
|
inferenceRouter = mockInferenceRouter("router reply"),
|
|
eventStore = eventStore,
|
|
config = TalkieConfig(tokenBudget = TokenBudget(limit = 5000)),
|
|
embedder = NoopEmbedder(dimension = 8),
|
|
l3MemoryStore = InMemoryL3MemoryStore(),
|
|
)
|
|
val sessionId = SessionId("history-session")
|
|
facade.onUserInput(sessionId = sessionId, input = "first message")
|
|
facade.onUserInput(sessionId = sessionId, input = "second message")
|
|
|
|
// Second call's context builder sees two user turns + one router turn from the first call
|
|
val stateOnSecondCall = capturedStates[1]
|
|
assertEquals(3, stateOnSecondCall.conversationHistory.size)
|
|
assertEquals(TurnRole.USER, stateOnSecondCall.conversationHistory[0].role)
|
|
assertEquals("first message", stateOnSecondCall.conversationHistory[0].content)
|
|
assertEquals(TurnRole.ROUTER, stateOnSecondCall.conversationHistory[1].role)
|
|
assertEquals("router reply", stateOnSecondCall.conversationHistory[1].content)
|
|
assertEquals(TurnRole.USER, stateOnSecondCall.conversationHistory[2].role)
|
|
assertEquals("second message", stateOnSecondCall.conversationHistory[2].content)
|
|
}
|
|
|
|
@Test
|
|
fun `conversation history is session-scoped - different sessions do not share history`(): Unit = runBlocking {
|
|
val capturedStates = mutableListOf<TalkieState>()
|
|
val eventStore = mockEventStore()
|
|
val replayer = DefaultEventReplayer<TalkieState>(
|
|
store = eventStore,
|
|
projection = TalkieProjector(DefaultTalkieReducer()),
|
|
)
|
|
val facade = DefaultTalkieFacade(
|
|
routerRepository = object : TalkieRepository {
|
|
override suspend fun getTalkieState(sessionId: SessionId): TalkieState =
|
|
replayer.rebuild(sessionId)
|
|
},
|
|
routerContextBuilder = object : TalkieContextBuilder {
|
|
override suspend fun build(state: TalkieState, budget: TokenBudget, availableWorkflows: List<WorkflowSummary>, projectProfileText: String?): ContextPack {
|
|
capturedStates.add(state)
|
|
return emptyContextPack()
|
|
}
|
|
},
|
|
inferenceRouter = mockInferenceRouter("response"),
|
|
eventStore = eventStore,
|
|
config = TalkieConfig(tokenBudget = TokenBudget(limit = 5000)),
|
|
embedder = NoopEmbedder(dimension = 8),
|
|
l3MemoryStore = InMemoryL3MemoryStore(),
|
|
)
|
|
facade.onUserInput(sessionId = SessionId("session-A"), input = "message A")
|
|
facade.onUserInput(sessionId = SessionId("session-B"), input = "message B")
|
|
|
|
val stateA = capturedStates[0]
|
|
val stateB = capturedStates[1]
|
|
assertEquals(1, stateA.conversationHistory.size)
|
|
assertEquals(1, stateB.conversationHistory.size)
|
|
assertEquals("message A", stateA.conversationHistory[0].content)
|
|
assertEquals("message B", stateB.conversationHistory[0].content)
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// Orchestration
|
|
// --------------------------------------------------------------------------
|
|
|
|
@Test
|
|
fun `state is passed through to context builder`(): Unit = runBlocking {
|
|
val capturedState = mutableListOf<TalkieState>()
|
|
val mockContextBuilder = object : TalkieContextBuilder {
|
|
override suspend fun build(state: TalkieState, budget: TokenBudget, availableWorkflows: List<WorkflowSummary>, projectProfileText: String?): ContextPack {
|
|
capturedState.add(state)
|
|
return emptyContextPack()
|
|
}
|
|
}
|
|
val facade = DefaultTalkieFacade(
|
|
routerRepository = object : TalkieRepository {
|
|
override suspend fun getTalkieState(sessionId: SessionId): TalkieState =
|
|
TalkieState(
|
|
sessionId = sessionId,
|
|
workflowStatus = WorkflowStatus.RUNNING,
|
|
currentStageId = StageId("s1"),
|
|
)
|
|
},
|
|
routerContextBuilder = mockContextBuilder,
|
|
inferenceRouter = mockInferenceRouter("inference response"),
|
|
eventStore = mockEventStore(),
|
|
config = TalkieConfig(tokenBudget = TokenBudget(limit = 5000)),
|
|
embedder = NoopEmbedder(dimension = 8),
|
|
l3MemoryStore = InMemoryL3MemoryStore(),
|
|
)
|
|
facade.onUserInput(sessionId = SessionId("test-session"), input = "Hello!")
|
|
assertEquals(1, capturedState.size)
|
|
assertEquals(SessionId("test-session"), capturedState[0].sessionId)
|
|
assertEquals(WorkflowStatus.RUNNING, capturedState[0].workflowStatus)
|
|
}
|
|
|
|
@Test
|
|
fun `budget is passed through to context builder`(): Unit = runBlocking {
|
|
val capturedBudget = mutableListOf<TokenBudget>()
|
|
val mockContextBuilder = object : TalkieContextBuilder {
|
|
override suspend fun build(state: TalkieState, budget: TokenBudget, availableWorkflows: List<WorkflowSummary>, projectProfileText: String?): ContextPack {
|
|
capturedBudget.add(budget)
|
|
return emptyContextPack()
|
|
}
|
|
}
|
|
val facade = DefaultTalkieFacade(
|
|
routerRepository = object : TalkieRepository {
|
|
override suspend fun getTalkieState(sessionId: SessionId): TalkieState = TalkieState()
|
|
},
|
|
routerContextBuilder = mockContextBuilder,
|
|
inferenceRouter = mockInferenceRouter("response"),
|
|
eventStore = mockEventStore(),
|
|
config = TalkieConfig(tokenBudget = TokenBudget(limit = 4200)),
|
|
embedder = NoopEmbedder(dimension = 8),
|
|
l3MemoryStore = InMemoryL3MemoryStore(),
|
|
)
|
|
facade.onUserInput(sessionId = SessionId("test-session"), input = "Hello!")
|
|
assertEquals(1, capturedBudget.size)
|
|
assertEquals(4200, capturedBudget[0].limit)
|
|
}
|
|
|
|
@Test
|
|
fun `stage ID uses state currentStageId`(): Unit = runBlocking {
|
|
val capturedStageId = mutableListOf<StageId>()
|
|
val mockInferenceRouter = object : InferenceRouter {
|
|
override suspend fun route(
|
|
stageId: StageId,
|
|
requiredCapabilities: Set<ModelCapability>,
|
|
): InferenceProvider {
|
|
capturedStageId.add(stageId)
|
|
return mockProvider("response")
|
|
}
|
|
}
|
|
val stateStageId = StageId("state-stage")
|
|
val facade = DefaultTalkieFacade(
|
|
routerRepository = object : TalkieRepository {
|
|
override suspend fun getTalkieState(sessionId: SessionId): TalkieState = TalkieState(
|
|
sessionId = sessionId,
|
|
workflowStatus = WorkflowStatus.RUNNING,
|
|
currentStageId = stateStageId,
|
|
)
|
|
},
|
|
routerContextBuilder = object : TalkieContextBuilder {
|
|
override suspend fun build(state: TalkieState, budget: TokenBudget, availableWorkflows: List<WorkflowSummary>, projectProfileText: String?): ContextPack = emptyContextPack()
|
|
},
|
|
inferenceRouter = mockInferenceRouter,
|
|
eventStore = mockEventStore(),
|
|
config = TalkieConfig(tokenBudget = TokenBudget(limit = 5000)),
|
|
embedder = NoopEmbedder(dimension = 8),
|
|
l3MemoryStore = InMemoryL3MemoryStore(),
|
|
)
|
|
facade.onUserInput(sessionId = SessionId("test-session"), input = "Hello!")
|
|
assertEquals(stateStageId, capturedStageId[0])
|
|
}
|
|
|
|
@Test
|
|
fun `stage ID falls back to StageId none when state has no currentStageId`(): Unit = runBlocking {
|
|
val capturedStageId = mutableListOf<StageId>()
|
|
val mockInferenceRouter = object : InferenceRouter {
|
|
override suspend fun route(
|
|
stageId: StageId,
|
|
requiredCapabilities: Set<ModelCapability>,
|
|
): InferenceProvider {
|
|
capturedStageId.add(stageId)
|
|
return mockProvider("response")
|
|
}
|
|
}
|
|
val facade = DefaultTalkieFacade(
|
|
routerRepository = object : TalkieRepository {
|
|
override suspend fun getTalkieState(sessionId: SessionId): TalkieState = TalkieState(
|
|
sessionId = sessionId,
|
|
workflowStatus = WorkflowStatus.IDLE,
|
|
currentStageId = null,
|
|
)
|
|
},
|
|
routerContextBuilder = object : TalkieContextBuilder {
|
|
override suspend fun build(state: TalkieState, budget: TokenBudget, availableWorkflows: List<WorkflowSummary>, projectProfileText: String?): ContextPack = emptyContextPack()
|
|
},
|
|
inferenceRouter = mockInferenceRouter,
|
|
eventStore = mockEventStore(),
|
|
config = TalkieConfig(tokenBudget = TokenBudget(limit = 5000)),
|
|
embedder = NoopEmbedder(dimension = 8),
|
|
l3MemoryStore = InMemoryL3MemoryStore(),
|
|
)
|
|
facade.onUserInput(sessionId = SessionId("test-session"), input = "Hello!")
|
|
assertEquals(StageId("none"), capturedStageId[0])
|
|
}
|
|
|
|
@Test
|
|
fun `new InferenceRequestId per call`(): Unit = runBlocking {
|
|
val capturedRequestIds = mutableListOf<InferenceRequestId>()
|
|
val mockInferenceRouter = object : InferenceRouter {
|
|
override suspend fun route(
|
|
stageId: StageId,
|
|
requiredCapabilities: Set<ModelCapability>,
|
|
): InferenceProvider {
|
|
return mockProviderWithCapture("response", capturedRequestIds)
|
|
}
|
|
}
|
|
val facade = DefaultTalkieFacade(
|
|
routerRepository = object : TalkieRepository {
|
|
override suspend fun getTalkieState(sessionId: SessionId): TalkieState = TalkieState()
|
|
},
|
|
routerContextBuilder = object : TalkieContextBuilder {
|
|
override suspend fun build(state: TalkieState, budget: TokenBudget, availableWorkflows: List<WorkflowSummary>, projectProfileText: String?): ContextPack = emptyContextPack()
|
|
},
|
|
inferenceRouter = mockInferenceRouter,
|
|
eventStore = mockEventStore(),
|
|
config = TalkieConfig(tokenBudget = TokenBudget(limit = 5000)),
|
|
embedder = NoopEmbedder(dimension = 8),
|
|
l3MemoryStore = InMemoryL3MemoryStore(),
|
|
)
|
|
facade.onUserInput(sessionId = SessionId("s1"), input = "first")
|
|
facade.onUserInput(sessionId = SessionId("s1"), input = "second")
|
|
assertEquals(2, capturedRequestIds.size)
|
|
assertFalse(capturedRequestIds[0] == capturedRequestIds[1])
|
|
}
|
|
|
|
@Test
|
|
fun `GenerationConfig defaults temperature 0 7 topP 0 9 maxTokens 512`(): Unit = runBlocking {
|
|
val capturedRequests = mutableListOf<InferenceRequest>()
|
|
val mockInferenceRouter = object : InferenceRouter {
|
|
override suspend fun route(
|
|
stageId: StageId,
|
|
requiredCapabilities: Set<ModelCapability>,
|
|
): InferenceProvider {
|
|
return mockProviderWithRequestCapture("response", capturedRequests)
|
|
}
|
|
}
|
|
val facade = DefaultTalkieFacade(
|
|
routerRepository = object : TalkieRepository {
|
|
override suspend fun getTalkieState(sessionId: SessionId): TalkieState = TalkieState()
|
|
},
|
|
routerContextBuilder = object : TalkieContextBuilder {
|
|
override suspend fun build(state: TalkieState, budget: TokenBudget, availableWorkflows: List<WorkflowSummary>, projectProfileText: String?): ContextPack = emptyContextPack()
|
|
},
|
|
inferenceRouter = mockInferenceRouter,
|
|
eventStore = mockEventStore(),
|
|
config = TalkieConfig(tokenBudget = TokenBudget(limit = 5000)),
|
|
embedder = NoopEmbedder(dimension = 8),
|
|
l3MemoryStore = InMemoryL3MemoryStore(),
|
|
)
|
|
facade.onUserInput(sessionId = SessionId("test-session"), input = "Hello!")
|
|
val req = capturedRequests[0]
|
|
assertEquals(0.7, req.generationConfig.temperature)
|
|
assertEquals(0.9, req.generationConfig.topP)
|
|
assertEquals(512, req.generationConfig.maxTokens)
|
|
}
|
|
|
|
@Test
|
|
fun `context pack is passed to inference provider`(): Unit = runBlocking {
|
|
val capturedContextPacks = mutableListOf<ContextPack>()
|
|
val facade = DefaultTalkieFacade(
|
|
routerRepository = object : TalkieRepository {
|
|
override suspend fun getTalkieState(sessionId: SessionId): TalkieState = TalkieState()
|
|
},
|
|
routerContextBuilder = object : TalkieContextBuilder {
|
|
override suspend fun build(state: TalkieState, budget: TokenBudget, availableWorkflows: List<WorkflowSummary>, projectProfileText: String?): ContextPack {
|
|
val pack = ContextPack(
|
|
id = ContextPackId("test-pack"),
|
|
sessionId = state.sessionId ?: SessionId("unknown"),
|
|
stageId = StageId("test"),
|
|
layers = emptyMap(),
|
|
budgetUsed = 0,
|
|
budgetLimit = budget.limit,
|
|
)
|
|
capturedContextPacks.add(pack)
|
|
return pack
|
|
}
|
|
},
|
|
inferenceRouter = object : InferenceRouter {
|
|
override suspend fun route(
|
|
stageId: StageId,
|
|
requiredCapabilities: Set<ModelCapability>,
|
|
): InferenceProvider {
|
|
return object : InferenceProvider {
|
|
override val id = ProviderId("mock")
|
|
override val name = "Mock"
|
|
override val tokenizer = MockTokenizer()
|
|
override suspend fun infer(request: InferenceRequest): InferenceResponse {
|
|
assertEquals(1, capturedContextPacks.size)
|
|
assertEquals(capturedContextPacks[0].id, request.contextPack.id)
|
|
return InferenceResponse(
|
|
requestId = request.requestId,
|
|
text = "response",
|
|
finishReason = FinishReason.Stop,
|
|
tokensUsed = TokenUsage(promptTokens = 10, completionTokens = 5),
|
|
latencyMs = 0,
|
|
)
|
|
}
|
|
|
|
override suspend fun healthCheck(): ProviderHealth =
|
|
ProviderHealth.Healthy
|
|
|
|
override fun capabilities(): Set<CapabilityScore> = emptySet()
|
|
}
|
|
}
|
|
},
|
|
eventStore = mockEventStore(),
|
|
config = TalkieConfig(tokenBudget = TokenBudget(limit = 5000)),
|
|
embedder = NoopEmbedder(dimension = 8),
|
|
l3MemoryStore = InMemoryL3MemoryStore(),
|
|
)
|
|
facade.onUserInput(sessionId = SessionId("test-session"), input = "Hello!")
|
|
}
|
|
|
|
@Test
|
|
fun `responseFormat defaults to Text`(): Unit = runBlocking {
|
|
val capturedRequests = mutableListOf<InferenceRequest>()
|
|
val facade = DefaultTalkieFacade(
|
|
routerRepository = object : TalkieRepository {
|
|
override suspend fun getTalkieState(sessionId: SessionId): TalkieState = TalkieState()
|
|
},
|
|
routerContextBuilder = object : TalkieContextBuilder {
|
|
override suspend fun build(state: TalkieState, budget: TokenBudget, availableWorkflows: List<WorkflowSummary>, projectProfileText: String?): ContextPack = emptyContextPack()
|
|
},
|
|
inferenceRouter = object : InferenceRouter {
|
|
override suspend fun route(
|
|
stageId: StageId,
|
|
requiredCapabilities: Set<ModelCapability>,
|
|
): InferenceProvider {
|
|
return mockProviderWithRequestCapture("response", capturedRequests)
|
|
}
|
|
},
|
|
eventStore = mockEventStore(),
|
|
config = TalkieConfig(tokenBudget = TokenBudget(limit = 5000)),
|
|
embedder = NoopEmbedder(dimension = 8),
|
|
l3MemoryStore = InMemoryL3MemoryStore(),
|
|
)
|
|
facade.onUserInput(sessionId = SessionId("test-session"), input = "Hello!")
|
|
val req = capturedRequests[0]
|
|
assertTrue(req.responseFormat is ResponseFormat.Text)
|
|
}
|
|
|
|
@Test
|
|
fun `onUserInput returns TalkieResponse with content and mode flag`(): Unit = runBlocking {
|
|
val mockStore = mockEventStore()
|
|
val facade = facadeWithMocks(eventStore = mockStore, chatMode = ChatMode.STEERING)
|
|
val response = facade.onUserInput(sessionId = SessionId("test-session"), input = "Hello!")
|
|
assertNotNull(response)
|
|
assertEquals("inference response", response.content)
|
|
assertTrue(response.steeringEmitted)
|
|
val payloads = mockStore.appendedEvents.map { it.payload }
|
|
assertEquals(2, payloads.filterIsInstance<com.correx.core.events.events.ChatTurnEvent>().size)
|
|
assertEquals(1, payloads.filterIsInstance<com.correx.core.events.events.SteeringNoteAddedEvent>().size)
|
|
}
|
|
|
|
@Test
|
|
fun `CHAT round-trip stores user and router turns in L3 memory`(): Unit = runBlocking {
|
|
val mockStore = mockEventStore()
|
|
val l3Store = InMemoryL3MemoryStore()
|
|
val embedder = NoopEmbedder(dimension = 8)
|
|
val facade = facadeWithMocks(
|
|
eventStore = mockStore,
|
|
chatMode = ChatMode.CHAT,
|
|
embedder = embedder,
|
|
l3MemoryStore = l3Store,
|
|
)
|
|
val sessionId = SessionId("test-session")
|
|
facade.onUserInput(sessionId = sessionId, input = "Hello!")
|
|
val hits = l3Store.query(
|
|
L3Query(
|
|
vector = FloatArray(8),
|
|
k = 10,
|
|
sessionIdFilter = sessionId,
|
|
)
|
|
)
|
|
assertEquals(2, hits.size)
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// Task 2.1 — latency + token metrics on ROUTER ChatTurnEvent
|
|
// --------------------------------------------------------------------------
|
|
|
|
@Test
|
|
fun `ROUTER ChatTurnEvent carries latencyMs and tokensUsed from inference and USER turn has both null`(): Unit = runBlocking {
|
|
val capturedRequests = mutableListOf<InferenceRequest>()
|
|
val knownLatencyMs = 42L
|
|
val knownTokenUsage = TokenUsage(promptTokens = 10, completionTokens = 20)
|
|
val metricsProvider = object : InferenceProvider {
|
|
override val id = ProviderId("mock")
|
|
override val name = "Mock"
|
|
override val tokenizer = MockTokenizer()
|
|
override suspend fun infer(request: InferenceRequest): InferenceResponse {
|
|
capturedRequests.add(request)
|
|
return InferenceResponse(
|
|
requestId = request.requestId,
|
|
text = "metrics response",
|
|
finishReason = FinishReason.Stop,
|
|
tokensUsed = knownTokenUsage,
|
|
latencyMs = knownLatencyMs,
|
|
)
|
|
}
|
|
override suspend fun healthCheck(): ProviderHealth = ProviderHealth.Healthy
|
|
override fun capabilities(): Set<CapabilityScore> = setOf(CapabilityScore(ModelCapability.General, 1.0))
|
|
}
|
|
val eventStore = mockEventStore()
|
|
val facade = DefaultTalkieFacade(
|
|
routerRepository = object : TalkieRepository {
|
|
override suspend fun getTalkieState(sessionId: SessionId): TalkieState = TalkieState()
|
|
},
|
|
routerContextBuilder = object : TalkieContextBuilder {
|
|
override suspend fun build(state: TalkieState, budget: TokenBudget, availableWorkflows: List<WorkflowSummary>, projectProfileText: String?): ContextPack = emptyContextPack()
|
|
},
|
|
inferenceRouter = object : InferenceRouter {
|
|
override suspend fun route(stageId: StageId, requiredCapabilities: Set<ModelCapability>): InferenceProvider =
|
|
metricsProvider
|
|
},
|
|
eventStore = eventStore,
|
|
config = TalkieConfig(tokenBudget = TokenBudget(limit = 5000)),
|
|
embedder = NoopEmbedder(dimension = 8),
|
|
l3MemoryStore = InMemoryL3MemoryStore(),
|
|
)
|
|
|
|
facade.onUserInput(sessionId = SessionId("metrics-session"), input = "hello metrics")
|
|
|
|
val chatEvents = eventStore.appendedEvents.map { it.payload }.filterIsInstance<ChatTurnEvent>()
|
|
assertEquals(2, chatEvents.size)
|
|
val userEvent = chatEvents.first { it.role == ChatTurnRole.USER }
|
|
val routerEvent = chatEvents.first { it.role == ChatTurnRole.ROUTER }
|
|
|
|
assertNull(userEvent.latencyMs, "USER ChatTurnEvent must have null latencyMs")
|
|
assertNull(userEvent.tokensUsed, "USER ChatTurnEvent must have null tokensUsed")
|
|
|
|
assertEquals(knownLatencyMs, routerEvent.latencyMs)
|
|
assertNotNull(routerEvent.tokensUsed)
|
|
assertEquals(30, routerEvent.tokensUsed!!.totalTokens)
|
|
}
|
|
|
|
@Test
|
|
fun `ChatTurnEvent backward-compat - legacy JSON without latencyMs and tokensUsed deserializes with both null`() {
|
|
val legacyJson = """{"sessionId":"s1","turnId":"t1","role":"USER","content":"hello","timestampMs":1000}"""
|
|
val event = com.correx.core.events.serialization.eventJson.decodeFromString<ChatTurnEvent>(legacyJson)
|
|
assertEquals(SessionId("s1"), event.sessionId)
|
|
assertEquals("hello", event.content)
|
|
assertNull(event.latencyMs)
|
|
assertNull(event.tokensUsed)
|
|
}
|
|
|
|
@Test
|
|
fun `triage proposing a known workflow emits WorkflowProposedEvent and strips the json block`(): Unit = runBlocking {
|
|
val store = mockEventStore()
|
|
val reply = """
|
|
The research workflow fits — it gathers sources then writes a report.
|
|
```json
|
|
{"propose_workflows":[{"id":"research","reason":"gather + report"}],"prompt":"Run it?"}
|
|
```
|
|
""".trimIndent()
|
|
val facade = facadeProposing(store, reply, listOf(WorkflowSummary("research", "desc", listOf("decompose"))))
|
|
|
|
facade.onUserInput(SessionId("s"), "find me papers on event sourcing")
|
|
|
|
val payloads = store.appendedEvents.map { it.payload }
|
|
val proposed = payloads.filterIsInstance<WorkflowProposedEvent>()
|
|
assertEquals(1, proposed.size)
|
|
assertEquals(listOf("research"), proposed.single().candidates.map { it.workflowId })
|
|
assertEquals("Run it?", proposed.single().prompt)
|
|
assertEquals("find me papers on event sourcing", proposed.single().originalRequest)
|
|
|
|
val routerTurn = payloads.filterIsInstance<ChatTurnEvent>().first { it.role == ChatTurnRole.ROUTER }
|
|
assertFalse(routerTurn.content.contains("```"), "json block must be stripped from the chat turn")
|
|
assertTrue(routerTurn.content.startsWith("The research workflow fits"))
|
|
}
|
|
|
|
@Test
|
|
fun `proposed workflow ids not in the registry are dropped so no proposal is recorded`(): Unit = runBlocking {
|
|
val store = mockEventStore()
|
|
val reply = "Try this.\n```json\n{\"propose_workflows\":[{\"id\":\"nonexistent\"}],\"prompt\":\"go?\"}\n```"
|
|
val facade = facadeProposing(store, reply, listOf(WorkflowSummary("research", "d", listOf("x"))))
|
|
|
|
facade.onUserInput(SessionId("s"), "do something")
|
|
|
|
assertTrue(store.appendedEvents.map { it.payload }.filterIsInstance<WorkflowProposedEvent>().isEmpty())
|
|
}
|
|
|
|
@Test
|
|
fun `a steering turn never records a workflow proposal`(): Unit = runBlocking {
|
|
val store = mockEventStore()
|
|
val reply = "x\n```json\n{\"propose_workflows\":[{\"id\":\"research\"}],\"prompt\":\"go?\"}\n```"
|
|
val facade = facadeProposing(store, reply, listOf(WorkflowSummary("research", "d", listOf("x"))))
|
|
|
|
facade.onUserInput(SessionId("s"), "steer it", ChatMode.STEERING)
|
|
|
|
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
|
|
// --------------------------------------------------------------------------
|
|
|
|
private fun facadeProposing(
|
|
store: EventStore,
|
|
reply: String,
|
|
workflows: List<WorkflowSummary>,
|
|
): TalkieFacade = DefaultTalkieFacade(
|
|
routerRepository = object : TalkieRepository {
|
|
override suspend fun getTalkieState(sessionId: SessionId): TalkieState = TalkieState(sessionId = sessionId)
|
|
},
|
|
routerContextBuilder = object : TalkieContextBuilder {
|
|
override suspend fun build(state: TalkieState, budget: TokenBudget, availableWorkflows: List<WorkflowSummary>, projectProfileText: String?): ContextPack = emptyContextPack()
|
|
},
|
|
inferenceRouter = mockInferenceRouter(reply),
|
|
eventStore = store,
|
|
config = TalkieConfig(tokenBudget = TokenBudget(limit = 5000)),
|
|
embedder = NoopEmbedder(dimension = 8),
|
|
l3MemoryStore = InMemoryL3MemoryStore(),
|
|
workflowSummaryProvider = { workflows },
|
|
)
|
|
|
|
private fun mockEventStore(): MapBackedEventStore = MapBackedEventStore()
|
|
|
|
private fun facadeWithMocks(
|
|
eventStore: EventStore,
|
|
chatMode: ChatMode = ChatMode.CHAT,
|
|
embedder: Embedder = NoopEmbedder(dimension = 8),
|
|
l3MemoryStore: L3MemoryStore = InMemoryL3MemoryStore(),
|
|
): TalkieFacade = DefaultTalkieFacade(
|
|
routerRepository = object : TalkieRepository {
|
|
override suspend fun getTalkieState(sessionId: SessionId): TalkieState =
|
|
TalkieState(
|
|
sessionId = sessionId,
|
|
workflowStatus = WorkflowStatus.RUNNING,
|
|
currentStageId = StageId("s1"),
|
|
)
|
|
},
|
|
routerContextBuilder = object : TalkieContextBuilder {
|
|
override suspend fun build(state: TalkieState, budget: TokenBudget, availableWorkflows: List<WorkflowSummary>, projectProfileText: String?): ContextPack = emptyContextPack()
|
|
},
|
|
inferenceRouter = mockInferenceRouter("inference response"),
|
|
eventStore = eventStore,
|
|
config = TalkieConfig(tokenBudget = TokenBudget(limit = 5000)),
|
|
embedder = embedder,
|
|
l3MemoryStore = l3MemoryStore,
|
|
).let { impl ->
|
|
object : TalkieFacade {
|
|
override suspend fun onUserInput(sessionId: SessionId, input: String, mode: ChatMode): TalkieResponse =
|
|
impl.onUserInput(sessionId = sessionId, input = input, mode = chatMode)
|
|
override suspend fun narrate(sessionId: SessionId, trigger: com.correx.core.talkie.model.NarrationTrigger) =
|
|
impl.narrate(sessionId = sessionId, trigger = trigger)
|
|
}
|
|
}
|
|
|
|
private fun mockInferenceRouter(responseText: String): InferenceRouter =
|
|
object : InferenceRouter {
|
|
override suspend fun route(
|
|
stageId: StageId,
|
|
requiredCapabilities: Set<ModelCapability>,
|
|
): InferenceProvider =
|
|
mockProvider(responseText)
|
|
}
|
|
|
|
private fun mockProvider(responseText: String): InferenceProvider =
|
|
object : InferenceProvider {
|
|
override val id = ProviderId("mock")
|
|
override val name = "Mock"
|
|
override val tokenizer = MockTokenizer()
|
|
override suspend fun infer(request: InferenceRequest): InferenceResponse = InferenceResponse(
|
|
requestId = request.requestId,
|
|
text = responseText,
|
|
finishReason = FinishReason.Stop,
|
|
tokensUsed = TokenUsage(promptTokens = 10, completionTokens = 5),
|
|
latencyMs = 0,
|
|
)
|
|
|
|
override suspend fun healthCheck(): ProviderHealth =
|
|
ProviderHealth.Healthy
|
|
|
|
override fun capabilities(): Set<CapabilityScore> =
|
|
setOf(CapabilityScore(ModelCapability.General, 1.0))
|
|
}
|
|
|
|
private fun mockProviderWithCapture(
|
|
responseText: String,
|
|
requestIds: MutableList<InferenceRequestId>,
|
|
): InferenceProvider =
|
|
object : InferenceProvider {
|
|
override val id = ProviderId("mock")
|
|
override val name = "Mock"
|
|
override val tokenizer = MockTokenizer()
|
|
override suspend fun infer(request: InferenceRequest): InferenceResponse {
|
|
requestIds.add(request.requestId)
|
|
return InferenceResponse(
|
|
requestId = request.requestId,
|
|
text = responseText,
|
|
finishReason = FinishReason.Stop,
|
|
tokensUsed = TokenUsage(promptTokens = 10, completionTokens = 5),
|
|
latencyMs = 0,
|
|
)
|
|
}
|
|
|
|
override suspend fun healthCheck(): ProviderHealth =
|
|
ProviderHealth.Healthy
|
|
|
|
override fun capabilities(): Set<CapabilityScore> = emptySet()
|
|
}
|
|
|
|
private fun mockProviderWithRequestCapture(
|
|
responseText: String,
|
|
requests: MutableList<InferenceRequest>,
|
|
): InferenceProvider =
|
|
object : InferenceProvider {
|
|
override val id = ProviderId("mock")
|
|
override val name = "Mock"
|
|
override val tokenizer = MockTokenizer()
|
|
override suspend fun infer(request: InferenceRequest): InferenceResponse {
|
|
requests.add(request)
|
|
return InferenceResponse(
|
|
requestId = request.requestId,
|
|
text = responseText,
|
|
finishReason = FinishReason.Stop,
|
|
tokensUsed = TokenUsage(promptTokens = 10, completionTokens = 5),
|
|
latencyMs = 0,
|
|
)
|
|
}
|
|
|
|
override suspend fun healthCheck(): ProviderHealth =
|
|
ProviderHealth.Healthy
|
|
|
|
override fun capabilities(): Set<CapabilityScore> = emptySet()
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// L3 retrieval slice 2a
|
|
// --------------------------------------------------------------------------
|
|
|
|
@Test
|
|
fun `retrieval emits L3MemoryRetrievedEvent with cross-session hit`(): Unit = runBlocking {
|
|
val dimension = 8
|
|
val knownVector = FloatArray(dimension) { if (it == 0) 1f else 0f }
|
|
val stubEmbedder = object : Embedder {
|
|
override val dimension: Int = dimension
|
|
override suspend fun embed(text: String): FloatArray = knownVector.copyOf()
|
|
}
|
|
val l3Store = InMemoryL3MemoryStore()
|
|
val otherSessionId = SessionId("other-session")
|
|
val crossSessionEntryId = "cross-entry-id"
|
|
val crossSessionTurnId = "cross-turn-id"
|
|
l3Store.store(
|
|
L3MemoryEntry(
|
|
id = crossSessionEntryId,
|
|
sessionId = otherSessionId,
|
|
turnId = crossSessionTurnId,
|
|
text = "memory from another session",
|
|
vector = knownVector.copyOf(),
|
|
timestampMs = 1000L,
|
|
)
|
|
)
|
|
|
|
val eventStore = mockEventStore()
|
|
val replayer = DefaultEventReplayer<TalkieState>(
|
|
store = eventStore,
|
|
projection = TalkieProjector(DefaultTalkieReducer()),
|
|
)
|
|
val facade = DefaultTalkieFacade(
|
|
routerRepository = object : TalkieRepository {
|
|
override suspend fun getTalkieState(sessionId: SessionId): TalkieState =
|
|
replayer.rebuild(sessionId)
|
|
},
|
|
routerContextBuilder = object : TalkieContextBuilder {
|
|
override suspend fun build(state: TalkieState, budget: TokenBudget, availableWorkflows: List<WorkflowSummary>, projectProfileText: String?): ContextPack = emptyContextPack()
|
|
},
|
|
inferenceRouter = mockInferenceRouter("router reply"),
|
|
eventStore = eventStore,
|
|
config = TalkieConfig(tokenBudget = TokenBudget(limit = 5000), retrievalK = 5),
|
|
embedder = stubEmbedder,
|
|
l3MemoryStore = l3Store,
|
|
)
|
|
|
|
facade.onUserInput(sessionId = SessionId("current-session"), input = "hello")
|
|
|
|
val l3Events = eventStore.appendedEvents.filter { it.payload is L3MemoryRetrievedEvent }
|
|
assertEquals(1, l3Events.size)
|
|
val retrieved = l3Events[0].payload as L3MemoryRetrievedEvent
|
|
assertEquals(1, retrieved.hits.size)
|
|
assertEquals(crossSessionEntryId, retrieved.hits[0].entryId)
|
|
assertEquals(otherSessionId, retrieved.hits[0].sourceSessionId)
|
|
assertEquals(crossSessionTurnId, retrieved.hits[0].sourceTurnId)
|
|
assertEquals("memory from another session", retrieved.hits[0].text)
|
|
}
|
|
|
|
@Test
|
|
fun `dedup filters hit whose turnId is already in current session history`(): Unit = runBlocking {
|
|
val dimension = 8
|
|
val knownVector = FloatArray(dimension) { if (it == 0) 1f else 0f }
|
|
val stubEmbedder = object : Embedder {
|
|
override val dimension: Int = dimension
|
|
override suspend fun embed(text: String): FloatArray = knownVector.copyOf()
|
|
}
|
|
val l3Store = InMemoryL3MemoryStore()
|
|
val currentSessionId = SessionId("dedup-session")
|
|
val inSessionTurnId = "in-session-turn-id"
|
|
|
|
// Pre-populate L3 with an entry whose turnId matches an in-session turn
|
|
l3Store.store(
|
|
L3MemoryEntry(
|
|
id = inSessionTurnId,
|
|
sessionId = currentSessionId,
|
|
turnId = inSessionTurnId,
|
|
text = "already in session history",
|
|
vector = knownVector.copyOf(),
|
|
timestampMs = 500L,
|
|
)
|
|
)
|
|
|
|
val eventStore = mockEventStore()
|
|
val replayer = DefaultEventReplayer<TalkieState>(
|
|
store = eventStore,
|
|
projection = TalkieProjector(DefaultTalkieReducer()),
|
|
)
|
|
|
|
// Bootstrap session with a ChatTurnEvent whose turnId matches the L3 entry
|
|
eventStore.append(
|
|
NewEvent(
|
|
metadata = EventMetadata(
|
|
eventId = EventId("seed-event-id"),
|
|
sessionId = currentSessionId,
|
|
timestamp = kotlinx.datetime.Clock.System.now(),
|
|
schemaVersion = 1,
|
|
causationId = null,
|
|
correlationId = null,
|
|
),
|
|
payload = ChatTurnEvent(
|
|
sessionId = currentSessionId,
|
|
turnId = inSessionTurnId,
|
|
role = ChatTurnRole.USER,
|
|
content = "already in session history",
|
|
timestampMs = 500L,
|
|
),
|
|
)
|
|
)
|
|
|
|
val facade = DefaultTalkieFacade(
|
|
routerRepository = object : TalkieRepository {
|
|
override suspend fun getTalkieState(sessionId: SessionId): TalkieState =
|
|
replayer.rebuild(sessionId)
|
|
},
|
|
routerContextBuilder = object : TalkieContextBuilder {
|
|
override suspend fun build(state: TalkieState, budget: TokenBudget, availableWorkflows: List<WorkflowSummary>, projectProfileText: String?): ContextPack = emptyContextPack()
|
|
},
|
|
inferenceRouter = mockInferenceRouter("router reply"),
|
|
eventStore = eventStore,
|
|
config = TalkieConfig(tokenBudget = TokenBudget(limit = 5000), retrievalK = 5),
|
|
embedder = stubEmbedder,
|
|
l3MemoryStore = l3Store,
|
|
)
|
|
|
|
facade.onUserInput(sessionId = currentSessionId, input = "a new message")
|
|
|
|
val l3Events = eventStore.appendedEvents.filter { it.payload is L3MemoryRetrievedEvent }
|
|
// The only hit was in-session, so deduped to empty → L3MemoryRetrievedEvent emitted with empty hits
|
|
assertEquals(1, l3Events.size, "Expected one L3MemoryRetrievedEvent even when all hits are deduplicated")
|
|
val deduplicatedRetrieved = l3Events[0].payload as L3MemoryRetrievedEvent
|
|
assertTrue(deduplicatedRetrieved.hits.isEmpty(), "Expected empty hits list when all hits are deduplicated")
|
|
}
|
|
|
|
@Test
|
|
fun `retrieval failure is non-fatal - CHAT turn still completes`(): Unit = runBlocking {
|
|
val throwingEmbedder = object : Embedder {
|
|
override val dimension: Int = 8
|
|
override suspend fun embed(text: String): FloatArray =
|
|
throw RuntimeException("embedding service unavailable")
|
|
}
|
|
val eventStore = mockEventStore()
|
|
val facade = DefaultTalkieFacade(
|
|
routerRepository = object : TalkieRepository {
|
|
override suspend fun getTalkieState(sessionId: SessionId): TalkieState = TalkieState()
|
|
},
|
|
routerContextBuilder = object : TalkieContextBuilder {
|
|
override suspend fun build(state: TalkieState, budget: TokenBudget, availableWorkflows: List<WorkflowSummary>, projectProfileText: String?): ContextPack = emptyContextPack()
|
|
},
|
|
inferenceRouter = mockInferenceRouter("response"),
|
|
eventStore = eventStore,
|
|
config = TalkieConfig(tokenBudget = TokenBudget(limit = 5000)),
|
|
embedder = throwingEmbedder,
|
|
l3MemoryStore = InMemoryL3MemoryStore(),
|
|
)
|
|
|
|
// Should not throw despite embedder failure
|
|
val response = facade.onUserInput(sessionId = SessionId("fail-session"), input = "test")
|
|
assertEquals("response", response.content)
|
|
|
|
val payloads = eventStore.appendedEvents.map { it.payload }
|
|
val chatEvents = payloads.filterIsInstance<com.correx.core.events.events.ChatTurnEvent>()
|
|
assertEquals(2, chatEvents.size, "USER and ROUTER ChatTurnEvents must be present")
|
|
// Retrieval failed → deduped is empty; L3MemoryRetrievedEvent still emitted with empty hits
|
|
val l3Events = payloads.filterIsInstance<L3MemoryRetrievedEvent>()
|
|
assertEquals(1, l3Events.size, "L3MemoryRetrievedEvent must be emitted even on retrieval failure")
|
|
assertTrue(l3Events[0].hits.isEmpty(), "Expected empty hits when retrieval failed")
|
|
}
|
|
|
|
@Test
|
|
fun `replay of L3MemoryRetrievedEvent populates lastRetrievedMemory in TalkieState`(): Unit = runBlocking {
|
|
val reducer = DefaultTalkieReducer()
|
|
val projector = TalkieProjector(reducer)
|
|
val sessionId = SessionId("replay-session")
|
|
val hits = listOf(
|
|
L3RetrievedHit(
|
|
entryId = "entry-1",
|
|
sourceSessionId = SessionId("source-session"),
|
|
sourceTurnId = "source-turn-1",
|
|
text = "remembered text",
|
|
score = 0.95f,
|
|
)
|
|
)
|
|
val event = EventFixtures.stored(
|
|
payload = L3MemoryRetrievedEvent(
|
|
sessionId = sessionId,
|
|
queryTurnId = "query-turn-id",
|
|
hits = hits,
|
|
timestampMs = 9999L,
|
|
)
|
|
)
|
|
val state = projector.apply(projector.initial(), event)
|
|
assertEquals(1, state.lastRetrievedMemory.size)
|
|
assertEquals("entry-1", state.lastRetrievedMemory[0].entryId)
|
|
assertEquals("remembered text", state.lastRetrievedMemory[0].text)
|
|
assertEquals(0.95f, state.lastRetrievedMemory[0].score)
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// Slice 2b: ContextTruncatedEvent + end-to-end L3 recall injection
|
|
// --------------------------------------------------------------------------
|
|
|
|
@Test
|
|
fun `ContextTruncatedEvent emitted when context builder drops entries`(): Unit = runBlocking {
|
|
// Use the real DefaultTalkieContextBuilder with a tight budget and large conversation
|
|
val longContent = "x".repeat(800) // ~200 tokens each
|
|
val sessionId = SessionId("truncation-session")
|
|
val eventStore = mockEventStore()
|
|
val replayer = DefaultEventReplayer<TalkieState>(
|
|
store = eventStore,
|
|
projection = TalkieProjector(DefaultTalkieReducer()),
|
|
)
|
|
val facade = DefaultTalkieFacade(
|
|
routerRepository = object : TalkieRepository {
|
|
override suspend fun getTalkieState(sessionId: SessionId): TalkieState =
|
|
replayer.rebuild(sessionId)
|
|
},
|
|
routerContextBuilder = DefaultTalkieContextBuilder(
|
|
config = TalkieConfig(
|
|
conversationKeepLast = 4,
|
|
tokenBudget = TokenBudget(limit = 80),
|
|
),
|
|
),
|
|
inferenceRouter = mockInferenceRouter("response"),
|
|
eventStore = eventStore,
|
|
config = TalkieConfig(
|
|
conversationKeepLast = 4,
|
|
tokenBudget = TokenBudget(limit = 80),
|
|
),
|
|
embedder = NoopEmbedder(dimension = 8),
|
|
l3MemoryStore = InMemoryL3MemoryStore(),
|
|
)
|
|
|
|
// Pre-populate with large conversation turns so budget overflows
|
|
eventStore.append(
|
|
NewEvent(
|
|
metadata = EventMetadata(
|
|
eventId = EventId("seed-1"),
|
|
sessionId = sessionId,
|
|
timestamp = kotlinx.datetime.Clock.System.now(),
|
|
schemaVersion = 1,
|
|
causationId = null,
|
|
correlationId = null,
|
|
),
|
|
payload = ChatTurnEvent(
|
|
sessionId = sessionId,
|
|
turnId = "old-turn",
|
|
role = ChatTurnRole.USER,
|
|
content = longContent,
|
|
timestampMs = 1000L,
|
|
),
|
|
)
|
|
)
|
|
|
|
facade.onUserInput(sessionId = sessionId, input = "current input")
|
|
|
|
val payloads = eventStore.appendedEvents.map { it.payload }
|
|
val truncEvents = payloads.filterIsInstance<ContextTruncatedEvent>()
|
|
assertEquals(1, truncEvents.size)
|
|
val truncEvent = truncEvents[0]
|
|
assertEquals(sessionId, truncEvent.sessionId)
|
|
assertTrue(truncEvent.entriesDropped > 0)
|
|
assertTrue(truncEvent.truncatedLayers.isNotEmpty())
|
|
}
|
|
|
|
@Test
|
|
fun `no ContextTruncatedEvent when budget is generous`(): Unit = runBlocking {
|
|
val eventStore = mockEventStore()
|
|
val facade = DefaultTalkieFacade(
|
|
routerRepository = object : TalkieRepository {
|
|
override suspend fun getTalkieState(sessionId: SessionId): TalkieState = TalkieState()
|
|
},
|
|
routerContextBuilder = DefaultTalkieContextBuilder(
|
|
config = TalkieConfig(tokenBudget = TokenBudget(limit = 10000)),
|
|
),
|
|
inferenceRouter = mockInferenceRouter("response"),
|
|
eventStore = eventStore,
|
|
config = TalkieConfig(tokenBudget = TokenBudget(limit = 10000)),
|
|
embedder = NoopEmbedder(dimension = 8),
|
|
l3MemoryStore = InMemoryL3MemoryStore(),
|
|
)
|
|
facade.onUserInput(sessionId = SessionId("generous-session"), input = "hello")
|
|
val payloads = eventStore.appendedEvents.map { it.payload }
|
|
val truncEvents = payloads.filterIsInstance<ContextTruncatedEvent>()
|
|
assertTrue(truncEvents.isEmpty(), "No ContextTruncatedEvent expected when budget is generous")
|
|
}
|
|
|
|
@Test
|
|
fun `ContextTruncatedEvent carries correct turnId matching userTurnId`(): Unit = runBlocking {
|
|
val longContent = "x".repeat(800)
|
|
val sessionId = SessionId("turnid-session")
|
|
val eventStore = mockEventStore()
|
|
val replayer = DefaultEventReplayer<TalkieState>(
|
|
store = eventStore,
|
|
projection = TalkieProjector(DefaultTalkieReducer()),
|
|
)
|
|
|
|
// Pre-seed with a large turn so the budget overflows on the next call
|
|
eventStore.append(
|
|
NewEvent(
|
|
metadata = EventMetadata(
|
|
eventId = EventId("seed-x"),
|
|
sessionId = sessionId,
|
|
timestamp = kotlinx.datetime.Clock.System.now(),
|
|
schemaVersion = 1,
|
|
causationId = null,
|
|
correlationId = null,
|
|
),
|
|
payload = ChatTurnEvent(
|
|
sessionId = sessionId,
|
|
turnId = "seeded-turn",
|
|
role = ChatTurnRole.USER,
|
|
content = longContent,
|
|
timestampMs = 500L,
|
|
),
|
|
)
|
|
)
|
|
|
|
val facade = DefaultTalkieFacade(
|
|
routerRepository = object : TalkieRepository {
|
|
override suspend fun getTalkieState(sessionId: SessionId): TalkieState =
|
|
replayer.rebuild(sessionId)
|
|
},
|
|
routerContextBuilder = DefaultTalkieContextBuilder(
|
|
config = TalkieConfig(conversationKeepLast = 4, tokenBudget = TokenBudget(limit = 80)),
|
|
),
|
|
inferenceRouter = mockInferenceRouter("response"),
|
|
eventStore = eventStore,
|
|
config = TalkieConfig(conversationKeepLast = 4, tokenBudget = TokenBudget(limit = 80)),
|
|
embedder = NoopEmbedder(dimension = 8),
|
|
l3MemoryStore = InMemoryL3MemoryStore(),
|
|
)
|
|
|
|
facade.onUserInput(sessionId = sessionId, input = "new input")
|
|
|
|
val payloads = eventStore.appendedEvents.map { it.payload }
|
|
val userChatEvent = payloads.filterIsInstance<ChatTurnEvent>().first { it.role == ChatTurnRole.USER && it.content == "new input" }
|
|
val truncEvent = payloads.filterIsInstance<ContextTruncatedEvent>().firstOrNull()
|
|
assertNotNull(truncEvent)
|
|
assertEquals(userChatEvent.turnId, truncEvent!!.turnId)
|
|
}
|
|
|
|
@Test
|
|
fun `empty retrieval always emits L3MemoryRetrievedEvent so next turn does not see stale memory`(): Unit = runBlocking {
|
|
// Scenario: two consecutive CHAT turns with no cross-session L3 entries.
|
|
// Each turn must emit L3MemoryRetrievedEvent (with empty hits), so that
|
|
// lastRetrievedMemory is always current-turn-scoped and never stale.
|
|
val eventStore = mockEventStore()
|
|
val replayer = DefaultEventReplayer<TalkieState>(
|
|
store = eventStore,
|
|
projection = TalkieProjector(DefaultTalkieReducer()),
|
|
)
|
|
val capturedStates = mutableListOf<TalkieState>()
|
|
val facade = DefaultTalkieFacade(
|
|
routerRepository = object : TalkieRepository {
|
|
override suspend fun getTalkieState(sessionId: SessionId): TalkieState =
|
|
replayer.rebuild(sessionId)
|
|
},
|
|
routerContextBuilder = object : TalkieContextBuilder {
|
|
private val real = DefaultTalkieContextBuilder(
|
|
config = TalkieConfig(tokenBudget = TokenBudget(limit = 10000)),
|
|
)
|
|
override suspend fun build(state: TalkieState, budget: TokenBudget, availableWorkflows: List<WorkflowSummary>, projectProfileText: String?): ContextPack {
|
|
capturedStates.add(state)
|
|
return real.build(state, budget)
|
|
}
|
|
},
|
|
inferenceRouter = mockInferenceRouter("reply"),
|
|
eventStore = eventStore,
|
|
config = TalkieConfig(tokenBudget = TokenBudget(limit = 10000), retrievalK = 5),
|
|
embedder = NoopEmbedder(dimension = 8),
|
|
l3MemoryStore = InMemoryL3MemoryStore(),
|
|
)
|
|
|
|
val sid = SessionId("stale-test-session")
|
|
facade.onUserInput(sessionId = sid, input = "first input")
|
|
facade.onUserInput(sessionId = sid, input = "second input")
|
|
|
|
// Exactly two L3MemoryRetrievedEvents must have been emitted — one per CHAT turn
|
|
val l3Events = eventStore.appendedEvents.filter { it.payload is L3MemoryRetrievedEvent }
|
|
assertEquals(2, l3Events.size, "One L3MemoryRetrievedEvent must be emitted per CHAT turn")
|
|
assertTrue((l3Events[0].payload as L3MemoryRetrievedEvent).hits.isEmpty(), "First turn: no cross-session entries → empty hits")
|
|
assertTrue((l3Events[1].payload as L3MemoryRetrievedEvent).hits.isEmpty(), "Second turn: no cross-session entries → empty hits")
|
|
|
|
// State passed to builder on both calls must have empty lastRetrievedMemory
|
|
// (because the L3MemoryRetrievedEvent with empty hits was emitted and replayed before each build)
|
|
assertEquals(2, capturedStates.size)
|
|
assertTrue(
|
|
capturedStates[0].lastRetrievedMemory.isEmpty(),
|
|
"First build: lastRetrievedMemory must be empty (no prior hits)",
|
|
)
|
|
assertTrue(
|
|
capturedStates[1].lastRetrievedMemory.isEmpty(),
|
|
"Second build: lastRetrievedMemory must be empty (stale memory guard)",
|
|
)
|
|
}
|
|
|
|
@Test
|
|
fun `end-to-end L3 hit from cross-session injected into context pack reaching inference`(): Unit = runBlocking {
|
|
val dimension = 8
|
|
val knownVector = FloatArray(dimension) { if (it == 0) 1f else 0f }
|
|
val stubEmbedder = object : Embedder {
|
|
override val dimension: Int = dimension
|
|
override suspend fun embed(text: String): FloatArray = knownVector.copyOf()
|
|
}
|
|
val l3Store = InMemoryL3MemoryStore()
|
|
val otherSessionId = SessionId("cross-session")
|
|
val crossSessionText = "important cross-session memory"
|
|
l3Store.store(
|
|
L3MemoryEntry(
|
|
id = "cross-entry",
|
|
sessionId = otherSessionId,
|
|
turnId = "cross-turn",
|
|
text = crossSessionText,
|
|
vector = knownVector.copyOf(),
|
|
timestampMs = 1000L,
|
|
)
|
|
)
|
|
|
|
val capturedPacks = mutableListOf<ContextPack>()
|
|
val eventStore = mockEventStore()
|
|
val replayer = DefaultEventReplayer<TalkieState>(
|
|
store = eventStore,
|
|
projection = TalkieProjector(DefaultTalkieReducer()),
|
|
)
|
|
val facade = DefaultTalkieFacade(
|
|
routerRepository = object : TalkieRepository {
|
|
override suspend fun getTalkieState(sessionId: SessionId): TalkieState =
|
|
replayer.rebuild(sessionId)
|
|
},
|
|
routerContextBuilder = object : TalkieContextBuilder {
|
|
private val real = DefaultTalkieContextBuilder(
|
|
config = TalkieConfig(tokenBudget = TokenBudget(limit = 10000)),
|
|
)
|
|
override suspend fun build(state: TalkieState, budget: TokenBudget, availableWorkflows: List<WorkflowSummary>, projectProfileText: String?): ContextPack =
|
|
real.build(state, budget).also { capturedPacks.add(it) }
|
|
},
|
|
inferenceRouter = mockInferenceRouter("router reply"),
|
|
eventStore = eventStore,
|
|
config = TalkieConfig(tokenBudget = TokenBudget(limit = 10000), retrievalK = 5),
|
|
embedder = stubEmbedder,
|
|
l3MemoryStore = l3Store,
|
|
)
|
|
|
|
facade.onUserInput(sessionId = SessionId("current-session"), input = "tell me something")
|
|
|
|
assertEquals(1, capturedPacks.size)
|
|
val pack = capturedPacks[0]
|
|
val l3Entries = pack.layers[ContextLayer.L3]
|
|
assertNotNull(l3Entries)
|
|
assertTrue(l3Entries!!.isNotEmpty())
|
|
assertTrue(l3Entries.any { it.content.contains(crossSessionText) })
|
|
assertTrue(l3Entries.any { it.content.contains("[recalled memory]") })
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// Narration model ID routing
|
|
// --------------------------------------------------------------------------
|
|
|
|
@Test
|
|
fun `narrate passes narrationModelId to inferenceRouter 3-arg route`(): Unit = runBlocking {
|
|
val capturedModelId = mutableListOf<String?>()
|
|
val mockInferenceRouter = object : InferenceRouter {
|
|
override suspend fun route(
|
|
stageId: StageId,
|
|
requiredCapabilities: Set<ModelCapability>,
|
|
): InferenceProvider = mockProvider("narration text")
|
|
|
|
override suspend fun route(
|
|
stageId: StageId,
|
|
requiredCapabilities: Set<ModelCapability>,
|
|
modelId: String?,
|
|
): InferenceProvider {
|
|
capturedModelId.add(modelId)
|
|
return mockProvider("narration text")
|
|
}
|
|
}
|
|
val facade = DefaultTalkieFacade(
|
|
routerRepository = object : TalkieRepository {
|
|
override suspend fun getTalkieState(sessionId: SessionId): TalkieState = TalkieState()
|
|
},
|
|
routerContextBuilder = object : TalkieContextBuilder {
|
|
override suspend fun build(state: TalkieState, budget: TokenBudget, availableWorkflows: List<WorkflowSummary>, projectProfileText: String?): ContextPack = emptyContextPack()
|
|
},
|
|
inferenceRouter = mockInferenceRouter,
|
|
eventStore = mockEventStore(),
|
|
config = TalkieConfig(
|
|
tokenBudget = TokenBudget(limit = 5000),
|
|
narrationModelId = "llama-cpp:phi-3-mini",
|
|
),
|
|
embedder = NoopEmbedder(dimension = 8),
|
|
l3MemoryStore = InMemoryL3MemoryStore(),
|
|
)
|
|
facade.narrate(
|
|
sessionId = SessionId("test-session"),
|
|
trigger = com.correx.core.talkie.model.NarrationTrigger(
|
|
kind = "test",
|
|
instruction = "describe what happened",
|
|
),
|
|
)
|
|
assertEquals(1, capturedModelId.size)
|
|
assertEquals("llama-cpp:phi-3-mini", capturedModelId[0])
|
|
}
|
|
|
|
@Test
|
|
fun `onUserInput does NOT pass modelId — uses 2-arg route`(): Unit = runBlocking {
|
|
var threeArgCallCount = 0
|
|
val mockInferenceRouter = object : InferenceRouter {
|
|
override suspend fun route(
|
|
stageId: StageId,
|
|
requiredCapabilities: Set<ModelCapability>,
|
|
): InferenceProvider = mockProvider("response")
|
|
|
|
override suspend fun route(
|
|
stageId: StageId,
|
|
requiredCapabilities: Set<ModelCapability>,
|
|
modelId: String?,
|
|
): InferenceProvider {
|
|
threeArgCallCount++
|
|
return mockProvider("response")
|
|
}
|
|
}
|
|
val facade = DefaultTalkieFacade(
|
|
routerRepository = object : TalkieRepository {
|
|
override suspend fun getTalkieState(sessionId: SessionId): TalkieState = TalkieState()
|
|
},
|
|
routerContextBuilder = object : TalkieContextBuilder {
|
|
override suspend fun build(state: TalkieState, budget: TokenBudget, availableWorkflows: List<WorkflowSummary>, projectProfileText: String?): ContextPack = emptyContextPack()
|
|
},
|
|
inferenceRouter = mockInferenceRouter,
|
|
eventStore = mockEventStore(),
|
|
config = TalkieConfig(
|
|
tokenBudget = TokenBudget(limit = 5000),
|
|
narrationModelId = "llama-cpp:phi-3-mini",
|
|
),
|
|
embedder = NoopEmbedder(dimension = 8),
|
|
l3MemoryStore = InMemoryL3MemoryStore(),
|
|
)
|
|
facade.onUserInput(sessionId = SessionId("test-session"), input = "Hello!")
|
|
assertEquals(0, threeArgCallCount, "onUserInput must not call 3-arg route()")
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// A4 — workflowSummaryProvider and sessionProfileProvider forwarding
|
|
// --------------------------------------------------------------------------
|
|
|
|
@Test
|
|
fun `workflowSummaryProvider is called and forwarded to context builder on each turn`(): Unit = runBlocking {
|
|
val captured = mutableListOf<List<WorkflowSummary>>()
|
|
val capturingBuilder = object : TalkieContextBuilder {
|
|
override suspend fun build(
|
|
state: TalkieState,
|
|
budget: TokenBudget,
|
|
availableWorkflows: List<WorkflowSummary>,
|
|
projectProfileText: String?,
|
|
): ContextPack {
|
|
captured.add(availableWorkflows)
|
|
return emptyContextPack()
|
|
}
|
|
}
|
|
val workflow = WorkflowSummary("wf", "desc", listOf("s1"))
|
|
val facade = facadeWith(contextBuilder = capturingBuilder, workflowSummaryProvider = { listOf(workflow) })
|
|
facade.onUserInput(SessionId("s"), "hello")
|
|
assertEquals(listOf(listOf(workflow)), captured)
|
|
}
|
|
|
|
@Test
|
|
fun `sessionProfileProvider is called and forwarded to context builder`(): Unit = runBlocking {
|
|
val capturedProfiles = mutableListOf<String?>()
|
|
val capturingBuilder = object : TalkieContextBuilder {
|
|
override suspend fun build(
|
|
state: TalkieState,
|
|
budget: TokenBudget,
|
|
availableWorkflows: List<WorkflowSummary>,
|
|
projectProfileText: String?,
|
|
): ContextPack {
|
|
capturedProfiles.add(projectProfileText)
|
|
return emptyContextPack()
|
|
}
|
|
}
|
|
val facade = facadeWith(
|
|
contextBuilder = capturingBuilder,
|
|
sessionProfileProvider = { "profile text" },
|
|
)
|
|
facade.onUserInput(SessionId("s"), "hello")
|
|
assertEquals(listOf("profile text"), capturedProfiles)
|
|
}
|
|
|
|
private fun facadeWith(
|
|
contextBuilder: TalkieContextBuilder = object : TalkieContextBuilder {
|
|
override suspend fun build(
|
|
state: TalkieState,
|
|
budget: TokenBudget,
|
|
availableWorkflows: List<WorkflowSummary>,
|
|
projectProfileText: String?,
|
|
): ContextPack = emptyContextPack()
|
|
},
|
|
workflowSummaryProvider: () -> List<WorkflowSummary> = { emptyList() },
|
|
sessionProfileProvider: suspend (SessionId) -> String? = { null },
|
|
): TalkieFacade = DefaultTalkieFacade(
|
|
routerRepository = object : TalkieRepository {
|
|
override suspend fun getTalkieState(sessionId: SessionId): TalkieState = TalkieState(sessionId = sessionId)
|
|
},
|
|
routerContextBuilder = contextBuilder,
|
|
inferenceRouter = mockInferenceRouter("inference response"),
|
|
eventStore = mockEventStore(),
|
|
config = TalkieConfig(tokenBudget = TokenBudget(limit = 5000)),
|
|
embedder = NoopEmbedder(dimension = 8),
|
|
l3MemoryStore = InMemoryL3MemoryStore(),
|
|
workflowSummaryProvider = workflowSummaryProvider,
|
|
sessionProfileProvider = sessionProfileProvider,
|
|
)
|
|
|
|
private fun emptyContextPack(): ContextPack = ContextPack(
|
|
id = ContextPackId("empty"),
|
|
sessionId = SessionId("unknown"),
|
|
stageId = StageId("none"),
|
|
layers = emptyMap(),
|
|
budgetUsed = 0,
|
|
budgetLimit = 5000,
|
|
)
|
|
|
|
private class MapBackedEventStore : EventStore {
|
|
val appendedEvents = mutableListOf<NewEvent>()
|
|
private val storedEvents: MutableMap<EventId, StoredEvent> = mutableMapOf()
|
|
private var nextSequence = 1L
|
|
|
|
override suspend fun append(event: NewEvent): StoredEvent {
|
|
appendedEvents.add(event)
|
|
val stored = StoredEvent(
|
|
metadata = event.metadata,
|
|
sequence = nextSequence,
|
|
sessionSequence = nextSequence++,
|
|
payload = event.payload,
|
|
)
|
|
storedEvents[event.metadata.eventId] = stored
|
|
return stored
|
|
}
|
|
|
|
override suspend fun appendAll(events: List<NewEvent>): List<StoredEvent> =
|
|
events.map { append(it) }
|
|
|
|
override fun read(sessionId: SessionId): List<StoredEvent> =
|
|
storedEvents.values.filter { it.metadata.sessionId == sessionId }.toList()
|
|
|
|
override fun readFrom(
|
|
sessionId: SessionId,
|
|
fromSequence: Long,
|
|
): List<StoredEvent> =
|
|
read(sessionId).filter { it.sequence >= fromSequence }
|
|
|
|
override fun lastSequence(sessionId: SessionId): Long? =
|
|
read(sessionId).maxOfOrNull { it.sequence }
|
|
|
|
override fun subscribe(sessionId: SessionId): Flow<StoredEvent> =
|
|
throw UnsupportedOperationException("subscribe not implemented for mock")
|
|
|
|
override fun allEvents(): Sequence<StoredEvent> =
|
|
storedEvents.values.asSequence()
|
|
|
|
override fun allSessionIds(): Set<SessionId> = storedEvents.values.map { it.metadata.sessionId }.toSet()
|
|
|
|
override fun subscribeAll(): Flow<StoredEvent> = TODO("Not needed in this test context")
|
|
|
|
override suspend fun lastGlobalSequence(): Long = TODO("Not needed in this test context")
|
|
}
|
|
}
|