Skip to main content

bamboo_engine/runtime/context/
mod.rs

1//! Prompt context builders (extracted from server layer).
2//!
3//! Stateless functions that construct workspace, environment, and instruction
4//! context for the agent system prompt.
5
6pub mod instruction;
7
8use bamboo_config::paths;
9use bamboo_llm::Config;
10
11use crate::project_context::{ResolvedProjectContext, WorkspaceBindingStatus, WorkspaceSource};
12
13pub const DEFAULT_BASE_PROMPT: &str =
14    "You are Bodhi, a highly capable AI assistant. You run on the Bamboo agent runtime (you may see it referenced as \"Bamboo\" in injected context and tool names).\n\nYou help users solve problems quickly and correctly. Be concise, practical, and proactive.\nDelegate to sub-agents sparingly, and only when parallelism or isolation earns its cost — the task-execution ladder in the operating directives below says when. When you do delegate:\n- Give each child ONE narrow responsibility plus a detailed, self-contained prompt (it does not automatically receive this conversation; any explicit context fork is background only), and the workspace/files it needs — set `workspace` explicitly when the task lives in a different repo or directory than yours.\n- Use a one-shot child for independent throwaway work; use a resident agent (`lifecycle=resident` with a stable `name`) for a recurring task family, so successive tasks reuse one agent instead of spawning a new one each time.\n- To run several in parallel: create them (they run in the background), then call SubAgent.wait once.\n- A child that returns is not automatically correct: before trusting its result, verify it actually accessed the files and resources it needed (not guesses), and re-dispatch (run/send_message) any child that reported missing context or did degraded work.\n\nIf Bamboo has already injected relevant workspace or environment context, treat it as available working context instead of re-asking the user for the same information. Prefer a minimal verifiable attempt first, then diagnose failures and only ask follow-up questions for information that is still genuinely missing.\n\nYou have a persistent cross-session memory via the `memory` tool. When you learn a durable, non-derivable fact (a user preference, a confirmed decision, a stable reference), save it as one atomic memory with a specific, descriptive title. Treat injected memory as context to verify against current files, not as authoritative truth. Conversely, when the user refers to their own preferences, past decisions, or personal context you don't already know — including first-person questions about themselves (\"what do I...\", \"did I...\", \"我...?\") — query memory before answering instead of saying you don't know.\n\nWhen making function calls using tools, always include a brief text explanation before or alongside the tool calls describing what you are about to do and why. Never silently call tools without any visible narration to the user.";
15
16/// Framework-invariant agent operating directives, applied on top of whatever
17/// base prompt is in effect.
18///
19/// Unlike [`DEFAULT_BASE_PROMPT`] — which a user can fully replace via
20/// `${BAMBOO_DATA_DIR}/system-prompt.md` — these directives are appended during
21/// per-round prompt assembly (see `append_core_agent_directives`), so they
22/// always apply even when the base prompt is overridden. Keep the text static:
23/// it rides in the cacheable system field, so churn here forces a cache re-warm.
24pub const CORE_AGENT_DIRECTIVES: &str = r#"Investigate before you conclude. When a request concerns a codebase (you have a workspace, or the question is about how something is built or behaves), gather enough grounding context before answering — never conclude from a README, a doc, or a single file alone. READMEs, docs, and comments state intent and can be stale or partial; read the relevant source and trace how the pieces actually connect (entry points, call sites, data flow) until the picture is consistent, and deliberately weigh more than one explanation rather than committing to the first plausible one. Treat the user's own account as a hypothesis to verify, not as ground truth: their mental model can lag the code — they may be recalling an older implementation that has since been replaced — so when their description and the current code disagree, trust the verified code and surface the gap rather than silently following either. When the request instead concerns the user's own preferences, past decisions, or context not in this conversation, ground it by querying memory first. Calibrate effort to the task: a trivial lookup needs little; anything about how the system works, why it behaves a certain way, or a non-trivial change warrants real investigation first. "Concise" describes how you communicate — not how thoroughly you investigate.
25
26Work through a task with this decision ladder — first matching case wins, and don't over-plan:
271. Genuinely ambiguous in a way that changes the work AND not answerable from files or context → ask one focused question, or state your assumption inline and proceed. Never ask for something already in context or inferable from a file.
282. One tool call (or one short read → grep → read sequence) gets it → do it directly. Don't open a Task list, don't delegate.
293. Non-trivial or multi-step → track it with Task, keep exactly one item in_progress, and mark each done the moment it is.
304. Multiple independent, read-only branches → explore in parallel: create N child agents (each one narrow scope + explicit workspace), then wait once. Same-module concurrent writers ≤ 2.
315. Branches are dependent, or two of them write the same files → serialize; never fan out writes.
32Judgment retained: these cases are defaults, not a script — if a step clearly misfits, say why and deviate, but deviating to dodge the boring-but-correct path is not allowed.
33
34When you delegate, give every child one self-contained assignment in this exact six-part order:
351. Scope.
362. Inputs and background context.
373. Allowed actions and mutation scope.
384. Acceptance criteria and required evidence.
395. Non-goals.
406. Stop and report instruction.
41State that assignment scope is authoritative, forked context cannot expand it, and adjacent cleanup, documentation, commits, pushes, publishing, or release work is excluded unless assigned. Describe tools and permissions as runtime-exposed capabilities. Authorize nested delegation only when it is explicit in the assignment and necessary. Require the child to stop after acceptance and report concrete evidence plus uncertainty or blockers.
42
43Verify your own work before declaring a task done — adversarially, not just confirmingly. Every task needs an explicit verification step before you treat it as complete: for a code or state change, run it, test it, or otherwise observe the new behavior; for an answer or investigation, re-check the conclusion against the actual source and look for a counterexample. Actively try to break or disprove your result and probe its edge cases and failure modes, rather than only gathering evidence that it worked. Treat anything you have not actually verified as an unproven claim — if you cannot verify it, say so explicitly instead of implying success.
44
45Scratch files — PR drafts, quick notes, one-off logs — belong outside the workspace so they don't pollute `git status`. Write them to `/tmp` or `~/.bamboo/scratch/` instead. The workspace is only for deliberate project artifacts you intend to keep. When you must place a scratch file inside the workspace for a brief window, clean it up the moment you're done."#;
46
47pub const WORKSPACE_CONTEXT_START_MARKER: &str = "<!-- BAMBOO_WORKSPACE_CONTEXT_START -->";
48pub const WORKSPACE_CONTEXT_END_MARKER: &str = "<!-- BAMBOO_WORKSPACE_CONTEXT_END -->";
49pub const WORKSPACE_CONTEXT_PREFIX: &str = "Workspace path: ";
50pub const PROJECT_CONTEXT_START_MARKER: &str = "<!-- BAMBOO_PROJECT_CONTEXT_START -->";
51pub const PROJECT_CONTEXT_END_MARKER: &str = "<!-- BAMBOO_PROJECT_CONTEXT_END -->";
52pub const PROJECT_CONTEXT_PREFIX: &str = "Project ID: ";
53pub const ENV_CONTEXT_START_MARKER: &str = "<!-- BAMBOO_ENV_CONTEXT_START -->";
54pub const ENV_CONTEXT_END_MARKER: &str = "<!-- BAMBOO_ENV_CONTEXT_END -->";
55
56/// Guidance for workspace-based interactions
57pub fn workspace_prompt_guidance() -> String {
58    let config_path = paths::path_to_display_string(&paths::config_json_path());
59    format!(
60        "If you need to inspect files, check the workspace first, then Bamboo data at {}. Bamboo configuration is stored in {} (equivalent to ${{BAMBOO_DATA_DIR}}/config.json).",
61        paths::bamboo_dir_display(),
62        config_path
63    )
64}
65
66fn build_env_prompt_guidance() -> Option<String> {
67    let env_vars = Config::current_prompt_safe_env_vars();
68    if env_vars.is_empty() {
69        return None;
70    }
71
72    let mut lines = vec![
73        "These environment variables were explicitly configured by the user inside Bodhi."
74            .to_string(),
75        "- They are already available to Bash/tool processes launched by Bodhi and may be relevant to tools and skills."
76            .to_string(),
77        "- Treat them as user-approved runtime context instead of asking the user to repeat them immediately."
78            .to_string(),
79        "- Secret values are intentionally hidden from the model.".to_string(),
80        "- If the listed variables appear sufficient, prefer a minimal verification or execution attempt before asking follow-up questions."
81            .to_string(),
82        "- Only ask the user for additional env details after identifying a concrete missing variable, malformed value shape, or execution failure that cannot be resolved from this injected context."
83            .to_string(),
84    ];
85
86    for entry in env_vars {
87        let visibility = if entry.secret { "secret" } else { "non-secret" };
88        let mut line = format!("- {} ({})", entry.name, visibility);
89        if let Some(description) = entry.description {
90            line.push_str(" — ");
91            line.push_str(&description);
92        }
93        lines.push(line);
94    }
95
96    Some(lines.join("\n"))
97}
98
99pub fn build_env_prompt_context() -> Option<String> {
100    let body = build_env_prompt_guidance()?;
101    Some(format!(
102        "{ENV_CONTEXT_START_MARKER}\n{body}\n{ENV_CONTEXT_END_MARKER}"
103    ))
104}
105
106pub fn build_workspace_prompt_context(workspace_path: &str) -> Option<String> {
107    build_workspace_prompt_context_with_binding(
108        workspace_path,
109        WorkspaceBindingStatus::Unregistered,
110    )
111}
112
113pub fn build_workspace_prompt_context_with_binding(
114    workspace_path: &str,
115    binding_status: WorkspaceBindingStatus,
116) -> Option<String> {
117    build_workspace_prompt_context_with_binding_and_source(workspace_path, binding_status, None)
118}
119
120pub fn build_workspace_prompt_context_with_binding_and_source(
121    workspace_path: &str,
122    binding_status: WorkspaceBindingStatus,
123    source: Option<WorkspaceSource>,
124) -> Option<String> {
125    let workspace_path = workspace_path.trim();
126    if workspace_path.is_empty() {
127        return None;
128    }
129
130    let body = format!(
131        "{WORKSPACE_CONTEXT_PREFIX}{}\nWorkspace source: {}\nBinding status: {}\nWorkspace-local resources may override Project-shared resources.\nChanging the workspace changes only the filesystem execution context; it does not change Project membership or Project memory.\n{}",
132        prompt_safe_scalar(workspace_path),
133        source.unwrap_or(WorkspaceSource::Session).as_str(),
134        binding_status.as_str(),
135        workspace_prompt_guidance()
136    );
137
138    Some(format!(
139        "{WORKSPACE_CONTEXT_START_MARKER}\n{body}\n{WORKSPACE_CONTEXT_END_MARKER}"
140    ))
141}
142
143/// Locate a complete unwrapped Workspace block emitted by legacy Bamboo builds.
144///
145/// Some persisted sessions predate the marker wrapper, but an ordinary custom
146/// System prompt is also allowed to discuss a `Workspace path:`. Treat the
147/// prefix as host authority only when it appears at the start of a line and is
148/// followed by the exact generated guidance, with only known generated
149/// metadata lines in between. Otherwise migration must leave the text alone.
150pub(crate) fn legacy_unwrapped_workspace_context_bounds(prompt: &str) -> Option<(usize, usize)> {
151    let guidance = workspace_prompt_guidance();
152
153    for (start_idx, _) in prompt.match_indices(WORKSPACE_CONTEXT_PREFIX) {
154        if start_idx > 0 && prompt.as_bytes()[start_idx - 1] != b'\n' {
155            continue;
156        }
157
158        let path_start = start_idx + WORKSPACE_CONTEXT_PREFIX.len();
159        let Some(path_end_rel) = prompt[path_start..].find('\n') else {
160            continue;
161        };
162        let path_end = path_start + path_end_rel;
163        if prompt[path_start..path_end].trim().is_empty() {
164            continue;
165        }
166
167        let metadata_start = path_end + 1;
168        let Some(guidance_rel) = prompt[metadata_start..].find(&guidance) else {
169            continue;
170        };
171        let guidance_start = metadata_start + guidance_rel;
172        if guidance_start > 0 && prompt.as_bytes()[guidance_start - 1] != b'\n' {
173            continue;
174        }
175
176        let metadata_is_generated = prompt[metadata_start..guidance_start]
177            .lines()
178            .map(str::trim)
179            .filter(|line| !line.is_empty())
180            .all(|line| {
181                matches!(
182                    line,
183                    "Workspace source: explicit"
184                        | "Workspace source: project_default"
185                        | "Workspace source: session"
186                        | "Binding status: registered"
187                        | "Binding status: unregistered"
188                        | "Workspace-local resources may override Project-shared resources."
189                        | "Changing the workspace changes only the filesystem execution context; it does not change Project membership or Project memory."
190                )
191            });
192        if !metadata_is_generated {
193            continue;
194        }
195
196        return Some((start_idx, guidance_start + guidance.len()));
197    }
198
199    None
200}
201
202/// Build the stable Project identity block.
203///
204/// Resource counts and revisions are intentionally excluded: they belong to
205/// the per-round dynamic resource envelope. Secret settings, credential
206/// values, MCP headers, and environment values do not belong in
207/// [`ResolvedProjectContext`] and therefore cannot leak through this builder.
208pub fn build_project_prompt_context(context: &ResolvedProjectContext) -> String {
209    let project = &context.project;
210    let project_path = project
211        .project_path
212        .as_deref()
213        .map(paths::path_to_display_string)
214        .unwrap_or_else(|| "not configured".to_string());
215    let body = format!(
216        "{PROJECT_CONTEXT_PREFIX}{}\nProject name: {}\nProject path: {}\nProject home (Bamboo data): {}\nThis session belongs to this Project.\nWorkspace is mutable execution context; changing it does not change Project membership, sidebar grouping, Project memory, or Project-shared resources.\nProject-shared resource inventory is supplied separately as per-round dynamic context.\nUse Workspace to inspect/change only the current directory.\nUse Project to inspect Project identity, bindings, and shared resources.",
217        prompt_safe_scalar(project.id.as_str()),
218        prompt_safe_scalar(&project.name),
219        prompt_safe_scalar(&project_path),
220        prompt_safe_scalar(&paths::path_to_display_string(&project.home)),
221    );
222    format!("{PROJECT_CONTEXT_START_MARKER}\n{body}\n{PROJECT_CONTEXT_END_MARKER}")
223}
224
225/// Build provider-visible Project identity without filesystem locations.
226///
227/// The active workspace path is rendered exactly once by the sibling Workspace
228/// context. Project roots and Bamboo-owned data paths are host details and must
229/// not duplicate that path or leak into provider-visible diagnostics.
230pub fn build_project_model_context(context: &ResolvedProjectContext) -> String {
231    let project = &context.project;
232    let body = format!(
233        "{PROJECT_CONTEXT_PREFIX}{}\nProject name: {}\nThis session belongs to this Project.\nWorkspace is mutable execution context; changing it does not change Project membership, sidebar grouping, Project memory, or Project-shared resources.\nProject-shared resource inventory is supplied separately as per-round dynamic context.\nThe host owns workspace selection and reports the active execution context separately.",
234        prompt_safe_scalar(project.id.as_str()),
235        prompt_safe_scalar(&project.name),
236    );
237    format!("{PROJECT_CONTEXT_START_MARKER}\n{body}\n{PROJECT_CONTEXT_END_MARKER}")
238}
239
240/// Replace every existing Project block with exactly one current block.
241///
242/// Workspace blocks are deliberately outside the removed marker range.
243pub fn upsert_project_prompt_context(
244    prompt: &str,
245    context: Option<&ResolvedProjectContext>,
246) -> String {
247    replace_prompt_block(
248        prompt,
249        PROJECT_CONTEXT_START_MARKER,
250        PROJECT_CONTEXT_END_MARKER,
251        context.map(build_project_prompt_context).as_deref(),
252    )
253}
254
255/// Replace every existing Workspace block with exactly one current block.
256///
257/// Project identity is left byte-for-byte unchanged.
258pub fn upsert_workspace_prompt_context(
259    prompt: &str,
260    workspace_path: Option<&str>,
261    binding_status: WorkspaceBindingStatus,
262) -> String {
263    upsert_workspace_prompt_context_with_source(prompt, workspace_path, binding_status, None)
264}
265
266pub fn upsert_workspace_prompt_context_with_source(
267    prompt: &str,
268    workspace_path: Option<&str>,
269    binding_status: WorkspaceBindingStatus,
270    source: Option<WorkspaceSource>,
271) -> String {
272    let block = workspace_path.and_then(|workspace| {
273        build_workspace_prompt_context_with_binding_and_source(workspace, binding_status, source)
274    });
275    replace_prompt_block(
276        prompt,
277        WORKSPACE_CONTEXT_START_MARKER,
278        WORKSPACE_CONTEXT_END_MARKER,
279        block.as_deref(),
280    )
281}
282
283fn prompt_safe_scalar(value: &str) -> String {
284    value
285        .chars()
286        .map(|character| {
287            if character.is_control() {
288                ' '
289            } else {
290                character
291            }
292        })
293        .collect::<String>()
294        .replace("<!--", "< !--")
295        .trim()
296        .to_string()
297}
298
299fn replace_prompt_block(
300    prompt: &str,
301    start_marker: &str,
302    end_marker: &str,
303    replacement: Option<&str>,
304) -> String {
305    let mut current = prompt.to_string();
306    while let Some(start) = current.find(start_marker) {
307        let content_start = start + start_marker.len();
308        let Some(relative_end) = current[content_start..].find(end_marker) else {
309            current.truncate(start);
310            break;
311        };
312        let end = content_start + relative_end + end_marker.len();
313        let before = current[..start].trim_end();
314        let after = current[end..].trim_start();
315        current = match (before.is_empty(), after.is_empty()) {
316            (true, true) => String::new(),
317            (true, false) => after.to_string(),
318            (false, true) => before.to_string(),
319            (false, false) => format!("{before}\n\n{after}"),
320        };
321    }
322
323    if let Some(replacement) = replacement.map(str::trim).filter(|value| !value.is_empty()) {
324        if !current.trim().is_empty() {
325            current = current.trim().to_string();
326            current.push_str("\n\n");
327        }
328        current.push_str(replacement);
329    }
330    current
331}
332
333/// Assemble a full system prompt from base prompt, optional enhancement, and context segments.
334///
335/// This is the shared prompt assembly logic used by both the HTTP handler and the schedule
336/// manager. System text contains only the caller-owned base plus optional
337/// enhancement. Project, Workspace, instruction, and environment context are
338/// assembled later as typed model-context blocks.
339pub fn assemble_system_prompt(
340    base: &str,
341    enhance: Option<&str>,
342    _workspace_path: Option<&str>,
343) -> String {
344    assemble_system_prompt_with_project(base, enhance, None, None)
345}
346
347pub fn assemble_system_prompt_with_project(
348    base: &str,
349    enhance: Option<&str>,
350    _project_context: Option<&ResolvedProjectContext>,
351    _workspace_path: Option<&str>,
352) -> String {
353    let mut prompt = base.trim().to_string();
354    if let Some(extra) = enhance.map(str::trim).filter(|v| !v.is_empty()) {
355        if !prompt.is_empty() {
356            prompt.push_str("\n\n");
357        }
358        prompt.push_str(extra);
359    }
360    prompt
361}
362
363#[cfg(test)]
364mod project_context_tests {
365    use std::path::PathBuf;
366
367    use crate::project_context::{
368        ProjectDescriptor, ResolvedProjectContext, WorkspaceBindingStatus,
369    };
370    use bamboo_domain::{
371        ProjectId, ProjectResourceEntry, ProjectResourceKind, ProjectResourceSummary,
372        WorkspaceBinding,
373    };
374
375    use super::*;
376
377    fn project_context(workspace: &str) -> ResolvedProjectContext {
378        let project_id = ProjectId::parse("01JPROJECT00000000000000000").expect("project id");
379        ResolvedProjectContext {
380            project: ProjectDescriptor {
381                id: project_id.clone(),
382                name: "Zenith".to_string(),
383                project_path: Some(PathBuf::from(workspace)),
384                home: PathBuf::from("/data/projects/01JPROJECT00000000000000000"),
385                workspace_bindings: vec![WorkspaceBinding {
386                    path: workspace.to_string(),
387                    label: Some("main".to_string()),
388                    git_common_dir: None,
389                }],
390                resources: ProjectResourceSummary {
391                    project_id,
392                    resource_revision: 9,
393                    resources: vec![
394                        ProjectResourceEntry {
395                            kind: ProjectResourceKind::Memory,
396                            present: true,
397                            item_count: 1,
398                        },
399                        ProjectResourceEntry {
400                            kind: ProjectResourceKind::Skills,
401                            present: true,
402                            item_count: 2,
403                        },
404                        ProjectResourceEntry {
405                            kind: ProjectResourceKind::Commands,
406                            present: true,
407                            item_count: 1,
408                        },
409                    ],
410                },
411            },
412            workspace: Some(PathBuf::from(workspace)),
413            workspace_source: WorkspaceSource::Session,
414            binding_status: WorkspaceBindingStatus::Registered,
415        }
416    }
417
418    #[test]
419    fn system_assembly_ignores_legacy_dynamic_context_arguments() {
420        let context = project_context("/workspace/private");
421        let prompt = assemble_system_prompt_with_project(
422            "base",
423            None,
424            Some(&context),
425            Some("/workspace/private"),
426        );
427        assert_eq!(prompt, "base");
428        assert!(!prompt.contains("/workspace/private"));
429        assert!(!prompt.contains("/data/projects"));
430        assert!(!prompt.contains(PROJECT_CONTEXT_START_MARKER));
431        assert!(!prompt.contains(WORKSPACE_CONTEXT_START_MARKER));
432        assert!(!prompt.contains(WORKSPACE_CONTEXT_END_MARKER));
433        assert!(!prompt.contains(ENV_CONTEXT_START_MARKER));
434    }
435
436    #[test]
437    fn provider_project_context_contains_no_host_paths() {
438        let context = project_context("/workspace/main");
439        let prompt = build_project_model_context(&context);
440        assert!(prompt.contains("Project ID:"));
441        assert!(!prompt.contains("/workspace/main"));
442        assert!(!prompt.contains("/data/projects"));
443    }
444
445    #[test]
446    fn workspace_upsert_preserves_project_block_byte_for_byte() {
447        let context = project_context("/workspace/main");
448        let project_block = build_project_prompt_context(&context);
449        let prompt = format!("base\n\n{project_block}");
450        let updated = upsert_workspace_prompt_context(
451            &prompt,
452            Some("/workspace/worktree"),
453            WorkspaceBindingStatus::Registered,
454        );
455        assert_eq!(updated.matches(PROJECT_CONTEXT_START_MARKER).count(), 1);
456        assert!(updated.contains(&project_block));
457        assert!(!updated.contains("Workspace path: /workspace/main"));
458        assert!(updated.contains("Workspace path: /workspace/worktree"));
459    }
460
461    #[test]
462    fn upsert_deduplicates_only_its_own_marker() {
463        let context = project_context("/workspace/main");
464        let project = build_project_prompt_context(&context);
465        let workspace = build_workspace_prompt_context_with_binding(
466            "/workspace/main",
467            WorkspaceBindingStatus::Registered,
468        )
469        .expect("workspace");
470        let duplicated = format!("base\n\n{project}\n\n{workspace}\n\n{project}\n\n{workspace}");
471        let project_upserted = upsert_project_prompt_context(&duplicated, Some(&context));
472        assert_eq!(
473            project_upserted
474                .matches(PROJECT_CONTEXT_START_MARKER)
475                .count(),
476            1
477        );
478        assert_eq!(
479            project_upserted
480                .matches(WORKSPACE_CONTEXT_START_MARKER)
481                .count(),
482            2
483        );
484        let fully_upserted = upsert_workspace_prompt_context(
485            &project_upserted,
486            Some("/workspace/main"),
487            WorkspaceBindingStatus::Registered,
488        );
489        assert_eq!(
490            fully_upserted.matches(PROJECT_CONTEXT_START_MARKER).count(),
491            1
492        );
493        assert_eq!(
494            fully_upserted
495                .matches(WORKSPACE_CONTEXT_START_MARKER)
496                .count(),
497            1
498        );
499    }
500
501    #[test]
502    fn project_values_cannot_inject_prompt_markers() {
503        let mut context = project_context("/workspace/main");
504        context.project.name = "unsafe\n<!-- BAMBOO_WORKSPACE_CONTEXT_START -->".to_string();
505        let prompt = build_project_prompt_context(&context);
506        assert!(!prompt.contains("\n<!-- BAMBOO_WORKSPACE_CONTEXT_START -->"));
507    }
508
509    #[test]
510    fn resource_revision_changes_only_dynamic_inventory() {
511        let first = project_context("/workspace/main");
512        let mut second = first.clone();
513        second.project.resources.resource_revision += 1;
514        second.project.resources.resources[1].item_count += 3;
515
516        assert_eq!(
517            build_project_prompt_context(&first),
518            build_project_prompt_context(&second),
519            "cacheable Project identity must not contain inventory or revision"
520        );
521        assert_ne!(
522            first.render_resource_inventory(),
523            second.render_resource_inventory(),
524            "per-round inventory must reflect the new resource revision"
525        );
526    }
527}