Skip to content

Commit 34162b4

Browse files
refactor(chat): отправка вынесена в ui/chat/ChatSendFlow, откат сессии не вслепую
Цепочка создать->отправить->проверить->откатить была зашита в IO-блоке send(), а главное правило (postAsync отдаёт true после записи тела, поэтому false не значит 'сервер ничего не получил') жило комментарием без теста. Теперь deliverPrompt(transport, sessionId, text) -> SendOutcome, транспорт подменяется в тесте, ServerSendTransport - единственное место, где решение касается сети. ChatSendFlowTest: 6 тестов, проверено откатом (3 мутации краснеют). Detekt: deliverPrompt на 4 return -> разбит на deliverPrompt + deliverInNewSession.
1 parent 1131872 commit 34162b4

4 files changed

Lines changed: 259 additions & 32 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,24 @@ Play: листинг, скриншоты, политика конфиденци
116116
удалить данные. Аналитики и трекеров в сборке нет (проверено по зависимостям).
117117
- Осталось: доступ к Play Console и URL политики (файл есть, хостинг — нет).
118118

119+
Чат: отправка вынесена в ui/chat/ChatSendFlow.kt, откат сессии больше не вслепую (26.10.2026)
120+
- `send()` держал в середине IO-блока цепочку «создать сессию → отправить → проверить,
121+
сохранил ли сервер → откатить», а главное правило жило комментарием на шесть строк:
122+
`postAsync` отдаёт true сразу после записи тела, поэтому `false` **не** значит «сервер
123+
ничего не получил». На живой сессии неизвестная модель даёт HTTP 500, а user-сообщение
124+
уже лежит в базе — слепой откат съел бы его вместе с сессией. Проверить это можно было
125+
только подсунув серверу такую модель и дождавшись 500.
126+
- Теперь `deliverPrompt(transport, sessionId, text)` → `SendOutcome(delivered,
127+
createdSession)`. Транспорт (`ChatSendTransport`) подменяется в тесте,
128+
`ServerSendTransport` — единственное место, где решение касается сети. В Composable
129+
остались вызов и реакция UI: чистка черновика, оптимистичная вставка, тост.
130+
- `ChatSendFlowTest` — 6 тестов: отправка в существующую сессию, отказ не плодит новую,
131+
создание не удалось, новая + успех, **сервер сохранил сообщение и ответил ошибкой →
132+
сессию не стираем**, пустая сирота откатывается. Проверено откатом: «откат вслепую»,
133+
«сироту не убираем», «сессия не запоминается» — все три краснеют, контроль зелёный.
134+
- Detekt по ходу: `deliverPrompt` вышел на 4 `return` при лимите 2 → разбит на
135+
`deliverPrompt` + `deliverInNewSession`.
136+
119137
Чат: тик опроса вынесен из Composable в ui/chat/ChatPollTick.kt (26.10.2026)
120138
- Тик опроса — это не «получить снапшот», а цепочка решений: завис ли ход, не пора ли
121139
оборвать его самим, что показать в UI, сдвинулся ли счётчик ответов и с какой паузой

‎app/src/main/java/org/opencode/mobile/ui/ChatOverlay.kt‎

