diff --git a/domain/src/main/kotlin/com/gamss/android/domain/conversation/CommentRevealPolicy.kt b/domain/src/main/kotlin/com/gamss/android/domain/conversation/CommentRevealPolicy.kt index 8787922b..3f5202f7 100644 --- a/domain/src/main/kotlin/com/gamss/android/domain/conversation/CommentRevealPolicy.kt +++ b/domain/src/main/kotlin/com/gamss/android/domain/conversation/CommentRevealPolicy.kt @@ -2,7 +2,7 @@ package com.gamss.android.domain.conversation import kotlin.random.Random -const val COMMENT_REVEAL_MIN_GAP_MILLIS = 1_000L +const val COMMENT_REVEAL_MIN_GAP_MILLIS = 500L const val COMMENT_REVEAL_MAX_GAP_MILLIS = 3_000L fun nextCommentRevealGapMillis(random: Random = Random.Default): Long = diff --git a/domain/src/main/kotlin/com/gamss/android/domain/conversation/ConversationSession.kt b/domain/src/main/kotlin/com/gamss/android/domain/conversation/ConversationSession.kt index 2f8c1bf9..cda89e1c 100644 --- a/domain/src/main/kotlin/com/gamss/android/domain/conversation/ConversationSession.kt +++ b/domain/src/main/kotlin/com/gamss/android/domain/conversation/ConversationSession.kt @@ -23,6 +23,7 @@ class ConversationSession @Inject constructor( private val createConversationCard: CreateConversationCardUseCase, private val summaryStore: ConversationSummaryStore, private val emotionAccumulator: ConversationEmotionAccumulator, + private val pendingReveal: PendingConversationReveal, ) { private val titleMutex = Mutex() @@ -75,11 +76,21 @@ class ConversationSession @Inject constructor( seed = result.data.message.content, ), ) + pendingReveal.save(result.data) } } return result } + /** 홈 쪽 인스턴스에서만 채워진 요약·감정 상태를, [restore] 와 같은 방식으로 이 인스턴스에도 시드해 둔다. */ + suspend fun consumePendingReveal(conversationId: Long): SentMessage? { + val sent = pendingReveal.consume(conversationId) ?: return null + val utterances = listOf(sent.message).userUtterances() + summaryStore.restore(utterances) + emotionAccumulator.restore(utterances) + return sent + } + suspend fun finishSend() = coroutineScope { launch { assignPendingTitle() } launch { diff --git a/domain/src/main/kotlin/com/gamss/android/domain/conversation/PendingConversationReveal.kt b/domain/src/main/kotlin/com/gamss/android/domain/conversation/PendingConversationReveal.kt new file mode 100644 index 00000000..9e555a5d --- /dev/null +++ b/domain/src/main/kotlin/com/gamss/android/domain/conversation/PendingConversationReveal.kt @@ -0,0 +1,29 @@ +package com.gamss.android.domain.conversation + +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import javax.inject.Inject +import javax.inject.Singleton + +const val PENDING_REVEAL_EXPIRY_MILLIS = 30_000L + +@Singleton +class PendingConversationReveal @Inject constructor() { + + private val mutex = Mutex() + + private var pending: Entry? = null + + suspend fun save(sent: SentMessage, now: Long = System.currentTimeMillis()) { + mutex.withLock { pending = Entry(sent, now) } + } + + suspend fun consume(conversationId: Long, now: Long = System.currentTimeMillis()): SentMessage? = + mutex.withLock { + val current = pending?.takeIf { it.sent.message.conversationId == conversationId } ?: return@withLock null + pending = null + current.sent.takeIf { now - current.savedAtMillis <= PENDING_REVEAL_EXPIRY_MILLIS } + } + + private data class Entry(val sent: SentMessage, val savedAtMillis: Long) +} diff --git a/domain/src/test/kotlin/com/gamss/android/domain/conversation/ConversationSessionTest.kt b/domain/src/test/kotlin/com/gamss/android/domain/conversation/ConversationSessionTest.kt index 21502d2c..d105583b 100644 --- a/domain/src/test/kotlin/com/gamss/android/domain/conversation/ConversationSessionTest.kt +++ b/domain/src/test/kotlin/com/gamss/android/domain/conversation/ConversationSessionTest.kt @@ -149,6 +149,68 @@ class ConversationSessionTest { assertEquals(1, repository.updatedTitles.size) } + @Test + fun 홈이_새_대화를_열면_다른_인스턴스인_채팅방도_reveal_캐시를_받는다() = runBlocking { + val repository = FakeConversationRepository() + val reveal = PendingConversationReveal() + val homeSession = session(repository, reveal) + val chatSession = session(repository, reveal) + + val result = homeSession.send(conversationId = null, content = SEED, replyToMessageId = null) + val sent = (result as AppResult.Success).data + + assertEquals(sent, chatSession.consumePendingReveal(ROOM_ID)) + } + + @Test + fun 채팅방이_reveal_캐시를_소비하면_요약_상태도_이어받는다() = runBlocking { + val repository = FakeConversationRepository() + val reveal = PendingConversationReveal() + val homeSession = session(repository, reveal) + val chatSession = session(repository, reveal) + + homeSession.send(conversationId = null, content = SEED, replyToMessageId = null) + chatSession.consumePendingReveal(ROOM_ID) + chatSession.send(conversationId = ROOM_ID, content = "팀장이 또 그랬어", replyToMessageId = null) + + assertEquals(listOf(null, SEED), repository.sentContextSummaries) + } + + @Test + fun reveal_캐시는_한_번_꺼내면_비워진다() = runBlocking { + val repository = FakeConversationRepository() + val reveal = PendingConversationReveal() + val homeSession = session(repository, reveal) + val chatSession = session(repository, reveal) + + homeSession.send(conversationId = null, content = SEED, replyToMessageId = null) + chatSession.consumePendingReveal(ROOM_ID) + + assertEquals(null, chatSession.consumePendingReveal(ROOM_ID)) + } + + @Test + fun 다른_대화_ID로는_reveal_캐시를_꺼내지_못한다() = runBlocking { + val repository = FakeConversationRepository() + val reveal = PendingConversationReveal() + val homeSession = session(repository, reveal) + val chatSession = session(repository, reveal) + + homeSession.send(conversationId = null, content = SEED, replyToMessageId = null) + + assertEquals(null, chatSession.consumePendingReveal(OTHER_ROOM_ID)) + } + + @Test + fun 이어_쓰는_대화는_reveal_캐시를_남기지_않는다() = runBlocking { + val repository = FakeConversationRepository() + val session = session(repository) + + session.continueWith(SEED) + + assertEquals(null, session.consumePendingReveal(ROOM_ID)) + } + @Test fun 제목으로_쓸_글자가_없으면_지정을_시도하지_않는다() = runBlocking { val repository = FakeConversationRepository() @@ -182,7 +244,15 @@ class ConversationSessionTest { finishSend() } - private fun session(repository: FakeConversationRepository) = ConversationSession( + /** + * 홈과 채팅방은 각자 다른 [ConversationSession] 인스턴스를 Hilt에서 주입받는다(무스코프). + * [pendingReveal]을 생략하면 새 인스턴스가 만들어져 서로 다른 화면처럼 격리되고, 같은 + * [PendingConversationReveal]을 넘기면 그 두 인스턴스가 캐시만 공유하는 실제 배선을 재현한다. + */ + private fun session( + repository: FakeConversationRepository, + pendingReveal: PendingConversationReveal = PendingConversationReveal(), + ) = ConversationSession( sendMessage = SendMessageUseCase(repository), getMessages = GetMessagesUseCase(repository), updateConversationTitle = UpdateConversationTitleUseCase(repository), @@ -196,6 +266,7 @@ class ConversationSessionTest { tokenCounter = CharLengthTokenCounter, ), emotionAccumulator = ConversationEmotionAccumulator(FlatClassifier), + pendingReveal = pendingReveal, ) private object PassThroughSummarizer : DiarySummarizer { diff --git a/domain/src/test/kotlin/com/gamss/android/domain/conversation/PendingConversationRevealTest.kt b/domain/src/test/kotlin/com/gamss/android/domain/conversation/PendingConversationRevealTest.kt new file mode 100644 index 00000000..a471121e --- /dev/null +++ b/domain/src/test/kotlin/com/gamss/android/domain/conversation/PendingConversationRevealTest.kt @@ -0,0 +1,79 @@ +package com.gamss.android.domain.conversation + +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class PendingConversationRevealTest { + + @Test + fun 저장한_값을_같은_대화_ID로_꺼낼_수_있다() = runBlocking { + val reveal = PendingConversationReveal() + val sent = sentMessage() + + reveal.save(sent, now = BASE_TIME) + + assertEquals(sent, reveal.consume(ROOM_ID, now = BASE_TIME)) + } + + @Test + fun 한_번_꺼내면_다시는_꺼낼_수_없다() = runBlocking { + val reveal = PendingConversationReveal() + reveal.save(sentMessage(), now = BASE_TIME) + + reveal.consume(ROOM_ID, now = BASE_TIME) + + assertNull(reveal.consume(ROOM_ID, now = BASE_TIME)) + } + + @Test + fun 다른_대화_ID로는_꺼낼_수_없고_원래_값은_남는다() = runBlocking { + val reveal = PendingConversationReveal() + val sent = sentMessage() + reveal.save(sent, now = BASE_TIME) + + assertNull(reveal.consume(OTHER_ROOM_ID, now = BASE_TIME)) + assertEquals(sent, reveal.consume(ROOM_ID, now = BASE_TIME)) + } + + @Test + fun 만료_시간_이내면_꺼낼_수_있다() = runBlocking { + val reveal = PendingConversationReveal() + reveal.save(sentMessage(), now = BASE_TIME) + + val result = reveal.consume(ROOM_ID, now = BASE_TIME + PENDING_REVEAL_EXPIRY_MILLIS) + + assertEquals(ROOM_ID, result?.message?.conversationId) + } + + @Test + fun 만료_시간이_지나면_꺼내지_못하고_폐기된다() = runBlocking { + val reveal = PendingConversationReveal() + reveal.save(sentMessage(), now = BASE_TIME) + + val expired = reveal.consume(ROOM_ID, now = BASE_TIME + PENDING_REVEAL_EXPIRY_MILLIS + 1) + + assertNull(expired) + // 만료돼 버려졌으니 시간을 되돌려도 다시 꺼낼 수 없다 — 자리는 이미 비었다. + assertNull(reveal.consume(ROOM_ID, now = BASE_TIME)) + } + + private fun sentMessage() = SentMessage( + message = Message( + id = MESSAGE_ID, + conversationId = ROOM_ID, + sender = MessageSender.User, + content = "아 진짜 짜증나", + ), + commentStatus = CommentGenerationStatus.DONE, + comments = emptyList(), + ) + + private companion object { + const val ROOM_ID = 7L + const val OTHER_ROOM_ID = 8L + const val MESSAGE_ID = 100L + const val BASE_TIME = 1_700_000_000_000L + } +} diff --git a/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomViewModel.kt b/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomViewModel.kt index e6dc3353..34170837 100644 --- a/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomViewModel.kt +++ b/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomViewModel.kt @@ -62,6 +62,20 @@ class ChatRoomViewModel @Inject constructor( private fun loadMessages(conversationId: Long) = intent { reduce { state.copy(conversationId = conversationId, isLoading = true) } + + val pending = session.consumePendingReveal(conversationId) + if (pending != null) { + reduce { + state.copy( + isLoading = false, + messages = listOf(pending.message), + pendingComments = pending.comments, + ) + } + launchCommentReveal() + return@intent + } + when (val result = session.restore(conversationId)) { is AppResult.Success -> reduce { state.copy(isLoading = false, messages = result.data, pendingComments = emptyList()) } @@ -136,8 +150,8 @@ class ChatRoomViewModel @Inject constructor( state.copy( isSending = false, conversationId = sent.message.conversationId, - messages = state.messages + sent.message + sent.comments.take(1), - pendingComments = sent.comments.drop(1), + messages = state.messages + sent.message, + pendingComments = sent.comments, input = if (state.input == sending.content) "" else state.input, replyTarget = state.replyTarget.takeIf { it?.messageId != sending.replyToMessageId }, ) diff --git a/feature/chat/src/test/java/com/gamss/android/feature/chat/ChatRoomLoadTest.kt b/feature/chat/src/test/java/com/gamss/android/feature/chat/ChatRoomLoadTest.kt new file mode 100644 index 00000000..e671cd9f --- /dev/null +++ b/feature/chat/src/test/java/com/gamss/android/feature/chat/ChatRoomLoadTest.kt @@ -0,0 +1,110 @@ +package com.gamss.android.feature.chat + +import com.gamss.android.domain.card.CreateCardUseCase +import com.gamss.android.domain.card.CreateConversationCardUseCase +import com.gamss.android.domain.conversation.ConversationSession +import com.gamss.android.domain.conversation.ConversationSummaryStore +import com.gamss.android.domain.conversation.EndConversationUseCase +import com.gamss.android.domain.conversation.GetMessagesUseCase +import com.gamss.android.domain.conversation.PendingConversationReveal +import com.gamss.android.domain.conversation.SendMessageUseCase +import com.gamss.android.domain.conversation.UpdateConversationTitleUseCase +import com.gamss.android.domain.emotion.ConversationEmotionAccumulator +import com.gamss.android.domain.summary.SummarizeDiaryUseCase +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.orbitmvi.orbit.test.test + +@OptIn(ExperimentalCoroutinesApi::class) +class ChatRoomLoadTest { + + @Before + fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun 홈에서_보낸_첫_교환은_채팅방_진입시_순차_노출된다() = runTest { + val repository = FakeConversationRepository(commentCount = 3) + val reveal = PendingConversationReveal() + val homeSession = homeSession(repository, reveal) + homeSession.send(conversationId = null, content = INPUT, replyToMessageId = null) + val viewModel = chatRoomViewModel(conversationRepository = repository, pendingReveal = reveal) + + viewModel.test(this) { + containerHost.start(ROOM_ID) + skipItems(1) // useChatEndFeature 반영 + skipItems(1) // isLoading = true + + val afterLoad = awaitState() + assertEquals(listOf(USER_ID), afterLoad.messages.map { it.id }) + assertEquals( + listOf(COMMENT_ID_BASE + 0, COMMENT_ID_BASE + 1, COMMENT_ID_BASE + 2), + afterLoad.pendingComments.map { it.id }, + ) + + val firstReveal = awaitState() + assertEquals(COMMENT_ID_BASE + 0, firstReveal.messages.last().id) + + val secondReveal = awaitState() + assertEquals(COMMENT_ID_BASE + 1, secondReveal.messages.last().id) + + val thirdReveal = awaitState() + assertEquals(COMMENT_ID_BASE + 2, thirdReveal.messages.last().id) + assertTrue(thirdReveal.pendingComments.isEmpty()) + + cancelAndIgnoreRemainingItems() + } + } + + @Test + fun reveal_캐시가_없으면_서버에서_다시_불러온다() = runTest { + val repository = FakeConversationRepository(commentCount = 3) + val viewModel = chatRoomViewModel(conversationRepository = repository) + + viewModel.test(this) { + containerHost.start(ROOM_ID) + skipItems(1) // useChatEndFeature 반영 + skipItems(1) // isLoading = true + + val afterLoad = awaitState() + assertTrue(afterLoad.messages.isEmpty()) + assertTrue(afterLoad.pendingComments.isEmpty()) + + cancelAndIgnoreRemainingItems() + } + } + + /** 홈 화면이 실제로 받는 것과 같은 모양의, 채팅방과는 별개인 ConversationSession 인스턴스. */ + private fun homeSession(repository: FakeConversationRepository, pendingReveal: PendingConversationReveal) = + ConversationSession( + sendMessage = SendMessageUseCase(repository), + getMessages = GetMessagesUseCase(repository), + updateConversationTitle = UpdateConversationTitleUseCase(repository), + endConversation = EndConversationUseCase(repository), + createConversationCard = CreateConversationCardUseCase( + summarizeDiary = SummarizeDiaryUseCase(PassThroughSummarizer), + createCard = CreateCardUseCase(CountingCardRepository()), + ), + summaryStore = ConversationSummaryStore( + summarizer = PassThroughSummarizer, + tokenCounter = CharLengthTokenCounter, + ), + emotionAccumulator = ConversationEmotionAccumulator(FlatClassifier), + pendingReveal = pendingReveal, + ) +} diff --git a/feature/chat/src/test/java/com/gamss/android/feature/chat/ChatRoomRevealTest.kt b/feature/chat/src/test/java/com/gamss/android/feature/chat/ChatRoomRevealTest.kt index 3b7403af..985f0c9c 100644 --- a/feature/chat/src/test/java/com/gamss/android/feature/chat/ChatRoomRevealTest.kt +++ b/feature/chat/src/test/java/com/gamss/android/feature/chat/ChatRoomRevealTest.kt @@ -29,7 +29,7 @@ class ChatRoomRevealTest { } @Test - fun 첫_댓글만_즉시_붙고_나머지는_하나씩_노출된다() = runTest { + fun 댓글은_첫_번째부터_하나씩_지연되어_노출된다() = runTest { val viewModel = viewModel(commentCount = 3) viewModel.test(this) { @@ -40,18 +40,25 @@ class ChatRoomRevealTest { skipItems(1) // isSending = true val afterSend = awaitState() - assertEquals(listOf(USER_ID, COMMENT_ID_BASE + 0), afterSend.messages.map { it.id }) - assertEquals(listOf(COMMENT_ID_BASE + 1, COMMENT_ID_BASE + 2), afterSend.pendingComments.map { it.id }) + assertEquals(listOf(USER_ID), afterSend.messages.map { it.id }) + assertEquals( + listOf(COMMENT_ID_BASE + 0, COMMENT_ID_BASE + 1, COMMENT_ID_BASE + 2), + afterSend.pendingComments.map { it.id }, + ) assertTrue(afterSend.isAwaitingComments) val firstReveal = awaitState() - assertEquals(COMMENT_ID_BASE + 1, firstReveal.messages.last().id) - assertEquals(1, firstReveal.pendingComments.size) + assertEquals(COMMENT_ID_BASE + 0, firstReveal.messages.last().id) + assertEquals(2, firstReveal.pendingComments.size) val secondReveal = awaitState() - assertEquals(COMMENT_ID_BASE + 2, secondReveal.messages.last().id) - assertTrue(secondReveal.pendingComments.isEmpty()) - assertFalse(secondReveal.isAwaitingComments) + assertEquals(COMMENT_ID_BASE + 1, secondReveal.messages.last().id) + assertEquals(1, secondReveal.pendingComments.size) + + val thirdReveal = awaitState() + assertEquals(COMMENT_ID_BASE + 2, thirdReveal.messages.last().id) + assertTrue(thirdReveal.pendingComments.isEmpty()) + assertFalse(thirdReveal.isAwaitingComments) cancelAndIgnoreRemainingItems() } @@ -68,7 +75,7 @@ class ChatRoomRevealTest { skipItems(1) // isSending = true val afterSend = awaitState() - assertEquals(3, afterSend.pendingComments.size) + assertEquals(4, afterSend.pendingComments.size) containerHost.onInputChange(INPUT) skipItems(1) // input 반영 @@ -83,7 +90,7 @@ class ChatRoomRevealTest { } @Test - fun 댓글이_하나면_노출_대기가_없다() = runTest { + fun 댓글이_하나여도_지연_후_노출된다() = runTest { val viewModel = viewModel(commentCount = 1) viewModel.test(this) { @@ -93,8 +100,12 @@ class ChatRoomRevealTest { skipItems(1) // isSending = true val afterSend = awaitState() - assertTrue(afterSend.pendingComments.isEmpty()) - assertEquals(2, afterSend.messages.size) + assertEquals(1, afterSend.pendingComments.size) + assertEquals(1, afterSend.messages.size) + + val afterReveal = awaitState() + assertTrue(afterReveal.pendingComments.isEmpty()) + assertEquals(2, afterReveal.messages.size) cancelAndIgnoreRemainingItems() } diff --git a/feature/chat/src/test/java/com/gamss/android/feature/chat/ChatRoomTestFakes.kt b/feature/chat/src/test/java/com/gamss/android/feature/chat/ChatRoomTestFakes.kt index 64323f47..9d451cc6 100644 --- a/feature/chat/src/test/java/com/gamss/android/feature/chat/ChatRoomTestFakes.kt +++ b/feature/chat/src/test/java/com/gamss/android/feature/chat/ChatRoomTestFakes.kt @@ -17,6 +17,7 @@ import com.gamss.android.domain.conversation.EndConversationUseCase import com.gamss.android.domain.conversation.GetMessagesUseCase import com.gamss.android.domain.conversation.Message import com.gamss.android.domain.conversation.MessageSender +import com.gamss.android.domain.conversation.PendingConversationReveal import com.gamss.android.domain.conversation.SendMessageUseCase import com.gamss.android.domain.conversation.SentMessage import com.gamss.android.domain.conversation.UpdateConversationTitleUseCase @@ -51,14 +52,8 @@ internal fun chatRoomViewModel( classifier: EmotionClassifier = FlatClassifier, tokenUsageRefreshNotifier: TokenUsageRefreshNotifier = RecordingTokenUsageRefreshNotifier(), remoteConfigRepository: RemoteConfigRepository = FakeRemoteConfigRepository(), -): ChatRoomViewModel = ChatRoomViewModel( - tokenUsageRefreshNotifier = tokenUsageRefreshNotifier, - detectRiskInText = DetectRiskInTextUseCase( - repository = NoRiskLexiconRepository, - matcher = RiskTermMatcher(), - ), - getRemoteConfigFlag = GetRemoteConfigFlagUseCase(remoteConfigRepository), - session = ConversationSession( + pendingReveal: PendingConversationReveal = PendingConversationReveal(), + session: ConversationSession = ConversationSession( sendMessage = SendMessageUseCase(conversationRepository), getMessages = GetMessagesUseCase(conversationRepository), updateConversationTitle = UpdateConversationTitleUseCase(conversationRepository), @@ -72,7 +67,16 @@ internal fun chatRoomViewModel( tokenCounter = CharLengthTokenCounter, ), emotionAccumulator = ConversationEmotionAccumulator(classifier), + pendingReveal = pendingReveal, ), +): ChatRoomViewModel = ChatRoomViewModel( + tokenUsageRefreshNotifier = tokenUsageRefreshNotifier, + detectRiskInText = DetectRiskInTextUseCase( + repository = NoRiskLexiconRepository, + matcher = RiskTermMatcher(), + ), + getRemoteConfigFlag = GetRemoteConfigFlagUseCase(remoteConfigRepository), + session = session, ) /** 원격 설정 조회 없이 항상 켜진 값을 돌려준다. 값 자체를 검증하는 테스트는 별도로 stub 한다. */ diff --git a/feature/home/src/test/java/com/gamss/android/feature/home/HomeTestFakes.kt b/feature/home/src/test/java/com/gamss/android/feature/home/HomeTestFakes.kt index ca5c12e6..17fcdf88 100644 --- a/feature/home/src/test/java/com/gamss/android/feature/home/HomeTestFakes.kt +++ b/feature/home/src/test/java/com/gamss/android/feature/home/HomeTestFakes.kt @@ -14,6 +14,7 @@ import com.gamss.android.domain.conversation.EndConversationUseCase import com.gamss.android.domain.conversation.GetMessagesUseCase import com.gamss.android.domain.conversation.Message import com.gamss.android.domain.conversation.MessageSender +import com.gamss.android.domain.conversation.PendingConversationReveal import com.gamss.android.domain.conversation.SendMessageUseCase import com.gamss.android.domain.conversation.SentMessage import com.gamss.android.domain.conversation.UpdateConversationTitleUseCase @@ -43,6 +44,7 @@ internal fun conversationSession(repository: ConversationRepository) = Conversat tokenCounter = CharLengthTokenCounter, ), emotionAccumulator = ConversationEmotionAccumulator(FlatClassifier), + pendingReveal = PendingConversationReveal(), ) /**