mindfork 0.10.2

A terminal AI chat written in Rust: local models via llama.cpp or OpenAI, Anthropic, Gemini and Grok in the cloud, with persistent memory, notes, RAG and tools.
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
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
//! Orchestrator tests — chat content search (`cache.db`). Part of the [`super`]
//! module (fixtures in mod.rs). See docs/research/chat-content-search.md.
//!
//! The reconciliation logic itself is unit-tested synchronously in
//! `orchestrator/search.rs`; these tests pin the wiring through the **real
//! `run` loop**: that a saved chat reaches the index, that the index survives a
//! restart, and what `SearchChats` answers.
//!
//! Two phases rather than a sleep: the post-save index update rides the 800 ms
//! save debounce, and `Quit` flushes it deterministically — so session 1 writes
//! and session 2 asks. Same shape as `remembers_and_restores_last_opened_chat`.

use super::*;
use crate::features::chat_search_sort::SortMode;

/// Runs one turn against a scripted engine, so the chat gains real messages.
fn scripted(reply: &str) -> Arc<dyn EngineBackend> {
    Arc::new(MockBackend::scripted(vec![
        ChatChunk::Text(reply.into()),
        ChatChunk::Finished(crate::shared::api::FinishReason::Stop),
    ]))
}

/// Sends a message, waits for the reply, then quits — flushing the save (and
/// with it the index update) before the loop returns. Returns the chat's id.
async fn record_a_conversation(root: &std::path::Path, user: &str, assistant: &str) -> Uuid {
    let (cmd_tx, mut evt_rx, handle) =
        spawn_orch_at(root, Some(scripted(assistant)), AppConfig::default());
    let activated = wait_for(&mut evt_rx, |e| matches!(e, AppEvent::ChatActivated { .. }))
        .await
        .unwrap();
    let chat_id = match activated {
        AppEvent::ChatActivated { id, .. } => id,
        _ => unreachable!(),
    };
    cmd_tx.send(AppCommand::SendMessage(user.into())).unwrap();
    wait_for(&mut evt_rx, |e| matches!(e, AppEvent::Finished { .. }))
        .await
        .unwrap();
    cmd_tx.send(AppCommand::Quit).unwrap();
    handle.await.unwrap();
    chat_id
}

/// Asks the running orchestrator a content query and returns what it answered.
async fn search(
    cmd_tx: &UnboundedSender<AppCommand>,
    evt_rx: &mut UnboundedReceiver<AppEvent>,
    query: &str,
) -> Option<Vec<Uuid>> {
    cmd_tx.send(AppCommand::SearchChats(query.into())).unwrap();
    let ev = wait_for(evt_rx, |e| matches!(e, AppEvent::ChatSearchResults { .. }))
        .await
        .unwrap();
    match ev {
        AppEvent::ChatSearchResults {
            query: echoed,
            chat_ids,
        } => {
            assert_eq!(echoed, query, "the query must be echoed back verbatim");
            chat_ids
        }
        _ => unreachable!(),
    }
}

#[tokio::test]
async fn a_saved_conversation_becomes_searchable_by_content() {
    // The end-to-end property: text the user never put in a *title* is findable.
    let dir = tempfile::tempdir().unwrap();
    let root = dir.path().to_path_buf();
    let chat_id = record_a_conversation(&root, "расскажи про кристаллографию", "конечно").await;

    let (cmd_tx, mut evt_rx, handle) = spawn_orch_at(&root, None, AppConfig::default());
    wait_for(&mut evt_rx, |e| matches!(e, AppEvent::ChatActivated { .. }))
        .await
        .unwrap();

    // The user's message…
    assert_eq!(
        search(&cmd_tx, &mut evt_rx, "кристалл").await,
        Some(vec![chat_id])
    );
    // …and the assistant's reply are both in the index.
    assert_eq!(
        search(&cmd_tx, &mut evt_rx, "конечно").await,
        Some(vec![chat_id])
    );
    // Only message text is indexed (fork F3): the chat's own title — the one
    // bootstrap gave it, `defaults.chat_title` — is not in the index. Content
    // search and the title filter stay separate mechanisms rather than one
    // blurred into the other. Taken from the bundle rather than spelled out, so
    // the test does not pin a translatable string.
    let bootstrap_title =
        crate::shared::i18n::locale(crate::shared::i18n::Lang::default()).t("defaults.chat_title");
    assert_eq!(
        search(&cmd_tx, &mut evt_rx, bootstrap_title).await,
        Some(Vec::new())
    );

    cmd_tx.send(AppCommand::Quit).unwrap();
    handle.await.unwrap();
}

