Skip to main content

agent_engine/runtime/
worker_context.rs

1//! Automatic context rollover for subagent workers.
2//!
3//! A worker is one brief followed by hundreds of assistant / tool_result
4//! rounds, and before this its context only ever grew: every round re-read the
5//! whole history at the cache-read price, and a worker that reached the window
6//! died on a terminal "prompt is too long". The foreground rollover
7//! (`continuation`) does not fit workers: its successor keeps user requests
8//! (a worker has one), it seals evicted history into the session's memory
9//! backend, and it needs a frontend to acknowledge the new head.
10//!
11//! This is the worker's host-only variant. The decision is the foreground's
12//! policy engine unchanged (`assess_context`, `ContextThresholds`,
13//! `target_tokens`, `successor_budget`); only the successor and where evicted
14//! history goes differ:
15//!
16//! - the successor keeps the brief (message 0) verbatim, later orchestrator
17//!   instructions verbatim (within a share of the target), a host-written note,
18//!   and the most recent rounds as a contiguous suffix cut at an assistant
19//!   message that no tool call/result group straddles. Assistant messages are
20//!   never edited, so signed thinking blocks travel verbatim;
21//! - the evicted messages are written as a plain-text transcript into the
22//!   worker's archive directory (`<handle>.window-<N>.md`, 0600), the same
23//!   place, permissions and retention as the finalize archive. The worker can
24//!   grep/read it; nothing goes to the memory backend.
25//!
26//! See docs/design/subagent-rollover.md.
27
28use std::collections::{BTreeSet, HashMap};
29use std::path::{Path, PathBuf};
30use std::sync::Arc;
31
32use agent_core::config::{ContextManagementConfig, ContextManagementMode, SynapsConfig};
33use agent_core::core::context_policy::ContextThresholds;
34use serde_json::{json, Value};
35
36use super::continuation::{
37    message_tokens, neutral_note, tool_groups, SuccessorBudget, TokenCalibration,
38    DROPPED_INDEX_TOKENS, PINNED_SHARE_PERCENT,
39};
40use crate::SharedMessage;
41
42/// Host-only key on the rollover note (stripped from every provider wire like
43/// every `_synaps_*` key): `{"schema", "window", "transcripts": [...]}`.
44pub(crate) const MARKER_KEY: &str = "_synaps_worker_context";
45pub(crate) const SCHEMA: &str = "synaps-worker-context/1";
46
47/// Longest input preview per tool call in the note's index.
48const INDEX_PREVIEW_CHARS: usize = 100;
49/// Room reserved for the note around its bounded index.
50const NOTE_FRAME_TOKENS: u64 = 600;
51/// Earlier windows the note lists by path; older ones are named by directory
52/// so a long-lived worker's note stays bounded.
53const NOTE_LISTED_WINDOWS: usize = 12;
54/// A rollover must shrink the estimate by more than this to be worth a cache
55/// rewrite (the foreground's no-shrink rule).
56const MIN_SHRINK_TOKENS: u64 = 1024;
57
58/// What config says about worker rollover. Set on every worker runtime by
59/// `apply_subagent_runtime_policy`; inert until a transcript sink is attached.
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub struct WorkerContextSettings {
62    pub enabled: bool,
63    pub rollover_tokens: u64,
64}
65
66impl WorkerContextSettings {
67    pub fn from_config(config: &SynapsConfig) -> Self {
68        Self {
69            enabled: config.subagent_context_management == ContextManagementMode::Auto,
70            rollover_tokens: config.subagent_rollover_tokens,
71        }
72    }
73
74    /// The policy for a model window: the foreground's policy config with
75    /// the rollover band lowered to `rollover_tokens` (the window's own band
76    /// wins when it is lower), pressure and target derived exactly as the
77    /// foreground derives them, and no Plan/WrapUp extensions (workers report
78    /// no phases). `None` when off or the window cannot hold a valid policy.
79    pub fn policy(&self, window: u64) -> Option<WorkerPolicy> {
80        if !self.enabled {
81            return None;
82        }
83        let base = ContextManagementConfig {
84            mode: ContextManagementMode::Auto,
85            finish_rounds: 0,
86            ..Default::default()
87        };
88        let band = ContextThresholds::resolve(&base, window).ok()?.rollover_tokens;
89        let config = ContextManagementConfig {
90            rollover_tokens: Some(self.rollover_tokens.min(band).max(2)),
91            ..base
92        };
93        let thresholds = ContextThresholds::resolve(&config, window).ok()?;
94        Some(WorkerPolicy { config, thresholds })
95    }
96}
97
98/// A resolved worker policy for one model window.
99#[derive(Debug, Clone, Copy, PartialEq, Eq)]
100pub struct WorkerPolicy {
101    pub config: ContextManagementConfig,
102    pub thresholds: ContextThresholds,
103}
104
105impl WorkerPolicy {
106    pub fn target_tokens(&self) -> u64 {
107        self.thresholds.target_tokens(&self.config)
108    }
109
110    /// Message budget for the successor (estimator units), exactly as the
111    /// foreground sizes its successor.
112    pub fn successor_budget(
113        &self,
114        overhead_tokens: u64,
115        calibration: Option<TokenCalibration>,
116    ) -> SuccessorBudget {
117        super::continuation::successor_budget(
118            self.target_tokens(),
119            self.thresholds.pressure_tokens,
120            overhead_tokens,
121            calibration,
122        )
123    }
124
125    /// Whether rolling over at hard capacity can make real room. Hard
126    /// capacity is the footprint `context_window - required_next_round`,
127    /// where the next round's reserves are its expected output (thinking and
128    /// response within `max_tokens`), the next tool result and the safety
129    /// margin. When that line is at or below this policy's successor target
130    /// the window is dominated by its reserves: no successor of the size the
131    /// policy aims for passes hard admission, and any successor the window
132    /// does hold is a fraction of it, back at hard capacity a few rounds
133    /// later. That was every 200k Opus window while the reserve was the full
134    /// 128k `max_tokens` (hard capacity near 32k against a 93k target;
135    /// workers rolled over every few tool calls to successors of about 9k).
136    /// With the realistic reserve it is ~127k there; small windows (about
137    /// 110k and below) can still be dominated. `false`: do not roll over at
138    /// hard capacity; send the request as before worker rollover existed and
139    /// let the provider judge it.
140    pub fn hard_capacity_has_room(&self, context_window: u64, required_next_round: u64) -> bool {
141        context_window.saturating_sub(required_next_round) > self.target_tokens()
142    }
143}
144
145/// At hard capacity the configured target may leave the successor little room
146/// to grow (a window whose reserves put hard capacity below the rollover band
147/// reaches it first; see [`WorkerPolicy::hard_capacity_has_room`] for one
148/// that cannot hold the target at all). Aim for two thirds of the history
149/// room the next round leaves, so the successor both fits and can grow.
150pub fn hard_capacity_budget(
151    budget: SuccessorBudget,
152    context_window: u64,
153    required_next_round: u64,
154    overhead_tokens: u64,
155) -> SuccessorBudget {
156    let room = context_window
157        .saturating_sub(required_next_round)
158        .saturating_sub(overhead_tokens);
159    SuccessorBudget {
160        history_tokens: budget.history_tokens.min(room * 2 / 3),
161    }
162}
163
164/// Called after every successful worker rollover (the spawn path updates the
165/// worker's state and its subagent panel line).
166pub type RolloverObserver = Arc<dyn Fn(&WorkerRolloverReport) + Send + Sync>;
167
168/// Where one worker's evicted history goes. Attached per spawn: the archive
169/// directory and the worker's handle id.
170#[derive(Clone)]
171pub struct WorkerTranscriptSink {
172    pub dir: PathBuf,
173    pub stem: String,
174    pub observer: Option<RolloverObserver>,
175}
176
177impl std::fmt::Debug for WorkerTranscriptSink {
178    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
179        f.debug_struct("WorkerTranscriptSink")
180            .field("dir", &self.dir)
181            .field("stem", &self.stem)
182            .field("observer", &self.observer.is_some())
183            .finish()
184    }
185}
186
187/// A worker runtime's rollover wiring. Active only with both halves: config
188/// on, and a sink to preserve what is evicted. Without a sink nothing is
189/// evicted (nothing may be lost), so the worker behaves as before.
190#[derive(Debug, Clone, Default)]
191pub struct WorkerContext {
192    pub settings: Option<WorkerContextSettings>,
193    pub sink: Option<WorkerTranscriptSink>,
194}
195
196impl WorkerContext {
197    /// The resolved policy and sink, when rollover is active for `window`.
198    pub fn resolve(&self, window: u64) -> Option<(WorkerPolicy, WorkerTranscriptSink)> {
199        let policy = self.settings?.policy(window)?;
200        Some((policy, self.sink.clone()?))
201    }
202}
203
204/// What one rollover did (observer, notice, status).
205#[derive(Debug, Clone, PartialEq, Eq)]
206pub struct WorkerRolloverReport {
207    /// The window the worker continues in (2 after the first rollover).
208    pub window: u64,
209    /// Estimated full request footprint before and after.
210    pub before_tokens: u64,
211    pub after_tokens: u64,
212    pub evicted_messages: usize,
213    /// The transcript this rollover wrote.
214    pub transcript: PathBuf,
215    /// Every earlier window's transcript, oldest first, this one last.
216    pub transcripts: Vec<PathBuf>,
217}
218
219impl WorkerRolloverReport {
220    /// The worker stream's notice.
221    pub fn notice(&self) -> String {
222        format!(
223            "Worker context rolled over to window {}: ~{}k → ~{}k tokens; {} earlier messages saved to {}.",
224            self.window,
225            self.before_tokens / 1000,
226            self.after_tokens / 1000,
227            self.evicted_messages,
228            self.transcript.display()
229        )
230    }
231
232    /// One line for the subagent panel.
233    pub fn step(&self) -> String {
234        format!(
235            "↻ context window {}: ~{}k → ~{}k tokens",
236            self.window,
237            self.before_tokens / 1000,
238            self.after_tokens / 1000
239        )
240    }
241}
242
243/// An earlier window's transcript, as recorded in the note's metadata.
244#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
245pub struct TranscriptRef {
246    pub window: u64,
247    pub path: String,
248    pub messages: usize,
249}
250
251/// The current window (1 before any rollover) and every recorded earlier
252/// transcript, from the host metadata on `messages`: the rollover note, or
253/// the single message of a cold resume (which carries it on message 0).
254pub fn window_state(messages: &[SharedMessage]) -> (u64, Vec<TranscriptRef>) {
255    let mut window = 1;
256    let mut seen = BTreeSet::new();
257    let mut transcripts = Vec::new();
258    for marker in messages
259        .iter()
260        .filter_map(|m| m.get(MARKER_KEY))
261        .filter(|m| m["schema"] == SCHEMA)
262    {
263        window = window.max(marker["window"].as_u64().unwrap_or(1));
264        for t in marker["transcripts"].as_array().into_iter().flatten() {
265            if let Ok(t) = serde_json::from_value::<TranscriptRef>(t.clone()) {
266                if seen.insert((t.window, t.path.clone())) {
267                    transcripts.push(t);
268                }
269            }
270        }
271    }
272    transcripts.sort_by_key(|t| t.window);
273    (window, transcripts)
274}
275
276/// The host metadata to carry onto a message that replaces a rolled history
277/// (cold resume), so later rollovers keep numbering and listing earlier
278/// windows. `None` when `history` never rolled over.
279pub fn carried_marker(history: &[Value]) -> Option<Value> {
280    let shared: Vec<SharedMessage> = history
281        .iter()
282        .filter(|m| m.get(MARKER_KEY).is_some())
283        .map(|m| Arc::new(m.clone()))
284        .collect();
285    if shared.is_empty() {
286        return None;
287    }
288    let (window, transcripts) = window_state(&shared);
289    Some(json!({"schema": SCHEMA, "window": window, "transcripts": transcripts}))
290}
291
292fn is_note(message: &Value) -> bool {
293    message.get(MARKER_KEY).is_some()
294}
295
296fn has_tool_result(message: &Value) -> bool {
297    message["content"]
298        .as_array()
299        .is_some_and(|b| b.iter().any(|b| b["type"] == "tool_result"))
300}
301
302/// Host bookkeeping that never travels as an instruction.
303fn is_host_bookkeeping(message: &Value) -> bool {
304    is_note(message)
305        || message.get("_synaps_context").is_some()
306        || crate::engine::interrupt::is_tool_in_flight_note(message)
307        || message["content"]
308            .as_str()
309            .is_some_and(crate::engine::interrupt::is_interruption_marker)
310}
311
312/// An orchestrator instruction in the evicted range, as it should travel:
313/// a plain user message as is (images included); from a tool_result carrier
314/// with text (resume merges its instructions there) only the text blocks,
315/// since a tool_result cannot travel without its tool_use.
316fn carried_instruction(message: &SharedMessage) -> Option<SharedMessage> {
317    if message["role"] != "user" || is_host_bookkeeping(message) {
318        return None;
319    }
320    match &message["content"] {
321        Value::String(s) if !s.trim().is_empty() => Some(Arc::clone(message)),
322        Value::Array(blocks) if !has_tool_result(message) => (!blocks.is_empty())
323            .then(|| Arc::clone(message)),
324        Value::Array(blocks) => {
325            let text: Vec<Value> = blocks
326                .iter()
327                .filter(|b| b["type"] == "text")
328                .filter(|b| b["text"].as_str().is_some_and(|t| !t.trim().is_empty()))
329                .map(|b| json!({"type": "text", "text": b["text"]}))
330                .collect();
331            (!text.is_empty()).then(|| Arc::new(json!({"role": "user", "content": text})))
332        }
333        _ => None,
334    }
335}
336
337/// A rollover ready to apply: write `transcript` to `transcript_path`, then
338/// replace the history with `successor`.
339#[derive(Debug)]
340pub struct RolloverPlan {
341    pub successor: Vec<SharedMessage>,
342    pub transcript: String,
343    pub transcript_path: PathBuf,
344    /// The window the successor starts.
345    pub window: u64,
346    pub evicted_messages: usize,
347    pub transcripts: Vec<TranscriptRef>,
348    /// Indices (in the planned history) the transcript renders.
349    evicted_indices: Vec<usize>,
350}
351
352/// Plan a rollover of `messages` into `budget` (message tokens, estimator
353/// units). `transcript_path(window)` names the file the evicted window is
354/// saved to. `None` when nothing can be evicted (no assistant round before
355/// the last one, or no valid cut); the caller treats that as no-shrink.
356///
357/// The successor is: the brief (message 0, always, even over budget); later
358/// orchestrator instructions from the evicted range, newest first within
359/// [`PINNED_SHARE_PERCENT`] of the budget; the host note; and the longest
360/// contiguous suffix that starts at an assistant message no tool group
361/// straddles and fits the rest of the budget. The last assistant message and
362/// everything after it are always kept.
363pub fn plan_rollover(
364    messages: &[SharedMessage],
365    budget: SuccessorBudget,
366    before_tokens: u64,
367    transcript_path: impl Fn(u64) -> PathBuf,
368) -> Option<RolloverPlan> {
369    let n = messages.len();
370    if n < 3 || messages[0]["role"] != "user" {
371        return None;
372    }
373    let last_assistant = messages.iter().rposition(|m| m["role"] == "assistant")?;
374    let after_notes = messages
375        .iter()
376        .enumerate()
377        .skip(1)
378        .filter(|(_, m)| is_note(m))
379        .map(|(i, _)| i + 1)
380        .max()
381        .unwrap_or(1);
382
383    // A cut at k is valid when no tool call/result group has members on
384    // both sides: no group with lo < k <= hi.
385    let groups = tool_groups(messages);
386    let mut extent: HashMap<usize, (usize, usize)> = HashMap::new();
387    for (i, g) in groups.iter().enumerate() {
388        let e = extent.entry(*g).or_insert((i, i));
389        e.0 = e.0.min(i);
390        e.1 = e.1.max(i);
391    }
392    let mut straddled = vec![false; n + 1];
393    for &(lo, hi) in extent.values() {
394        for flag in straddled.iter_mut().take(hi + 1).skip(lo + 1) {
395            *flag = true;
396        }
397    }
398    let candidates: Vec<usize> = (after_notes.max(1)..=last_assistant)
399        .filter(|&k| messages[k]["role"] == "assistant" && !straddled[k])
400        .collect();
401    let &must_keep = candidates.last()?;
402
403    let costs: Vec<u64> = messages.iter().map(message_tokens).collect();
404    let mut suffix = vec![0u64; n + 1];
405    for i in (0..n).rev() {
406        suffix[i] = suffix[i + 1] + costs[i];
407    }
408    let total = budget.history_tokens;
409    let share = total.min(u64::MAX / 100) * PINNED_SHARE_PERCENT / 100;
410    let instructions: Vec<(usize, SharedMessage, u64)> = (1..must_keep)
411        .filter_map(|i| carried_instruction(&messages[i]).map(|m| (i, m)))
412        .map(|(i, m)| {
413            let cost = message_tokens(&m);
414            (i, m, cost)
415        })
416        .collect();
417    let instruction_reserve = instructions.iter().map(|(_, _, c)| c).sum::<u64>().min(share);
418    let (_, prior) = window_state(messages);
419    let note_reserve =
420        DROPPED_INDEX_TOKENS + NOTE_FRAME_TOKENS + 60 * (prior.len() as u64 + 1);
421    let available =
422        total.saturating_sub(costs[0] + instruction_reserve + note_reserve);
423    // First cut whose suffix fits the reserve estimate; then measure the
424    // real successor (the note grows with the index and every earlier
425    // window) and step to the next boundary while it is over target.
426    let first = candidates
427        .iter()
428        .position(|&k| suffix[k] <= available)
429        .unwrap_or(candidates.len() - 1);
430    let (window, prior_transcripts) = window_state(messages);
431    let path = transcript_path(window);
432    let mut plan = None;
433    for &cut in &candidates[first..] {
434        let built = build_successor(
435            messages,
436            cut,
437            after_notes,
438            &instructions,
439            share,
440            window,
441            &prior_transcripts,
442            &path,
443            before_tokens,
444            &costs,
445        )?;
446        let size = super::context::estimate_history(&built.successor);
447        plan = Some(built);
448        if size <= total || cut == must_keep {
449            if size > total && total != u64::MAX {
450                tracing::warn!(
451                    successor_tokens = size,
452                    target_tokens = total,
453                    "worker rollover must-keep tail exceeds the successor target; keeping it whole"
454                );
455            }
456            break;
457        }
458    }
459    let mut plan = plan?;
460    let evicted: Vec<usize> = std::mem::take(&mut plan.evicted_indices);
461    plan.transcript = render_transcript(messages, &evicted, window, before_tokens);
462    Some(plan)
463}
464
465/// The successor for a cut at `cut` (see [`plan_rollover`]); `None` when the
466/// cut evicts nothing that is not carried anyway.
467#[allow(clippy::too_many_arguments)]
468fn build_successor(
469    messages: &[SharedMessage],
470    cut: usize,
471    after_notes: usize,
472    instructions: &[(usize, SharedMessage, u64)],
473    share: u64,
474    window: u64,
475    prior_transcripts: &[TranscriptRef],
476    path: &Path,
477    before_tokens: u64,
478    costs: &[u64],
479) -> Option<RolloverPlan> {
480    // Instructions evicted by this cut, newest first within the share.
481    let mut pinned = Vec::new();
482    let mut unpinned = Vec::new();
483    let mut used = 0u64;
484    for (i, m, cost) in instructions.iter().rev().filter(|(i, _, _)| *i < cut) {
485        if used + cost <= share {
486            used += cost;
487            pinned.push((*i, Arc::clone(m)));
488        } else {
489            unpinned.push(*i);
490        }
491    }
492    pinned.reverse();
493    unpinned.reverse();
494
495    // Saved and counted: what this window added before the cut. Messages
496    // before the old note are instructions an earlier rollover carried; they
497    // are already in an earlier window's transcript.
498    let evicted: Vec<usize> = (after_notes..cut)
499        .filter(|&i| !is_note(&messages[i]))
500        .collect();
501    if evicted.is_empty() || evicted.iter().all(|i| pinned.iter().any(|(p, _)| p == i)) {
502        return None;
503    }
504    let mut transcripts = prior_transcripts.to_vec();
505    transcripts.push(TranscriptRef {
506        window,
507        path: path.display().to_string(),
508        messages: evicted.len(),
509    });
510    let evicted_tokens: u64 = evicted.iter().map(|&i| costs[i]).sum();
511    let kept = messages.len() - cut;
512    let note = note_message(NoteFacts {
513        window: window + 1,
514        before_tokens,
515        evicted: evicted.len(),
516        evicted_tokens,
517        kept,
518        carried_instructions: !pinned.is_empty(),
519        transcripts: &transcripts,
520        tool_index: tool_index(messages, &evicted),
521        unpinned: &unpinned
522            .iter()
523            .map(|&i| (i, preview(&messages[i])))
524            .collect::<Vec<_>>(),
525    });
526
527    let mut successor = Vec::with_capacity(2 + pinned.len() + kept);
528    successor.push(Arc::clone(&messages[0]));
529    successor.extend(pinned.into_iter().map(|(_, m)| m));
530    successor.push(Arc::new(note));
531    successor.extend(messages[cut..].iter().cloned());
532    // A retained read-dedupe stub must not point at an evicted read.
533    super::read_dedupe::rewrite_dangling_stubs(&mut successor);
534    Some(RolloverPlan {
535        successor,
536        transcript: String::new(),
537        transcript_path: path.to_path_buf(),
538        window: window + 1,
539        evicted_messages: evicted.len(),
540        transcripts,
541        evicted_indices: evicted,
542    })
543}
544
545/// Whether `plan` is worth applying: it must shrink the history estimate by
546/// more than [`MIN_SHRINK_TOKENS`] and keep every tool call paired as it was.
547pub fn plan_is_productive(messages: &[SharedMessage], plan: &RolloverPlan) -> bool {
548    let before = super::context::estimate_history(messages);
549    let after = super::context::estimate_history(&plan.successor);
550    after.saturating_add(MIN_SHRINK_TOKENS) < before
551        && unpaired_tool_ids(&plan.successor).is_subset(&unpaired_tool_ids(messages))
552}
553
554/// Tool ids with a call but no result after it, or a result with no call
555/// before it. A valid history has none; a successor must add none.
556pub fn unpaired_tool_ids(messages: &[SharedMessage]) -> BTreeSet<String> {
557    let mut calls = BTreeSet::new();
558    let mut unpaired = BTreeSet::new();
559    for m in messages {
560        for b in m["content"].as_array().into_iter().flatten() {
561            match (b["type"].as_str(), m["role"].as_str()) {
562                (Some("tool_use"), Some("assistant")) => {
563                    if let Some(id) = b["id"].as_str() {
564                        calls.insert(id.to_owned());
565                        unpaired.insert(id.to_owned());
566                    }
567                }
568                (Some("tool_result"), Some("user")) => {
569                    if let Some(id) = b["tool_use_id"].as_str() {
570                        if !unpaired.remove(id) || !calls.contains(id) {
571                            unpaired.insert(format!("result:{id}"));
572                        }
573                    }
574                }
575                _ => {}
576            }
577        }
578    }
579    unpaired
580}
581
582struct NoteFacts<'a> {
583    window: u64,
584    before_tokens: u64,
585    evicted: usize,
586    evicted_tokens: u64,
587    kept: usize,
588    carried_instructions: bool,
589    transcripts: &'a [TranscriptRef],
590    tool_index: Vec<String>,
591    unpinned: &'a [(usize, String)],
592}
593
594fn note_message(f: NoteFacts<'_>) -> Value {
595    let mut text = format!(
596        "[Worker context rollover — host note, not a new instruction]\n\
597         Your context reached about {} tokens, so the host started context window {}. \
598         {} earlier messages (about {} tokens) were moved out of your context unchanged; \
599         nothing was summarized. Your task (the first message){} and your most recent {} \
600         messages are kept verbatim.\n\
601         The moved messages are saved as plain text you can search with grep or read by line range:",
602        f.before_tokens,
603        f.window,
604        f.evicted,
605        f.evicted_tokens,
606        if f.carried_instructions {
607            ", later orchestrator instructions"
608        } else {
609            ""
610        },
611        f.kept,
612    );
613    let hidden = f.transcripts.len().saturating_sub(NOTE_LISTED_WINDOWS);
614    if let Some(oldest) = f.transcripts.first().filter(|_| hidden > 0) {
615        let dir = Path::new(&oldest.path)
616            .parent()
617            .map(|d| d.display().to_string())
618            .unwrap_or_default();
619        text.push_str(&format!(
620            "\n- windows {}–{}: earlier transcripts (*.window-N.md) in {}",
621            oldest.window,
622            f.transcripts[hidden - 1].window,
623            neutral_note(&dir)
624        ));
625    }
626    for t in &f.transcripts[hidden..] {
627        text.push_str(&format!(
628            "\n- window {}: {} ({} messages)",
629            t.window,
630            neutral_note(&t.path),
631            t.messages
632        ));
633    }
634    text.push_str(
635        "\nLook there only when you need a specific earlier detail (an error, a file path, a \
636         decision); do not re-read them wholesale.",
637    );
638    if !f.unpinned.is_empty() {
639        text.push_str("\nEarlier orchestrator messages not kept verbatim (they are in the saved text):");
640        for (i, p) in f.unpinned {
641            text.push_str(&format!("\n- #{i}: {p}"));
642        }
643    }
644    if !f.tool_index.is_empty() {
645        text.push_str("\nTool calls in the moved messages, most recent first:");
646        let mut listed = 0;
647        for line in &f.tool_index {
648            let line = format!("\n- {line}");
649            if super::context::conservative_token_estimate(&text)
650                + super::context::conservative_token_estimate(&line)
651                > DROPPED_INDEX_TOKENS + NOTE_FRAME_TOKENS / 2
652            {
653                break;
654            }
655            text.push_str(&line);
656            listed += 1;
657        }
658        if listed < f.tool_index.len() {
659            text.push_str(&format!(
660                "\n- … and {} older not listed",
661                f.tool_index.len() - listed
662            ));
663        }
664    }
665    text.push_str(
666        "\nContinue the task from where the kept messages leave off; do not redo completed \
667         steps.\n[End worker context rollover]",
668    );
669    let transcripts: Vec<Value> = f
670        .transcripts
671        .iter()
672        .map(|t| serde_json::to_value(t).unwrap_or(Value::Null))
673        .collect();
674    json!({
675        "role": "user",
676        MARKER_KEY: {"schema": SCHEMA, "window": f.window, "transcripts": transcripts},
677        "content": [{"type": "text", "text": text}],
678    })
679}
680
681/// `name: <the input's main argument>` for every evicted tool call, newest first.
682fn tool_index(messages: &[SharedMessage], evicted: &[usize]) -> Vec<String> {
683    let mut lines = Vec::new();
684    for &i in evicted.iter().rev() {
685        let blocks: Vec<&Value> = messages[i]["content"]
686            .as_array()
687            .into_iter()
688            .flatten()
689            .filter(|b| b["type"] == "tool_use")
690            .collect();
691        for b in blocks.into_iter().rev() {
692            let name = neutral_note(b["name"].as_str().unwrap_or("?"));
693            let input = &b["input"];
694            let arg = ["command", "path", "file_path", "pattern", "query", "url", "handle_id"]
695                .iter()
696                .find_map(|k| input[*k].as_str().map(str::to_owned))
697                .unwrap_or_else(|| input.to_string());
698            lines.push(format!("{name}: {}", clip(&flat(&arg), INDEX_PREVIEW_CHARS)));
699        }
700    }
701    lines
702}
703
704fn flat(text: &str) -> String {
705    neutral_note(&text.split_whitespace().collect::<Vec<_>>().join(" "))
706}
707
708fn clip(text: &str, chars: usize) -> String {
709    let mut out: String = text.chars().take(chars).collect();
710    if text.chars().count() > chars {
711        out.push('…');
712    }
713    out
714}
715
716fn preview(message: &Value) -> String {
717    let text = match &message["content"] {
718        Value::String(s) => s.clone(),
719        Value::Array(blocks) => blocks
720            .iter()
721            .filter_map(|b| b["text"].as_str())
722            .collect::<Vec<_>>()
723            .join(" "),
724        _ => String::new(),
725    };
726    clip(&flat(&text), 80)
727}
728
729/// Plain text of every evicted message, in order, for grep and ranged read.
730/// Thinking is omitted (private reasoning; signed blocks cannot be re-sent out
731/// of their turn anyway); images and documents are named only.
732pub fn render_transcript(
733    messages: &[SharedMessage],
734    evicted: &[usize],
735    window: u64,
736    before_tokens: u64,
737) -> String {
738    let mut out = format!(
739        "# Worker context window {window}: {} messages moved out of context at a rollover\n\
740         # (context about {before_tokens} tokens). Thinking is omitted; images are named only.\n",
741        evicted.len()
742    );
743    for &i in evicted {
744        let m = &messages[i];
745        let role = m["role"].as_str().unwrap_or("user");
746        out.push_str(&format!("\n## [{i}] {role}\n"));
747        match &m["content"] {
748            Value::String(s) => {
749                out.push_str(s);
750                out.push('\n');
751            }
752            Value::Array(blocks) => {
753                for b in blocks {
754                    render_block(&mut out, b);
755                }
756            }
757            _ => {}
758        }
759    }
760    out
761}
762
763fn render_block(out: &mut String, b: &Value) {
764    match b["type"].as_str() {
765        Some("text") => {
766            out.push_str(b["text"].as_str().unwrap_or_default());
767            out.push('\n');
768        }
769        Some("tool_use") => {
770            let input = serde_json::to_string_pretty(&b["input"]).unwrap_or_default();
771            out.push_str(&format!(
772                "→ tool_use {} ({})\n{input}\n",
773                b["name"].as_str().unwrap_or("?"),
774                b["id"].as_str().unwrap_or("?")
775            ));
776        }
777        Some("tool_result") => {
778            let error = if b["is_error"] == true { " error" } else { "" };
779            out.push_str(&format!(
780                "← tool_result{error} ({})\n",
781                b["tool_use_id"].as_str().unwrap_or("?")
782            ));
783            match &b["content"] {
784                Value::String(s) => {
785                    out.push_str(s);
786                    out.push('\n');
787                }
788                Value::Array(inner) => {
789                    for x in inner {
790                        render_block(out, x);
791                    }
792                }
793                Value::Null => {}
794                other => {
795                    out.push_str(&other.to_string());
796                    out.push('\n');
797                }
798            }
799        }
800        Some("thinking") | Some("redacted_thinking") => {}
801        Some(other) => out.push_str(&format!("[{other}]\n")),
802        None => {}
803    }
804}
805
806/// Apply one worker rollover: plan it, refuse one that cannot shrink the
807/// context or would start where the next round triggers another rollover
808/// (the pressure band below hard capacity, the hard-capacity line at it),
809/// save the evicted messages, and return the successor with its report.
810/// `hard_capacity` is `Some(line)` for a rollover at hard capacity, where
811/// `line` is the footprint at which the next round is at hard capacity
812/// (`context_window - required_next_round`). `Ok(None)` = no-shrink (the
813/// caller defers like the foreground's unproductive rollover). An error
814/// writing the transcript means no rollover: nothing is evicted that was not
815/// saved.
816pub fn roll_over(
817    messages: &[SharedMessage],
818    budget: SuccessorBudget,
819    before_tokens: u64,
820    overhead_tokens: u64,
821    policy: &WorkerPolicy,
822    sink: &WorkerTranscriptSink,
823    hard_capacity: Option<u64>,
824) -> std::io::Result<Option<(Vec<SharedMessage>, WorkerRolloverReport)>> {
825    let Some(plan) = plan_rollover(messages, budget, before_tokens, |window| {
826        transcript_path(&sink.dir, &sink.stem, window)
827    }) else {
828        return Ok(None);
829    };
830    if !plan_is_productive(messages, &plan) {
831        return Ok(None);
832    }
833    let after_tokens =
834        overhead_tokens.saturating_add(super::context::estimate_history(&plan.successor));
835    if after_tokens >= hard_capacity.unwrap_or(policy.thresholds.pressure_tokens) {
836        return Ok(None);
837    }
838    crate::tools::write_window_transcript(
839        &plan.transcript_path,
840        &plan.transcript,
841    )?;
842    let report = WorkerRolloverReport {
843        window: plan.window,
844        before_tokens,
845        after_tokens,
846        evicted_messages: plan.evicted_messages,
847        transcript: plan.transcript_path,
848        transcripts: plan
849            .transcripts
850            .iter()
851            .map(|t| PathBuf::from(&t.path))
852            .collect(),
853    };
854    Ok(Some((plan.successor, report)))
855}
856
857/// `<dir>/<stem>.window-<window>.md`.
858pub fn transcript_path(dir: &Path, stem: &str, window: u64) -> PathBuf {
859    let stem: String = stem
860        .chars()
861        .map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '-' })
862        .collect();
863    dir.join(format!("{stem}.window-{window}.md"))
864}
865
866#[cfg(test)]
867mod tests {
868    use super::*;
869
870    fn user(text: &str) -> SharedMessage {
871        Arc::new(json!({"role": "user", "content": text}))
872    }
873
874    /// One worker round: an assistant tool call (signed thinking first) and
875    /// its result, padded to roughly `pad` characters.
876    fn round(i: usize, pad: usize) -> [SharedMessage; 2] {
877        [
878            Arc::new(json!({"role": "assistant", "content": [
879                {"type": "thinking", "thinking": format!("step {i}"), "signature": format!("sig{i}")},
880                {"type": "tool_use", "id": format!("t{i}"), "name": "bash",
881                 "input": {"command": format!("cargo test step{i}")}}
882            ]})),
883            Arc::new(json!({"role": "user", "content": [
884                {"type": "tool_result", "tool_use_id": format!("t{i}"),
885                 "content": format!("output {i} {}", "x".repeat(pad))}
886            ]})),
887        ]
888    }
889
890    fn worker_history(rounds: usize, pad: usize) -> Vec<SharedMessage> {
891        let mut m = vec![user("BRIEF: fix the parser")];
892        for i in 0..rounds {
893            m.extend(round(i, pad));
894        }
895        m
896    }
897
898    fn path(w: u64) -> PathBuf {
899        PathBuf::from(format!("/tmp/x/sa_1.window-{w}.md"))
900    }
901
902    fn budget(tokens: u64) -> SuccessorBudget {
903        SuccessorBudget { history_tokens: tokens }
904    }
905
906    #[test]
907    fn policy_lowers_the_band_and_derives_pressure_and_target_like_the_foreground() {
908        let on = WorkerContextSettings { enabled: true, rollover_tokens: 200_000 };
909        let p = on.policy(1_000_000).unwrap();
910        assert_eq!(p.thresholds.rollover_tokens, 200_000);
911        assert_eq!(p.thresholds.pressure_tokens, 199_999);
912        assert_eq!(p.target_tokens(), 100_000);
913        assert_eq!(p.config.finish_rounds, 0);
914        // A 200k window's own band (180k) wins over a higher setting.
915        let p = on.policy(200_000).unwrap();
916        assert_eq!(p.thresholds.rollover_tokens, 180_000);
917        assert_eq!(p.thresholds.pressure_tokens, 140_000);
918        assert_eq!(p.target_tokens(), 93_333);
919        assert!(p.target_tokens() < p.thresholds.pressure_tokens);
920        let off = WorkerContextSettings { enabled: false, ..on };
921        assert!(off.policy(1_000_000).is_none());
922        assert!(on.policy(1).is_none(), "a window that holds no policy disables it");
923        // Inert without a sink: nothing may be evicted without a place to keep it.
924        let ctx = WorkerContext { settings: Some(on), sink: None };
925        assert!(ctx.resolve(1_000_000).is_none());
926    }
927
928    #[test]
929    fn policy_engine_admits_then_rolls_at_the_worker_band() {
930        use agent_core::core::context_policy::{assess_context, ContextAction, ContextBudget, ContextState};
931        let p = WorkerContextSettings { enabled: true, rollover_tokens: 200_000 }
932            .policy(1_000_000)
933            .unwrap();
934        let at = |used: u64| ContextBudget {
935            context_window_tokens: 1_000_000,
936            used_tokens: used,
937            hard_remaining_tokens: 1_000_000 - used,
938            required_next_round_tokens: 150_000,
939        };
940        let state = ContextState::default();
941        assert_eq!(assess_context(&p.config, &state, at(150_000)).action, ContextAction::Continue);
942        assert_eq!(assess_context(&p.config, &state, at(200_000)).action, ContextAction::Rollover);
943        // Hard capacity is a rollover attempt too (the caller treats HardStop alike).
944        assert_eq!(assess_context(&p.config, &state, at(900_000)).action, ContextAction::HardStop);
945    }
946
947    #[test]
948    fn hard_capacity_aims_inside_the_room_the_window_has_left() {
949        let normal = budget(56_000);
950        // 120k window, 72k reserved for the next round, 1k fixed overhead.
951        assert_eq!(hard_capacity_budget(normal, 120_000, 72_000, 1_000).history_tokens, 31_333);
952        // A roomy window keeps the configured target.
953        assert_eq!(hard_capacity_budget(normal, 1_000_000, 150_000, 1_000).history_tokens, 56_000);
954        assert_eq!(hard_capacity_budget(normal, 10, 72_000, 1_000).history_tokens, 0);
955    }
956
957    /// The next round's required room, as `pre_request_context_budget`
958    /// computes it for an empty request (adaptive thinking, 30k tool cap).
959    fn required(model: &str, window: u64) -> u64 {
960        super::super::stream::pre_request_context_budget(model, window, None, &[], &[], 0, 30_000, None)
961            .1
962            .required_next_round_tokens
963    }
964
965    /// The live thrash (#332): an Opus worker on the 200k default window
966    /// reserved the full 128k `max_tokens` for the next round: hard capacity
967    /// at 31,488, a third of the 93,333 target, so no rollover there could
968    /// make room. The next round now reserves its expected output (32,768 of
969    /// thinking and response), the 10k tool result, the 15% margin and the
970    /// 512 advisory reserve: hard capacity at 126,720 on 200k for Opus and
971    /// Sonnet alike, and every such window holds the target. Small windows
972    /// can still be reserve-dominated.
973    #[test]
974    fn hard_capacity_has_room_only_when_the_target_fits_below_it() {
975        use agent_core::core::context_policy::{assess_context, ContextAction, ContextBudget, ContextState};
976        let on = WorkerContextSettings { enabled: true, rollover_tokens: 200_000 };
977        let p = on.policy(200_000).unwrap();
978        assert_eq!(p.target_tokens(), 93_333);
979        for model in ["claude-opus-5-5", "claude-sonnet-4-6"] {
980            let need = required(model, 200_000);
981            assert_eq!(need, 32_768 + 10_000 + 30_000 + 512, "{model}");
982            assert!(p.hard_capacity_has_room(200_000, need), "{model}");
983            // ~33k of context is far from hard capacity now...
984            let at = |used: u64| ContextBudget {
985                context_window_tokens: 200_000,
986                used_tokens: used,
987                hard_remaining_tokens: 200_000 - used,
988                required_next_round_tokens: need,
989            };
990            let state = ContextState::default();
991            assert_eq!(assess_context(&p.config, &state, at(33_000)).action, ContextAction::Continue);
992            // ...which is 126,720 here.
993            assert_eq!(assess_context(&p.config, &state, at(126_720)).action, ContextAction::Continue);
994            assert_eq!(assess_context(&p.config, &state, at(126_721)).action, ContextAction::HardStop);
995            let need_1m = required(model, 1_000_000);
996            assert!(on.policy(1_000_000).unwrap().hard_capacity_has_room(1_000_000, need_1m));
997        }
998        // The old 128k reserve on that window would have been dominated.
999        assert!(!p.hard_capacity_has_room(200_000, 128_000 + 10_000 + 30_000 + 512));
1000        // A 100k window still is: 41,720 against 46,666.
1001        let small = on.policy(100_000).unwrap();
1002        assert_eq!(small.target_tokens(), 46_666);
1003        assert_eq!(100_000 - required("claude-opus-5-5", 100_000), 41_720);
1004        assert!(!small.hard_capacity_has_room(100_000, required("claude-opus-5-5", 100_000)));
1005        // The boundary: a successor at the target must start below the line.
1006        assert!(!p.hard_capacity_has_room(200_000, 200_000 - 93_333));
1007        assert!(p.hard_capacity_has_room(200_000, 200_000 - 93_334));
1008        assert!(!p.hard_capacity_has_room(100_000, 200_000), "reserves over the window");
1009    }
1010
1011    /// At hard capacity a successor that would itself be at hard capacity
1012    /// (its must-keep tail alone is too big) makes no room: refused, as the
1013    /// pressure band refuses one below hard capacity.
1014    #[test]
1015    fn a_successor_at_the_line_that_triggered_it_is_refused() {
1016        let tmp = tempfile::tempdir().unwrap();
1017        let sink = WorkerTranscriptSink { dir: tmp.path().into(), stem: "sa_1".into(), observer: None };
1018        let policy = WorkerContextSettings { enabled: true, rollover_tokens: 200_000 }
1019            .policy(200_000)
1020            .unwrap();
1021        let mut m = worker_history(20, 3_000);
1022        m.extend(round(20, 60_000)); // the must-keep round: ~24k tokens
1023        let before = super::super::context::estimate_history(&m);
1024        let roll = |line| roll_over(&m, budget(10_000), before, 1_000, &policy, &sink, line).unwrap();
1025        assert!(roll(Some(20_000)).is_none(), "would start at hard capacity again");
1026        let (successor, report) = roll(Some(100_000)).expect("room below the line");
1027        assert!(report.after_tokens < 100_000 && successor.len() < m.len());
1028        // Below hard capacity the pressure band (140k) is the line.
1029        assert!(roll(None).is_some());
1030    }
1031
1032    #[test]
1033    fn successor_keeps_brief_and_recent_rounds_within_the_target() {
1034        let messages = worker_history(40, 4_000);
1035        let before = super::super::context::estimate_history(&messages);
1036        let plan = plan_rollover(&messages, budget(20_000), before, path).unwrap();
1037        let s = &plan.successor;
1038        assert!(Arc::ptr_eq(&s[0], &messages[0]), "the brief survives verbatim");
1039        assert!(s[1].get(MARKER_KEY).is_some(), "then the host note");
1040        assert_eq!(s[2]["role"], "assistant", "the suffix starts at an assistant turn");
1041        assert!(Arc::ptr_eq(s.last().unwrap(), messages.last().unwrap()));
1042        // Every retained round is the same Arc: nothing edited (thinking intact).
1043        for m in &s[2..] {
1044            assert!(messages.iter().any(|o| Arc::ptr_eq(o, m)));
1045        }
1046        assert!(s[2]["content"][0]["type"] == "thinking" && s[2]["content"][0]["signature"].is_string());
1047        let after = super::super::context::estimate_history(s);
1048        assert!(after <= 20_000, "target respected: {after}");
1049        assert!(after < before / 3);
1050        assert!(unpaired_tool_ids(s).is_empty());
1051        assert!(plan_is_productive(&messages, &plan));
1052        assert_eq!(plan.window, 2);
1053        assert_eq!(plan.evicted_messages, messages.len() - 1 - (s.len() - 2));
1054        // The transcript holds every evicted round, without thinking.
1055        assert!(plan.transcript.contains("cargo test step0"));
1056        assert!(plan.transcript.contains("output 0 "));
1057        assert!(!plan.transcript.contains("sig0") && !plan.transcript.contains("step 0\n"));
1058        // The note names the transcript and indexes the evicted calls.
1059        let note = s[1]["content"][0]["text"].as_str().unwrap();
1060        assert!(note.contains("/tmp/x/sa_1.window-1.md"), "{note}");
1061        assert!(note.contains("bash: cargo test step"), "{note}");
1062        assert_eq!(s[1][MARKER_KEY]["window"], 2);
1063    }
1064
1065    /// A read-dedupe stub the cut keeps while evicting the read it names is
1066    /// rewritten so it no longer claims the content is in context; with the
1067    /// original kept too, the stub is untouched.
1068    #[test]
1069    fn a_kept_dedupe_stub_whose_read_was_evicted_is_rewritten() {
1070        use crate::runtime::read_dedupe::{unchanged_stub, DROPPED_STUB};
1071        let read = |id: &str, content: String| -> [SharedMessage; 2] {
1072            [
1073                Arc::new(json!({"role":"assistant","content":[
1074                    {"type":"tool_use","id":id,"name":"read","input":{"path":"/w/database.py"}}]})),
1075                Arc::new(json!({"role":"user","content":[
1076                    {"type":"tool_result","tool_use_id":id,"content":content}]})),
1077            ]
1078        };
1079        let full = "1\tdb = connect()\n".repeat(50);
1080        let history = |original_late: bool| {
1081            let mut m = vec![user("BRIEF: fix the parser")];
1082            if !original_late {
1083                m.extend(read("toolu_r1", full.clone()));
1084            }
1085            for i in 0..20 {
1086                m.extend(round(i, 4_000));
1087            }
1088            if original_late {
1089                m.extend(read("toolu_r1", full.clone()));
1090            }
1091            m.extend(read("toolu_r2", unchanged_stub("toolu_r1")));
1092            m.extend(round(99, 10));
1093            m
1094        };
1095
1096        let m = history(false);
1097        let plan = plan_rollover(&m, budget(8_000), 1, path).unwrap();
1098        let s = &plan.successor;
1099        assert!(!s.iter().any(|x| x.to_string().contains("toolu_r1")), "r1 evicted");
1100        let at = s.iter().position(|x| x["content"][0]["tool_use_id"] == "toolu_r2").unwrap();
1101        assert_eq!(s[at]["content"][0]["content"], DROPPED_STUB);
1102        assert!(Arc::ptr_eq(&s[at - 1], &m[m.len() - 4]), "the call itself is never edited");
1103        assert!(unpaired_tool_ids(s).is_empty() && plan_is_productive(&m, &plan));
1104
1105        // Original kept by the cut: the stub (and every kept round) untouched.
1106        let m = history(true);
1107        let plan = plan_rollover(&m, budget(8_000), 1, path).unwrap();
1108        for x in &plan.successor[2..] {
1109            assert!(m.iter().any(|o| Arc::ptr_eq(o, x)), "nothing edited");
1110        }
1111        assert!(plan.successor.iter().any(|x| x["content"][0]["content"] == unchanged_stub("toolu_r1")));
1112    }
1113
1114    #[test]
1115    fn cut_never_splits_a_tool_group_and_keeps_the_pending_loop() {
1116        // A parallel round whose second result arrives in a later message
1117        // straddles the boundary right after it; the cut must move past it.
1118        let mut m = worker_history(6, 2_000);
1119        m.push(Arc::new(json!({"role":"assistant","content":[
1120            {"type":"tool_use","id":"p1","name":"read","input":{"path":"a"}},
1121            {"type":"tool_use","id":"p2","name":"read","input":{"path":"b"}}]})));
1122        m.push(Arc::new(json!({"role":"user","content":[
1123            {"type":"tool_result","tool_use_id":"p1","content":"A"}]})));
1124        m.push(Arc::new(json!({"role":"assistant","content":[{"type":"text","text":"hm"}]})));
1125        m.push(Arc::new(json!({"role":"user","content":[
1126            {"type":"tool_result","tool_use_id":"p2","content":"B"}]})));
1127        m.extend(round(99, 10));
1128        for tokens in [0, 1_000, 3_000, 6_000, 50_000] {
1129            let Some(plan) = plan_rollover(&m, budget(tokens), 1, path) else { continue };
1130            assert!(unpaired_tool_ids(&plan.successor).is_empty(), "budget {tokens}");
1131            let note = plan.successor.iter().position(|x| x.get(MARKER_KEY).is_some()).unwrap();
1132            assert_eq!(plan.successor[note + 1]["role"], "assistant");
1133            assert!(Arc::ptr_eq(plan.successor.last().unwrap(), m.last().unwrap()));
1134        }
1135        // Even a zero budget keeps the last assistant turn and its results.
1136        let plan = plan_rollover(&m, budget(0), 1, path).unwrap();
1137        assert_eq!(plan.successor.len(), 4, "brief, note, last call, its result");
1138    }
1139
1140    #[test]
1141    fn orchestrator_instructions_survive_and_bookkeeping_does_not() {
1142        let mut m = worker_history(3, 3_000);
1143        m.push(user("STEER: also update the docs"));
1144        // Resume merges its instructions into a tool_result carrier.
1145        m.extend(round(3, 3_000));
1146        let mut carrier = (*m.pop().unwrap()).clone();
1147        carrier["content"].as_array_mut().unwrap().push(
1148            json!({"type":"text","text":"[Resumed by the orchestrator.] RESUME: finish the tests"}),
1149        );
1150        m.push(Arc::new(carrier));
1151        m.push(Arc::new(json!({"role":"user","content":
1152            crate::engine::interrupt::InterruptReason::ALL[0].marker()})));
1153        for i in 4..30 {
1154            m.extend(round(i, 3_000));
1155        }
1156        let plan = plan_rollover(&m, budget(15_000), 1, path).unwrap();
1157        let s = &plan.successor;
1158        let texts: Vec<String> = s
1159            .iter()
1160            .take_while(|x| x.get(MARKER_KEY).is_none())
1161            .map(|x| x["content"].to_string())
1162            .collect();
1163        assert_eq!(texts.len(), 3, "brief + two carried instructions: {texts:?}");
1164        assert!(texts[1].contains("STEER: also update the docs"));
1165        assert!(texts[2].contains("RESUME: finish the tests"));
1166        assert!(!texts[2].contains("tool_result"), "only the text travels");
1167        assert!(!s.iter().any(|x| x["content"].as_str().is_some_and(
1168            crate::engine::interrupt::is_interruption_marker)));
1169        assert!(unpaired_tool_ids(s).is_empty());
1170    }
1171
1172    #[test]
1173    fn second_rollover_continues_numbering_and_lists_every_window() {
1174        let messages = worker_history(30, 3_000);
1175        let first = plan_rollover(&messages, budget(15_000), 1, path).unwrap();
1176        let mut next = first.successor.clone();
1177        for i in 100..130 {
1178            next.extend(round(i, 3_000));
1179        }
1180        assert_eq!(window_state(&next).0, 2);
1181        let second = plan_rollover(&next, budget(15_000), 1, path).unwrap();
1182        assert_eq!(second.window, 3);
1183        let s = &second.successor;
1184        assert!(Arc::ptr_eq(&s[0], &messages[0]), "the brief survives every rollover");
1185        assert_eq!(s.iter().filter(|x| x.get(MARKER_KEY).is_some()).count(), 1, "one note");
1186        let note = s[1]["content"][0]["text"].as_str().unwrap();
1187        assert!(note.contains("window-1.md") && note.contains("window-2.md"), "{note}");
1188        assert!(!second.transcript.contains("Worker context rollover"), "old note not re-saved");
1189        let (window, transcripts) = window_state(s);
1190        assert_eq!(window, 3);
1191        assert_eq!(transcripts.iter().map(|t| t.window).collect::<Vec<_>>(), vec![1, 2]);
1192        // A cold resume carries the metadata on its single message.
1193        let history: Vec<Value> = s.iter().map(|x| (**x).clone()).collect();
1194        let marker = carried_marker(&history).unwrap();
1195        assert_eq!(marker["window"], 3);
1196        assert_eq!(marker["transcripts"].as_array().unwrap().len(), 2);
1197        assert!(carried_marker(&[json!({"role":"user","content":"x"})]).is_none());
1198    }
1199
1200    #[test]
1201    fn the_note_stays_bounded_however_many_windows_a_worker_has_had() {
1202        let transcripts: Vec<TranscriptRef> = (1..=40)
1203            .map(|w| TranscriptRef {
1204                window: w,
1205                path: format!("/d/sa_1.window-{w}.md"),
1206                messages: 9,
1207            })
1208            .collect();
1209        let note = note_message(NoteFacts {
1210            window: 41,
1211            before_tokens: 200_000,
1212            evicted: 9,
1213            evicted_tokens: 100_000,
1214            kept: 30,
1215            carried_instructions: false,
1216            transcripts: &transcripts,
1217            tool_index: (0..5_000).map(|i| format!("bash: step {i}")).collect(),
1218            unpinned: &[],
1219        });
1220        let text = note["content"][0]["text"].as_str().unwrap();
1221        assert!(text.contains("windows 1–28: earlier transcripts (*.window-N.md) in /d"), "{text}");
1222        assert!(!text.contains("sa_1.window-28.md") && text.contains("sa_1.window-29.md"));
1223        assert!(text.contains("sa_1.window-40.md"));
1224        assert!(text.contains("older not listed"));
1225        let tokens = super::super::context::conservative_token_estimate(text);
1226        assert!(tokens <= DROPPED_INDEX_TOKENS + NOTE_FRAME_TOKENS, "{tokens}");
1227    }
1228
1229    #[test]
1230    fn nothing_to_evict_is_no_plan_and_small_wins_are_unproductive() {
1231        assert!(plan_rollover(&[user("brief")], budget(1), 1, path).is_none());
1232        let one = worker_history(1, 50_000);
1233        assert!(plan_rollover(&one, budget(1), 1, path).is_none(), "only the must-keep round");
1234        let two = worker_history(2, 100);
1235        let plan = plan_rollover(&two, budget(0), 1, path).unwrap();
1236        assert!(!plan_is_productive(&two, &plan), "a tiny round is not worth a rewrite");
1237    }
1238
1239    #[test]
1240    fn host_metadata_never_reaches_the_wire() {
1241        let plan = plan_rollover(&worker_history(10, 3_000), budget(5_000), 1, path).unwrap();
1242        let wire = serde_json::to_string(&super::super::continuation::WireMessages(&plan.successor)).unwrap();
1243        assert!(!wire.contains(MARKER_KEY));
1244        assert!(wire.contains("Worker context rollover"));
1245    }
1246
1247    #[test]
1248    fn transcript_path_is_confined_to_the_directory() {
1249        let p = transcript_path(Path::new("/d"), "../sa 1", 3);
1250        assert_eq!(p, PathBuf::from("/d/---sa-1.window-3.md"));
1251    }
1252}