From 362d0a014f8acd2d7108a778d2db550aad1dcc5c Mon Sep 17 00:00:00 2001 From: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Date: Sat, 11 Apr 2026 11:53:17 -0700 Subject: [PATCH] fix(context): preserve cache-boundary metadata through prompt builders (#506) * feat(prompt): enhance system prompt handling with cache boundary support - Updated the `SystemPromptBuilder` to include a new method for building prompts with cache metadata, returning a `RenderedPrompt` struct that contains the prompt text and an optional cache boundary index. - Introduced a constant for the cache boundary marker to improve readability and maintainability. - Modified the `extract_cache_boundary` function to cleanly remove the cache boundary marker from the rendered prompt while returning the relevant index. - Updated tests to validate the new cache boundary functionality and ensure that the system prompt does not leak internal markers. - Adjusted the agent's session management to track the cache boundary, enhancing the overall prompt handling and memory context management. * feat(context): add method to build system prompt with cache metadata - Introduced `build_system_prompt_with_cache_metadata` method in `ContextManager` to assemble the opening system prompt while preserving cache-boundary metadata for improved request prefix caching. - Updated imports to include `RenderedPrompt` for the new functionality, enhancing the context management capabilities. * feat(prompt): include cache boundary in subagent system prompt rendering - Updated `render_subagent_system_prompt` to insert a cache boundary marker before the workspace section, allowing typed sub-agents to maintain static instructions above this boundary for efficient prompt reuse. - Added a new test to verify that the cache boundary is correctly included in the rendered prompt, ensuring that the system prompt structure supports improved caching behavior. - Adjusted comments for clarity and updated the runtime banner numbering to reflect the new structure. --- .../agent/harness/session/builder.rs | 1 + .../agent/harness/session/runtime.rs | 1 + src/openhuman/agent/harness/session/tests.rs | 16 +++ src/openhuman/agent/harness/session/turn.rs | 27 ++-- src/openhuman/agent/harness/session/types.rs | 3 + .../agent/harness/subagent_runner.rs | 135 ++++++++++++++---- src/openhuman/context/manager.rs | 12 +- src/openhuman/context/prompt.rs | 94 ++++++++++-- 8 files changed, 243 insertions(+), 46 deletions(-) diff --git a/src/openhuman/agent/harness/session/builder.rs b/src/openhuman/agent/harness/session/builder.rs index 7b99e6046..b3aa4e952 100644 --- a/src/openhuman/agent/harness/session/builder.rs +++ b/src/openhuman/agent/harness/session/builder.rs @@ -267,6 +267,7 @@ impl AgentBuilder { skills: self.skills.unwrap_or_default(), auto_save: self.auto_save.unwrap_or(false), last_memory_context: None, + system_prompt_cache_boundary: None, history: Vec::new(), post_turn_hooks: self.post_turn_hooks, learning_enabled: self.learning_enabled, diff --git a/src/openhuman/agent/harness/session/runtime.rs b/src/openhuman/agent/harness/session/runtime.rs index 34d97b5b1..34c29c5c7 100644 --- a/src/openhuman/agent/harness/session/runtime.rs +++ b/src/openhuman/agent/harness/session/runtime.rs @@ -113,6 +113,7 @@ impl Agent { /// Clears the agent's conversation history. pub fn clear_history(&mut self) { self.history.clear(); + self.system_prompt_cache_boundary = None; } // ───────────────────────────────────────────────────────────────── diff --git a/src/openhuman/agent/harness/session/tests.rs b/src/openhuman/agent/harness/session/tests.rs index 058f19ae9..f58156cd2 100644 --- a/src/openhuman/agent/harness/session/tests.rs +++ b/src/openhuman/agent/harness/session/tests.rs @@ -63,6 +63,7 @@ struct RecordingProvider { struct CapturedCall { system_prompt: Option, model: String, + cache_boundary: Option, } #[async_trait] @@ -91,6 +92,7 @@ impl Provider for RecordingProvider { self.captures.lock().push(CapturedCall { system_prompt, model: model.to_string(), + cache_boundary: request.system_prompt_cache_boundary, }); let mut guard = self.responses.lock(); @@ -578,5 +580,19 @@ async fn system_prompt_and_model_are_byte_stable_across_turns() { "model name flipped on turn {} — KV cache namespace broken", idx ); + assert_eq!( + cap.cache_boundary, captures[0].cache_boundary, + "cache boundary drifted on turn {} — provider prompt caching became unstable", + idx + ); + assert!( + cap.cache_boundary.is_some(), + "turn {} should carry an explicit prompt cache boundary", + idx + ); + assert!( + !sys.contains(""), + "system prompt should not leak the internal cache marker" + ); } } diff --git a/src/openhuman/agent/harness/session/turn.rs b/src/openhuman/agent/harness/session/turn.rs index 43c9177b7..a6dba1070 100644 --- a/src/openhuman/agent/harness/session/turn.rs +++ b/src/openhuman/agent/harness/session/turn.rs @@ -24,7 +24,9 @@ use crate::core::event_bus::{publish_global, DomainEvent}; use crate::openhuman::agent::dispatcher::{ParsedToolCall, ToolExecutionResult}; use crate::openhuman::agent::harness; use crate::openhuman::agent::hooks::{self, ToolCallRecord, TurnContext}; -use crate::openhuman::context::prompt::{LearnedContextData, PromptContext, PromptTool}; +use crate::openhuman::context::prompt::{ + LearnedContextData, PromptContext, PromptTool, RenderedPrompt, +}; use crate::openhuman::context::{ReductionOutcome, ARCHIVIST_EXTRACTION_PROMPT}; use crate::openhuman::memory::MemoryCategory; use crate::openhuman::providers::{ChatMessage, ChatRequest, ConversationMessage}; @@ -55,16 +57,17 @@ impl Agent { // inference backend has already tokenised. Fetching it later // would just burn memory-store reads on data we throw away. let learned = self.fetch_learned_context().await; - let system_prompt = self.build_system_prompt(learned)?; + let rendered_prompt = self.build_system_prompt(learned)?; log::info!("[agent] system prompt built — initialising conversation history"); log::info!( "[agent_loop] system prompt built chars={}", - system_prompt.chars().count() + rendered_prompt.text.chars().count() ); - log::debug!("[agent_loop] system prompt body:\n{system_prompt}"); + log::debug!("[agent_loop] system prompt body:\n{}", rendered_prompt.text); + self.system_prompt_cache_boundary = rendered_prompt.cache_boundary; self.history .push(ConversationMessage::Chat(ChatMessage::system( - system_prompt, + rendered_prompt.text, ))); } else { // Deliberately do NOT rebuild the system prompt on subsequent @@ -239,7 +242,7 @@ impl Agent { } else { None }, - system_prompt_cache_boundary: None, + system_prompt_cache_boundary: self.system_prompt_cache_boundary, }, &effective_model, self.temperature, @@ -656,7 +659,7 @@ impl Agent { system_prompt: Arc::new(system_prompt), tool_specs: Arc::clone(&self.visible_tool_specs), message_prefix: Arc::new(messages), - cache_boundary: None, + cache_boundary: self.system_prompt_cache_boundary, fork_task_prompt, } } @@ -751,7 +754,10 @@ impl Agent { /// Builds the system prompt for the current turn, including tool /// instructions and learned context. - pub(super) fn build_system_prompt(&self, learned: LearnedContextData) -> Result { + pub(super) fn build_system_prompt( + &self, + learned: LearnedContextData, + ) -> Result { let tools_slice: &[Box] = self.tools.as_slice(); let instructions = self.tool_dispatcher.prompt_instructions(tools_slice); // Adapt the owned Box slice into the shared PromptTool @@ -770,8 +776,9 @@ impl Agent { }; // Route through the global context manager so every // prompt-building call-site — main agent, sub-agent runner, - // channel runtimes — shares one builder configuration. - self.context.build_system_prompt(&ctx) + // channel runtimes — shares one builder configuration while + // still preserving cache-boundary metadata for provider calls. + self.context.build_system_prompt_with_cache_metadata(&ctx) } // ───────────────────────────────────────────────────────────────── diff --git a/src/openhuman/agent/harness/session/types.rs b/src/openhuman/agent/harness/session/types.rs index 8f7d83064..c4581f766 100644 --- a/src/openhuman/agent/harness/session/types.rs +++ b/src/openhuman/agent/harness/session/types.rs @@ -50,6 +50,9 @@ pub struct Agent { /// Last memory context loaded for the current turn. Stored so it can /// be forwarded to subagents via `ParentExecutionContext`. pub(super) last_memory_context: Option, + /// Explicit cache boundary for the current rendered system prompt. + /// `None` means the prompt is treated as entirely dynamic. + pub(super) system_prompt_cache_boundary: Option, pub(super) history: Vec, pub(super) post_turn_hooks: Vec>, pub(super) learning_enabled: bool, diff --git a/src/openhuman/agent/harness/subagent_runner.rs b/src/openhuman/agent/harness/subagent_runner.rs index 9982832b9..371a840c6 100644 --- a/src/openhuman/agent/harness/subagent_runner.rs +++ b/src/openhuman/agent/harness/subagent_runner.rs @@ -30,6 +30,9 @@ use super::definition::{AgentDefinition, PromptSource, ToolScope}; use super::fork_context::{current_fork, current_parent, ForkContext, ParentExecutionContext}; +use crate::openhuman::context::prompt::{ + extract_cache_boundary, render_subagent_system_prompt, SubagentRenderOptions, +}; use crate::openhuman::providers::{ChatMessage, ChatRequest, Provider, ToolCall}; use crate::openhuman::tools::{Tool, ToolCategory, ToolSpec}; use std::collections::HashSet; @@ -238,27 +241,31 @@ async fn run_typed_mode( // hard-coded all three as "omit", which silently downgraded // definitions like `code_executor` / `tool_maker` / `skills_agent` // that set `omit_safety_preamble = false`. - let render_options = - crate::openhuman::context::prompt::SubagentRenderOptions::from_definition_flags( - definition.omit_identity, - definition.omit_safety_preamble, - definition.omit_skills_catalog, - ); - let system_prompt = crate::openhuman::context::prompt::render_subagent_system_prompt( + let render_options = SubagentRenderOptions::from_definition_flags( + definition.omit_identity, + definition.omit_safety_preamble, + definition.omit_skills_catalog, + ); + let rendered_prompt = extract_cache_boundary(&render_subagent_system_prompt( &parent.workspace_dir, &model, &allowed_indices, &parent.all_tools, &archetype_prompt_body, render_options, - ); + )); + let system_prompt = rendered_prompt.text; + let system_prompt_cache_boundary = rendered_prompt.cache_boundary; // ── Build the user message (with optional context prefix) ────────── - // Merge explicit context from the orchestrator with the parent's - // auto-loaded memory context so the subagent has full visibility. + // Merge explicit orchestrator context with the parent's auto-loaded + // memory context, but only when the definition opts into memory + // inheritance. let mut context_parts: Vec<&str> = Vec::new(); - if let Some(ref mem_ctx) = parent.memory_context { - context_parts.push(mem_ctx); + if !definition.omit_memory_context { + if let Some(ref mem_ctx) = parent.memory_context { + context_parts.push(mem_ctx); + } } if let Some(ref ctx) = options.context { context_parts.push(ctx); @@ -284,6 +291,7 @@ async fn run_typed_mode( &model, temperature, definition.max_iterations, + system_prompt_cache_boundary, task_id, &definition.id, ) @@ -341,8 +349,10 @@ async fn run_fork_mode( let mut history: Vec = (*fork.message_prefix).clone(); history.push(ChatMessage::user(fork_task_prompt)); - // All parent tools are allowed in fork mode (the whole point is - // byte-identical tool schemas). + // Fork mode keeps the parent's exact tool schema snapshot so the + // request body matches the prefix the backend has already cached. + // Runtime execution still resolves against the parent's live tool + // registry. let allowed_names: HashSet = parent .all_tools .iter() @@ -358,11 +368,12 @@ async fn run_fork_mode( parent.provider.as_ref(), &mut history, &parent.all_tools, - &parent.all_tool_specs, + fork.tool_specs.as_slice(), &allowed_names, &model, temperature, max_iterations, + fork.cache_boundary, task_id, &definition.id, ) @@ -392,6 +403,7 @@ async fn run_inner_loop( model: &str, temperature: f64, max_iterations: usize, + system_prompt_cache_boundary: Option, task_id: &str, agent_id: &str, ) -> Result<(String, usize), SubagentRunError> { @@ -417,7 +429,7 @@ async fn run_inner_loop( ChatRequest { messages: history.as_slice(), tools: request_tools, - system_prompt_cache_boundary: None, + system_prompt_cache_boundary, }, model, temperature, @@ -873,9 +885,16 @@ mod tests { /// Mock provider whose response queue can be inspected by the test /// to verify the bytes that arrive at the model. + #[derive(Clone)] + struct CapturedRequest { + messages: Vec, + cache_boundary: Option, + tool_count: usize, + } + struct ScriptedProvider { responses: Mutex>, - captured: Mutex>>, + captured: Mutex>, } impl ScriptedProvider { @@ -905,7 +924,11 @@ mod tests { _model: &str, _temperature: f64, ) -> anyhow::Result { - self.captured.lock().push(request.messages.to_vec()); + self.captured.lock().push(CapturedRequest { + messages: request.messages.to_vec(), + cache_boundary: request.system_prompt_cache_boundary, + tool_count: request.tools.map_or(0, |tools| tools.len()), + }); let mut q = self.responses.lock(); if q.is_empty() { return Ok(ChatResponse { @@ -1067,6 +1090,7 @@ mod tests { let captured = provider.captured.lock(); assert_eq!(captured.len(), 1); let user_msg = captured[0] + .messages .iter() .find(|m| m.role == "user") .expect("user message should be present"); @@ -1078,6 +1102,60 @@ mod tests { assert!(user_msg.content.contains("the actual task prompt")); } + #[tokio::test] + async fn typed_mode_includes_memory_context_when_definition_allows_it() { + let provider = ScriptedProvider::new(vec![text_response("ok")]); + let mut parent = make_parent(provider.clone(), vec![stub("file_read")]); + parent.memory_context = Some("[Memory context]\n- prior fact: branch X failed\n".into()); + let mut def = make_def_named_tools(&[]); + def.omit_memory_context = false; + + let _ = with_parent_context(parent, async { + run_subagent( + &def, + "the actual task prompt", + SubagentRunOptions::default(), + ) + .await + }) + .await + .unwrap(); + + let captured = provider.captured.lock(); + let user_msg = captured[0] + .messages + .iter() + .find(|m| m.role == "user") + .expect("user message should be present"); + assert!(user_msg.content.contains("[Memory context]")); + assert!(user_msg.content.contains("branch X failed")); + } + + #[tokio::test] + async fn typed_mode_threads_system_prompt_cache_boundary() { + let provider = ScriptedProvider::new(vec![text_response("ok")]); + let parent = make_parent(provider.clone(), vec![stub("file_read")]); + let def = make_def_named_tools(&[]); + + let _ = with_parent_context(parent, async { + run_subagent( + &def, + "the actual task prompt", + SubagentRunOptions::default(), + ) + .await + }) + .await + .unwrap(); + + let captured = provider.captured.lock(); + assert_eq!(captured.len(), 1); + assert!( + captured[0].cache_boundary.is_some(), + "typed sub-agent request should carry a prompt cache boundary" + ); + } + #[tokio::test] async fn typed_mode_filters_tools_by_skill_filter() { // Parent has tools spanning notion__*, gmail__*, and a generic @@ -1117,6 +1195,7 @@ mod tests { // name and NOT mention gmail/file_read. let captured = provider.captured.lock(); let system_msg = captured[0] + .messages .iter() .find(|m| m.role == "system") .expect("system message present"); @@ -1157,7 +1236,7 @@ mod tests { // by the runner from StubTool's "ok" output. let captured = provider.captured.lock(); assert_eq!(captured.len(), 2); - let second_call_messages = &captured[1]; + let second_call_messages = &captured[1].messages; let has_tool_msg = second_call_messages.iter().any(|m| m.role == "tool"); assert!( has_tool_msg, @@ -1189,7 +1268,7 @@ mod tests { assert!(outcome.output.contains("oops")); let captured = provider.captured.lock(); - let second_call_messages = &captured[1]; + let second_call_messages = &captured[1].messages; let tool_msg = second_call_messages .iter() .find(|m| m.role == "tool") @@ -1206,7 +1285,7 @@ mod tests { // The runner should replay it byte-for-byte plus a single // appended user message carrying the fork directive. let provider = ScriptedProvider::new(vec![text_response("fork done")]); - let parent = make_parent(provider.clone(), vec![stub("file_read")]); + let parent = make_parent(provider.clone(), vec![stub("file_read"), stub("shell")]); let prefix = vec![ crate::openhuman::providers::ChatMessage::system("PARENT_SYSTEM_PROMPT_BYTES"), @@ -1216,9 +1295,9 @@ mod tests { let fork = ForkContext { system_prompt: Arc::new("PARENT_SYSTEM_PROMPT_BYTES".into()), - tool_specs: Arc::clone(&parent.all_tool_specs), + tool_specs: Arc::new(vec![parent.all_tool_specs[0].clone()]), message_prefix: Arc::new(prefix.clone()), - cache_boundary: None, + cache_boundary: Some(9), fork_task_prompt: "ANALYSE THIS BRANCH".into(), }; @@ -1245,15 +1324,17 @@ mod tests { // prefix exactly and appends only the fork directive. let captured = provider.captured.lock(); let first_call = &captured[0]; - assert_eq!(first_call.len(), prefix.len() + 1); + assert_eq!(first_call.messages.len(), prefix.len() + 1); for (i, msg) in prefix.iter().enumerate() { - assert_eq!(first_call[i].role, msg.role); - assert_eq!(first_call[i].content, msg.content); + assert_eq!(first_call.messages[i].role, msg.role); + assert_eq!(first_call.messages[i].content, msg.content); } // The appended user message carries the fork directive. - let appended = first_call.last().unwrap(); + let appended = first_call.messages.last().unwrap(); assert_eq!(appended.role, "user"); assert_eq!(appended.content, "ANALYSE THIS BRANCH"); + assert_eq!(first_call.cache_boundary, Some(9)); + assert_eq!(first_call.tool_count, 1); } #[tokio::test] diff --git a/src/openhuman/context/manager.rs b/src/openhuman/context/manager.rs index 95bf0e0bc..2c97a83d4 100644 --- a/src/openhuman/context/manager.rs +++ b/src/openhuman/context/manager.rs @@ -33,7 +33,7 @@ use std::sync::Arc; use super::pipeline::{ ContextPipeline, ContextPipelineConfig, PipelineOutcome, SessionMemoryHandle, }; -use super::prompt::{PromptContext, SystemPromptBuilder}; +use super::prompt::{PromptContext, RenderedPrompt, SystemPromptBuilder}; use super::session_memory::SessionMemoryConfig; use super::summarizer::{Summarizer, SummaryStats}; use crate::openhuman::config::ContextConfig; @@ -234,6 +234,16 @@ impl ContextManager { self.default_prompt_builder.build(ctx) } + /// Assemble the opening system prompt for a session using the + /// manager's default builder and preserve cache-boundary metadata + /// for provider request prefix caching. + pub fn build_system_prompt_with_cache_metadata( + &self, + ctx: &PromptContext<'_>, + ) -> Result { + self.default_prompt_builder.build_with_cache_metadata(ctx) + } + /// Assemble the system prompt via a caller-supplied builder. /// /// Sub-agents pass `SystemPromptBuilder::for_subagent(...)` and diff --git a/src/openhuman/context/prompt.rs b/src/openhuman/context/prompt.rs index 53a5ee8d9..399425250 100644 --- a/src/openhuman/context/prompt.rs +++ b/src/openhuman/context/prompt.rs @@ -6,6 +6,7 @@ use std::fmt::Write; use std::path::Path; const BOOTSTRAP_MAX_CHARS: usize = 20_000; +const CACHE_BOUNDARY_MARKER: &str = ""; /// Pre-fetched learned context data for prompt sections (avoids blocking the runtime). #[derive(Debug, Clone, Default)] @@ -91,6 +92,12 @@ pub trait PromptSection: Send + Sync { fn build(&self, ctx: &PromptContext<'_>) -> Result; } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RenderedPrompt { + pub text: String, + pub cache_boundary: Option, +} + #[derive(Default)] pub struct SystemPromptBuilder { sections: Vec>, @@ -165,6 +172,10 @@ impl SystemPromptBuilder { } pub fn build(&self, ctx: &PromptContext<'_>) -> Result { + Ok(self.build_with_cache_metadata(ctx)?.text) + } + + pub fn build_with_cache_metadata(&self, ctx: &PromptContext<'_>) -> Result { let mut output = String::new(); let mut cache_boundary_inserted = false; for section in &self.sections { @@ -176,13 +187,34 @@ impl SystemPromptBuilder { // Static sections (identity, tools, safety, skills) are cacheable; // dynamic sections (workspace, datetime, runtime) change per request. if !cache_boundary_inserted && is_dynamic_section(section.name()) { - output.push_str("\n\n"); + output.push_str(CACHE_BOUNDARY_MARKER); + output.push_str("\n\n"); cache_boundary_inserted = true; } output.push_str(part.trim_end()); output.push_str("\n\n"); } - Ok(output) + Ok(extract_cache_boundary(&output)) + } +} + +pub fn extract_cache_boundary(rendered: &str) -> RenderedPrompt { + if let Some(marker_idx) = rendered.find(CACHE_BOUNDARY_MARKER) { + let mut text = rendered.to_string(); + let end = marker_idx + CACHE_BOUNDARY_MARKER.len(); + text.replace_range(marker_idx..end, ""); + if text[marker_idx..].starts_with("\n\n") { + text.replace_range(marker_idx..marker_idx + 2, ""); + } + return RenderedPrompt { + text, + cache_boundary: Some(marker_idx), + }; + } + + RenderedPrompt { + text: rendered.to_string(), + cache_boundary: None, } } @@ -560,7 +592,14 @@ pub fn render_subagent_system_prompt( ); } - // 4. Workspace so the model knows where it is. Intentionally stable: + // 4. Insert the cache boundary before the dynamic tail. Typed + // sub-agents keep the narrow/static instructions above this + // marker and thread the resulting byte offset through the + // provider request so repeat spawns can reuse prompt prefill. + out.push_str(CACHE_BOUNDARY_MARKER); + out.push_str("\n\n"); + + // 5. Workspace so the model knows where it is. Intentionally stable: // no datetime, no hostname, no pid — see the KV-cache note above. let _ = writeln!( out, @@ -568,7 +607,7 @@ pub fn render_subagent_system_prompt( workspace_dir.display() ); - // 5. Runtime banner — model name only. Stable for the lifetime of + // 6. Runtime banner — model name only. Stable for the lifetime of // this sub-agent's definition. let _ = writeln!(out, "## Runtime\n\nModel: {model_name}"); @@ -719,10 +758,14 @@ mod tests { learned: LearnedContextData::default(), visible_tool_names: &NO_FILTER, }; - let prompt = SystemPromptBuilder::with_defaults().build(&ctx).unwrap(); - assert!(prompt.contains("## Tools")); - assert!(prompt.contains("test_tool")); - assert!(prompt.contains("instr")); + let rendered = SystemPromptBuilder::with_defaults() + .build_with_cache_metadata(&ctx) + .unwrap(); + assert!(rendered.text.contains("## Tools")); + assert!(rendered.text.contains("test_tool")); + assert!(rendered.text.contains("instr")); + assert!(!rendered.text.contains(CACHE_BOUNDARY_MARKER)); + assert!(rendered.cache_boundary.is_some()); } #[test] @@ -783,4 +826,39 @@ mod tests { assert!(payload.contains(" (")); assert!(payload.ends_with(')')); } + + #[test] + fn extract_cache_boundary_removes_marker_and_returns_offset() { + let rendered = extract_cache_boundary("static\n\n\n\ndynamic\n"); + assert_eq!(rendered.text, "static\n\ndynamic\n"); + assert_eq!(rendered.cache_boundary, Some("static\n\n".len())); + } + + #[test] + fn render_subagent_system_prompt_includes_cache_boundary_before_workspace() { + let workspace = std::env::temp_dir().join(format!( + "openhuman_prompt_subagent_{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&workspace).unwrap(); + + let tools: Vec> = vec![Box::new(TestTool)]; + let rendered = extract_cache_boundary(&render_subagent_system_prompt( + &workspace, + "test-model", + &[0], + &tools, + "You are a focused sub-agent.", + SubagentRenderOptions::narrow(), + )); + + assert!( + rendered.cache_boundary.is_some(), + "typed sub-agent prompts should expose an explicit cache boundary" + ); + assert!(rendered.text.contains("## Workspace")); + assert!(!rendered.text.contains(CACHE_BOUNDARY_MARKER)); + + let _ = std::fs::remove_dir_all(workspace); + } }