Lines changed: 10 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@ import org.opencode.mobile.ui.chat.MessageRow
141141
import org.opencode.mobile.ui.chat.NarrationInput
142142
import org.opencode.mobile.ui.chat.NoticeRow
143143
import org.opencode.mobile.ui.chat.QuotaBadge
144+
import org.opencode.mobile.ui.chat.ServerSendTransport
144145
import org.opencode.mobile.ui.chat.SharedPreferencesAppearanceStore
145146
import org.opencode.mobile.ui.chat.SharedPreferencesVoiceStore
146147
import org.opencode.mobile.ui.chat.StalledRow
@@ -157,12 +158,11 @@ import org.opencode.mobile.ui.chat.answerPendingAsk
157158
import org.opencode.mobile.ui.chat.createSession
158159
import org.opencode.mobile.ui.chat.createSpeechRecognizer
159160
import org.opencode.mobile.ui.chat.deleteSession
161+
import org.opencode.mobile.ui.chat.deliverPrompt
160162
import org.opencode.mobile.ui.chat.fetchChatSnapshot
161163
import org.opencode.mobile.ui.chat.playNotificationSound
162-
import org.opencode.mobile.ui.chat.postMessage
163164
import org.opencode.mobile.ui.chat.pruneEmptySessions
164165
import org.opencode.mobile.ui.chat.readWavPcm16
165-
import org.opencode.mobile.ui.chat.sessionHasMessages
166166
import org.opencode.mobile.ui.chat.sessionRunning
167167
import org.opencode.mobile.ui.chat.stopAndPurge
168168
import org.opencode.mobile.ui.chat.typedKeyOf
@@ -557,37 +557,15 @@ fun ChatOverlay(
557557
sending = true
558558
userScrolledUp = false
559559
scope.launch {
560-
// Создание сессии идёт ВНУТРИ той же операции, что и отправка.
561-
// Раньше сессия создавалась, а отправка могла не уйти - и оставалась
562-
// сирота с одним session.created.1 и нулём сообщений (три такие
563-
// нашлись в базе при разборе). Молчание было худшей частью: у
564-
// пользователя сообщение выглядело отправленным, а текст оставался
565-
// в поле случайно.
566-
val (ok, createdHere) =
560+
// Создание сессии и отправка — одна операция, и правило «сервер мог
561+
// сохранить сообщение и всё равно ответить ошибкой» живёт в
562+
// ui/chat/ChatSendFlow.kt с тестом. Здесь только вызов и реакция UI.
563+
val outcome =
567564
withContext(Dispatchers.IO) {
568-
if (sessionId != null) {
569-
postMessage(serverPort, sessionId, text) to null
570-
} else {
571-
val fresh = createSession(serverPort, context)
572-
when {
573-
fresh == null -> false to null
574-
postMessage(serverPort, fresh, text) -> true to null
575-
else -> {
576-
// postAsync отдаёт true сразу после записи тела, поэтому
577-
// false означает «успешного ответа не было». Но сервер мог
578-
// успеть сохранить сообщение и упасть уже после этого —
579-
// проверено на живой сессии: неизвестная модель даёт
580-
// HTTP 500, а user-сообщение уже лежит в базе. Стирать
581-
// такую сессию нельзя, поэтому сначала спрашиваем сервер.
582-
val saved = sessionHasMessages(serverPort, fresh)
583-
if (!saved) deleteSession(serverPort, fresh)
584-
false to fresh
585-
}
586-
}
587-
}
565+
deliverPrompt(ServerSendTransport(serverPort, context), sessionId, text)
588566
}
589567
sending = false
590-
if (ok) {
568+
if (outcome.delivered) {
591569
draft = ""
592570
keyboard?.hide()
593571
focusManager.clearFocus()
@@ -605,8 +583,8 @@ fun ChatOverlay(
605583
Toast.LENGTH_LONG,
606584
)
607585
toast.show()
608-
if (createdHere != null) {
609-
android.util.Log.w("ChatOverlay", "откат новой сессии $createdHere после сбоя отправки")
586+
outcome.createdSession?.let {
587+
android.util.Log.w("ChatOverlay", "откат новой сессии $it после сбоя отправки")
610588
}
611589
}
612590
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
package org.opencode.mobile.ui.chat
2+
3+
import android.content.Context
4+
5+
/**
6+
* Что нужно решению об отправке, без порта, контекста и HTTP.
7+
*
8+
* Отдельный интерфейс, а не прямые вызовы, потому что интересен не сам запрос,
9+
* а порядок «создать → отправить → проверить → откатить» и то, что при сбое
10+
* откат нельзя делать вслепую. Проверять это на живом сервере можно только
11+
* подсунув ему несуществующую модель и дождавшись 500.
12+
*/
13+
internal interface ChatSendTransport {
14+
fun createSession(): String?
15+
16+
fun postPrompt(
17+
sessionId: String,
18+
text: String,
19+
): Boolean
20+
21+
fun sessionHasMessages(sessionId: String): Boolean
22+
23+
fun deleteSession(sessionId: String): Boolean
24+
}
25+
26+
/**
27+
* Чем закончилась отправка.
28+
*
29+
* [createdSession] — сессия, созданная ЭТИМ вызовом и оставшаяся после сбоя.
30+
* Она не всегда удаляется (см. [deliverPrompt]), поэтому имя говорит «создана
31+
* здесь», а не «откачена»: вызывающему она нужна только для журнала.
32+
*/
33+
internal data class SendOutcome(
34+
val delivered: Boolean,
35+
val createdSession: String? = null,
36+
)
37+
38+
/**
39+
* Отправка сообщения: в существующую сессию или в новую, созданную здесь же.
40+
*
41+
* [sessionId] == null значит «активной сессии нет» — тогда создаём новую, и
42+
* создание с отправкой считаются одной операцией: иначе при сбое отправки
43+
* остаётся сирота (одно `session.created.1`, ноль сообщений).
44+
*/
45+
internal fun deliverPrompt(
46+
transport: ChatSendTransport,
47+
sessionId: String?,
48+
text: String,
49+
): SendOutcome =
50+
if (sessionId != null) {
51+
SendOutcome(delivered = transport.postPrompt(sessionId, text))
52+
} else {
53+
deliverInNewSession(transport, text)
54+
}
55+
56+
/**
57+
* Новая сессия: создание и отправка как одна операция.
58+
*
59+
* Раньше сессия создавалась отдельно, а отправка могла не уйти — и оставалась
60+
* сирота с одним `session.created.1` и нулём сообщений (три такие нашлись в базе
61+
* при разборе).
62+
*/
63+
private fun deliverInNewSession(
64+
transport: ChatSendTransport,
65+
text: String,
66+
): SendOutcome {
67+
val fresh = transport.createSession() ?: return SendOutcome(delivered = false)
68+
val delivered = transport.postPrompt(fresh, text)
69+
// postAsync отдаёт true сразу после записи тела, поэтому false означает
70+
// «успешного ответа не было». Но сервер мог успеть сохранить сообщение и
71+
// упасть уже после этого — проверено на живой сессии: неизвестная модель даёт
72+
// HTTP 500, а user-сообщение уже лежит в базе. Стирать такую сессию нельзя,
73+
// поэтому сначала спрашиваем сервер.
74+
if (!delivered && !transport.sessionHasMessages(fresh)) transport.deleteSession(fresh)
75+
return SendOutcome(delivered = delivered, createdSession = if (delivered) null else fresh)
76+
}
77+
78+
/**
79+
* Транспорт поверх живого сервера: единственное место, где решение касается сети.
80+
*
81+
* Порт и контекст захвачены, чтобы вызывающий собирал объект одной строкой и не
82+
* повторял их в четырёх лямбдах.
83+
*/
84+
internal class ServerSendTransport(
85+
private val port: Int,
86+
private val context: Context,
87+
) : ChatSendTransport {
88+
override fun createSession(): String? = createSession(port, context)
89+
90+
override fun postPrompt(
91+
sessionId: String,
92+
text: String,
93+
): Boolean = postMessage(port, sessionId, text)
94+
95+
override fun sessionHasMessages(sessionId: String): Boolean = sessionHasMessages(port, sessionId)
96+
97+
override fun deleteSession(sessionId: String): Boolean = deleteSession(port, sessionId)
98+
}
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
package org.opencode.mobile.ui.chat
2+
3+
import org.junit.Assert.assertEquals
4+
import org.junit.Assert.assertFalse
5+
import org.junit.Assert.assertNull
6+
import org.junit.Assert.assertTrue
7+
import org.junit.Test
8+
9+
/**
10+
* Отправка как одна операция: создать сессию, отправить, проверить, откатить.
11+
*
12+
* Ценность здесь ровно в одном правиле, которое нельзя проверить «на глаз»:
13+
* `postAsync` отдаёт true сразу после записи тела, поэтому `false` НЕ значит
14+
* «сервер ничего не получил». На живой сессии неизвестная модель даёт HTTP 500,
15+
* а user-сообщение уже лежит в базе — и слепой откат съел бы его вместе с
16+
* сессией. Подсунуть серверу такую модель и дождаться 500 в тесте нельзя,
17+
* поэтому транспорт подменён.
18+
*/
19+
class ChatSendFlowTest {
20+
private class FakeTransport(
21+
private val created: String? = null,
22+
private val postOk: Boolean = true,
23+
private val savedAnyway: Boolean = false,
24+
) : ChatSendTransport {
25+
var createCalls = 0
26+
val posted = mutableListOf<Pair<String, String>>()
27+
var hasMessagesCalls = 0
28+
val deleted = mutableListOf<String>()
29+
30+
override fun createSession(): String? {
31+
createCalls++
32+
return created
33+
}
34+
35+
override fun postPrompt(
36+
sessionId: String,
37+
text: String,
38+
): Boolean {
39+
posted += sessionId to text
40+
return postOk
41+
}
42+
43+
override fun sessionHasMessages(sessionId: String): Boolean {
44+
hasMessagesCalls++
45+
return savedAnyway
46+
}
47+
48+
override fun deleteSession(sessionId: String): Boolean {
49+
deleted += sessionId
50+
return true
51+
}
52+
}
53+
54+
@Test
55+
fun `существующая сессия - отправляем прямо в неё`() {
56+
val transport = FakeTransport()
57+
58+
val outcome = deliverPrompt(transport, "ses_old", "привет")
59+
60+
assertTrue(outcome.delivered)
61+
assertNull("новую сессию создавать не за чем", outcome.createdSession)
62+
assertEquals(0, transport.createCalls)
63+
assertEquals(listOf("ses_old" to "привет"), transport.posted)
64+
assertTrue("откатывать нечего", transport.deleted.isEmpty())
65+
}
66+
67+
@Test
68+
fun `отказ в существующей сессии не создаёт новую`() {
69+
// Сессия уже есть — её история дороже сообщения. Новую не создаём,
70+
// иначе одно нажатие плодило бы сессии при каждом сбое сети.
71+
val transport = FakeTransport(postOk = false)
72+
73+
val outcome = deliverPrompt(transport, "ses_old", "привет")
74+
75+
assertFalse(outcome.delivered)
76+
assertNull(outcome.createdSession)
77+
assertEquals(0, transport.createCalls)
78+
assertTrue(transport.deleted.isEmpty())
79+
}
80+
81+
@Test
82+
fun `создать сессию не удалось - отправлять некуда`() {
83+
val transport = FakeTransport(created = null)
84+
85+
val outcome = deliverPrompt(transport, null, "привет")
86+
87+
assertFalse(outcome.delivered)
88+
assertNull(outcome.createdSession)
89+
assertTrue("без сессии постить нечего", transport.posted.isEmpty())
90+
assertEquals("и проверять нечего", 0, transport.hasMessagesCalls)
91+
assertTrue(transport.deleted.isEmpty())
92+
}
93+
94+
@Test
95+
fun `новая сессия и удачная отправка`() {
96+
val transport = FakeTransport(created = "ses_new")
97+
98+
val outcome = deliverPrompt(transport, null, "привет")
99+
100+
assertTrue(outcome.delivered)
101+
assertNull("откатывать нечего", outcome.createdSession)
102+
assertEquals(listOf("ses_new" to "привет"), transport.posted)
103+
assertTrue("при успехе сервер не переспрашиваем", transport.hasMessagesCalls == 0)
104+
assertTrue(transport.deleted.isEmpty())
105+
}
106+
107+
@Test
108+
fun `сервер сохранил сообщение и ответил ошибкой - сессию не стираем`() {
109+
// Главное правило файла. Сообщение уже в базе, откат съел бы его вместе
110+
// с сессией — и человек потерял бы текст, который сервер принял.
111+
val transport = FakeTransport(created = "ses_new", postOk = false, savedAnyway = true)
112+
113+
val outcome = deliverPrompt(transport, null, "привет")
114+
115+
assertFalse("ответа об успехе не было — черновик остаётся", outcome.delivered)
116+
assertEquals("ses_new", outcome.createdSession)
117+
assertEquals(1, transport.hasMessagesCalls)
118+
assertTrue("сессию с сохранённым сообщением удалять нельзя", transport.deleted.isEmpty())
119+
}
120+
121+
@Test
122+
fun `пустая сессия после сбоя откатывается`() {
123+
// Сирота (session.created.1 и ноль сообщений) засоряет список сессий —
124+
// ровно то, что нашлось в базе при разборе.
125+
val transport = FakeTransport(created = "ses_new", postOk = false, savedAnyway = false)
126+
127+
val outcome = deliverPrompt(transport, null, "привет")
128+
129+
assertFalse(outcome.delivered)
130+
assertEquals("ses_new", outcome.createdSession)
131+
assertEquals(listOf("ses_new"), transport.deleted)
132+
}
133+
}

0 commit comments

Comments
 (0)