#[tokio::test]
async fn a_query_matching_nothing_returns_an_empty_list() {
    // Empty is NOT the same answer as "don't filter": the list must end up
    // empty, not unfiltered.
    let dir = tempfile::tempdir().unwrap();
    let root = dir.path().to_path_buf();
    record_a_conversation(&root, "про кошек", "мяу").await;

    let (cmd_tx, mut evt_rx, handle) = spawn_orch_at(&root, None, AppConfig::default());
    wait_for(&mut evt_rx, |e| matches!(e, AppEvent::ChatActivated { .. }))
        .await
        .unwrap();

    assert_eq!(
        search(&cmd_tx, &mut evt_rx, "бетономешалка").await,
        Some(Vec::new())
    );

    cmd_tx.send(AppCommand::Quit).unwrap();
    handle.await.unwrap();
}

#[tokio::test]
async fn a_too_short_query_does_not_filter() {
    // Trigram cannot match fewer than 3 characters (research §5), so such a
    // query answers `None` — "show everything" — rather than an empty list.
    // Escaping happens in the orchestrator: `C++` would be an FTS5 syntax error
    // raw, and here it is simply too short after the floor drops nothing…
    let (_d, cmd_tx, mut evt_rx, handle) = spawn_orch(None);
    wait_for(&mut evt_rx, |e| matches!(e, AppEvent::ChatActivated { .. }))
        .await
        .unwrap();

    assert_eq!(search(&cmd_tx, &mut evt_rx, "ab").await, None);
    assert_eq!(search(&cmd_tx, &mut evt_rx, "   ").await, None);
    // …while `C++` survives the floor and must come back as a *search*, not an
    // error — that is what the escaping buys (research §4).
    assert_eq!(search(&cmd_tx, &mut evt_rx, "C++").await, Some(Vec::new()));

    cmd_tx.send(AppCommand::Quit).unwrap();
    handle.await.unwrap();
}

// ---- stage 2b: message-level search ----

/// Puts a chat into the orchestrator, saves it and indexes it exactly as a save
/// would. Returns the chat and its message ids.
fn indexed_chat(orch: &mut Orchestrator, title: &str, texts: &[&str]) -> (Uuid, Vec<Uuid>) {
    let profile = Profile::new("P", "sys");
    let mut chat = Chat::from_profile(&profile, title);
    for t in texts {
        chat.push_message(Message::user(*t));
    }
    let ids: Vec<Uuid> = chat.messages.iter().map(|m| m.id).collect();
    orch.storage.json().save_chat(&chat).unwrap();
    orch.index_saved_chat(&chat);
    let id = chat.id;
    orch.chats.push(chat);
    (id, ids)
}

/// Like [`indexed_chat`], with one sub-agent transcript hung on the last
/// message's tool-call record. Returns the chat id, the transcript id and the
/// transcript's message ids.
fn indexed_chat_with_run(
    orch: &mut Orchestrator,
    title: &str,
    own: &[&str],
    run_title: &str,
    run_texts: &[&str],
) -> (Uuid, Uuid, Vec<Uuid>) {
    let profile = Profile::new("P", "sys");
    let mut chat = Chat::from_profile(&profile, title);
    for t in own {
        chat.push_message(Message::user(*t));
    }
    let run = crate::entities::subagent::SubagentRun::fixture(run_title, run_texts);
    let run_id = run.id;
    let run_msgs: Vec<Uuid> = run.messages.iter().map(|m| m.id).collect();
    let mut carrier = Message::assistant("delegated");
    carrier.tool_calls = vec![run.on_record()];
    chat.push_message(carrier);
    orch.storage.json().save_chat(&chat).unwrap();
    orch.index_saved_chat(&chat);
    let id = chat.id;
    orch.chats.push(chat);
    (id, run_id, run_msgs)
}

