aidaemon 0.11.11

A personal AI agent that runs as a background daemon, accessible via Telegram, Slack, or Discord, with tool use, MCP integration, and persistent memory
Documentation
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
598
599
600
601
602
603
604
605
606
607
608
609
610
611
//! Phase 0 observability — deterministic prefix fingerprinting for the
//! sliding-window cache-reuse investigation.
//!
//! The llama.cpp prefix cache only reuses a prompt prefix while the serialized
//! bytes are byte-identical to a prior request. This module computes stable
//! SHA-256 fingerprints over canonical JSON so the daemon log can attribute a
//! cache break to the *region* of the prompt that changed (message zero, the
//! pre-boundary history region, the tool definitions, or the session summary)
//! without ever emitting raw message content.
//!
//! The single source of truth is [`canonical_prefix`]: it is used both by the
//! provider-call fingerprint in `llm_phase` and by the hash unit tests, so the
//! tests exercise exactly the code path that runs in production.
//!
//! Canonicalization recursively sorts object keys so that two semantically
//! identical messages with differently-ordered keys hash the same. Hashes are
//! computed over the **complete** message objects (including `tool_calls`,
//! `name`, and `tool_call_id`), so a change to any of those fields flips the
//! hash even when `role` and `content` are unchanged.

use serde_json::Value;
use sha2::{Digest, Sha256};

/// Compute the region fingerprints for a finalized provider message payload.
///
/// Returns `(hash_system, hash_pre_boundary, boundary_pos)`:
/// - `hash_system` — hash of message zero alone (the system prompt). Must be
///   constant across within-task consecutive calls once the cache-stable
///   system-prompt work is in place.
/// - `hash_pre_boundary` — hash of the complete message objects in
///   `[1..boundary)`. The current interaction's tool chain sits at/after the
///   boundary, so ordinary per-iteration tail growth does not flip this hash.
/// - `boundary_pos` — the index of the last user-role message whose `content`
///   equals `user_text`; if no such message exists, `messages.len()`.
///
/// Hashes never include raw message content in their output — only the hex
/// digest is returned.
pub(crate) fn canonical_prefix(messages: &[Value], user_text: &str) -> (String, String, usize) {
    let boundary = boundary_pos(messages, user_text);

    let hash_system = match messages.first() {
        Some(m) => hash_canonical(m),
        None => hash_canonical(&Value::Null),
    };

    let pre_region: Vec<Value> = messages.iter().take(boundary).skip(1).cloned().collect();
    let hash_pre_boundary = hash_canonical(&Value::Array(pre_region));

    (hash_system, hash_pre_boundary, boundary)
}

/// All region fingerprints for one finalized provider call, emitted as a
/// single structured `info!` line in `llm_phase` immediately before the
/// provider request. Hashes never carry raw content; only digests are logged.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ProviderCallFingerprint {
    /// Hash of message zero (the system prompt).
    pub hash_system: String,
    /// Hash of the complete pre-boundary message objects in `[1..boundary)`.
    pub hash_pre_boundary: String,
    /// Index of the boundary (last user message matching `user_text`).
    pub boundary_pos: usize,
    /// Total number of messages in the payload.
    pub message_count: usize,
    /// Hash of the effective tool definitions passed to this attempt, in the
    /// order sent. Empty string only when the call genuinely carries no
    /// tools. Force-text calls retain the tool definitions (calling is
    /// disabled via tool_choice=none), so their hash is computed normally —
    /// stability across a force-text turn is part of the signal.
    pub tool_defs_hash: String,
    /// Retired by Pillar A; summary participates in `tail_hash` instead.
    /// Field stays for parser/back-compat but is always written as an empty
    /// string.
    pub session_summary_hash: String,
    /// Hash of the task-context tail message (the `role == "system"` message
    /// whose content starts with `TASK_CONTEXT_TAIL_MARKER`) within the
    /// `[1..boundary)` region. Empty string when no tail message is present.
    ///
    /// A tail-only flip (this field changes while `prefix_hash_archived` is
    /// stable) is EXPECTED per task: the tail carries per-task volatile context
    /// (timestamp, session context, memories) that changes every turn. It is
    /// not a bug signal. Only a `prefix_hash_archived` flip without a Window
    /// decision / Prefix mutation / fp_mismatch indicates a problem.
    pub tail_hash: String,
    /// Hash of the pre-boundary archived region `[1..boundary)` EXCLUDING the
    /// tail message. Equals `hash_pre_boundary` when no tail is present.
    ///
    /// Diagnosis rule: `prefix_hash_archived` flipping without a Window
    /// decision / Prefix mutation / fp_mismatch = bug; tail-only flip =
    /// expected.
    pub prefix_hash_archived: String,
    /// Marks force-text iterations (plain-text required; tool calling
    /// disabled via tool_choice=none while tool definitions stay in the
    /// payload). The mode marker for attribution; it does not affect any
    /// hash in this struct.
    pub force_text: bool,
}

