1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
//! Cancelling a turn (Esc-Esc) must not lose the completion for HTTP/streaming
//! providers (#1468), and must not re-persist history that is already durable
//! (#2127).
//!
//! The real-time live persister (#269) that writes streamed text to the DB as
//! it arrives is gated `if is_cli_provider`. For HTTP providers the assistant
//! row stays an empty placeholder until turn-end, so a cancel before then left
//! the row 0 chars, and `load_session` (called right after the abort to re-inline
//! tool calls) rebuilt the view from that empty row and wiped the in-memory copy
//! too: the whole visible completion vanished from screen AND disk. Confirmed on
//! `modelscope-qwen38max` with 0-length assistant rows in the live DB and abort
//! log lines carrying no "Persisted N chars" (the `content.is_empty()` early
//! return).
//!
//! `persist_streaming_state` reconstructs the turn's assistant text from
//! `self.messages` via `collect_turn_assistant_text` and writes it when the DB
//! row is still empty. These pin that reconstruction, the novel logic. It is
//! pure over the message slice, so it needs no live terminal or DB.
//!
//! #2127 added the second bound: reconstruction stops at `turn_start_len`, the
//! index where the in-flight turn begins. A turn resumed at boot has no user
//! row, so the last-user-message floor does not apply, and every visible row is
//! already durable. Without the anchor the whole previous turn was rebuilt and
//! written into the fresh placeholder row as one marker-less duplicate.
use crate::tui::app::DisplayMessage;
use crate::tui::app::messaging::{collect_turn_assistant_text, shift_turn_start};
fn dm(role: &str, content: &str) -> DisplayMessage {
DisplayMessage {
id: uuid::Uuid::new_v4(),
role: role.to_string(),
content: content.to_string(),
timestamp: chrono::Utc::now(),
token_count: None,
cost: None,
approval: None,
approve_menu: None,
details: None,
expanded: false,
expanded_full: false,
tool_group: None,
duration_secs: None,
}
}
#[test]
fn collects_assistant_text_after_the_last_user_message() {
// A turn that streamed text, ran a tool, then streamed more: the visible
// completion is the two assistant entries after the last user message.
let msgs = vec![
dm("user", "earlier question"),
dm("assistant", "earlier answer"),
dm("user", "the question being answered now"),
dm("assistant", "first half of the completion"),
dm("tool_group", "3 tool calls"),
dm("assistant", "second half of the completion"),
];
let text = collect_turn_assistant_text(&msgs, 3);
assert_eq!(
text, "first half of the completion\n\nsecond half of the completion",
"both assistant fragments of the in-flight turn must be reconstructed \
in order, joined the way the DB row stores them"
);
}
#[test]
fn never_pulls_text_from_a_previous_turn() {
// The blast radius guard: if the reconstruction reached past the last user
// message it would re-persist settled answers and duplicate history on
// every cancel. Passed a zero anchor on purpose: the user-message floor
// alone must hold when no anchor was set.
let msgs = vec![
dm("user", "q1"),
dm("assistant", "answer to q1"),
dm("user", "q2"),
dm("assistant", "answer to q2"),
];
let text = collect_turn_assistant_text(&msgs, 0);
assert_eq!(text, "answer to q2");
assert!(
!text.contains("answer to q1"),
"text before the last user message belongs to a settled turn that is \
already durable; re-persisting it would duplicate history"
);
}
#[test]
fn empty_when_the_turn_produced_no_visible_text() {
// Cancelled before anything streamed: nothing to save, and persist must
// stay a no-op rather than write an empty row.
let msgs = vec![dm("user", "a question cancelled before any output")];
assert_eq!(collect_turn_assistant_text(&msgs, 1), "");
}
#[test]
fn skips_blank_assistant_entries() {
// Placeholders and whitespace-only fragments must not introduce separators
// or empty content into the reconstructed text.
let msgs = vec![
dm("user", "q"),
dm("assistant", " "),
dm("assistant", "real content"),
dm("assistant", ""),
];
assert_eq!(collect_turn_assistant_text(&msgs, 1), "real content");
}
#[test]
fn ignores_tool_group_entries() {
// Tool groups persist through their own marker path; the reconstruction is
// for assistant prose only, so tool_group rows are skipped, not rendered.
let msgs = vec![
dm("user", "q"),
dm("tool_group", "2 tool calls"),
dm("assistant", "the answer"),
];
assert_eq!(collect_turn_assistant_text(&msgs, 1), "the answer");
}
// ── the #2127 bound ─────────────────────────────────────────────────────────
#[test]
fn a_turn_start_at_the_end_of_history_collects_nothing() {
// #2127: a resumed turn has no user row and every visible row is already
// durable. The anchor sits at the end of the loaded history, so the
// reconstruction is empty and `persist_streaming_state` writes nothing.
let msgs = vec![
dm("user", "old question"),
dm("assistant", "old answer"),
dm("tool_group", "2 tool calls"),
dm("assistant", "old answer tail"),
];
assert_eq!(
collect_turn_assistant_text(&msgs, msgs.len()),
"",
"durable rows must never be re-collected into the fresh placeholder row"
);
}
#[test]
fn collects_only_rows_after_the_anchor_when_there_is_no_user_message() {
// The other side of the same bound: a system-triggered turn pushes no user
// row, but its own rows sit after the anchor and must still survive a
// cancel (#1468 must keep working for these turns).
let msgs = vec![
dm("assistant", "durable answer from the previous turn"),
dm("assistant", "this turn's first fragment"),
dm("tool_group", "1 tool call"),
dm("assistant", "this turn's second fragment"),
];
assert_eq!(
collect_turn_assistant_text(&msgs, 1),
"this turn's first fragment\n\nthis turn's second fragment"
);
}
#[test]
fn resumed_turn_with_all_durable_rows_persists_nothing() {
// The incident shape from the issue: history loaded at boot, a resumed
// request creates a fresh empty placeholder, nothing new is visible, the
// user aborts two seconds later. Rows 7829-7831 (narration + tool groups +
// answer) must not be rebuilt and written into seq 7832.
let msgs = vec![
dm("user", "Push it and close #2122"),
dm(
"assistant",
"One contract item from the issue's test list ...",
),
dm("tool_group", "3 tool calls"),
dm(
"assistant",
"Done. #2122 is closed and both commits are on main.",
),
];
let anchor = msgs.len();
assert_eq!(
collect_turn_assistant_text(&msgs, anchor),
"",
"a resumed turn that produced nothing must write nothing"
);
}
#[test]
fn an_anchor_past_the_end_is_clamped() {
// Defensive: a stale anchor (e.g. history trimmed under it) must not panic
// on the slice index.
let msgs = vec![dm("assistant", "only row")];
assert_eq!(collect_turn_assistant_text(&msgs, 99), "");
}
// ── wiring sentinels (#1468, #2127) ─────────────────────────────────────────
//
// The pure tests above only prove the reconstruction helper is correct. The
// actual bug was that `persist_streaming_state` never USED it: it looked at
// `streaming_response` alone, found it empty (the text had been promoted into
// `self.messages`), and early-returned, leaving the non-CLI placeholder row at
// 0 chars. These sentinels fail if that call site is removed again, so the fix
// cannot silently regress while the helper tests stay green. Source-level
// because `persist_streaming_state` is an async `App` method over a live DB
// with no in-process seam, the same approach as `cli_silent_failure_test`.
const MESSAGING_SRC: &str = include_str!("../tui/app/messaging.rs");
const STATE_SRC: &str = include_str!("../tui/app/state.rs");
#[test]
fn persist_streaming_state_reconstructs_the_turn_from_messages() {
assert!(
MESSAGING_SRC.contains("collect_turn_assistant_text(&self.messages, self.turn_start_len)"),
"persist_streaming_state must reconstruct the in-flight turn from \
self.messages, bounded by the turn anchor; without the call the \
HTTP-provider completion is lost on cancel (#1468) and without the \
anchor the previous turn is duplicated (#2127)"
);
}
#[test]
fn persist_streaming_state_writes_the_full_turn_when_the_db_row_is_empty() {
assert!(
MESSAGING_SRC.contains("let db_empty = msg.content.trim().is_empty();"),
"the cancel-persist must detect the empty non-CLI placeholder row"
);
assert!(
MESSAGING_SRC.contains("if db_empty && !memory_turn_text.trim().is_empty() {"),
"when the DB row is still empty (HTTP provider; the live persister is \
CLI-only) the full reconstructed turn must be written, not skipped, \
or the completion vanishes on Esc-Esc (#1468)"
);
}
#[test]
fn the_anchor_is_set_at_every_turn_start() {
// load_session, send_message_inner and resume_background_turn live in
// messaging.rs; PendingResumed (boot resume) lives in state.rs. A site
// that disappears drops the count and fails here.
assert!(
MESSAGING_SRC
.matches("self.turn_start_len = self.messages.len();")
.count()
>= 3,
"load_session, send_message_inner and resume_background_turn must each \
set the turn anchor (#2127)"
);
assert!(
STATE_SRC
.matches("self.turn_start_len = self.messages.len();")
.count()
>= 1,
"the boot-resume handler (PendingResumed) must set the turn anchor, or \
an aborted resume duplicates the previous turn (#2127)"
);
assert!(
MESSAGING_SRC.contains("shift_turn_start("),
"load_more_history prepends older rows, so the anchor must shift with \
them or it points into the wrong turn (#2127). It must go through the \
tested helper: measuring the prepended count after `append` drains the \
source reports the whole history and clamps the anchor past the end"
);
}
#[test]
fn prepending_rows_shifts_the_turn_anchor_by_the_front_block() {
// 5 durable rows, turn starts at 5. Three older rows arrive at the front.
assert_eq!(shift_turn_start(5, 3, 0, 8), 8);
}
#[test]
fn a_removed_history_marker_does_not_count_as_prepended() {
// The marker at the front is dropped, two rows are prepended, so the old
// index 4 lands at 4 + 2 - 1 = 5.
assert_eq!(shift_turn_start(4, 2, 1, 6), 5);
}
#[test]
fn a_shift_can_never_point_past_the_end() {
// The drained-append bug produced a prepended count equal to the whole
// history; the clamp is what kept that from panicking.
assert_eq!(shift_turn_start(5, 500, 0, 8), 8);
}
#[test]
fn a_shift_saturates_instead_of_overflowing() {
// Both inputs at the type's limit must clamp, not panic in a debug build.
assert_eq!(shift_turn_start(usize::MAX, usize::MAX, 0, 4), 4);
}
#[test]
fn an_anchor_already_at_the_end_stays_there_when_nothing_is_prepended() {
assert_eq!(shift_turn_start(7, 0, 0, 7), 7);
}