返回 DeepSeek-TUI-2026
cycle_manager.rs
根目录 / crates / tui / src / cycle_manager.rs
1 //! Checkpoint-restart cycle management for long-running sessions (issue #124).
2 //!
3 //! ## Why
4 //!
5 //! DeepSeek V4's empirical retrieval degradation begins around the 256K band
6 //! (paper Figure 9: 8K/0.90, 64K/0.87, 128K/0.85, 256K/0.76,
7 //! 512K/0.66, 1M/0.59). Lossy
8 //! summarization compaction creates a "Frankenstein" context — half verbatim,
9 //! half paraphrased — that the model cannot tell apart, so it treats the
10 //! summary as if it were verbatim and confabulates around the gaps.
11 //!
12 //! Checkpoint-restart fixes this by giving every cycle a *homogeneous* fresh
13 //! context: original system prompt, structured state (todos / plan / working
14 //! set / sub-agent handles), and a model-curated free-form briefing of at
15 //! most ~3,000 tokens. The previous cycle is archived to disk in JSONL form
16 //! so a future `recall_archive` tool (issue #127) can search it on demand.
17 //!
18 //! ## Layers of carry-forward
19 //!
20 //! 1. **Auto-preserved** (deterministic, no agent judgment): the original
21 //! system prompt, `SharedTodoList`, `SharedPlanState`, working-set paths,
22 //! open sub-agent snapshots, mode / workspace / cwd, and the user's most
23 //! recent unsent message.
24 //! 2. **Free-form briefing** (model-curated, wrapped as `<carry_forward>`):
25 //! decisions made + why, constraints discovered, hypotheses being tested,
26 //! approaches that failed, open questions. Tool output bytes, file
27 //! contents, and step-by-step recaps explicitly do NOT belong here —
28 //! they're either in the archive or recoverable from disk.
29 //!
30 //! ## Trigger
31 //!
32 //! - Token threshold: **768K** active input by default (~75% of the 1M window).
33 //! This is a rare overflow safety net. The trigger is based on the next
34 //! request's live input estimate, not lifetime summed API usage, with
35 //! assistant-output and safety headroom considered against the model window.
36 //! Optional soft seams at 192K/384K/576K are controlled by the opt-in layered
37 //! context manager (#159).
38 //! - Phase guard: callers only invoke `should_advance_cycle` at clean turn
39 //! boundaries (no in-flight tool, no streaming, no approval modal).
40 //! - Per-model overrides: `[cycle.per_model]` in config.toml lets operators
41 //! tune the threshold separately for `deepseek-v4-pro` vs. `-flash`.
42
43 use std::collections::HashMap;
44 use std::fs::{File, OpenOptions};
45 use std::io::Write;
46 use std::path::{Path, PathBuf};
47
48 use anyhow::{Context, Result};
49 use chrono::{DateTime, Utc};
50 use serde::{Deserialize, Serialize};
51
52 use crate::client::DeepSeekClient;
53 use crate::llm_client::LlmClient;
54 use crate::models::{
55 ContentBlock, Message, MessageRequest, SystemBlock, SystemPrompt, context_window_for_model,
56 };
57 use crate::tools::plan::{PlanSnapshot, SharedPlanState};
58 use crate::tools::subagent::{SharedSubAgentManager, SubAgentResult, SubAgentStatus};
59 use crate::tools::todo::{SharedTodoList, TodoListSnapshot};
60 use crate::working_set::WorkingSet;
61
62 /// JSONL header record emitted as the first line of an archived cycle file.
63 const CYCLE_ARCHIVE_SCHEMA_VERSION: u32 = 1;
64
65 /// Default token threshold at which a cycle boundary fires.
66 ///
67 /// Bumped from 110K to 768K (~75% of 1M window). The layered context manager
68 /// (#159) can add opt-in soft seams at 192K/384K/576K; the hard cycle remains
69 /// a near-wall safety net.
70 pub const DEFAULT_CYCLE_THRESHOLD_TOKENS: usize = 768_000;
71
72 /// Default cap on the model-curated briefing block.
73 pub const DEFAULT_BRIEFING_MAX_TOKENS: usize = 3_000;
74
75 /// Conservative chars-per-token used to bound the briefing length to the
76 /// configured token cap. Matches `compaction::estimate_tokens` (~4 chars/token).
77 const APPROX_CHARS_PER_TOKEN: usize = 4;
78
79 /// Per-model cycle tuning. Loaded from `[cycle.per_model.<model>]`.
80 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
81 pub struct ModelCycleConfig {
82 /// Token threshold above which a cycle boundary fires.
83 pub threshold_tokens: usize,
84 /// Cap on the model-curated `<carry_forward>` briefing.
85 pub briefing_max_tokens: usize,
86 }
87
88 impl Default for ModelCycleConfig {
89 fn default() -> Self {
90 Self {
91 threshold_tokens: DEFAULT_CYCLE_THRESHOLD_TOKENS,
92 briefing_max_tokens: DEFAULT_BRIEFING_MAX_TOKENS,
93 }
94 }
95 }
96
97 /// Top-level cycle configuration.
98 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
99 pub struct CycleConfig {
100 /// Whether checkpoint-restart cycles are enabled. Defaults to true.
101 pub enabled: bool,
102 /// Default token threshold; per-model overrides take precedence when present.
103 pub threshold_tokens: usize,
104 /// Default briefing cap; per-model overrides take precedence when present.
105 pub briefing_max_tokens: usize,
106 /// Per-model overrides keyed by model identifier (e.g. `deepseek-v4-pro`).
107 pub per_model: HashMap<String, ModelCycleConfig>,
108 }
109
110 impl Default for CycleConfig {
111 fn default() -> Self {
112 let mut per_model: HashMap<String, ModelCycleConfig> = HashMap::new();
113 per_model.insert("deepseek-v4-pro".to_string(), ModelCycleConfig::default());
114 per_model.insert("deepseek-v4-flash".to_string(), ModelCycleConfig::default());
115 Self {
116 enabled: true,
117 threshold_tokens: DEFAULT_CYCLE_THRESHOLD_TOKENS,
118 briefing_max_tokens: DEFAULT_BRIEFING_MAX_TOKENS,
119 per_model,
120 }
121 }
122 }
123
124 impl CycleConfig {
125 /// Resolve the threshold for a given model (per-model override > default).
126 #[must_use]
127 pub fn threshold_for(&self, model: &str) -> usize {
128 self.per_model
129 .get(model)
130 .map(|m| m.threshold_tokens)
131 .unwrap_or(self.threshold_tokens)
132 }
133
134 /// Resolve the briefing-token cap for a given model.
135 #[must_use]
136 pub fn briefing_max_for(&self, model: &str) -> usize {
137 self.per_model
138 .get(model)
139 .map(|m| m.briefing_max_tokens)
140 .unwrap_or(self.briefing_max_tokens)
141 }
142 }
143
144 /// Snapshot of a model-curated briefing produced at cycle handoff.
145 #[derive(Debug, Clone, Serialize, Deserialize)]
146 pub struct CycleBriefing {
147 /// 1-based cycle number this briefing closes (i.e. the cycle being archived).
148 pub cycle: u32,
149 /// UTC timestamp when the briefing turn completed.
150 pub timestamp: DateTime<Utc>,
151 /// Extracted contents of the `<carry_forward>` block.
152 pub briefing_text: String,
153 /// Approximate token count of `briefing_text`.
154 pub token_estimate: usize,
155 }
156
157 /// Decide whether a cycle boundary should fire.
158 ///
159 /// `active_input_tokens` is the estimated token count of the next request's
160 /// current input, including previous assistant/tool output that is now part of
161 /// the transcript. `reserved_response_headroom_tokens` is the max output budget
162 /// plus any provider safety headroom reserved for that next request. Lifetime
163 /// API usage is intentionally not used here because it repeatedly counts the
164 /// same stable prefix across requests.
165 ///
166 /// `in_flight` is true when a tool is mid-execution, stream is open, or an
167 /// approval modal is pending — in those cases the caller must wait until the
168 /// next clean boundary.
169 #[must_use]
170 pub fn should_advance_cycle(
171 active_input_tokens: u64,
172 reserved_response_headroom_tokens: u64,
173 model: &str,
174 cfg: &CycleConfig,
175 in_flight: bool,
176 ) -> bool {
177 if !cfg.enabled || in_flight {
178 return false;
179 }
180 let threshold = cfg.threshold_for(model) as u64;
181 if threshold == 0 {
182 return false;
183 }
184 let trigger_floor = context_window_for_model(model)
185 .map(|window| u64::from(window).saturating_sub(reserved_response_headroom_tokens))
186 .map_or(threshold, |window_floor| threshold.min(window_floor));
187 active_input_tokens >= trigger_floor
188 }
189
190 /// Roll-up of state that survives a cycle boundary deterministically.
191 ///
192 /// Construction is cheap — borrow the live state, snapshot it once, render it
193 /// into a system block. The snapshot decouples rendering from any mutex held
194 /// by the engine.
195 #[derive(Debug, Clone, Default)]
196 pub struct StructuredState {
197 pub mode_label: String,
198 pub workspace: PathBuf,
199 pub cwd: Option<PathBuf>,
200 pub working_set_summary: Option<String>,
201 pub todo_snapshot: Option<TodoListSnapshot>,
202 pub plan_snapshot: Option<PlanSnapshot>,
203 pub subagent_snapshots: Vec<SubAgentResult>,
204 }
205
206 impl StructuredState {
207 /// Capture the current state. All locks are held only for the duration of
208 /// the snapshot.
209 pub async fn capture(
210 mode_label: impl Into<String>,
211 workspace: PathBuf,
212 cwd: Option<PathBuf>,
213 working_set: &WorkingSet,
214 todos: &SharedTodoList,
215 plan_state: &SharedPlanState,
216 subagents: Option<&SharedSubAgentManager>,
217 ) -> Self {
218 let working_set_summary = working_set.summary_block(&workspace);
219
220 let todo_snapshot = {
221 let guard = todos.lock().await;
222 let snap = guard.snapshot();
223 if snap.items.is_empty() {
224 None
225 } else {
226 Some(snap)
227 }
228 };
229
230 let plan_snapshot = {
231 let guard = plan_state.lock().await;
232 if guard.is_empty() {
233 None
234 } else {
235 Some(guard.snapshot())
236 }
237 };
238
239 let subagent_snapshots = if let Some(handle) = subagents {
240 let guard = handle.read().await;
241 guard
242 .list()
243 .into_iter()
244 .filter(|s| matches!(s.status, SubAgentStatus::Running))
245 .collect()
246 } else {
247 Vec::new()
248 };
249
250 Self {
251 mode_label: mode_label.into(),
252 workspace,
253 cwd,
254 working_set_summary,
255 todo_snapshot,
256 plan_snapshot,
257 subagent_snapshots,
258 }
259 }
260
261 /// Render the structured state as a single system block. Returns `None`
262 /// when there is nothing meaningful to carry forward (rare in practice —
263 /// at least the workspace and mode are always present).
264 #[must_use]
265 pub fn to_system_block(&self) -> Option<String> {
266 let mut out = String::new();
267 out.push_str("## Cycle State (Auto-Preserved)\n\n");
268 out.push_str(&format!("- Mode: `{}`\n", self.mode_label));
269 out.push_str(&format!("- Workspace: `{}`\n", self.workspace.display()));
270 if let Some(cwd) = self.cwd.as_ref() {
271 out.push_str(&format!("- Cwd: `{}`\n", cwd.display()));
272 }
273
274 if let Some(plan) = self.plan_snapshot.as_ref() {
275 out.push_str("\n### Plan\n");
276 if let Some(explanation) = plan.explanation.as_ref() {
277 out.push_str(&format!("{explanation}\n\n"));
278 }
279 for item in &plan.items {
280 let marker = match item.status {
281 crate::tools::plan::StepStatus::Pending => "[ ]",
282 crate::tools::plan::StepStatus::InProgress => "[~]",
283 crate::tools::plan::StepStatus::Completed => "[x]",
284 };
285 out.push_str(&format!("- {marker} {}\n", item.step));
286 }
287 }
288
289 if let Some(todos) = self.todo_snapshot.as_ref() {
290 out.push_str(&format!(
291 "\n### Todos ({}% complete)\n",
292 todos.completion_pct
293 ));
294 for item in &todos.items {
295 let marker = match item.status {
296 crate::tools::todo::TodoStatus::Pending => "[ ]",
297 crate::tools::todo::TodoStatus::InProgress => "[~]",
298 crate::tools::todo::TodoStatus::Completed => "[x]",
299 };
300 out.push_str(&format!("- {marker} {}\n", item.content));
301 }
302 }
303
304 if !self.subagent_snapshots.is_empty() {
305 out.push_str("\n### Open Sub-Agents\n");
306 for s in &self.subagent_snapshots {
307 let role = s.assignment.role.as_deref().unwrap_or("—");
308 let goal = if s.assignment.objective.is_empty() {
309 "(no objective set)"
310 } else {
311 s.assignment.objective.as_str()
312 };
313 out.push_str(&format!("- `{}` (role: {}) — {}\n", s.agent_id, role, goal));
314 }
315 }
316
317 if let Some(working_set) = self.working_set_summary.as_deref() {
318 out.push('\n');
319 out.push_str(working_set);
320 out.push('\n');
321 }
322
323 Some(out)
324 }
325 }
326
327 /// Build the prompt the model uses to produce its `<carry_forward>` briefing.
328 pub const CYCLE_HANDOFF_TEMPLATE: &str = include_str!("prompts/cycle_handoff.md");
329
330 /// Run the briefing turn. The caller drives this just before swapping the
331 /// session message buffer. The returned text is the contents of the
332 /// `<carry_forward>` block — outer tags stripped, length-bounded to
333 /// `max_briefing_tokens` worth of characters as a defensive backstop in case
334 /// the model ignores the cap.
335 pub async fn produce_briefing(
336 client: &DeepSeekClient,
337 model: &str,
338 conversation: &[Message],
339 max_briefing_tokens: usize,
340 ) -> Result<String> {
341 if conversation.is_empty() {
342 return Ok(String::new());
343 }
344
345 // Append a synthetic instruction asking for the carry_forward block. We
346 // do not mutate the caller's conversation; this is a one-shot turn.
347 let mut messages: Vec<Message> = conversation.to_vec();
348 messages.push(Message {
349 role: "user".to_string(),
350 content: vec![ContentBlock::Text {
351 text: format!(
352 "[CYCLE BOUNDARY] {}\n\nProduce your `<carry_forward>` block now. \
353 Stay under {} tokens. Output only the block — no other text.",
354 "The next turn starts in a fresh context.", max_briefing_tokens
355 ),
356 cache_control: None,
357 }],
358 });
359
360 let request = MessageRequest {
361 model: model.to_string(),
362 messages,
363 max_tokens: u32::try_from(max_briefing_tokens.saturating_mul(2))
364 .unwrap_or(8_192)
365 .max(1_024),
366 system: Some(SystemPrompt::Blocks(vec![SystemBlock {
367 block_type: "text".to_string(),
368 text: CYCLE_HANDOFF_TEMPLATE.to_string(),
369 cache_control: None,
370 }])),
371 tools: None,
372 tool_choice: None,
373 metadata: None,
374 thinking: None,
375 reasoning_effort: None,
376 stream: Some(false),
377 // Briefings benefit from low temperature — we want consistent state
378 // capture, not stylistic variation.
379 temperature: Some(0.2),
380 top_p: None,
381 };
382
383 let response = client
384 .create_message(request)
385 .await
386 .with_context(|| format!("Cycle briefing turn failed for model {model}"))?;
387 // Cycle briefing calls are billed; route through the side-channel
388 // (#526) so the footer total matches the DeepSeek website.
389 crate::cost_status::report(&response.model, &response.usage);
390
391 let raw = response
392 .content
393 .iter()
394 .filter_map(|block| match block {
395 ContentBlock::Text { text, .. } => Some(text.as_str()),
396 _ => None,
397 })
398 .collect::<Vec<_>>()
399 .join("\n");
400
401 let extracted = extract_carry_forward(&raw);
402 let bounded = enforce_briefing_cap(&extracted, max_briefing_tokens);
403 Ok(bounded)
404 }
405
406 /// Pull the contents of the first `<carry_forward>...</carry_forward>` block
407 /// out of the raw model response. If the tags are missing, return the trimmed
408 /// raw text — the caller would rather have *some* briefing than nothing.
409 #[must_use]
410 pub fn extract_carry_forward(raw: &str) -> String {
411 let lower = raw.to_ascii_lowercase();
412 let open_tag = "<carry_forward>";
413 let close_tag = "</carry_forward>";
414
415 if let Some(start) = lower.find(open_tag) {
416 let after = start + open_tag.len();
417 let tail = &raw[after..];
418 let tail_lower = &lower[after..];
419 if let Some(end) = tail_lower.find(close_tag) {
420 return tail[..end].trim().to_string();
421 }
422 // Open tag without close tag — take everything after, trimmed.
423 return tail.trim().to_string();
424 }
425 raw.trim().to_string()
426 }
427
428 /// Defensive bound on briefing length. Calibrated at ~4 chars/token to match
429 /// the rest of the codebase's token estimator.
430 fn enforce_briefing_cap(text: &str, max_tokens: usize) -> String {
431 let max_chars = max_tokens.saturating_mul(APPROX_CHARS_PER_TOKEN);
432 if max_chars == 0 {
433 return String::new();
434 }
435 if text.chars().count() <= max_chars {
436 return text.to_string();
437 }
438 let mut out: String = text.chars().take(max_chars).collect();
439 out.push_str("\n\n[...briefing truncated to fit cap...]");
440 out
441 }
442
443 /// Estimate briefing tokens — same method as `compaction::estimate_tokens`
444 /// for symmetry: ~4 chars per token.
445 #[must_use]
446 pub fn estimate_briefing_tokens(text: &str) -> usize {
447 text.len().div_ceil(APPROX_CHARS_PER_TOKEN)
448 }
449
450 /// Header record written as the first line of an archived cycle JSONL file.
451 #[derive(Debug, Clone, Serialize, Deserialize)]
452 pub struct CycleArchiveHeader {
453 pub schema_version: u32,
454 pub cycle: u32,
455 pub session_id: String,
456 pub model: String,
457 pub started: DateTime<Utc>,
458 pub ended: DateTime<Utc>,
459 pub message_count: usize,
460 }
461
462 /// Resolve the on-disk archive directory: `~/.deepseek/sessions/<id>/cycles`.
463 fn archive_dir_for(session_id: &str) -> Result<PathBuf> {
464 let home = dirs::home_dir().context("Could not resolve home directory for cycle archive")?;
465 Ok(home
466 .join(".deepseek")
467 .join("sessions")
468 .join(session_id)
469 .join("cycles"))
470 }
471
472 /// Archive a cycle's messages to JSONL on disk and return the path written.
473 ///
474 /// The first line is a `CycleArchiveHeader` JSON object; each subsequent
475 /// line is a single `Message` serialized as JSON.
476 pub fn archive_cycle(
477 session_id: &str,
478 cycle_n: u32,
479 messages: &[Message],
480 model: &str,
481 started: DateTime<Utc>,
482 ) -> Result<PathBuf> {
483 let dir = archive_dir_for(session_id)?;
484 std::fs::create_dir_all(&dir).with_context(|| {
485 format!(
486 "Failed to create cycle archive directory at {}",
487 dir.display()
488 )
489 })?;
490
491 let path = dir.join(format!("{cycle_n}.jsonl"));
492 let header = CycleArchiveHeader {
493 schema_version: CYCLE_ARCHIVE_SCHEMA_VERSION,
494 cycle: cycle_n,
495 session_id: session_id.to_string(),
496 model: model.to_string(),
497 started,
498 ended: Utc::now(),
499 message_count: messages.len(),
500 };
501
502 write_archive_file(&path, &header, messages)
503 .with_context(|| format!("Failed to write cycle archive at {}", path.display()))?;
504
505 Ok(path)
506 }
507
508 fn write_archive_file(
509 path: &Path,
510 header: &CycleArchiveHeader,
511 messages: &[Message],
512 ) -> Result<()> {
513 let tmp_path = path.with_extension("jsonl.tmp");
514 {
515 let file = OpenOptions::new()
516 .create(true)
517 .truncate(true)
518 .write(true)
519 .open(&tmp_path)?;
520 let mut buf = std::io::BufWriter::new(file);
521 let header_line = serde_json::to_string(header)?;
522 buf.write_all(header_line.as_bytes())?;
523 buf.write_all(b"\n")?;
524 for message in messages {
525 let line = serde_json::to_string(message)?;
526 buf.write_all(line.as_bytes())?;
527 buf.write_all(b"\n")?;
528 }
529 // BufWriter flushes on drop, but we want any error surfaced now —
530 // not silently into the void.
531 buf.flush()?;
532 // File handle drops with `buf`.
533 }
534 std::fs::rename(&tmp_path, path)?;
535 Ok(())
536 }
537
538 /// Open an archived cycle JSONL for streaming reads. Returns the parsed
539 /// header and an iterator over messages. Reserved for the future
540 /// `recall_archive` tool (#127).
541 #[allow(dead_code)]
542 pub fn open_archive(path: &Path) -> Result<(CycleArchiveHeader, ArchiveMessageReader)> {
543 use std::io::{BufRead, BufReader};
544
545 let file = File::open(path)
546 .with_context(|| format!("Failed to open cycle archive at {}", path.display()))?;
547 let mut reader = BufReader::new(file);
548 let mut header_line = String::new();
549 reader.read_line(&mut header_line)?;
550 let header: CycleArchiveHeader =
551 serde_json::from_str(header_line.trim()).with_context(|| {
552 format!(
553 "Cycle archive at {} is missing a valid header",
554 path.display()
555 )
556 })?;
557
558 if header.schema_version > CYCLE_ARCHIVE_SCHEMA_VERSION {
559 anyhow::bail!(
560 "Cycle archive schema v{} at {} is newer than supported v{}",
561 header.schema_version,
562 path.display(),
563 CYCLE_ARCHIVE_SCHEMA_VERSION
564 );
565 }
566
567 Ok((header, ArchiveMessageReader { reader }))
568 }
569
570 /// Iterator yielding `Message`s from an opened archive file. Yields `None`
571 /// when the file is exhausted. Errors propagate through the `Result`.
572 #[allow(dead_code)]
573 #[derive(Debug)]
574 pub struct ArchiveMessageReader {
575 reader: std::io::BufReader<File>,
576 }
577
578 #[allow(dead_code)]
579 impl Iterator for ArchiveMessageReader {
580 type Item = Result<Message>;
581
582 fn next(&mut self) -> Option<Self::Item> {
583 use std::io::BufRead;
584
585 let mut line = String::new();
586 match self.reader.read_line(&mut line) {
587 Ok(0) => None,
588 Ok(_) => {
589 let trimmed = line.trim();
590 if trimmed.is_empty() {
591 return self.next();
592 }
593 Some(
594 serde_json::from_str::<Message>(trimmed)
595 .map_err(|e| anyhow::anyhow!("Archive line parse failed: {e}")),
596 )
597 }
598 Err(e) => Some(Err(anyhow::Error::new(e))),
599 }
600 }
601 }
602
603 /// Compose the seed messages for the next cycle.
604 ///
605 /// Layout (deterministic order):
606 ///
607 /// 1. (system prompt is provided separately, not as a `Message`)
608 /// 2. Optional structured-state user message (todos / plan / working set /
609 /// sub-agents) — labeled with `[CYCLE STATE]` so the assistant can tell
610 /// it apart from a real user turn.
611 /// 3. The model-curated `<carry_forward>` briefing — labeled with `[CYCLE
612 /// BRIEFING]` so the assistant knows it was self-authored on the previous
613 /// cycle.
614 /// 4. Optional pending user message that hadn't been sent yet.
615 ///
616 /// The original system prompt is composed by the engine and stays separate
617 /// from this list — the engine sets `session.system_prompt` directly.
618 #[must_use]
619 pub fn build_seed_messages(
620 structured_state_block: Option<&str>,
621 briefing: Option<&CycleBriefing>,
622 pending_user_message: Option<&str>,
623 ) -> Vec<Message> {
624 let mut out: Vec<Message> = Vec::new();
625
626 if let Some(state) = structured_state_block
627 && !state.trim().is_empty()
628 {
629 out.push(Message {
630 role: "user".to_string(),
631 content: vec![ContentBlock::Text {
632 text: format!(
633 "[CYCLE STATE — auto-preserved across the cycle boundary]\n\n{}",
634 state.trim()
635 ),
636 cache_control: None,
637 }],
638 });
639 // A user message expects an assistant ack so the next real user
640 // message lands on a clean alternation. We synthesize a one-line ack.
641 out.push(Message {
642 role: "assistant".to_string(),
643 content: vec![ContentBlock::Text {
644 text: "Acknowledged. State carried into the new cycle.".to_string(),
645 cache_control: None,
646 }],
647 });
648 }
649
650 if let Some(brief) = briefing
651 && !brief.briefing_text.trim().is_empty()
652 {
653 out.push(Message {
654 role: "user".to_string(),
655 content: vec![ContentBlock::Text {
656 text: format!(
657 "[CYCLE BRIEFING — written by you on cycle {} at {}]\n\n<carry_forward>\n{}\n</carry_forward>",
658 brief.cycle,
659 brief.timestamp.to_rfc3339(),
660 brief.briefing_text.trim()
661 ),
662 cache_control: None,
663 }],
664 });
665 out.push(Message {
666 role: "assistant".to_string(),
667 content: vec![ContentBlock::Text {
668 text: "Briefing absorbed. Continuing.".to_string(),
669 cache_control: None,
670 }],
671 });
672 }
673
674 if let Some(pending) = pending_user_message
675 && !pending.trim().is_empty()
676 {
677 out.push(Message {
678 role: "user".to_string(),
679 content: vec![ContentBlock::Text {
680 text: pending.trim().to_string(),
681 cache_control: None,
682 }],
683 });
684 }
685
686 out
687 }
688
689 #[cfg(test)]
690 mod tests {
691 use super::*;
692 use crate::models::{ContentBlock, Message};
693 use std::path::PathBuf;
694 use tempfile::tempdir;
695
696 fn user_msg(text: &str) -> Message {
697 Message {
698 role: "user".to_string(),
699 content: vec![ContentBlock::Text {
700 text: text.to_string(),
701 cache_control: None,
702 }],
703 }
704 }
705
706 fn asst_msg(text: &str) -> Message {
707 Message {
708 role: "assistant".to_string(),
709 content: vec![ContentBlock::Text {
710 text: text.to_string(),
711 cache_control: None,
712 }],
713 }
714 }
715
716 #[test]
717 fn cycle_config_default_includes_v4_overrides() {
718 let cfg = CycleConfig::default();
719 assert!(cfg.enabled);
720 assert!(cfg.per_model.contains_key("deepseek-v4-pro"));
721 assert!(cfg.per_model.contains_key("deepseek-v4-flash"));
722 assert_eq!(cfg.threshold_tokens, DEFAULT_CYCLE_THRESHOLD_TOKENS);
723 assert_eq!(cfg.briefing_max_tokens, DEFAULT_BRIEFING_MAX_TOKENS);
724 }
725
726 #[test]
727 fn threshold_for_falls_back_to_default() {
728 let cfg = CycleConfig::default();
729 assert_eq!(
730 cfg.threshold_for("deepseek-v4-pro"),
731 DEFAULT_CYCLE_THRESHOLD_TOKENS
732 );
733 assert_eq!(
734 cfg.threshold_for("unknown-model"),
735 DEFAULT_CYCLE_THRESHOLD_TOKENS
736 );
737 }
738
739 #[test]
740 fn threshold_for_uses_per_model_override() {
741 let mut cfg = CycleConfig::default();
742 cfg.per_model.insert(
743 "deepseek-v4-pro".to_string(),
744 ModelCycleConfig {
745 threshold_tokens: 80_000,
746 briefing_max_tokens: 2_000,
747 },
748 );
749 assert_eq!(cfg.threshold_for("deepseek-v4-pro"), 80_000);
750 assert_eq!(cfg.briefing_max_for("deepseek-v4-pro"), 2_000);
751 }
752
753 #[test]
754 fn should_advance_below_threshold_returns_false() {
755 let cfg = CycleConfig::default();
756 assert!(!should_advance_cycle(
757 50_000,
758 0,
759 "deepseek-v4-pro",
760 &cfg,
761 false
762 ));
763 }
764
765 #[test]
766 fn should_advance_at_threshold_returns_true() {
767 let cfg = CycleConfig::default();
768 assert!(should_advance_cycle(
769 DEFAULT_CYCLE_THRESHOLD_TOKENS as u64,
770 0,
771 "deepseek-v4-pro",
772 &cfg,
773 false
774 ));
775 }
776
777 #[test]
778 fn should_advance_considers_output_plus_safety_headroom() {
779 let cfg = CycleConfig::default();
780 // Below the 768K active-input threshold, but too close to the 1M
781 // model window once the next assistant response and safety headroom are
782 // included.
783 assert!(should_advance_cycle(
784 737_000,
785 263_168,
786 "deepseek-v4-pro",
787 &cfg,
788 false
789 ));
790 }
791
792 #[test]
793 fn should_not_count_lifetime_api_usage_as_active_context() {
794 let cfg = CycleConfig::default();
795 assert!(!should_advance_cycle(
796 120_000,
797 64_000,
798 "deepseek-v4-pro",
799 &cfg,
800 false
801 ));
802 }
803
804 #[test]
805 fn should_advance_v4_calibrates_threshold_against_output_reserve() {
806 let cfg = CycleConfig::default();
807 let reserve = 263_168;
808 assert!(!should_advance_cycle(
809 700_000,
810 reserve,
811 "deepseek-v4-pro",
812 &cfg,
813 false
814 ));
815 assert!(should_advance_cycle(
816 738_000,
817 reserve,
818 "deepseek-v4-pro",
819 &cfg,
820 false
821 ));
822 assert!(should_advance_cycle(
823 768_000,
824 reserve,
825 "deepseek-v4-pro",
826 &cfg,
827 false
828 ));
829 assert!(should_advance_cycle(
830 900_000,
831 reserve,
832 "deepseek-v4-pro",
833 &cfg,
834 false
835 ));
836 }
837
838 #[test]
839 fn in_flight_phase_guard_blocks_advance() {
840 let cfg = CycleConfig::default();
841 assert!(!should_advance_cycle(
842 DEFAULT_CYCLE_THRESHOLD_TOKENS as u64 * 2,
843 0,
844 "deepseek-v4-pro",
845 &cfg,
846 true,
847 ));
848 }
849
850 #[test]
851 fn disabled_config_blocks_advance() {
852 let cfg = CycleConfig {
853 enabled: false,
854 ..Default::default()
855 };
856 assert!(!should_advance_cycle(
857 DEFAULT_CYCLE_THRESHOLD_TOKENS as u64 * 2,
858 0,
859 "deepseek-v4-pro",
860 &cfg,
861 false,
862 ));
863 }
864
865 #[test]
866 fn extract_carry_forward_pulls_block() {
867 let raw = "Here is your handoff:\n<carry_forward>\nDecision A: chose X because Y.\n</carry_forward>\nDone.";
868 assert_eq!(extract_carry_forward(raw), "Decision A: chose X because Y.");
869 }
870
871 #[test]
872 fn extract_carry_forward_handles_missing_close_tag() {
873 let raw = "<carry_forward>\nDecision A: chose X.";
874 // Missing close tag → returns the tail, trimmed.
875 assert_eq!(extract_carry_forward(raw), "Decision A: chose X.");
876 }
877
878 #[test]
879 fn extract_carry_forward_no_tags_returns_trimmed_body() {
880 let raw = " Decision A: chose X. ";
881 assert_eq!(extract_carry_forward(raw), "Decision A: chose X.");
882 }
883
884 #[test]
885 fn extract_carry_forward_case_insensitive() {
886 let raw = "<CARRY_FORWARD>\nState here.\n</CARRY_FORWARD>";
887 assert_eq!(extract_carry_forward(raw), "State here.");
888 }
889
890 #[test]
891 fn enforce_briefing_cap_truncates_oversized_text() {
892 let max_tokens = 10; // 10 * 4 = 40 chars
893 let big = "x".repeat(200);
894 let bounded = enforce_briefing_cap(&big, max_tokens);
895 assert!(bounded.starts_with(&"x".repeat(40)));
896 assert!(bounded.contains("[...briefing truncated"));
897 }
898
899 #[test]
900 fn enforce_briefing_cap_passes_short_text_through() {
901 let txt = "hello world";
902 assert_eq!(enforce_briefing_cap(txt, 100), "hello world");
903 }
904
905 #[test]
906 fn build_seed_messages_empty_when_all_inputs_empty() {
907 let seeds = build_seed_messages(None, None, None);
908 assert!(seeds.is_empty());
909 }
910
911 #[test]
912 fn build_seed_messages_includes_state_briefing_and_pending() {
913 let briefing = CycleBriefing {
914 cycle: 1,
915 timestamp: Utc::now(),
916 briefing_text: "Decisions: chose A.".to_string(),
917 token_estimate: 5,
918 };
919
920 let seeds = build_seed_messages(
921 Some("## Cycle State\n- Mode: agent"),
922 Some(&briefing),
923 Some("Continue working on issue #124"),
924 );
925
926 // Expected layout: state user + ack assistant + briefing user + ack assistant + pending user.
927 assert_eq!(seeds.len(), 5);
928 assert_eq!(seeds[0].role, "user");
929 assert_eq!(seeds[1].role, "assistant");
930 assert_eq!(seeds[2].role, "user");
931 assert_eq!(seeds[3].role, "assistant");
932 assert_eq!(seeds[4].role, "user");
933
934 if let ContentBlock::Text { text, .. } = &seeds[0].content[0] {
935 assert!(text.contains("[CYCLE STATE"));
936 assert!(text.contains("agent"));
937 } else {
938 panic!("expected text block");
939 }
940 if let ContentBlock::Text { text, .. } = &seeds[2].content[0] {
941 assert!(text.contains("[CYCLE BRIEFING"));
942 assert!(text.contains("<carry_forward>"));
943 assert!(text.contains("Decisions: chose A."));
944 } else {
945 panic!("expected text block");
946 }
947 if let ContentBlock::Text { text, .. } = &seeds[4].content[0] {
948 assert_eq!(text, "Continue working on issue #124");
949 } else {
950 panic!("expected text block");
951 }
952 }
953
954 #[test]
955 fn build_seed_messages_skips_blank_pending() {
956 let seeds = build_seed_messages(Some("## State"), None, Some(" "));
957 // State block + ack — no pending message.
958 assert_eq!(seeds.len(), 2);
959 assert_eq!(seeds[0].role, "user");
960 assert_eq!(seeds[1].role, "assistant");
961 }
962
963 #[test]
964 fn structured_state_to_system_block_renders_minimal() {
965 let state = StructuredState {
966 mode_label: "agent".to_string(),
967 workspace: PathBuf::from("/tmp/ws"),
968 cwd: None,
969 working_set_summary: None,
970 todo_snapshot: None,
971 plan_snapshot: None,
972 subagent_snapshots: Vec::new(),
973 };
974 let block = state.to_system_block().expect("renders");
975 assert!(block.contains("Mode: `agent`"));
976 assert!(block.contains("Workspace: `/tmp/ws`"));
977 }
978
979 #[test]
980 fn archive_cycle_writes_jsonl_with_header_and_messages() {
981 let dir = tempdir().expect("tempdir");
982 let session_id = format!("test-session-{}", uuid::Uuid::new_v4());
983
984 // Redirect dirs::home_dir() into our tempdir. On Unix that reads
985 // HOME; on Windows it reads USERPROFILE — set both so the test is
986 // platform-portable. SAFETY: cargo runs each test binary
987 // single-threaded by default; we do not await across the env
988 // mutation window.
989 let original_home = std::env::var("HOME").ok();
990 let original_userprofile = std::env::var("USERPROFILE").ok();
991 unsafe {
992 std::env::set_var("HOME", dir.path());
993 std::env::set_var("USERPROFILE", dir.path());
994 }
995
996 let messages = vec![
997 user_msg("hello"),
998 asst_msg("hi"),
999 user_msg("can you read Cargo.toml?"),
1000 ];
1001
1002 let started = Utc::now();
1003 let path = archive_cycle(&session_id, 1, &messages, "deepseek-v4-pro", started)
1004 .expect("archive_cycle should succeed");
1005
1006 assert!(path.exists(), "archive file should exist on disk");
1007 assert_eq!(path.file_name().and_then(|s| s.to_str()), Some("1.jsonl"));
1008
1009 let contents = std::fs::read_to_string(&path).expect("read archive back");
1010 let mut lines = contents.lines();
1011
1012 let header_line = lines.next().expect("header line present");
1013 let header: CycleArchiveHeader = serde_json::from_str(header_line).expect("header parses");
1014 assert_eq!(header.cycle, 1);
1015 assert_eq!(header.session_id, session_id);
1016 assert_eq!(header.model, "deepseek-v4-pro");
1017 assert_eq!(header.message_count, 3);
1018 assert_eq!(header.schema_version, CYCLE_ARCHIVE_SCHEMA_VERSION);
1019
1020 for expected in &messages {
1021 let line = lines.next().expect("message line present");
1022 let parsed: Message = serde_json::from_str(line).expect("message parses");
1023 assert_eq!(&parsed, expected);
1024 }
1025 assert!(lines.next().is_none(), "no extra trailing lines");
1026
1027 // Restore env so subsequent tests aren't surprised.
1028 unsafe {
1029 match original_home {
1030 Some(value) => std::env::set_var("HOME", value),
1031 None => std::env::remove_var("HOME"),
1032 }
1033 match original_userprofile {
1034 Some(value) => std::env::set_var("USERPROFILE", value),
1035 None => std::env::remove_var("USERPROFILE"),
1036 }
1037 }
1038 }
1039
1040 #[test]
1041 fn open_archive_rejects_newer_schema_version() {
1042 let dir = tempdir().expect("tempdir");
1043 let path = dir.path().join("999.jsonl");
1044 let header = CycleArchiveHeader {
1045 schema_version: CYCLE_ARCHIVE_SCHEMA_VERSION + 5,
1046 cycle: 999,
1047 session_id: "future-session".to_string(),
1048 model: "deepseek-v9".to_string(),
1049 started: Utc::now(),
1050 ended: Utc::now(),
1051 message_count: 0,
1052 };
1053 let mut payload = serde_json::to_string(&header).unwrap();
1054 payload.push('\n');
1055 std::fs::write(&path, payload).unwrap();
1056
1057 let err = open_archive(&path).expect_err("must reject newer schema version");
1058 let msg = format!("{err:#}");
1059 assert!(msg.contains("newer than supported"), "got: {msg}");
1060 }
1061
1062 /// Mock `produce_briefing`-style flow purely client-side: we feed a known
1063 /// raw string through `extract_carry_forward` + `enforce_briefing_cap`
1064 /// and assert the same result we'd produce after a real LLM call.
1065 /// Avoids spinning up a live mock server while still proving the
1066 /// extraction contract.
1067 #[test]
1068 fn briefing_extraction_pipeline_preserves_block() {
1069 let raw = "thinking: ok\n<carry_forward>\nDecision: pick lib A; constraint: no async.\n</carry_forward>\n";
1070 let extracted = extract_carry_forward(raw);
1071 let bounded = enforce_briefing_cap(&extracted, 50);
1072 assert_eq!(bounded, "Decision: pick lib A; constraint: no async.");
1073 }
1074 }
1075
1075 lines RUST