/// The reply to a message-level query, taken off the event channel.
fn message_search(
    orch: &mut Orchestrator,
    rx: &mut UnboundedReceiver<AppEvent>,
    query: &str,
) -> (Vec<crate::features::chat_search::SearchGroup>, usize) {
    message_search_sorted(orch, rx, query, SortMode::Modified)
}

/// The same, for the tests that care which way the chat list is sorted.
fn message_search_sorted(
    orch: &mut Orchestrator,
    rx: &mut UnboundedReceiver<AppEvent>,
    query: &str,
    sort: SortMode,
) -> (Vec<crate::features::chat_search::SearchGroup>, usize) {
    orch.handle_search_messages(query.into(), sort);
    loop {
        match rx.try_recv().expect("no MessageSearchResults arrived") {
            AppEvent::MessageSearchResults {
                query: echoed,
                groups,
                total,
            } => {
                assert_eq!(echoed, query, "the query must be echoed back verbatim");
                return (groups, total);
            }
            _ => continue,
        }
    }
}

/// Fork S2: hits are grouped by chat, chats in the chat list's order (most
/// recently modified first), messages in **chat** order — none of which the
/// index can answer on its own, which is why grouping lives in the orchestrator.
#[test]
fn message_search_groups_by_chat_in_list_order_with_titles() {
    let (_d, mut orch, mut rx) = bare_orch_rx();
    let (older, older_msgs) = indexed_chat(
        &mut orch,
        "Старый чат",
        &["первое про маркер", "без совпадения", "второе про маркер"],
    );
    let (newer, _) = indexed_chat(&mut orch, "Свежий чат", &["ещё раз маркер"]);
    // A chat present in the index but not in the orchestrator (deleted between
    // indexing and now) must be dropped rather than shown as unopenable.
    orch.storage
        .cache()
        .index_chat(
            Uuid::new_v4(),
            1,
            1,
            &[crate::shared::storage::cache::IndexedMessage {
                id: Uuid::new_v4(),
                sub_id: None,
                role: "user".into(),
                ts: "2026-07-29T10:00:00+00:00".into(),
                text: "призрачный маркер".into(),
            }],
        )
        .unwrap();

    // Make the index order differ from chat order, which is the only way this
    // test can tell the two apart: editing a message deletes and re-inserts its
    // row, so the *first* message ends up with the *highest* rowid.
    let chat = orch.chats.iter_mut().find(|c| c.id == older).unwrap();
    chat.messages[0].text = "первое про маркер, переписанное".into();
    let chat = chat.clone();
    orch.storage.json().save_chat(&chat).unwrap();
    orch.index_saved_chat(&chat);

    // Make the chat ordering explicit rather than relying on creation timing.
    let now = chrono::Utc::now();
    for chat in &mut orch.chats {
        chat.modified_at = if chat.id == newer {
            now
        } else {
            now - chrono::Duration::hours(1)
        };
    }

    let (groups, total) = message_search(&mut orch, &mut rx, "маркер");
    assert_eq!(groups.len(), 2, "the unknown chat's hit is dropped");
    assert_eq!(groups[0].chat_id, newer, "most recently modified first");
    assert_eq!(
        groups[0].title, "Свежий чат",
        "the title comes from the chat"
    );
    assert_eq!(groups[1].chat_id, older);
    assert_eq!(groups[1].title, "Старый чат");

    // Within a chat: **chat** order (not the index's), and only the matching
    // messages.
    let hits: Vec<Uuid> = groups[1].hits.iter().map(|h| h.message_id).collect();
    assert_eq!(
        hits,
        vec![older_msgs[0], older_msgs[2]],
        "hits must follow the conversation, not the index's rowids"
    );
    // Each hit carries what the screen shows, snippet highlights included.
    let first = &groups[1].hits[0];
    assert_eq!(first.role, "user");
    assert!(!first.ts.is_empty());
    assert!(first.snippet.text.contains("маркер"), "{:?}", first.snippet);
    assert_eq!(
        first.snippet.matches.len(),
        1,
        "the match must be located for highlighting"
    );
    // `total` counts the index, which still holds the ghost chat's hit.
    assert_eq!(total, 4);
}

