fix(chat): anchor local commands, feedback, and clarify replies in transcript (#1241)
This commit is contained in:
1 parent
844a88daa4
commit
c9bbadf5d5
6 files changed
+268
-15
No files matched your search
@@ -748,6 +748,151 @@ class ChatPagingMergeTest {
|
||||
assertEquals(listOf("rest-s-1", "live-reply", "review", "rest-s-3"), synced.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun stopCommandAndSessionInterruptedStayInPlaceAcrossFutureSyncs() {
|
||||
val prompt = ChatMessage(id = "rest-session-0", role = MessageRole.USER, content = "long running task")
|
||||
val partialAssistant =
|
||||
ChatMessage(id = "rest-session-1", role = MessageRole.ASSISTANT, content = "Working on it...")
|
||||
val stopCommand = ChatMessage(id = "uuid-stop", role = MessageRole.USER, content = "/stop")
|
||||
val interrupted =
|
||||
ChatMessage(id = "uuid-interrupted", role = MessageRole.SYSTEM, content = "Session interrupted")
|
||||
|
||||
val current = listOf(prompt, partialAssistant, stopCommand, interrupted)
|
||||
|
||||
// Now later turn arrives from server: user asks something else, assistant answers
|
||||
val serverRows =
|
||||
listOf(
|
||||
SessionMessage(
|
||||
id = 0,
|
||||
role = "user",
|
||||
content = JsonPrimitive("long running task"),
|
||||
timestamp = JsonPrimitive(1),
|
||||
),
|
||||
SessionMessage(
|
||||
id = 1,
|
||||
role = "assistant",
|
||||
content = JsonPrimitive("Working on it..."),
|
||||
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),
|
||||
),
|
||||
)
|
||||
|
||||
val merged = applyServerPage(current, serverRows)
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
"rest-session-0",
|
||||
"rest-session-1",
|
||||
"uuid-stop",
|
||||
"uuid-interrupted",
|
||||
"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?")
|
||||
val answer = ChatMessage(id = "rest-session-1", role = MessageRole.ASSISTANT, content = "I am Hermes.")
|
||||
val command = ChatMessage(id = "uuid-cmd", role = MessageRole.USER, content = "/reasoning")
|
||||
val output =
|
||||
ChatMessage(id = "uuid-output", role = MessageRole.ASSISTANT, content = "reasoning: low · display on")
|
||||
|
||||
val current = listOf(prompt, answer, command, output)
|
||||
|
||||
val serverRows =
|
||||
listOf(
|
||||
SessionMessage(
|
||||
id = 0,
|
||||
role = "user",
|
||||
content = JsonPrimitive("what model are you?"),
|
||||
timestamp = JsonPrimitive(1),
|
||||
),
|
||||
SessionMessage(
|
||||
id = 1,
|
||||
role = "assistant",
|
||||
content = JsonPrimitive("I am Hermes."),
|
||||
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),
|
||||
),
|
||||
)
|
||||
|
||||
val merged = applyServerPage(current, serverRows)
|
||||
|
||||
assertEquals(
|
||||
listOf("rest-session-0", "rest-session-1", "uuid-cmd", "uuid-output", "rest-session-2", "rest-session-3"),
|
||||
merged.map { it.canonicalRestId ?: it.id },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clarifyAnswerStaysInPlaceAcrossFutureSyncs() {
|
||||
val prompt = ChatMessage(id = "rest-session-0", role = MessageRole.USER, content = "deploy this")
|
||||
val assistant = ChatMessage(id = "rest-session-1", role = MessageRole.ASSISTANT, content = "Where?")
|
||||
val clarifyAnswer =
|
||||
ChatMessage(
|
||||
id = "uuid-clarify",
|
||||
role = MessageRole.USER,
|
||||
content = "production",
|
||||
displayKind = "clarify_response",
|
||||
)
|
||||
|
||||
val current = listOf(prompt, assistant, clarifyAnswer)
|
||||
|
||||
val serverRows =
|
||||
listOf(
|
||||
SessionMessage(
|
||||
id = 0,
|
||||
role = "user",
|
||||
content = JsonPrimitive("deploy this"),
|
||||
timestamp = JsonPrimitive(1),
|
||||
),
|
||||
SessionMessage(
|
||||
id = 1,
|
||||
role = "assistant",
|
||||
content = JsonPrimitive("Where?"),
|
||||
timestamp = JsonPrimitive(2),
|
||||
),
|
||||
SessionMessage(
|
||||
id = 2,
|
||||
role = "assistant",
|
||||
content = JsonPrimitive("Deploying to production now..."),
|
||||
timestamp = JsonPrimitive(3),
|
||||
),
|
||||
)
|
||||
|
||||
val merged = applyServerPage(current, serverRows)
|
||||
|
||||
assertEquals(
|
||||
listOf("rest-session-0", "rest-session-1", "uuid-clarify", "rest-session-2"),
|
||||
merged.map { it.canonicalRestId ?: it.id },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sessionCreatedMarkerStaysAtStartOfTranscript() {
|
||||
val sessionCreated =
|
||||
|
||||
@@ -700,6 +700,86 @@ class ChatViewModelTest {
|
||||
verify { HermesWsClient.send(WsMethods.SESSION_INTERRUPT, any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSlashCommand_stop_andInterruptedNoticeStayInPlaceAcrossSync() =
|
||||
runTest {
|
||||
val (viewModel, sessionId) = createViewModelWithSession()
|
||||
|
||||
// 1. Initial messages in UI state
|
||||
val m0 = ChatMessage(id = "rest-$sessionId-0", role = MessageRole.USER, content = "start task")
|
||||
val m1 = ChatMessage(id = "rest-$sessionId-1", role = MessageRole.ASSISTANT, content = "running...")
|
||||
val stateField =
|
||||
ChatViewModel::class.java
|
||||
.getDeclaredField("_uiState")
|
||||
.apply { isAccessible = true }
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val state = stateField.get(viewModel) as MutableStateFlow<ChatUiState>
|
||||
state.value = viewModel.uiState.value.copy(messages = listOf(m0, m1))
|
||||
|
||||
every {
|
||||
HermesWsClient.send(WsMethods.SESSION_INTERRUPT, any(), any())
|
||||
} answers {
|
||||
val onSent = thirdArg<((String) -> Unit)?>()
|
||||
onSent?.invoke("interrupt-req-1")
|
||||
"interrupt-req-1"
|
||||
}
|
||||
|
||||
// 2. User sends /stop
|
||||
viewModel.sendMessage("/stop")
|
||||
advanceUntilIdle()
|
||||
|
||||
// 3. WS returns interrupt success -> adds "Session interrupted"
|
||||
mockEventsFlow.emit(WsEvent.RpcResult("interrupt-req-1", mapOf("ok" to true)))
|
||||
advanceUntilIdle()
|
||||
|
||||
// 4. Server transcript sync arrives with newer turns
|
||||
val serverRows =
|
||||
listOf(
|
||||
com.m57.hermescontrol.data.model.SessionMessage(
|
||||
id = 0,
|
||||
role = "user",
|
||||
content = kotlinx.serialization.json.JsonPrimitive("start task"),
|
||||
timestamp = kotlinx.serialization.json.JsonPrimitive(1),
|
||||
),
|
||||
com.m57.hermescontrol.data.model.SessionMessage(
|
||||
id = 1,
|
||||
role = "assistant",
|
||||
content = kotlinx.serialization.json.JsonPrimitive("running..."),
|
||||
timestamp = kotlinx.serialization.json.JsonPrimitive(2),
|
||||
),
|
||||
com.m57.hermescontrol.data.model.SessionMessage(
|
||||
id = 2,
|
||||
role = "user",
|
||||
content = kotlinx.serialization.json.JsonPrimitive("next prompt"),
|
||||
timestamp = kotlinx.serialization.json.JsonPrimitive(3),
|
||||
),
|
||||
com.m57.hermescontrol.data.model.SessionMessage(
|
||||
id = 3,
|
||||
role = "assistant",
|
||||
content = kotlinx.serialization.json.JsonPrimitive("next reply"),
|
||||
timestamp = kotlinx.serialization.json.JsonPrimitive(4),
|
||||
),
|
||||
)
|
||||
|
||||
// Simulate sync/merge with serverRows
|
||||
val current = viewModel.uiState.value.messages
|
||||
val merged =
|
||||
com.m57.hermescontrol.ui.chat.mergeTranscriptWithLive(
|
||||
com.m57.hermescontrol.ui.chat
|
||||
.mapServerMessages(sessionId, serverRows, 0, true, current),
|
||||
current,
|
||||
chronological = true,
|
||||
preserveLiveIds = true,
|
||||
)
|
||||
|
||||
val contents = merged.map { it.content }
|
||||
assertEquals(
|
||||
listOf("start task", "running...", "/stop", "Session interrupted", "next prompt", "next reply"),
|
||||
contents,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSlashCommand_interrupt_sendsInterrupt() =
|
||||
runTest {
|
||||
|
||||
Reference in new issue
Block a user