fix(chat): keep interrupted replies and notices in place (#1413)
This commit is contained in:
6 files changed
+188
-3
No files matched your search
@@ -952,6 +952,100 @@ class ChatPagingMergeTest {
|
||||
)
|
||||
}
|
||||
|
||||
private fun laterTurnRows(assistantText: String) =
|
||||
listOf(
|
||||
SessionMessage(
|
||||
id = 0,
|
||||
role = "user",
|
||||
content = JsonPrimitive("long running task"),
|
||||
timestamp = JsonPrimitive(1),
|
||||
),
|
||||
SessionMessage(
|
||||
id = 1,
|
||||
role = "assistant",
|
||||
content = JsonPrimitive(assistantText),
|
||||
timestamp = JsonPrimitive(2),
|
||||
),
|
||||
SessionMessage(
|
||||
id = 2,
|
||||
role = "user",
|
||||
content = JsonPrimitive("next question"),
|
||||
timestamp = JsonPrimitive(3),
|
||||
),
|
||||
SessionMessage(
|
||||
id = 3,
|
||||
role = "assistant",
|
||||
content = JsonPrimitive("next answer"),
|
||||
timestamp = JsonPrimitive(4),
|
||||
),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun stopNoticesStayInPlaceWhenInterruptedReplyIsStillLiveOnly() {
|
||||
val prompt = ChatMessage(id = "rest-session-0", role = MessageRole.USER, content = "long running task")
|
||||
val liveReply = ChatMessage(id = "live-reply", role = MessageRole.ASSISTANT, content = "Working on it...")
|
||||
val stop = ChatMessage(id = "uuid-stop", role = MessageRole.USER, content = "/stop")
|
||||
val processes =
|
||||
ChatMessage(id = "uuid-procs", role = MessageRole.SYSTEM, content = "Stopped 2 background processes.")
|
||||
val interrupted = ChatMessage(id = "uuid-int", role = MessageRole.SYSTEM, content = "Session interrupted")
|
||||
|
||||
val merged =
|
||||
applyServerPage(listOf(prompt, liveReply, stop, processes, interrupted), laterTurnRows("Working on it..."))
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
"rest-session-0",
|
||||
"rest-session-1",
|
||||
"uuid-stop",
|
||||
"uuid-procs",
|
||||
"uuid-int",
|
||||
"rest-session-2",
|
||||
"rest-session-3",
|
||||
),
|
||||
merged.map { it.canonicalRestId ?: it.id },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun stopNoticesStayInPlaceWhenLongInterruptedReplyIsAPrefixOfTheServerCopy() {
|
||||
val long = "Working on the migration plan step by step, first the schema then the data"
|
||||
val prompt = ChatMessage(id = "rest-session-0", role = MessageRole.USER, content = "long running task")
|
||||
val liveReply = ChatMessage(id = "live-reply", role = MessageRole.ASSISTANT, content = long)
|
||||
val stop = ChatMessage(id = "uuid-stop", role = MessageRole.USER, content = "/stop")
|
||||
val interrupted = ChatMessage(id = "uuid-int", role = MessageRole.SYSTEM, content = "Session interrupted")
|
||||
|
||||
val merged = applyServerPage(listOf(prompt, liveReply, stop, interrupted), laterTurnRows("$long, then indexes"))
|
||||
|
||||
val ids = merged.map { it.canonicalRestId ?: it.id }
|
||||
assertTrue("notices before later turns: $ids", ids.indexOf("uuid-int") < ids.indexOf("rest-session-2"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun processResultArrivingAfterInterruptNoticeStaysBeforeLaterTurns() {
|
||||
val prompt = ChatMessage(id = "rest-session-0", role = MessageRole.USER, content = "long running task")
|
||||
val reply = ChatMessage(id = "rest-session-1", role = MessageRole.ASSISTANT, content = "Working on it...")
|
||||
val stop = ChatMessage(id = "uuid-stop", role = MessageRole.USER, content = "/stop")
|
||||
val interrupted = ChatMessage(id = "uuid-int", role = MessageRole.SYSTEM, content = "Session interrupted")
|
||||
val processes =
|
||||
ChatMessage(id = "uuid-procs", role = MessageRole.SYSTEM, content = "No background processes to stop.")
|
||||
|
||||
val merged =
|
||||
applyServerPage(listOf(prompt, reply, stop, interrupted, processes), laterTurnRows("Working on it..."))
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
"rest-session-0",
|
||||
"rest-session-1",
|
||||
"uuid-stop",
|
||||
"uuid-int",
|
||||
"uuid-procs",
|
||||
"rest-session-2",
|
||||
"rest-session-3",
|
||||
),
|
||||
merged.map { it.canonicalRestId ?: it.id },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun commandEchoAndOutputStayInPlaceAcrossFutureSyncs() {
|
||||
val prompt = ChatMessage(id = "rest-session-0", role = MessageRole.USER, content = "what model are you?")
|
||||
|
||||
@@ -3922,6 +3922,41 @@ class ChatViewModelTest {
|
||||
verify(exactly = 1) { HermesWsClient.sendMessage(sessionId, "Fresh turn", any(), false) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun lateCompletionAfterInterruptUpdatesSealedReplyInPlace() =
|
||||
runTest {
|
||||
val (viewModel, sessionId) = createViewModelWithSession()
|
||||
mockEventsFlow.emit(WsEvent.MessageStart(sessionId))
|
||||
mockEventsFlow.emit(WsEvent.MessageToken("partial reply", sessionId))
|
||||
advanceUntilIdle()
|
||||
val originalId =
|
||||
viewModel.streamingState.value.streamingMessage!!
|
||||
.id
|
||||
|
||||
viewModel.interruptSession()
|
||||
advanceUntilIdle()
|
||||
val id = sentRequestMethods.last { it.first == WsMethods.SESSION_INTERRUPT }.second
|
||||
mockEventsFlow.emit(WsEvent.RpcResult(id, mapOf("status" to "interrupted")))
|
||||
advanceUntilIdle()
|
||||
assertNull(viewModel.streamingState.value.streamingMessage)
|
||||
|
||||
mockEventsFlow.emit(WsEvent.MessageToken("late tail", sessionId))
|
||||
advanceUntilIdle()
|
||||
assertNull(viewModel.streamingState.value.streamingMessage)
|
||||
mockEventsFlow.emit(WsEvent.MessageComplete("partial reply with final tail", sessionId))
|
||||
advanceUntilIdle()
|
||||
|
||||
val messages = viewModel.uiState.value.messages
|
||||
val replies = messages.filter { it.role == MessageRole.ASSISTANT }
|
||||
assertEquals(1, replies.size)
|
||||
assertEquals(originalId, replies.single().id)
|
||||
assertEquals("partial reply with final tail", replies.single().content)
|
||||
assertTrue(
|
||||
messages.indexOf(replies.single()) < messages.indexOfFirst { it.content == "Session interrupted" },
|
||||
)
|
||||
assertNull(viewModel.streamingState.value.interruptedMessage)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hardInterruptWaitsForAckBeforeSubmitting() =
|
||||
runTest {
|
||||
|
||||
@@ -9,6 +9,43 @@ import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ChatWsEventReducerTest {
|
||||
@Test
|
||||
fun interruptedIdentitySurvivesDoneButNotANewStart() {
|
||||
val partial = ChatMessage(id = "partial", role = MessageRole.ASSISTANT, content = "old reply")
|
||||
val state = ChatUiState(currentSessionId = "session-1", messages = listOf(partial))
|
||||
val interrupted = StreamingState(interruptedMessage = partial)
|
||||
val done =
|
||||
ChatWsEventReducer.reduce(
|
||||
state,
|
||||
interrupted,
|
||||
WsEvent.MessageDone("session-1"),
|
||||
"session-1",
|
||||
)
|
||||
assertEquals(partial, done.streamingState.interruptedMessage)
|
||||
val started =
|
||||
ChatWsEventReducer.reduce(
|
||||
done.state,
|
||||
done.streamingState,
|
||||
WsEvent.MessageStart("session-1"),
|
||||
"session-1",
|
||||
)
|
||||
assertNull(started.streamingState.interruptedMessage)
|
||||
val complete =
|
||||
ChatWsEventReducer.reduce(
|
||||
started.state,
|
||||
started.streamingState,
|
||||
WsEvent.MessageComplete("new reply", "session-1"),
|
||||
"session-1",
|
||||
)
|
||||
assertEquals(
|
||||
"old reply",
|
||||
complete.state.messages
|
||||
.first()
|
||||
.content,
|
||||
)
|
||||
assertEquals(2, complete.state.messages.count { it.role == MessageRole.ASSISTANT })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testMessageComplete_clearsResolvedClarifyRequest() {
|
||||
val state =
|
||||
|
||||
Reference in new issue
Block a user