/// Sub-agent transcripts in the results (spec §11.2.1, research §3.9): a
/// transcript's hits form a group of their own right after its parent's,
/// carrying `parent`; a parent none of whose own messages match still heads
/// the group, with no hits; and the `Ctrl+F` id set names the transcript by
/// its own id — and *not* the parent, so the list's membership rule can
/// dim it.
#[test]
fn message_search_nests_a_transcripts_hits_under_its_parent() {
    let (_d, mut orch, mut rx) = bare_orch_rx();
    let (parent, run, run_msgs) = indexed_chat_with_run(
        &mut orch,
        "Родитель",
        &["свой маркер", "и тайна"],
        "Критик",
        &["маркер в задании", "маркер в ответе", "тайна внутри"],
    );
    let (_plain, _) = indexed_chat(&mut orch, "Обычный", &["маркер тут"]);

    // Both levels match: the parent's group, then the transcript's.
    let (groups, total) = message_search(&mut orch, &mut rx, "маркер");
    assert_eq!(total, 4);
    let shape: Vec<(Uuid, Option<Uuid>, usize)> = groups
        .iter()
        .map(|g| (g.chat_id, g.parent, g.hits.len()))
        .collect();
    let i = shape.iter().position(|g| g.0 == parent).unwrap();
    assert_eq!(shape[i], (parent, None, 1));
    assert_eq!(shape[i + 1], (run, Some(parent), 2), "{shape:?}");
    assert_eq!(groups[i + 1].title, "Критик");
    let hits: Vec<Uuid> = groups[i + 1].hits.iter().map(|h| h.message_id).collect();
    assert_eq!(hits, vec![run_msgs[0], run_msgs[1]], "in the run's order");

    // Only the transcript matches: the parent still heads it, with 0 hits.
    let (groups, total) = message_search(&mut orch, &mut rx, "внутри");
    assert_eq!(total, 1);
    assert_eq!(groups.len(), 2, "{groups:?}");
    assert_eq!((groups[0].chat_id, groups[0].hits.len()), (parent, 0));
    assert_eq!((groups[1].chat_id, groups[1].parent), (run, Some(parent)));

    // The id set for the list filter.
    let ids = |orch: &Orchestrator, rx: &mut UnboundedReceiver<AppEvent>, q: &str| {
        orch.handle_search_chats(q.into());
        loop {
            if let AppEvent::ChatSearchResults { chat_ids, .. } = rx.try_recv().unwrap() {
                return chat_ids.unwrap();
            }
        }
    };
    assert_eq!(
        ids(&orch, &mut rx, "внутри"),
        vec![run],
        "the transcript stands for itself"
    );
    let mut both = ids(&orch, &mut rx, "тайна");
    both.sort();
    let mut want = vec![parent, run];
    want.sort();
    assert_eq!(both, want);
}

/// `Enter` on a row in content mode: a transcript row opens the transcript on
/// *its* first match, a parent row on the parent's own — a match inside a
/// transcript never drags the parent to a message it does not have.
#[test]
fn first_match_in_chat_resolves_each_level_separately() {
    let (_d, mut orch, _rx) = bare_orch_rx();
    let (parent, run, run_msgs) = indexed_chat_with_run(
        &mut orch,
        "Родитель",
        &["без совпадения", "свой маркер"],
        "Критик",
        &["маркер в задании"],
    );
    let own_hit = orch.chats.iter().find(|c| c.id == parent).unwrap().messages[1].id;
    assert_eq!(orch.first_match_in_chat(run, "маркер"), Some(run_msgs[0]));
    assert_eq!(orch.first_match_in_chat(parent, "маркер"), Some(own_hit));
    assert_eq!(orch.first_match_in_chat(parent, "задании"), None);
}

/// The cap truncates the hits, never the count — that is what lets the screen
/// say "showing N of M" instead of quietly dropping results.
#[test]
fn message_search_reports_the_true_total_when_the_cap_bites() {
    let (_d, mut orch, mut rx) = bare_orch_rx();
    let cap = crate::features::chat_search::HIT_CAP;
    let texts: Vec<String> = (0..cap + 5)
        .map(|i| format!("совпадение номер {i}"))
        .collect();
    let refs: Vec<&str> = texts.iter().map(String::as_str).collect();
    indexed_chat(&mut orch, "Большой чат", &refs);

    let (groups, total) = message_search(&mut orch, &mut rx, "совпадение");
    let shown: usize = groups.iter().map(|g| g.hits.len()).sum();
    assert_eq!(shown, cap, "the hits are capped");
    assert_eq!(total, cap + 5, "the total stays honest");
}

