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