/// Marker prefix for the task context tail message. SHARED between the tail
/// builder (`system_prompt.rs`) and this module so the constant never drifts.
/// The tail builder re-exports this constant; do not duplicate the literal.
pub(crate) const TASK_CONTEXT_TAIL_MARKER: &str = "[Task Context]";

/// Build the [`ProviderCallFingerprint`] for the final provider payload.
///
/// `effective_tools` is the actual tool set passed to the provider. In
/// force-text mode the definitions are *retained* (see `effective_tools_for_call`
/// in `llm_phase.rs`: tool defs stay in the payload for prefix-cache stability;
/// only calling is disabled via `tool_choice=none`), so `tool_defs_hash` stays
/// stable across a force-text turn. The hash is empty only when
/// `effective_tools` is genuinely empty. The tool hash preserves the array
/// order so that a reordering (a genuine cache break) is observable; it is
/// *not* name-sorted here (that name-sorted form is the Phase 1 validity hash,
/// a different concern). `force_text` is recorded as a fingerprint tag, not
/// used to alter any hash.
pub(crate) fn provider_call_fingerprint(
    messages: &[Value],
    user_text: &str,
    effective_tools: &[Value],
    force_text: bool,
) -> ProviderCallFingerprint {
    let (hash_system, hash_pre_boundary, boundary_pos) = canonical_prefix(messages, user_text);

    let tool_defs_hash = if effective_tools.is_empty() {
        String::new()
    } else {
        hash_canonical(&Value::Array(effective_tools.to_vec()))
    };

    // Locate the tail message within [1..boundary). The tail is identified by
    // role == "system" AND content starting with TASK_CONTEXT_TAIL_MARKER.
    // The role guard prevents a user message echoing the marker from being
    // misidentified as the tail and silently excluded from prefix_hash_archived.
    let tail_idx = messages
        .iter()
        .enumerate()
        .skip(1)
        .take(boundary_pos.saturating_sub(1))
        .find(|(_, m)| {
            m.get("role").and_then(|r| r.as_str()) == Some("system")
                && m.get("content")
                    .and_then(|c| c.as_str())
                    .is_some_and(|s| s.starts_with(TASK_CONTEXT_TAIL_MARKER))
        })
        .map(|(i, _)| i);

    let tail_hash = tail_idx
        .map(|i| hash_canonical(&messages[i]))
        .unwrap_or_default();

    // prefix_hash_archived = hash of [1..boundary) EXCLUDING the tail message.
    let prefix_hash_archived = {
        let archived: Vec<Value> = messages
            .iter()
            .enumerate()
            .skip(1)
            .take(boundary_pos.saturating_sub(1))
            .filter(|(i, _)| Some(*i) != tail_idx)
            .map(|(_, m)| m.clone())
            .collect();
        hash_canonical(&Value::Array(archived))
    };

    // session_summary_hash is retired: always empty. The summary now
    // participates in tail_hash when the tail builder includes it.
    let session_summary_hash = String::new();

    ProviderCallFingerprint {
        hash_system,
        hash_pre_boundary,
        boundary_pos,
        message_count: messages.len(),
        tool_defs_hash,
        session_summary_hash,
        tail_hash,
        prefix_hash_archived,
        force_text,
    }
}

/// Index of the last user-role message whose content equals `user_text`.
///
/// This is the prompt boundary: everything before it is reusable history,
/// everything at/after it is the current interaction (current user message +
/// its tool chain + checkpoint). Falls back to `messages.len()` when no
/// matching user message is present, so the pre-boundary region degrades to
/// "everything after message zero" rather than panicking.
pub(crate) fn boundary_pos(messages: &[Value], user_text: &str) -> usize {
    find_current_user_boundary(messages, user_text).unwrap_or(messages.len())
}