/// An unsearchable query answers with nothing at all — no groups, no error
/// popup. (Unlike the chat-list filter, where "nothing searchable" means "show
/// everything": here there is no list to leave unfiltered.)
#[test]
fn an_unsearchable_message_query_yields_no_groups() {
    let (_d, mut orch, mut rx) = bare_orch_rx();
    indexed_chat(&mut orch, "Чат", &["какое-то содержимое"]);

    for query in ["ab", "   ", ""] {
        let (groups, total) = message_search(&mut orch, &mut rx, query);
        assert!(groups.is_empty(), "query {query:?}");
        assert_eq!(total, 0, "query {query:?}");
    }
    // And a query that matches nothing is empty too, not "everything".
    let (groups, total) = message_search(&mut orch, &mut rx, "бетономешалка");
    assert!(groups.is_empty());
    assert_eq!(total, 0);
}

/// `Enter` in content mode opens a chat at its **first** match in conversation
/// order. The index cannot answer this: a message whose text changed is deleted
/// and re-inserted with a fresh rowid, so index order is not chat order — which
/// is exactly what this test arranges.
#[test]
fn first_match_in_chat_uses_chat_order_not_index_order() {
    let (_d, mut orch, _rx) = bare_orch_rx();
    let (chat_id, msgs) = indexed_chat(
        &mut orch,
        "Чат",
        &["первое про маркер", "без", "третье про маркер"],
    );

    // Edit the *first* message, as a re-save would: it leaves and re-enters the
    // index, taking the highest rowid — behind the third message.
    let chat = orch.chats.iter_mut().find(|c| c.id == chat_id).unwrap();
    chat.messages[0].text = "первое про маркер, переписанное".into();
    let chat = chat.clone();
    orch.storage.json().save_chat(&chat).unwrap();
    orch.index_saved_chat(&chat);

    assert_eq!(
        orch.first_match_in_chat(chat_id, "маркер"),
        Some(msgs[0]),
        "the earliest message in the conversation, not the lowest rowid"
    );
    // Nothing matching, an unsearchable query, or an unknown chat — no jump,
    // which the caller turns into a plain "open at the tail".
    assert_eq!(orch.first_match_in_chat(chat_id, "бетономешалка"), None);
    assert_eq!(orch.first_match_in_chat(chat_id, "ab"), None);
    assert_eq!(orch.first_match_in_chat(Uuid::new_v4(), "маркер"), None);
}

/// The whole `Enter`-in-content-mode path through the real loop: the chat is
/// activated **with the focus on its first match**, which is what stage 2a's
/// jump consumes.
#[tokio::test]
async fn open_chat_at_first_match_activates_with_the_focus() {
    let dir = tempfile::tempdir().unwrap();
    let root = dir.path().to_path_buf();
    let chat_id = record_a_conversation(&root, "вопрос про кристаллографию", "ответ").await;

    let (cmd_tx, mut evt_rx, handle) = spawn_orch_at(&root, None, AppConfig::default());
    let activated = wait_for(&mut evt_rx, |e| matches!(e, AppEvent::ChatActivated { .. }))
        .await
        .unwrap();
    let user_msg = match activated {
        AppEvent::ChatActivated { messages, .. } => messages[0].id,
        _ => unreachable!(),
    };

    // A second chat to jump *from*, so this exercises a real cross-chat open
    // rather than only moving the feed of the chat already on screen.
    cmd_tx
        .send(AppCommand::NewChat { profile_id: None })
        .unwrap();
    let other = match wait_for(
        &mut evt_rx,
        |e| matches!(e, AppEvent::ChatActivated { id, .. } if *id != chat_id),
    )
    .await
    .unwrap()
    {
        AppEvent::ChatActivated { id, .. } => id,
        _ => unreachable!(),
    };

    cmd_tx
        .send(AppCommand::OpenChatAtFirstMatch {
            chat: chat_id,
            query: "кристалл".into(),
        })
        .unwrap();
    let ev = wait_for(&mut evt_rx, |e| matches!(e, AppEvent::ChatActivated { .. }))
        .await
        .unwrap();
    match ev {
        AppEvent::ChatActivated { id, focus, .. } => {
            assert_eq!(id, chat_id, "the chat must open");
            let focus = focus.expect("on the message that matched");
            assert_eq!(focus.message, user_msg);
            // The query travels too, so the feed can highlight it inside that
            // message rather than only marking the bubble (fork S3(b)).
            assert_eq!(focus.query, "кристалл");
        }
        _ => unreachable!(),
    }

    // Nothing matching → the chat still opens, just at its tail. (From the
    // other chat again: a focus-less jump onto the chat already open is a
    // deliberate no-op — there is nothing to move.)
    cmd_tx.send(AppCommand::SwitchChat(other)).unwrap();
    wait_for(
        &mut evt_rx,
        |e| matches!(e, AppEvent::ChatActivated { id, .. } if *id == other),
    )
    .await
    .unwrap();
    cmd_tx
        .send(AppCommand::OpenChatAtFirstMatch {
            chat: chat_id,
            query: "бетономешалка".into(),
        })
        .unwrap();
    let ev = wait_for(&mut evt_rx, |e| matches!(e, AppEvent::ChatActivated { .. }))
        .await
        .unwrap();
    assert!(matches!(
        ev,
        AppEvent::ChatActivated { id, focus: None, .. } if id == chat_id
    ));

    cmd_tx.send(AppCommand::Quit).unwrap();
    handle.await.unwrap();
}

