返回 CodeWhale
context_report.rs
根目录 / crates / tui / src / context_report.rs
1 //! Diagnostic prompt source map for context pressure reports.
2 //!
3 //! The report is approximate and describes the runtime sources CodeWhale
4 //! already tracks, without claiming provider-tokenizer parity. Its headline
5 //! (`active_context_estimated_tokens`) is the pressure estimate the context
6 //! meter and the auto-compaction gate read
7 //! (`compaction::estimate_input_tokens_for_pressure`, lifted to the last
8 //! provider-billed prompt). The 1.5x-inflated conservative estimate that
9 //! request-overflow protection uses is reported separately as the overflow
10 //! guard, never as the headline. Per-source entries keep the conservative
11 //! per-text heuristic.
12
13 mod portable_projection;
14 pub(crate) use portable_projection::source_map as project_source_map;
15
16 use std::path::Path;
17
18 use chrono::{SecondsFormat, Utc};
19 use serde::Serialize;
20
21 use crate::compaction::{
22 estimate_input_tokens_conservative, estimate_input_tokens_for_pressure,
23 estimate_text_tokens_conservative,
24 };
25 use crate::config::Config;
26 #[cfg(test)]
27 use crate::context_budget::PressureLevel;
28 use crate::prompts::{CORE_EXECUTION_PROFILE_PROMPT, Personality};
29 use crate::route_budget::route_context_window_tokens;
30 use crate::tui::app::App;
31 use codewhale_config::AppMode;
32 use codewhale_models::{CacheControl, ContentBlock, Message, SystemPrompt, Tool};
33
34 #[derive(Debug, Clone, Serialize)]
35 pub struct PromptSourceMap {
36 pub entries: Vec<SourceEntry>,
37 pub total_estimated_tokens: usize,
38 /// Headline: the same pressure estimate the context meter and the
39 /// auto-compaction gate read, so `/context` never disagrees with them.
40 pub active_context_estimated_tokens: usize,
41 /// Secondary: the 1.5x-inflated conservative estimate request-overflow
42 /// protection guards with. `None` when there is no live conversation to
43 /// measure (headless doctor reports).
44 pub overflow_guard_estimated_tokens: Option<usize>,
45 pub context_window_tokens: Option<u32>,
46 /// Non-secret receipt for the effective context-window value.
47 pub context_window_source: Option<String>,
48 pub budget_used_percent: Option<f64>,
49 pub generated_at: String,
50 pub note: String,
51 }
52
53 /// Inspectable request-prefix context for the current session.
54 ///
55 /// `PromptSourceMap` explains provenance and estimated pressure. This sibling
56 /// type exposes the current assembled system-prompt sections and most recently
57 /// sent model tool catalog so users can audit the prompt plumbing as JSON.
58 #[derive(Debug, Clone, Serialize)]
59 pub struct PromptContext {
60 pub schema_version: u8,
61 pub provider: String,
62 pub model: String,
63 pub system_prompt_state: &'static str,
64 pub tool_catalog_state: &'static str,
65 pub sections: Vec<PromptContextSection>,
66 pub tools: Vec<Tool>,
67 pub source_map: PromptSourceMap,
68 }
69
70 #[derive(Debug, Clone, Serialize)]
71 pub struct PromptContextSection {
72 pub index: usize,
73 pub block_type: String,
74 pub cache_control: Option<CacheControl>,
75 pub estimated_tokens: usize,
76 pub text: String,
77 }
78
79 #[derive(Debug, Clone, Serialize)]
80 pub struct SourceEntry {
81 pub source_kind: SourceKind,
82 pub label: String,
83 pub source_path: Option<String>,
84 pub activation_reason: ActivationReason,
85 pub estimated_tokens: usize,
86 pub counting_confidence: CountingConfidence,
87 pub authority_tier: Option<u8>,
88 pub truncation_reason: Option<String>,
89 }
90
91 impl SourceEntry {
92 fn text(
93 source_kind: SourceKind,
94 label: impl Into<String>,
95 source_path: Option<String>,
96 activation_reason: ActivationReason,
97 text: &str,
98 counting_confidence: CountingConfidence,
99 authority_tier: Option<u8>,
100 ) -> Self {
101 Self::estimate(
102 source_kind,
103 label,
104 source_path,
105 activation_reason,
106 estimate_text_tokens_conservative(text),
107 counting_confidence,
108 authority_tier,
109 )
110 }
111
112 fn estimate(
113 source_kind: SourceKind,
114 label: impl Into<String>,
115 source_path: Option<String>,
116 activation_reason: ActivationReason,
117 estimated_tokens: usize,
118 counting_confidence: CountingConfidence,
119 authority_tier: Option<u8>,
120 ) -> Self {
121 Self {
122 source_kind,
123 label: label.into(),
124 source_path,
125 activation_reason,
126 estimated_tokens,
127 counting_confidence,
128 authority_tier,
129 truncation_reason: None,
130 }
131 }
132
133 fn omitted(
134 source_kind: SourceKind,
135 label: impl Into<String>,
136 source_path: Option<String>,
137 authority_tier: Option<u8>,
138 reason: impl Into<String>,
139 ) -> Self {
140 Self {
141 source_kind,
142 label: label.into(),
143 source_path,
144 activation_reason: ActivationReason::Omitted,
145 estimated_tokens: 0,
146 counting_confidence: CountingConfidence::High,
147 authority_tier,
148 truncation_reason: Some(reason.into()),
149 }
150 }
151
152 fn diagnostic(
153 source_kind: SourceKind,
154 label: impl Into<String>,
155 source_path: Option<String>,
156 activation_reason: ActivationReason,
157 detail: impl Into<String>,
158 estimated_tokens: usize,
159 authority_tier: Option<u8>,
160 ) -> Self {
161 Self {
162 source_kind,
163 label: label.into(),
164 source_path,
165 activation_reason,
166 estimated_tokens,
167 counting_confidence: CountingConfidence::High,
168 authority_tier,
169 truncation_reason: Some(detail.into()),
170 }
171 }
172 }
173
174 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
175 #[serde(rename_all = "snake_case")]
176 pub enum SourceKind {
177 Constitution,
178 UserConstitution,
179 RepoConstitution,
180 ProjectContext,
181 ProjectContextWarning,
182 ProjectContextPack,
183 SkillsBlock,
184 ContextManagement,
185 CompactionRelayTemplate,
186 RuntimePolicy,
187 AuthorityRecap,
188 EnvironmentBlock,
189 UserMemory,
190 SessionGoal,
191 HandoffRelay,
192 ToolSchemas,
193 UserRequest,
194 ConversationHistory,
195 ToolResult,
196 ModelProviderFact,
197 }
198
199 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
200 #[serde(rename_all = "snake_case")]
201 pub enum ActivationReason {
202 AlwaysOn,
203 FilePresent,
204 ConfigEnabled,
205 RuntimeState,
206 PerRequest,
207 Omitted,
208 }
209
210 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
211 #[serde(rename_all = "snake_case")]
212 pub enum CountingConfidence {
213 High,
214 Approximate,
215 }
216
217 struct ReportBuilder {
218 entries: Vec<SourceEntry>,
219 }
220
221 impl ReportBuilder {
222 fn new() -> Self {
223 Self {
224 entries: Vec::new(),
225 }
226 }
227
228 fn push(&mut self, entry: SourceEntry) {
229 self.entries.push(entry);
230 }
231
232 /// The window arrives as one resolution rather than a number plus a
233 /// separately chosen label, so the report can never attribute one rung's
234 /// tokens to another rung.
235 fn finish(
236 self,
237 context_window: crate::route_runtime::ContextWindowResolution,
238 active_context_estimated_tokens: usize,
239 overflow_guard_estimated_tokens: Option<usize>,
240 note: impl Into<String>,
241 ) -> PromptSourceMap {
242 let total_estimated_tokens = self
243 .entries
244 .iter()
245 .map(|entry| entry.estimated_tokens)
246 .sum();
247 let budget_used_percent =
248 ((active_context_estimated_tokens as f64 / f64::from(context_window.tokens)) * 100.0)
249 .clamp(0.0, 100.0);
250 PromptSourceMap {
251 entries: self.entries,
252 total_estimated_tokens,
253 active_context_estimated_tokens,
254 overflow_guard_estimated_tokens,
255 context_window_tokens: Some(context_window.tokens),
256 context_window_source: Some(context_window.source.label().to_string()),
257 budget_used_percent: Some(budget_used_percent),
258 generated_at: Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true),
259 note: note.into(),
260 }
261 }
262 }
263
264 pub fn build_context_report(app: &App) -> PromptSourceMap {
265 // The host still stores the rung apart from the number; pair them against
266 // the same route limits the pressure meter reads.
267 let context_window = crate::route_runtime::ContextWindowResolution {
268 tokens: route_context_window_tokens(
269 app.api_provider,
270 app.effective_model_for_budget(),
271 app.active_route_limits,
272 ),
273 source: app.active_context_window_source,
274 };
275 let mut builder = base_source_entries(
276 &app.model,
277 &app.workspace,
278 Some(&app.skills_dir),
279 app.project_context_pack_enabled,
280 app.skills_discovery_mode,
281 app.ui_locale.tag(),
282 app.mode,
283 Some(app.plugin_registry.as_ref()),
284 Some(context_window.tokens),
285 );
286 add_app_runtime_entries(&mut builder, app);
287 builder.finish(
288 context_window,
289 pressure_estimated_tokens(app),
290 Some(estimate_input_tokens_conservative(
291 &app.api_messages,
292 app.system_prompt.as_ref(),
293 )),
294 "Diagnostic source map. The headline is the pressure estimate the context meter and auto-compaction gate use; per-source counts are conservative estimates. All counts may differ from provider billing.",
295 )
296 }
297
298 /// The one pressure number the context meter and the auto-compaction gate
299 /// decide on: the un-inflated estimate over the live request, lifted to the
300 /// provider's last billed prompt when that is higher (#5577).
301 fn pressure_estimated_tokens(app: &App) -> usize {
302 let estimated =
303 estimate_input_tokens_for_pressure(&app.api_messages, app.system_prompt.as_ref());
304 let billed = app
305 .last_billed_input_tokens
306 .and_then(|tokens| usize::try_from(tokens).ok())
307 .unwrap_or(0);
308 estimated.max(billed)
309 }
310
311 #[must_use]
312 pub fn build_prompt_context(app: &App) -> PromptContext {
313 let tool_catalog_state = if app.session.last_tool_catalog.is_some() {
314 "last_sent"
315 } else {
316 "not_yet_sent"
317 };
318 let sections = match app.system_prompt.as_ref() {
319 Some(SystemPrompt::Text(text)) => vec![PromptContextSection {
320 index: 0,
321 block_type: "text".to_string(),
322 cache_control: None,
323 estimated_tokens: estimate_text_tokens_conservative(text),
324 text: text.clone(),
325 }],
326 Some(SystemPrompt::Blocks(blocks)) => blocks
327 .iter()
328 .enumerate()
329 .map(|(index, block)| PromptContextSection {
330 index,
331 block_type: block.block_type.clone(),
332 cache_control: block.cache_control.clone(),
333 estimated_tokens: estimate_text_tokens_conservative(&block.text),
334 text: block.text.clone(),
335 })
336 .collect(),
337 None => Vec::new(),
338 };
339 PromptContext {
340 schema_version: 1,
341 provider: app.api_provider.as_str().to_string(),
342 model: app.model.clone(),
343 system_prompt_state: "current_session",
344 tool_catalog_state,
345 sections,
346 tools: app.session.last_tool_catalog.clone().unwrap_or_default(),
347 source_map: build_context_report(app),
348 }
349 }
350
351 pub fn build_headless_context_report(config: &Config, workspace: &Path) -> PromptSourceMap {
352 let model = config.default_model();
353 let identity = config.active_provider_identity().ok();
354 let provider = identity
355 .as_ref()
356 .map_or(crate::config::ProviderKind::Custom, |identity| {
357 identity.provider
358 });
359 let provider_identity = identity
360 .as_ref()
361 .map(|identity| identity.key.to_string())
362 .unwrap_or_else(|| "unavailable".to_string());
363 let route = identity.as_ref().and_then(|identity| {
364 crate::route_runtime::resolve_runtime_route_for_identity(config, identity, Some(&model))
365 .ok()
366 });
367 // A route we could not resolve does not erase an operator-configured
368 // window: doctor must report the same number the session would use.
369 let context_window = route.as_ref().map_or_else(
370 || {
371 crate::route_runtime::resolve_context_window(
372 provider,
373 &model,
374 None,
375 identity
376 .as_ref()
377 .and_then(|identity| config.context_window_for_provider_config(identity)),
378 identity
379 .as_ref()
380 .and_then(|identity| config.model_context_windows_for(identity)),
381 )
382 },
383 |route| route.context_window,
384 );
385 let global_skills_dir = config.skills_dir();
386 let selected_skills_dir =
387 crate::tui::app::resolve_skills_dir(workspace, &global_skills_dir, config);
388 let mut builder = base_source_entries(
389 &model,
390 workspace,
391 Some(&selected_skills_dir),
392 config.project_context_pack_enabled(),
393 crate::skills::SkillDiscoveryMode::from_config(&config.skills_config()),
394 "en",
395 AppMode::Agent,
396 None,
397 Some(context_window.tokens),
398 );
399 let memory_path = config.memory_path();
400 let memory_enabled = config.memory_enabled();
401
402 if let Some(memory_block) =
403 crate::native_memory::native_prompt_block(memory_enabled, &memory_path, workspace)
404 {
405 builder.push(SourceEntry::text(
406 SourceKind::UserMemory,
407 "User memory",
408 Some(memory_path.display().to_string()),
409 ActivationReason::ConfigEnabled,
410 &memory_block,
411 CountingConfidence::High,
412 Some(6),
413 ));
414 } else {
415 builder.push(SourceEntry::omitted(
416 SourceKind::UserMemory,
417 "User memory",
418 Some(memory_path.display().to_string()),
419 Some(6),
420 "disabled, missing, or empty",
421 ));
422 }
423
424 builder.push(SourceEntry::text(
425 SourceKind::ModelProviderFact,
426 format!("Provider facts ({provider_identity})"),
427 None,
428 ActivationReason::RuntimeState,
429 &format!(
430 "provider: {}\nmodel: {}\ncontext_window: {}\ncontext_window_source: {}",
431 provider_identity,
432 model,
433 context_window.tokens,
434 context_window.source.label()
435 ),
436 CountingConfidence::Approximate,
437 None,
438 ));
439
440 let active_context_estimated_tokens = builder
441 .entries
442 .iter()
443 .map(|entry| entry.estimated_tokens)
444 .sum();
445 builder.finish(
446 context_window,
447 active_context_estimated_tokens,
448 None,
449 "Headless diagnostic source map. Conversation, tool results, and live TUI state are unavailable in doctor mode.",
450 )
451 }
452
453 #[allow(clippy::too_many_arguments)]
454 fn base_source_entries(
455 model: &str,
456 workspace: &Path,
457 skills_dir: Option<&Path>,
458 project_pack_enabled: bool,
459 skills_discovery_mode: crate::skills::SkillDiscoveryMode,
460 locale_tag: &str,
461 mode: AppMode,
462 plugin_registry: Option<&crate::plugins::PluginRegistry>,
463 context_window_tokens: Option<u32>,
464 ) -> ReportBuilder {
465 let mut builder = ReportBuilder::new();
466
467 let constitution = crate::prompts::compose_default_static_layers(Personality::Calm, model);
468 builder.push(SourceEntry::text(
469 SourceKind::Constitution,
470 "Bundled constitution, language policy, and output policy",
471 Some(crate::prompts::base_prompt_origin().label().to_string()),
472 ActivationReason::AlwaysOn,
473 &constitution,
474 CountingConfidence::High,
475 Some(1),
476 ));
477
478 if let Some(block) = crate::prompts::load_user_constitution_block() {
479 builder.push(SourceEntry::text(
480 SourceKind::UserConstitution,
481 "User-global constitution",
482 codewhale_config::UserConstitution::path()
483 .ok()
484 .map(|path| path.display().to_string()),
485 ActivationReason::FilePresent,
486 &block,
487 CountingConfidence::High,
488 Some(2),
489 ));
490 }
491
492 let project_context = crate::project_context::load_project_context_with_parents(workspace);
493 if let Some(block) = project_context.constitution_block.as_deref() {
494 builder.push(SourceEntry::text(
495 SourceKind::RepoConstitution,
496 "Repository constitution",
497 project_context
498 .constitution_source_path
499 .as_ref()
500 .map(|path| path.display().to_string()),
501 ActivationReason::FilePresent,
502 block,
503 CountingConfidence::High,
504 Some(4),
505 ));
506 }
507
508 if let Some(content) = project_context.instructions.as_deref() {
509 // Same helper as ProjectContext::as_system_block, so the report's
510 // source token derives from the same label the prompt shows. (The
511 // entry below still displays the absolute source path for operators;
512 // only the prompt label is relativized.)
513 let source = crate::project_context::project_instructions_source_label(
514 project_context.source_path.as_deref(),
515 );
516 let mut block = format!(
517 "<project_instructions source=\"{source}\">\n{content}\n</project_instructions>"
518 );
519 // Include rules in the report when present
520 if let Some(rules) = &project_context.rules_block {
521 block.push('\n');
522 block.push_str(rules);
523 }
524 builder.push(SourceEntry::text(
525 SourceKind::ProjectContext,
526 "Project instructions",
527 project_context
528 .source_path
529 .as_ref()
530 .map(|path| path.display().to_string()),
531 ActivationReason::FilePresent,
532 &block,
533 CountingConfidence::High,
534 Some(5),
535 ));
536 } else if let Some(rules) = &project_context.rules_block {
537 // Rules exist without main instructions
538 builder.push(SourceEntry::text(
539 SourceKind::ProjectContext,
540 "Project rules",
541 None::<String>,
542 ActivationReason::FilePresent,
543 rules,
544 CountingConfidence::High,
545 Some(5),
546 ));
547 }
548
549 if project_context.constitution_block.is_none() && project_context.instructions.is_none() {
550 builder.push(SourceEntry::omitted(
551 SourceKind::ProjectContext,
552 "Project context and repository instructions",
553 Some(workspace.display().to_string()),
554 Some(5),
555 "no project context block available",
556 ));
557 }
558 if !project_context.warnings.is_empty() {
559 let warnings = project_context.warnings.join("\n");
560 let estimated_tokens = estimate_text_tokens_conservative(&warnings);
561 builder.push(SourceEntry::diagnostic(
562 SourceKind::ProjectContextWarning,
563 "Project context warnings",
564 Some(workspace.display().to_string()),
565 ActivationReason::RuntimeState,
566 warnings,
567 estimated_tokens,
568 Some(4),
569 ));
570 }
571
572 if project_pack_enabled {
573 if let Some(pack) = crate::project_context::generate_project_context_pack(workspace) {
574 builder.push(SourceEntry::text(
575 SourceKind::ProjectContextPack,
576 "Project context pack",
577 Some(workspace.display().to_string()),
578 ActivationReason::ConfigEnabled,
579 &pack,
580 CountingConfidence::Approximate,
581 Some(5),
582 ));
583 }
584 } else {
585 builder.push(SourceEntry::omitted(
586 SourceKind::ProjectContextPack,
587 "Project context pack",
588 Some(workspace.display().to_string()),
589 Some(5),
590 "disabled; project_map provides this information on demand",
591 ));
592 }
593
594 let skill_discovery_mode = skills_discovery_mode;
595 let skills_budget = crate::skills::skills_prompt_budget_chars(context_window_tokens);
596 let skills_block = match skills_dir {
597 Some(dir) => crate::skills::render_available_skills_context_for_workspace_and_dir_with_mode_and_plugins(
598 workspace,
599 dir,
600 skill_discovery_mode,
601 locale_tag,
602 plugin_registry,
603 skills_budget,
604 ),
605 None => crate::skills::render_available_skills_context_for_workspace_with_mode_and_plugins(
606 workspace,
607 skill_discovery_mode,
608 locale_tag,
609 plugin_registry,
610 skills_budget,
611 ),
612 };
613 if let Some(block) = skills_block {
614 builder.push(SourceEntry::text(
615 SourceKind::SkillsBlock,
616 "Available skills",
617 skills_dir.map(|path| path.display().to_string()),
618 ActivationReason::FilePresent,
619 &block,
620 CountingConfidence::High,
621 Some(5),
622 ));
623 } else {
624 builder.push(SourceEntry::omitted(
625 SourceKind::SkillsBlock,
626 "Available skills",
627 skills_dir.map(|path| path.display().to_string()),
628 Some(5),
629 "no skills discovered",
630 ));
631 }
632
633 builder.push(SourceEntry::omitted(
634 SourceKind::ContextManagement,
635 format!("{} runtime mode", mode.label()),
636 None,
637 Some(3),
638 "mode enforced by runtime policy and the live tool catalog; no prompt doctrine",
639 ));
640 builder.push(SourceEntry::omitted(
641 SourceKind::CompactionRelayTemplate,
642 "Session relay template",
643 Some("bundled in this codewhale-tui build (COMPACT_TEMPLATE, compiled in)".to_string()),
644 Some(3),
645 "loaded only when /relay is requested; automatic compaction owns its successor brief",
646 ));
647 builder.push(SourceEntry::text(
648 SourceKind::RuntimePolicy,
649 "Core execution discipline",
650 None,
651 ActivationReason::AlwaysOn,
652 CORE_EXECUTION_PROFILE_PROMPT,
653 CountingConfidence::High,
654 Some(3),
655 ));
656 builder.push(SourceEntry::text(
657 SourceKind::AuthorityRecap,
658 "Authority recap",
659 None,
660 ActivationReason::AlwaysOn,
661 crate::prompts::effective_authority_recap(),
662 CountingConfidence::High,
663 Some(1),
664 ));
665 builder.push(SourceEntry::text(
666 SourceKind::EnvironmentBlock,
667 "Runtime environment",
668 Some(workspace.display().to_string()),
669 ActivationReason::AlwaysOn,
670 &crate::prompts::render_environment_block(workspace, locale_tag),
671 CountingConfidence::High,
672 Some(4),
673 ));
674
675 add_handoff_entry(&mut builder, workspace);
676 builder
677 }
678
679 fn add_app_runtime_entries(builder: &mut ReportBuilder, app: &App) {
680 if let Some(memory_block) =
681 crate::native_memory::native_prompt_block(app.use_memory, &app.memory_path, &app.workspace)
682 {
683 builder.push(SourceEntry::text(
684 SourceKind::UserMemory,
685 "User memory",
686 Some(app.memory_path.display().to_string()),
687 ActivationReason::ConfigEnabled,
688 &memory_block,
689 CountingConfidence::High,
690 Some(6),
691 ));
692 } else {
693 builder.push(SourceEntry::omitted(
694 SourceKind::UserMemory,
695 "User memory",
696 Some(app.memory_path.display().to_string()),
697 Some(6),
698 "disabled, missing, or empty",
699 ));
700 }
701
702 if let Some(goal) = app
703 .goal
704 .objective
705 .as_deref()
706 .filter(|goal| !goal.trim().is_empty())
707 {
708 builder.push(SourceEntry::text(
709 SourceKind::SessionGoal,
710 "Session goal",
711 None,
712 ActivationReason::RuntimeState,
713 goal,
714 CountingConfidence::High,
715 Some(6),
716 ));
717 } else {
718 builder.push(SourceEntry::omitted(
719 SourceKind::SessionGoal,
720 "Session goal",
721 None,
722 Some(6),
723 "no active /goal objective",
724 ));
725 }
726
727 if let Some(tools) = app.session.last_tool_catalog.as_ref() {
728 let rendered = serde_json::to_string(tools).unwrap_or_default();
729 builder.push(SourceEntry::text(
730 SourceKind::ToolSchemas,
731 format!("Tool schemas ({} tools)", tools.len()),
732 None,
733 ActivationReason::PerRequest,
734 &rendered,
735 CountingConfidence::Approximate,
736 Some(3),
737 ));
738 } else {
739 builder.push(SourceEntry::omitted(
740 SourceKind::ToolSchemas,
741 "Tool schemas",
742 None,
743 Some(3),
744 "no tool catalog has been sent yet",
745 ));
746 }
747
748 add_message_entries(builder, &app.api_messages);
749 }
750
751 fn add_handoff_entry(builder: &mut ReportBuilder, workspace: &Path) {
752 let primary = workspace.join(crate::prompts::HANDOFF_RELATIVE_PATH);
753 let legacy = workspace.join(".deepseek/handoff.md");
754 let path = if primary.exists() { primary } else { legacy };
755 let Some(raw) = std::fs::read_to_string(&path)
756 .ok()
757 .filter(|raw| !raw.trim().is_empty())
758 else {
759 builder.push(SourceEntry::omitted(
760 SourceKind::HandoffRelay,
761 "Previous session relay",
762 Some(
763 workspace
764 .join(crate::prompts::HANDOFF_RELATIVE_PATH)
765 .display()
766 .to_string(),
767 ),
768 Some(6),
769 "no relay artifact found",
770 ));
771 return;
772 };
773
774 builder.push(SourceEntry::text(
775 SourceKind::HandoffRelay,
776 "Previous session relay",
777 Some(path.display().to_string()),
778 ActivationReason::FilePresent,
779 &raw,
780 CountingConfidence::High,
781 Some(6),
782 ));
783 }
784
785 fn add_message_entries(builder: &mut ReportBuilder, messages: &[Message]) {
786 if messages.is_empty() {
787 builder.push(SourceEntry::omitted(
788 SourceKind::ConversationHistory,
789 "Conversation history",
790 None,
791 None,
792 "no API messages yet",
793 ));
794 return;
795 }
796
797 let latest_user = messages.iter().rposition(|message| message.role == "user");
798 let mut latest_user_tokens = 0usize;
799 let mut conversation_tokens = 0usize;
800 let mut tool_result_tokens = 0usize;
801 let mut tool_result_count = 0usize;
802
803 for (index, message) in messages.iter().enumerate() {
804 for block in &message.content {
805 let tokens = estimate_text_tokens_conservative(&content_block_text(block));
806 match block {
807 ContentBlock::ToolResult { .. }
808 | ContentBlock::ToolSearchToolResult { .. }
809 | ContentBlock::CodeExecutionToolResult { .. } => {
810 tool_result_tokens += tokens;
811 tool_result_count += 1;
812 }
813 ContentBlock::Text { .. } if Some(index) == latest_user => {
814 latest_user_tokens += tokens;
815 }
816 _ => {
817 conversation_tokens += tokens;
818 }
819 }
820 }
821 }
822
823 if latest_user_tokens > 0 {
824 builder.push(SourceEntry::estimate(
825 SourceKind::UserRequest,
826 "Latest user request",
827 None,
828 ActivationReason::PerRequest,
829 latest_user_tokens,
830 CountingConfidence::High,
831 Some(7),
832 ));
833 }
834 if conversation_tokens > 0 {
835 builder.push(SourceEntry::estimate(
836 SourceKind::ConversationHistory,
837 "Conversation history",
838 None,
839 ActivationReason::RuntimeState,
840 conversation_tokens,
841 CountingConfidence::High,
842 None,
843 ));
844 }
845 if tool_result_count > 0 {
846 builder.push(SourceEntry::estimate(
847 SourceKind::ToolResult,
848 format!("Tool results ({tool_result_count})"),
849 None,
850 ActivationReason::RuntimeState,
851 tool_result_tokens,
852 CountingConfidence::High,
853 None,
854 ));
855 }
856 }
857
858 fn content_block_text(block: &ContentBlock) -> String {
859 match block {
860 ContentBlock::Text { text, .. } => text.clone(),
861 ContentBlock::Thinking { thinking, .. } => thinking.clone(),
862 ContentBlock::ToolResult { content, .. } => content.clone(),
863 ContentBlock::ToolSearchToolResult { content, .. }
864 | ContentBlock::CodeExecutionToolResult { content, .. } => content.to_string(),
865 ContentBlock::ToolUse { input, .. } | ContentBlock::ServerToolUse { input, .. } => {
866 input.to_string()
867 }
868 ContentBlock::ImageUrl { image_url } => image_url.url.clone(),
869 }
870 }
871
872 #[cfg(test)]
873 fn pressure_label(percent: Option<f64>) -> &'static str {
874 // Delegate to the unified pressure thresholds so this diagnostic label can't
875 // drift from `context_budget::PressureLevel`. `None` (unknown window) keeps
876 // its own sentinel since a level requires a usage percentage.
877 match percent {
878 Some(value) => PressureLevel::from_usage_percent(value).label(),
879 None => "unknown",
880 }
881 }
882
883 #[cfg(test)]
884 pub fn format_context_report(report: &PromptSourceMap) -> String {
885 crate::diagnostics_reports::format_context_report(&project_source_map(report.clone()))
886 }
887
888 #[cfg(test)]
889 pub fn format_context_summary(report: &PromptSourceMap) -> String {
890 crate::diagnostics_reports::format_context_summary(&project_source_map(report.clone()))
891 }
892
893 pub fn context_report_json(report: &PromptSourceMap) -> String {
894 crate::diagnostics_reports::context_report_json(&project_source_map(report.clone()))
895 }
896
897 #[cfg(test)]
898 mod pressure_fixture_tests;
899
900 #[cfg(test)]
901 mod tests {
902 use super::*;
903 use crate::config::{Config, ProviderKind};
904 use crate::route_runtime::{ContextWindowResolution, ContextWindowSource};
905 use codewhale_config::route::RouteLimits;
906 use codewhale_models::Role;
907 use codewhale_models::Tool;
908 use std::fs;
909 use tempfile::tempdir;
910
911 #[test]
912 fn context_report_json_contains_sources_and_tool_results() {
913 let messages = vec![
914 Message {
915 role: Role::User,
916 content: vec![ContentBlock::Text {
917 text: "read src/lib.rs".to_string(),
918 cache_control: None,
919 }],
920 },
921 Message {
922 role: Role::Assistant,
923 content: vec![ContentBlock::ToolResult {
924 execution_id: None,
925 tool_use_id: "call_1".to_string(),
926 content: "large tool output".repeat(40),
927 is_error: None,
928 content_blocks: None,
929 }],
930 },
931 ];
932 let mut builder = ReportBuilder::new();
933 builder.push(SourceEntry::text(
934 SourceKind::Constitution,
935 "Test static",
936 None,
937 ActivationReason::AlwaysOn,
938 "static",
939 CountingConfidence::High,
940 Some(1),
941 ));
942 add_message_entries(&mut builder, &messages);
943 let report = builder.finish(
944 ContextWindowResolution {
945 tokens: 128_000,
946 source: ContextWindowSource::Fallback,
947 },
948 123,
949 Some(185),
950 "test",
951 );
952 let json = context_report_json(&report);
953
954 assert!(json.contains("\"source_kind\": \"tool_result\""));
955 assert!(json.contains("\"active_context_estimated_tokens\": 123"));
956 assert!(json.contains("\"overflow_guard_estimated_tokens\": 185"));
957 }
958
959 #[test]
960 fn context_report_surfaces_repo_constitution_source_and_warnings() {
961 let tmp = tempdir().expect("tempdir");
962 fs::create_dir(tmp.path().join(".git")).expect("mkdir .git");
963 fs::create_dir(tmp.path().join(".codewhale")).expect("mkdir .codewhale");
964 fs::write(
965 tmp.path().join(".codewhale").join("constitution.json"),
966 r#"{
967 "schema_version": 1,
968 "authority": ["current user request"],
969 "branch_policy": "v0.8.53 work targets the codex/v0.8.53 integration branch, not main"
970 }"#,
971 )
972 .expect("write constitution");
973
974 let report = build_headless_context_report(&Config::default(), tmp.path());
975 assert!(
976 report.entries.iter().any(|entry| {
977 entry.source_kind == SourceKind::RepoConstitution
978 && entry.source_path.as_deref().is_some_and(|path| {
979 path.replace('\\', "/")
980 .ends_with(".codewhale/constitution.json")
981 })
982 }),
983 "repo constitution source should be an explicit source-map entry: {:?}",
984 report.entries
985 );
986 assert!(
987 report.entries.iter().any(|entry| {
988 entry.source_kind == SourceKind::ProjectContextWarning
989 && entry
990 .truncation_reason
991 .as_deref()
992 .is_some_and(|reason| reason.contains("branch_policy appears stale"))
993 && entry.estimated_tokens > 0
994 }),
995 "repo constitution warnings should be explicit source-map entries: {:?}",
996 report.entries
997 );
998
999 let formatted = format_context_report(&report);
1000 assert!(formatted.contains("Repository constitution"));
1001 assert!(formatted.contains("Project context warnings"));
1002 assert!(formatted.contains("/constitution"));
1003 assert!(formatted.contains("/setup report"));
1004 let json = context_report_json(&report);
1005 assert!(json.contains("\"repo_constitution\""));
1006 assert!(json.contains("branch_policy appears stale"));
1007 }
1008
1009 #[test]
1010 fn headless_context_report_uses_kimi_code_k3_route_context() {
1011 let tmp = tempdir().expect("workspace");
1012 let config = Config {
1013 provider: Some("moonshot".to_string()),
1014 providers: Some(crate::config::ProvidersConfig {
1015 moonshot: crate::config::ProviderConfig {
1016 api_key: Some("test-kimi-key".to_string()),
1017 base_url: Some(crate::config::DEFAULT_KIMI_CODE_BASE_URL.to_string()),
1018 model: Some(crate::config::KIMI_CODE_K3_MODEL.to_string()),
1019 ..Default::default()
1020 },
1021 ..Default::default()
1022 }),
1023 ..Default::default()
1024 };
1025
1026 let report = build_headless_context_report(&config, tmp.path());
1027
1028 assert_eq!(report.context_window_tokens, Some(262_144));
1029 assert_eq!(
1030 report.context_window_source.as_deref(),
1031 Some("static Kimi Code safe floor")
1032 );
1033 assert!(context_report_json(&report).contains("\"context_window_tokens\": 262144"));
1034 }
1035
1036 #[test]
1037 fn headless_context_report_honors_kimi_code_k3_context_override() {
1038 let tmp = tempdir().expect("workspace");
1039 let config = Config {
1040 provider: Some("moonshot".to_string()),
1041 providers: Some(crate::config::ProvidersConfig {
1042 moonshot: crate::config::ProviderConfig {
1043 api_key: Some("test-kimi-key".to_string()),
1044 base_url: Some(crate::config::DEFAULT_KIMI_CODE_BASE_URL.to_string()),
1045 model: Some(crate::config::KIMI_CODE_K3_MODEL.to_string()),
1046 context_window: Some(1_048_576),
1047 ..Default::default()
1048 },
1049 ..Default::default()
1050 }),
1051 ..Default::default()
1052 };
1053
1054 let report = build_headless_context_report(&config, tmp.path());
1055
1056 assert_eq!(report.context_window_tokens, Some(1_048_576));
1057 assert_eq!(report.context_window_source.as_deref(), Some("configured"));
1058 }
1059
1060 fn private_deployment_config(context_window: Option<u32>) -> Config {
1061 Config {
1062 provider: Some("custom".to_string()),
1063 providers: Some(crate::config::ProvidersConfig {
1064 custom: std::collections::HashMap::from([(
1065 "custom".to_string(),
1066 crate::config::ProviderConfig {
1067 kind: Some("openai-compatible".to_string()),
1068 api_key: Some("test-private-key".to_string()),
1069 base_url: Some("https://private.test/v1".to_string()),
1070 model: Some("private-1m-deployment-v9".to_string()),
1071 context_window,
1072 ..Default::default()
1073 },
1074 )]),
1075 ..Default::default()
1076 }),
1077 ..Default::default()
1078 }
1079 }
1080
1081 /// #5239: a privately deployed id nobody catalogs, with an operator
1082 /// override, is a 1M route in the report — no route-resolution outcome may
1083 /// silently substitute the legacy window.
1084 #[test]
1085 fn headless_context_report_honors_a_private_model_context_override() {
1086 let tmp = tempdir().expect("workspace");
1087
1088 let report =
1089 build_headless_context_report(&private_deployment_config(Some(1_048_576)), tmp.path());
1090
1091 assert_eq!(report.context_window_tokens, Some(1_048_576));
1092 assert_eq!(report.context_window_source.as_deref(), Some("configured"));
1093 }
1094
1095 /// The same id without an override is a guess, and the report must say so
1096 /// against the window it actually used.
1097 #[test]
1098 fn headless_context_report_marks_an_unknown_private_model_unverified() {
1099 let tmp = tempdir().expect("workspace");
1100
1101 let report = build_headless_context_report(&private_deployment_config(None), tmp.path());
1102
1103 assert_eq!(report.context_window_source.as_deref(), Some("fallback"));
1104 let formatted = format_context_report(&report);
1105 assert!(formatted.contains("this window is a guess"), "{formatted}");
1106 assert!(
1107 !formatted.contains("128K"),
1108 "the fallback rung must not assert a window it did not read: {formatted}"
1109 );
1110 }
1111
1112 #[test]
1113 fn context_report_marks_whale_md_ignored_without_loading_body() {
1114 let tmp = tempdir().expect("tempdir");
1115 fs::write(tmp.path().join("WHALE.md"), "SECRET_LEGACY_WHALE_BODY").expect("write whale");
1116
1117 let report = build_headless_context_report(&Config::default(), tmp.path());
1118 assert!(
1119 report.entries.iter().any(|entry| {
1120 entry.source_kind == SourceKind::ProjectContextWarning
1121 && entry
1122 .truncation_reason
1123 .as_deref()
1124 .is_some_and(|reason| reason.contains("WHALE.md is ignored"))
1125 }),
1126 "ignored WHALE.md should be visible as a migration warning: {:?}",
1127 report.entries
1128 );
1129 assert!(
1130 !context_report_json(&report).contains("SECRET_LEGACY_WHALE_BODY"),
1131 "ignored WHALE.md body must not enter context report"
1132 );
1133 }
1134
1135 #[test]
1136 fn app_context_report_omits_legacy_plain_file_memory() {
1137 // The legacy single-file memory path (`~/.deepseek/memory.md` and
1138 // friends) was deleted for v0.9.4: only the native
1139 // `memory/global/MEMORY.md` store injects.
1140 let tmp = tempdir().expect("tempdir");
1141 let memory_path = tmp.path().join("memory.md");
1142 fs::write(&memory_path, "private legacy memory").expect("write memory");
1143 let config: Config = toml::from_str(
1144 r#"
1145 [memory]
1146 enabled = true
1147 "#,
1148 )
1149 .expect("parse config");
1150 let app = App::new(
1151 crate::tui::app::TuiOptions {
1152 screen_mode: crate::tui::app::ScreenMode::Inline,
1153 use_bracketed_paste: false,
1154 memory_path: memory_path.clone(),
1155 notes_path: tmp.path().join("notes.txt"),
1156 mcp_config_path: tmp.path().join("mcp.json"),
1157 use_memory: true,
1158 start_in_agent_mode: true,
1159 ..crate::test_support::test_tui_options(tmp.path())
1160 },
1161 &config,
1162 );
1163
1164 let report = build_context_report(&app);
1165 let memory_entry = report
1166 .entries
1167 .iter()
1168 .find(|entry| entry.source_kind == SourceKind::UserMemory)
1169 .expect("user memory source entry");
1170
1171 assert_eq!(memory_entry.activation_reason, ActivationReason::Omitted);
1172 assert!(!context_report_json(&report).contains("private legacy memory"));
1173 }
1174
1175 #[test]
1176 fn headless_report_counts_project_pack_only_when_configured() {
1177 let tmp = tempdir().expect("tempdir");
1178 fs::create_dir(tmp.path().join(".git")).expect("mkdir .git");
1179 fs::create_dir(tmp.path().join("src")).expect("mkdir src");
1180 fs::write(tmp.path().join("src/lib.rs"), "pub fn fixture() {}\n").expect("write fixture");
1181
1182 let default_report = build_headless_context_report(&Config::default(), tmp.path());
1183 let default_pack = default_report
1184 .entries
1185 .iter()
1186 .find(|entry| entry.source_kind == SourceKind::ProjectContextPack)
1187 .expect("project pack entry");
1188 assert_eq!(default_pack.activation_reason, ActivationReason::Omitted);
1189 assert_eq!(default_pack.estimated_tokens, 0);
1190 assert_eq!(
1191 default_pack.truncation_reason.as_deref(),
1192 Some("disabled; project_map provides this information on demand")
1193 );
1194
1195 let relay = default_report
1196 .entries
1197 .iter()
1198 .find(|entry| entry.source_kind == SourceKind::CompactionRelayTemplate)
1199 .expect("relay template entry");
1200 assert_eq!(relay.activation_reason, ActivationReason::Omitted);
1201 assert_eq!(relay.estimated_tokens, 0);
1202
1203 let mut configured = Config::default();
1204 configured.context.project_pack = Some(true);
1205 let configured_report = build_headless_context_report(&configured, tmp.path());
1206 let configured_pack = configured_report
1207 .entries
1208 .iter()
1209 .find(|entry| entry.source_kind == SourceKind::ProjectContextPack)
1210 .expect("configured project pack entry");
1211 assert_eq!(
1212 configured_pack.activation_reason,
1213 ActivationReason::ConfigEnabled
1214 );
1215 assert!(
1216 configured_pack.estimated_tokens > 0,
1217 "configured project pack must be counted"
1218 );
1219
1220 let environment = configured_report
1221 .entries
1222 .iter()
1223 .find(|entry| entry.source_kind == SourceKind::EnvironmentBlock)
1224 .expect("runtime environment entry");
1225 assert_eq!(environment.activation_reason, ActivationReason::AlwaysOn);
1226 }
1227
1228 #[test]
1229 fn app_context_report_counts_configured_project_pack_before_first_turn() {
1230 let tmp = tempdir().expect("tempdir");
1231 fs::create_dir(tmp.path().join(".git")).expect("mkdir .git");
1232 fs::create_dir(tmp.path().join("src")).expect("mkdir src");
1233 fs::write(tmp.path().join("src/lib.rs"), "pub fn fixture() {}\n").expect("write fixture");
1234 let mut config = Config::default();
1235 config.context.project_pack = Some(true);
1236 let app = App::new(
1237 crate::tui::app::TuiOptions {
1238 screen_mode: crate::tui::app::ScreenMode::Inline,
1239 use_bracketed_paste: false,
1240 notes_path: tmp.path().join("notes.txt"),
1241 mcp_config_path: tmp.path().join("mcp.json"),
1242 start_in_agent_mode: true,
1243 ..crate::test_support::test_tui_options(tmp.path())
1244 },
1245 &config,
1246 );
1247
1248 assert!(
1249 app.system_prompt.is_none(),
1250 "fixture must be pre-first-turn"
1251 );
1252 let report = build_context_report(&app);
1253 let project_pack = report
1254 .entries
1255 .iter()
1256 .find(|entry| entry.source_kind == SourceKind::ProjectContextPack)
1257 .expect("project pack entry");
1258 assert_eq!(
1259 project_pack.activation_reason,
1260 ActivationReason::ConfigEnabled
1261 );
1262 assert!(project_pack.estimated_tokens > 0);
1263 }
1264
1265 #[test]
1266 fn headless_context_report_omits_legacy_plain_file_memory() {
1267 let tmp = tempdir().expect("tempdir");
1268 let memory_path = tmp.path().join("memory.md");
1269 fs::write(&memory_path, "private legacy memory").expect("write memory");
1270 let mut config: Config = toml::from_str(
1271 r#"
1272 [memory]
1273 enabled = true
1274 "#,
1275 )
1276 .expect("parse config");
1277 config.memory_path = Some(memory_path.to_string_lossy().into_owned());
1278
1279 let report = build_headless_context_report(&config, tmp.path());
1280 let memory_entry = report
1281 .entries
1282 .iter()
1283 .find(|entry| entry.source_kind == SourceKind::UserMemory)
1284 .expect("user memory source entry");
1285
1286 assert_eq!(memory_entry.activation_reason, ActivationReason::Omitted);
1287 assert!(!context_report_json(&report).contains("private legacy memory"));
1288 }
1289
1290 #[test]
1291 fn format_summary_lists_largest_sources() {
1292 let mut builder = ReportBuilder::new();
1293 builder.push(SourceEntry::estimate(
1294 SourceKind::ToolSchemas,
1295 "Tool schemas",
1296 None,
1297 ActivationReason::PerRequest,
1298 500,
1299 CountingConfidence::Approximate,
1300 Some(3),
1301 ));
1302 builder.push(SourceEntry::estimate(
1303 SourceKind::UserRequest,
1304 "Latest user request",
1305 None,
1306 ActivationReason::PerRequest,
1307 25,
1308 CountingConfidence::High,
1309 Some(7),
1310 ));
1311 let report = builder.finish(
1312 ContextWindowResolution {
1313 tokens: 128_000,
1314 source: ContextWindowSource::Fallback,
1315 },
1316 525,
1317 Some(800),
1318 "test",
1319 );
1320 let summary = format_context_summary(&report);
1321
1322 assert!(summary.contains("Context Summary"));
1323 assert!(summary.contains("Tool schemas (500)"));
1324 // The headline is the pressure number; the inflated figure is only
1325 // ever the labeled secondary overflow-guard line.
1326 assert!(summary.contains("Estimated active context: 525 tokens"));
1327 assert!(summary.contains("Overflow guard: 800 tokens"));
1328 let full = format_context_report(&report);
1329 let headline = full
1330 .find("Estimated active context: 525 tokens")
1331 .expect("pressure headline");
1332 let guard = full.find("Overflow guard: 800 tokens").expect("guard line");
1333 assert!(headline < guard, "{full}");
1334 }
1335
1336 #[test]
1337 fn finish_reflects_route_context_window_over_model_default() {
1338 // deepseek-v4-pro defaults to a 1M window; a resolved route advertising a
1339 // smaller window must win in the report's context_window_tokens.
1340 let route_window = 128_000u64;
1341 let model_default = codewhale_models::context_window_for_model("deepseek-v4-pro")
1342 .expect("model has a default window");
1343 assert_ne!(
1344 u64::from(model_default),
1345 route_window,
1346 "test fixture must differ from the model default to be meaningful"
1347 );
1348
1349 let limits = RouteLimits {
1350 context_tokens: Some(route_window),
1351 input_tokens: None,
1352 output_tokens: None,
1353 };
1354 let resolved = crate::route_runtime::resolve_context_window(
1355 ProviderKind::Deepseek,
1356 "deepseek-v4-pro",
1357 Some(limits),
1358 None,
1359 None,
1360 );
1361 assert_eq!(resolved.source, ContextWindowSource::Catalog);
1362
1363 let builder = ReportBuilder::new();
1364 let report = builder.finish(resolved, 10_000, None, "test");
1365
1366 assert_eq!(report.context_window_tokens, Some(route_window as u32));
1367 assert_eq!(report.context_window_source.as_deref(), Some("catalog"));
1368 // Budget percent is computed against the route window, not the default.
1369 let expected = (10_000.0 / route_window as f64) * 100.0;
1370 let actual = report.budget_used_percent.expect("window known");
1371 assert!(
1372 (actual - expected).abs() < 1e-6,
1373 "got {actual}, want {expected}"
1374 );
1375 }
1376
1377 #[test]
1378 fn pressure_label_matches_unified_pressure_levels() {
1379 // Boundaries mirror context_budget::PressureLevel.
1380 assert_eq!(pressure_label(None), "unknown");
1381 assert_eq!(pressure_label(Some(0.0)), "low");
1382 assert_eq!(pressure_label(Some(39.9)), "low");
1383 assert_eq!(pressure_label(Some(40.0)), "moderate");
1384 assert_eq!(pressure_label(Some(74.9)), "moderate");
1385 assert_eq!(pressure_label(Some(75.0)), "high");
1386 assert_eq!(pressure_label(Some(89.9)), "high");
1387 assert_eq!(pressure_label(Some(90.0)), "critical");
1388 assert_eq!(pressure_label(Some(100.0)), "critical");
1389 }
1390
1391 #[test]
1392 fn tool_schema_entry_serializes_like_runtime_catalog() {
1393 let tool = Tool {
1394 tool_type: Some("function".to_string()),
1395 name: "read_file".to_string(),
1396 description: "read a file".to_string(),
1397 input_schema: serde_json::json!({"type": "object"}),
1398 allowed_callers: None,
1399 defer_loading: None,
1400 input_examples: None,
1401 strict: Some(true),
1402 cache_control: None,
1403 };
1404 let rendered = serde_json::to_string(&vec![tool]).expect("serialize tool");
1405
1406 assert!(rendered.contains("read_file"));
1407 }
1408 }
1409
1409 lines RUST