fn find_current_user_boundary(messages: &[Value], user_text: &str) -> Option<usize> {
    messages.iter().enumerate().rev().find_map(|(i, m)| {
        if m.get("role").and_then(|r| r.as_str()) == Some("user")
            && m.get("content").is_some_and(|content| {
                crate::agent::vision::user_message_content_matches(content, user_text)
            })
        {
            Some(i)
        } else {
            None
        }
    })
}

/// Hash the pre-boundary history region of an intermediate build stage.
///
/// Skips a leading system-role message when present so the value is comparable
/// across stages that run before vs. after system-prompt insertion (and to the
/// provider-call `hash_pre_boundary`). The attribution value is in comparing a
/// single stage's hash across consecutive builds — a stage whose hash flips
/// while `keep_from` is stable is content mutation, not window-trim movement.
pub(crate) fn stage_pre_boundary_hash(messages: &[Value], user_text: &str) -> String {
    let boundary = boundary_pos(messages, user_text);
    let skip = usize::from(
        messages
            .first()
            .and_then(|m| m.get("role"))
            .and_then(|r| r.as_str())
            == Some("system"),
    );
    let region: Vec<Value> = messages.iter().take(boundary).skip(skip).cloned().collect();
    hash_canonical(&Value::Array(region))
}

/// SHA-256 hex digest over the canonical (recursively key-sorted) JSON form of
/// `value`. Exposed for the call site to fingerprint tool definitions and the
/// session summary as separate region hashes.
pub(crate) fn hash_canonical(value: &Value) -> String {
    let mut canonical = String::new();
    write_canonical(value, &mut canonical);
    let mut hasher = Sha256::new();
    hasher.update(canonical.as_bytes());
    format!("{:x}", hasher.finalize())
}