#[tokio::test]
async fn a_deleted_chat_drops_out_of_results() {
    // A hidden chat never appears in the list, so it must not appear in search
    // results either — including within the same session, before any
    // reconciliation gets a chance to notice.
    let dir = tempfile::tempdir().unwrap();
    let root = dir.path().to_path_buf();
    let chat_id = record_a_conversation(&root, "секретное содержимое", "ага").await;

    let (cmd_tx, mut evt_rx, handle) = spawn_orch_at(&root, None, AppConfig::default());
    wait_for(&mut evt_rx, |e| matches!(e, AppEvent::ChatActivated { .. }))
        .await
        .unwrap();
    assert_eq!(
        search(&cmd_tx, &mut evt_rx, "секретное").await,
        Some(vec![chat_id])
    );

    cmd_tx.send(AppCommand::DeleteChat(chat_id)).unwrap();
    wait_for(&mut evt_rx, |e| matches!(e, AppEvent::ChatList(_)))
        .await
        .unwrap();

    assert_eq!(
        search(&cmd_tx, &mut evt_rx, "секретное").await,
        Some(Vec::new()),
        "a deleted chat must leave the index at once"
    );

    cmd_tx.send(AppCommand::Quit).unwrap();
    handle.await.unwrap();
}

/// Fork S2 promised "chats ordered by your existing sort", so the chat list's
/// `Tab` toggle has to carry over into the results. It did not at first — the
/// screen hardcoded `modified_at` — and nothing caught it, because the default
/// toggle position *is* `Modified`. This asserts the other position.
#[test]
fn message_search_follows_the_chat_lists_sort_toggle() {
    let (_d, mut orch, mut rx) = bare_orch_rx();
    // Created early, modified late; and the reverse. The two sort modes must
    // therefore put them in opposite orders.
    let (old_new, _) = indexed_chat(&mut orch, "Создан раньше", &["про маркер"]);
    let (new_old, _) = indexed_chat(&mut orch, "Создан позже", &["тоже маркер"]);
    {
        let a = orch.chats.iter_mut().find(|c| c.id == old_new).unwrap();
        a.created_at = "2020-01-01T00:00:00Z".parse().unwrap();
        a.modified_at = "2030-01-01T00:00:00Z".parse().unwrap();
        let b = orch.chats.iter_mut().find(|c| c.id == new_old).unwrap();
        b.created_at = "2029-01-01T00:00:00Z".parse().unwrap();
        b.modified_at = "2021-01-01T00:00:00Z".parse().unwrap();
    }

    let (by_modified, _) = message_search_sorted(&mut orch, &mut rx, "маркер", SortMode::Modified);
    let (by_created, _) = message_search_sorted(&mut orch, &mut rx, "маркер", SortMode::Created);

    assert_eq!(by_modified[0].chat_id, old_new, "newest modification first");
    assert_eq!(by_created[0].chat_id, new_old, "newest creation first");
}