/// Serialize `value` into a deterministic string form with all object keys
/// recursively sorted. Independent of serde_json's `preserve_order` feature so
/// the hash is stable regardless of build configuration.
fn write_canonical(value: &Value, out: &mut String) {
    match value {
        Value::Object(map) => {
            let mut keys: Vec<&String> = map.keys().collect();
            keys.sort();
            out.push('{');
            for (i, key) in keys.iter().enumerate() {
                if i > 0 {
                    out.push(',');
                }
                out.push_str(&Value::String((*key).clone()).to_string());
                out.push(':');
                write_canonical(&map[*key], out);
            }
            out.push('}');
        }
        Value::Array(items) => {
            out.push('[');
            for (i, item) in items.iter().enumerate() {
                if i > 0 {
                    out.push(',');
                }
                write_canonical(item, out);
            }
            out.push(']');
        }
        scalar => out.push_str(&scalar.to_string()),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    fn sample_messages() -> Vec<Value> {
        vec![
            json!({"role": "system", "content": "You are a helpful assistant."}),
            json!({"role": "user", "content": "old question"}),
            json!({"role": "assistant", "content": "old answer"}),
            json!({"role": "user", "content": "current question"}),
            json!({
                "role": "assistant",
                "content": null,
                "tool_calls": [{"id": "tc1", "function": {"name": "read_file", "arguments": "{}"}}]
            }),
            json!({"role": "tool", "content": "file body", "tool_call_id": "tc1", "name": "read_file"}),
        ]
    }

    #[test]
    fn boundary_is_last_user_message_matching_user_text() {
        let messages = sample_messages();
        let (_, _, boundary) = canonical_prefix(&messages, "current question");
        // Index 3 is the current user message; the tool chain at 4/5 is excluded.
        assert_eq!(boundary, 3);
    }

    #[test]
    fn boundary_falls_back_to_len_when_user_text_absent() {
        let messages = sample_messages();
        let (_, _, boundary) = canonical_prefix(&messages, "no such message");
        assert_eq!(boundary, messages.len());
    }

    #[test]
    fn identical_inputs_produce_identical_hashes() {
        let messages = sample_messages();
        let first = canonical_prefix(&messages, "current question");
        let second = canonical_prefix(&messages.clone(), "current question");
        assert_eq!(first, second);
    }

    #[test]
    fn key_order_does_not_affect_hash() {
        let a = vec![json!({"role": "system", "alpha": 1, "beta": 2, "content": "x"})];
        // Same object, keys inserted in a different textual order.
        let b: Vec<Value> =
            vec![
                serde_json::from_str(r#"{"content":"x","beta":2,"alpha":1,"role":"system"}"#)
                    .unwrap(),
            ];
        assert_eq!(canonical_prefix(&a, "").0, canonical_prefix(&b, "").0);
    }

    #[test]
    fn pre_boundary_hash_changes_when_tool_calls_field_changes() {
        let mut messages = sample_messages();
        // Insert a pre-boundary assistant message carrying tool_calls.
        messages.insert(
            3,
            json!({
                "role": "assistant",
                "content": "thinking",
                "tool_calls": [{"id": "tcA", "function": {"name": "grep", "arguments": "{}"}}]
            }),
        );
        let baseline = canonical_prefix(&messages, "current question").1;

        // Change ONLY the tool_calls field (role + content unchanged).
        messages[3] = json!({
            "role": "assistant",
            "content": "thinking",
            "tool_calls": [{"id": "tcB", "function": {"name": "grep", "arguments": "{}"}}]
        });
        let changed = canonical_prefix(&messages, "current question").1;

        assert_ne!(baseline, changed);
    }

    #[test]
    fn pre_boundary_hash_changes_when_tool_call_id_changes() {
        let mut messages = sample_messages();
        messages.insert(
            3,
            json!({"role": "tool", "content": "r", "tool_call_id": "id1", "name": "t"}),
        );
        let baseline = canonical_prefix(&messages, "current question").1;
        messages[3] = json!({"role": "tool", "content": "r", "tool_call_id": "id2", "name": "t"});
        let changed = canonical_prefix(&messages, "current question").1;
        assert_ne!(baseline, changed);
    }

    #[test]
    fn pre_boundary_hash_changes_when_name_field_changes() {
        let mut messages = sample_messages();
        messages.insert(
            3,
            json!({"role": "tool", "content": "r", "tool_call_id": "id", "name": "name_a"}),
        );
        let baseline = canonical_prefix(&messages, "current question").1;
        messages[3] =
            json!({"role": "tool", "content": "r", "tool_call_id": "id", "name": "name_b"});
        let changed = canonical_prefix(&messages, "current question").1;
        assert_ne!(baseline, changed);
    }

    #[test]
    fn system_hash_is_independent_of_history_growth() {
        let mut messages = sample_messages();
        let system_before = canonical_prefix(&messages, "current question").0;
        // Append tail growth (a new tool result) — system region must not move.
        messages
            .push(json!({"role": "tool", "content": "more", "tool_call_id": "tc2", "name": "x"}));
        let system_after = canonical_prefix(&messages, "current question").0;
        assert_eq!(system_before, system_after);
    }

    #[test]
    fn tail_growth_after_boundary_does_not_flip_pre_boundary_hash() {
        let mut messages = sample_messages();
        let pre_before = canonical_prefix(&messages, "current question").1;
        // Appending more tool results in the current interaction (after boundary).
        messages
            .push(json!({"role": "tool", "content": "more", "tool_call_id": "tc2", "name": "x"}));
        let pre_after = canonical_prefix(&messages, "current question").1;
        assert_eq!(pre_before, pre_after);
    }

    #[test]
    fn empty_messages_do_not_panic() {
        let (sys, pre, boundary) = canonical_prefix(&[], "anything");
        assert_eq!(boundary, 0);
        // Stable hashes for the degenerate case.
        assert_eq!(sys, hash_canonical(&Value::Null));
        assert_eq!(pre, hash_canonical(&Value::Array(vec![])));
    }

    #[test]
    fn fingerprint_is_deterministic_across_identical_inputs() {
        let messages = sample_messages();
        let tools = vec![json!({"name": "read_file", "parameters": {}})];
        let a = provider_call_fingerprint(&messages, "current question", &tools, false);
        let b = provider_call_fingerprint(&messages.clone(), "current question", &tools, false);
        assert_eq!(a, b);
    }

    #[test]
    fn force_text_keeps_tool_defs_hash_and_sets_flag() {
        let messages = sample_messages();
        let tools = vec![json!({"name": "read_file", "parameters": {}})];
        // Force-text retains the tool definitions in the payload (calling is
        // disabled via tool_choice=none) — the hash must be computed normally
        // so attribution can SEE that the defs stayed stable across the
        // force-text turn. Blanking it here would destroy the evidence that
        // the tool-def-refit fix works. The `force_text` boolean is the mode
        // marker; the hash carries the stability signal.
        let fp = provider_call_fingerprint(&messages, "current question", &tools, true);
        assert!(fp.force_text);
        let normal = provider_call_fingerprint(&messages, "current question", &tools, false);
        assert!(!normal.force_text);
        assert_ne!(fp.tool_defs_hash, "");
        assert_eq!(
            fp.tool_defs_hash, normal.tool_defs_hash,
            "same tool defs must hash identically regardless of force-text mode"
        );

        // A genuinely tool-free call still reports an empty hash.
        let empty = provider_call_fingerprint(&messages, "current question", &[], false);
        assert_eq!(empty.tool_defs_hash, "");
    }

    #[test]
    fn fingerprint_surfaces_canonical_prefix_fields() {
        let messages = sample_messages();
        let fp = provider_call_fingerprint(&messages, "current question", &[], false);
        let (sys, pre, boundary) = canonical_prefix(&messages, "current question");
        assert_eq!(fp.hash_system, sys);
        assert_eq!(fp.hash_pre_boundary, pre);
        assert_eq!(fp.boundary_pos, boundary);
        assert_eq!(fp.message_count, messages.len());
    }

    #[test]
    fn session_summary_hash_is_always_empty_after_retirement() {
        // Pillar A retires session_summary_hash; the field stays for
        // parser/back-compat but must always be the empty string.
        let mut messages = sample_messages();
        let without = provider_call_fingerprint(&messages, "current question", &[], false);
        assert_eq!(without.session_summary_hash, "");

        messages.insert(
            1,
            json!({"role": "system", "content": "[Session Summary]\nUser likes coffee."}),
        );
        let with_summary = provider_call_fingerprint(&messages, "current question", &[], false);
        assert_eq!(
            with_summary.session_summary_hash, "",
            "session_summary_hash retired: must be empty even when summary message is present"
        );
    }

    #[test]
    fn tool_defs_hash_changes_when_schema_changes() {
        let messages = sample_messages();
        let tools_a = vec![json!({"name": "read_file", "parameters": {"type": "object"}})];
        let tools_b = vec![json!({"name": "read_file", "parameters": {"type": "string"}})];
        let a = provider_call_fingerprint(&messages, "current question", &tools_a, false);
        let b = provider_call_fingerprint(&messages, "current question", &tools_b, false);
        assert_ne!(a.tool_defs_hash, b.tool_defs_hash);
    }

    #[test]
    fn stage_hash_skips_leading_system_message() {
        // Same history, one stage has a system prompt at index 0, the other
        // does not. The pre-boundary region (history before the boundary)
        // hashes the same because the leading system message is skipped.
        let with_system = vec![
            json!({"role": "system", "content": "sys"}),
            json!({"role": "user", "content": "old"}),
            json!({"role": "assistant", "content": "ans"}),
            json!({"role": "user", "content": "current question"}),
        ];
        let without_system = vec![
            json!({"role": "user", "content": "old"}),
            json!({"role": "assistant", "content": "ans"}),
            json!({"role": "user", "content": "current question"}),
        ];
        assert_eq!(
            stage_pre_boundary_hash(&with_system, "current question"),
            stage_pre_boundary_hash(&without_system, "current question"),
        );
    }

    #[test]
    fn stage_hash_changes_when_pre_boundary_content_mutates() {
        let base = vec![
            json!({"role": "user", "content": "old"}),
            json!({"role": "assistant", "content": "ans"}),
            json!({"role": "user", "content": "current question"}),
        ];
        let mut mutated = base.clone();
        mutated[1] = json!({"role": "assistant", "content": "ans (truncated…)"});
        assert_ne!(
            stage_pre_boundary_hash(&base, "current question"),
            stage_pre_boundary_hash(&mutated, "current question"),
        );
    }

    #[test]
    fn hash_canonical_is_order_independent_for_nested_objects() {
        let a = json!({"outer": {"b": 1, "a": 2}, "list": [{"y": 1, "x": 2}]});
        let b = json!({"list": [{"x": 2, "y": 1}], "outer": {"a": 2, "b": 1}});
        assert_eq!(hash_canonical(&a), hash_canonical(&b));
    }

    #[test]
    fn tail_hash_separates_tail_from_archived_region() {
        // Payload: [system, history-a, history-b, TAIL, user]. The tail is
        // located by TASK_CONTEXT_TAIL_MARKER; prefix_hash_archived covers
        // [1..boundary) EXCLUDING the tail; tail_hash covers the tail alone.
        let mut messages = sample_messages();
        // The tail sits immediately BEFORE the current user message, INSIDE the
        // [1..boundary) region the fingerprint searches. In sample_messages() the
        // current user message ("current question") is at index 3 and its tool
        // chain follows it (indices 4-5) — the last message is a `tool`, NOT the
        // user message. Locate the insertion via boundary_pos so the fixture stays
        // correct (and inside the searched region) regardless of the sample shape.
        let tail_pos = boundary_pos(&messages, "current question");
        messages.insert(
            tail_pos,
            serde_json::json!({
                "role": "system",
                "content": format!("{TASK_CONTEXT_TAIL_MARKER}\n[Current Date & Time]\nstub"),
            }),
        );
        let fp = provider_call_fingerprint(&messages, "current question", &[], false);
        assert!(!fp.tail_hash.is_empty(), "tail must be located and hashed");

        // Changing ONLY the tail flips tail_hash and pre_boundary, but NOT archived.
        let mut tail_changed = messages.clone();
        tail_changed[tail_pos]["content"] =
            format!("{TASK_CONTEXT_TAIL_MARKER}\n[Current Date & Time]\nother").into();
        let fp2 = provider_call_fingerprint(&tail_changed, "current question", &[], false);
        assert_ne!(fp.tail_hash, fp2.tail_hash);
        assert_eq!(fp.prefix_hash_archived, fp2.prefix_hash_archived);
        assert_ne!(fp.hash_pre_boundary, fp2.hash_pre_boundary);

        // Changing an archived message flips archived but not tail.
        let mut hist_changed = messages.clone();
        hist_changed[1]["content"] = "mutated history".into();
        let fp3 = provider_call_fingerprint(&hist_changed, "current question", &[], false);
        assert_ne!(fp.prefix_hash_archived, fp3.prefix_hash_archived);
        assert_eq!(fp.tail_hash, fp3.tail_hash);
    }

    #[test]
    fn no_tail_marker_means_empty_tail_hash_and_archived_equals_pre_boundary() {
        let messages = sample_messages();
        let fp = provider_call_fingerprint(&messages, "current question", &[], false);
        assert!(fp.tail_hash.is_empty());
        assert_eq!(fp.prefix_hash_archived, fp.hash_pre_boundary);
    }

    #[test]
    fn session_summary_hash_is_retired() {
        // Field stays for parser compatibility but always reports empty.
        let messages = sample_messages();
        let fp = provider_call_fingerprint(&messages, "current question", &[], false);
        assert!(fp.session_summary_hash.is_empty());
    }

    /// A user message whose content starts with TASK_CONTEXT_TAIL_MARKER must
    /// NOT be treated as the tail — the role guard requires `role == "system"`.
    /// The tail must remain absent (`tail_hash.is_empty()`) and
    /// `prefix_hash_archived` must equal `hash_pre_boundary` (no tail excluded).
    #[test]
    fn user_role_with_tail_marker_content_is_not_treated_as_tail() {
        let mut messages = sample_messages();
        // Insert a user message whose content starts with the marker into the
        // [1..boundary) region, immediately before the current user message.
        let tail_pos = boundary_pos(&messages, "current question");
        messages.insert(
            tail_pos,
            serde_json::json!({
                "role": "user",
                "content": format!("{TASK_CONTEXT_TAIL_MARKER}\nsome injected context"),
            }),
        );
        let fp = provider_call_fingerprint(&messages, "current question", &[], false);
        // The user message must NOT be identified as the tail.
        assert!(
            fp.tail_hash.is_empty(),
            "user-role message with tail marker content must not be treated as the tail"
        );
        // With no tail located, archived == pre_boundary.
        assert_eq!(
            fp.prefix_hash_archived, fp.hash_pre_boundary,
            "prefix_hash_archived must equal hash_pre_boundary when no system-role tail exists"
        );
    }
}