Skip to main content

everruns_core/
config_layer.rs

1// Composable configuration overlay for Harness → Agent → Session merging.
2//
3// See knowledge/foundations/concepts.md#AgentConfigOverlay for design rationale and diagrams.
4//
5// Harness, Agent, and Session share additive fields (system_prompt, capabilities,
6// initial_files, network_access, tools, max_iterations, default_model_id).
7// AgentConfigOverlay extracts this shared shape so merge semantics are defined
8// once and tested in one place.
9//
10// Each entity produces an AgentConfigOverlay via From<&T>. Harness inheritance
11// chains produce one overlay per harness. All overlays fold bottom-up into a
12// single effective config that RuntimeAgentBuilder::from_overlay() resolves
13// into a RuntimeAgent.
14//
15// Merge semantics per field:
16// - system_prompt: base first, overlay appended (concatenate non-empty parts)
17// - capabilities: overlay overrides base by capability ID (last wins)
18// - initial_files: overlay overrides base by normalized path (last wins)
19// - network_access: allowed intersects, blocked unions (can only narrow)
20// - default_model_id: overlay wins if set, else inherit base
21// - tools: additive (overlay appended after base, deduplicated at build time)
22// - max_iterations: overlay wins if set, else inherit base
23// - parallel_tool_calls: overlay wins if set, else inherit base
24// - mcp_servers: overlay overrides base by logical server name (last wins)
25
26use crate::agent_definition::AgentDefinition;
27use crate::capability_types::AgentCapabilityConfig;
28use crate::harness_definition::HarnessDefinition;
29use crate::mcp_server::{ScopedMcpServers, merge_scoped_mcp_servers};
30use crate::network_access::{self, NetworkAccessList};
31use crate::session::ExecutionSession;
32use crate::session_file::InitialFile;
33use crate::tool_types::ToolDefinition;
34use crate::typed_id::ModelId;
35
36/// A composable configuration layer.
37///
38/// Produced by Harness, Agent, or Session via `From<&T>`. Layers merge
39/// bottom-up into a single effective config for RuntimeAgent building.
40#[derive(Debug, Clone, Default)]
41pub struct AgentConfigOverlay {
42    /// System prompt fragment for this layer.
43    pub system_prompt: Option<String>,
44    /// Capabilities with per-layer configuration.
45    pub capabilities: Vec<AgentCapabilityConfig>,
46    /// Starter files for the session filesystem.
47    pub initial_files: Vec<InitialFile>,
48    /// Network access restrictions.
49    pub network_access: Option<NetworkAccessList>,
50    /// Default model ID for this layer.
51    pub default_model_id: Option<ModelId>,
52    /// Tool definitions (client-side or capability-provided).
53    pub tools: Vec<ToolDefinition>,
54    /// Max iterations per turn.
55    pub max_iterations: Option<usize>,
56    /// Request-level parallel tool calling preference (EVE-598).
57    pub parallel_tool_calls: Option<bool>,
58    /// Remote MCP servers scoped to this layer.
59    pub mcp_servers: ScopedMcpServers,
60}
61
62impl AgentConfigOverlay {
63    /// Merge an overlay on top of this base layer, producing a new effective layer.
64    ///
65    /// This is the core composition operation. Each field follows its own merge
66    /// semantic (see module-level docs). The result represents the combined
67    /// configuration of both layers.
68    pub fn merge(self, overlay: AgentConfigOverlay) -> AgentConfigOverlay {
69        let system_prompt = merge_system_prompts(self.system_prompt, overlay.system_prompt);
70        let capabilities = merge_capabilities(&self.capabilities, &overlay.capabilities);
71        let initial_files = merge_initial_files(&self.initial_files, &overlay.initial_files);
72        let network_access = network_access::merge_network_access(
73            self.network_access.as_ref(),
74            overlay.network_access.as_ref(),
75        );
76        let default_model_id = overlay.default_model_id.or(self.default_model_id);
77        let max_iterations = overlay.max_iterations.or(self.max_iterations);
78        let parallel_tool_calls = overlay.parallel_tool_calls.or(self.parallel_tool_calls);
79        let mcp_servers = merge_scoped_mcp_servers(&self.mcp_servers, &overlay.mcp_servers);
80
81        let mut tools = self.tools;
82        tools.extend(overlay.tools);
83
84        AgentConfigOverlay {
85            system_prompt,
86            capabilities,
87            initial_files,
88            network_access,
89            default_model_id,
90            tools,
91            max_iterations,
92            parallel_tool_calls,
93            mcp_servers,
94        }
95    }
96
97    /// Fold multiple layers bottom-up into a single effective layer.
98    ///
99    /// Layers are applied in order: the first layer is the base, each subsequent
100    /// layer is merged on top.
101    pub fn fold(layers: impl IntoIterator<Item = AgentConfigOverlay>) -> AgentConfigOverlay {
102        layers
103            .into_iter()
104            .fold(AgentConfigOverlay::default(), |acc, layer| acc.merge(layer))
105    }
106}
107
108// ---------------------------------------------------------------------------
109// Merge helpers (shared by harness inheritance and config layer merging)
110// ---------------------------------------------------------------------------
111
112/// Merge two optional system prompt fragments. Base first, overlay appended.
113fn merge_system_prompts(base: Option<String>, overlay: Option<String>) -> Option<String> {
114    let base = base.map(|s| s.trim().to_string()).filter(|s| !s.is_empty());
115    let overlay = overlay
116        .map(|s| s.trim().to_string())
117        .filter(|s| !s.is_empty());
118
119    match (base, overlay) {
120        (None, None) => None,
121        (Some(b), None) => Some(b),
122        (None, Some(o)) => Some(o),
123        (Some(b), Some(o)) => Some(format!("{b}\n\n{o}")),
124    }
125}
126
127/// Merge capabilities: overlay overrides base by capability ID (last wins).
128pub fn merge_capabilities(
129    base: &[AgentCapabilityConfig],
130    overlay: &[AgentCapabilityConfig],
131) -> Vec<AgentCapabilityConfig> {
132    // A single execution environment selected by a more specific layer
133    // displaces every legacy compute attachment inherited from broader layers.
134    // This lets a Session profile choose Daytona over a Harness's Bashkit (or
135    // the reverse) without creating two competing filesystems/tool surfaces.
136    // Multiple compute refs in one legacy layer are left untouched; profile
137    // validation always emits exactly one.
138    let selected_environment = overlay
139        .iter()
140        .filter(|capability| is_execution_environment_capability(capability.capability_id()))
141        .count()
142        == 1;
143    let mut merged = if selected_environment {
144        base.iter()
145            .filter(|capability| !is_execution_environment_capability(capability.capability_id()))
146            .cloned()
147            .collect()
148    } else {
149        base.to_vec()
150    };
151
152    for overlay_cap in overlay {
153        if let Some(existing) = merged
154            .iter_mut()
155            .find(|existing| existing.capability_id() == overlay_cap.capability_id())
156        {
157            *existing = overlay_cap.clone();
158        } else {
159            merged.push(overlay_cap.clone());
160        }
161    }
162
163    merged
164}
165
166/// Compute attachments superseded by the first-class Environment profile.
167/// Provider-specific IDs remain here only for migration compatibility; new
168/// managed profiles resolve to `session_sandbox` plus a provider id.
169fn is_execution_environment_capability(id: &str) -> bool {
170    matches!(
171        id,
172        "host_shell"
173            | "bashkit_shell"
174            | "virtual_bash"
175            | "container_sandbox"
176            | "docker_container"
177            | "session_sandbox"
178            | "daytona"
179            | "e2b"
180    )
181}
182
183/// Merge initial files: overlay overrides base by normalized path (last wins).
184pub fn merge_initial_files(base: &[InitialFile], overlay: &[InitialFile]) -> Vec<InitialFile> {
185    let mut merged = base.to_vec();
186
187    for overlay_file in overlay {
188        let normalized_path = normalize_initial_file_path(&overlay_file.path);
189        if let Some(existing) = merged
190            .iter_mut()
191            .find(|existing| normalize_initial_file_path(&existing.path) == normalized_path)
192        {
193            *existing = overlay_file.clone();
194        } else {
195            merged.push(overlay_file.clone());
196        }
197    }
198
199    merged
200}
201
202/// Normalize an initial file path to a canonical form for comparison.
203///
204/// Strips `/workspace/` prefix, ensures leading `/`.
205pub fn normalize_initial_file_path(path: &str) -> String {
206    if path == "/workspace" {
207        "/".to_string()
208    } else if let Some(stripped) = path.strip_prefix("/workspace/") {
209        format!("/{}", stripped.trim_start_matches('/'))
210    } else if path.starts_with('/') {
211        path.to_string()
212    } else {
213        format!("/{}", path)
214    }
215}
216
217// ---------------------------------------------------------------------------
218// From conversions
219// ---------------------------------------------------------------------------
220
221impl From<&HarnessDefinition> for AgentConfigOverlay {
222    fn from(h: &HarnessDefinition) -> Self {
223        AgentConfigOverlay {
224            system_prompt: h.system_prompt.clone(),
225            capabilities: h.capabilities.clone(),
226            initial_files: h.initial_files.clone(),
227            network_access: h.network_access.clone(),
228            default_model_id: h.default_model_id,
229            tools: vec![],
230            max_iterations: None,
231            parallel_tool_calls: h.parallel_tool_calls,
232            mcp_servers: h.mcp_servers.clone(),
233        }
234    }
235}
236
237impl From<&AgentDefinition> for AgentConfigOverlay {
238    fn from(a: &AgentDefinition) -> Self {
239        AgentConfigOverlay {
240            system_prompt: Some(a.system_prompt.clone()),
241            capabilities: a.capabilities.clone(),
242            initial_files: a.initial_files.clone(),
243            network_access: a.network_access.clone(),
244            default_model_id: a.default_model_id,
245            tools: a.tools.clone(),
246            max_iterations: a.max_iterations,
247            parallel_tool_calls: a.parallel_tool_calls,
248            mcp_servers: a.mcp_servers.clone(),
249        }
250    }
251}
252
253impl From<&ExecutionSession> for AgentConfigOverlay {
254    fn from(s: &ExecutionSession) -> Self {
255        let goal_prompt = s
256            .goal
257            .as_ref()
258            .map(|goal| goal.trim())
259            .filter(|goal| !goal.is_empty())
260            .map(|goal| format!("<session-goal>\n{goal}\n</session-goal>"));
261        AgentConfigOverlay {
262            system_prompt: merge_system_prompts(s.system_prompt.clone(), goal_prompt),
263            capabilities: s.capabilities.clone(),
264            initial_files: s.initial_files.clone(),
265            network_access: s.network_access.clone(),
266            default_model_id: s.model_id,
267            tools: s.tools.clone(),
268            max_iterations: s.max_iterations,
269            parallel_tool_calls: s.parallel_tool_calls,
270            mcp_servers: s.mcp_servers.clone(),
271        }
272    }
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278    use crate::mcp_server::{McpServerAuthMode, ScopedMcpServer};
279    use crate::tool_types::{BuiltinTool, ToolHints, ToolPolicy};
280    use serde_json::json;
281
282    fn file(path: &str, content: &str, readonly: bool) -> InitialFile {
283        InitialFile {
284            path: path.into(),
285            content: content.into(),
286            encoding: "text".into(),
287            is_readonly: readonly,
288        }
289    }
290
291    fn tool(name: &str, description: &str) -> ToolDefinition {
292        ToolDefinition::Builtin(BuiltinTool {
293            name: name.into(),
294            description: description.into(),
295            display_name: Some("Display".into()),
296            parameters: json!({"type":"object","properties":{"path":{"type":"string"}}}),
297            policy: ToolPolicy::Auto,
298            category: None,
299            deferrable: Default::default(),
300            hints: ToolHints::default(),
301            full_parameters: None,
302        })
303    }
304
305    fn server(url: &str, marker: &str) -> ScopedMcpServer {
306        ScopedMcpServer {
307            url: url.into(),
308            headers: [("X-Test".into(), marker.into())].into(),
309            auth_mode: McpServerAuthMode::OAuth,
310            oauth_provider_id: Some(marker.into()),
311            tool_discovery: false,
312            ..Default::default()
313        }
314    }
315
316    fn sample_overlay() -> AgentConfigOverlay {
317        AgentConfigOverlay {
318            system_prompt: Some("Base prompt.".into()),
319            capabilities: vec![AgentCapabilityConfig::with_config(
320                "web_fetch",
321                json!({"download":false}),
322            )],
323            initial_files: vec![file("/config.txt", "config", true)],
324            network_access: Some(NetworkAccessList::allow_only(["api.example.com"])),
325            default_model_id: Some(ModelId::from_uuid(uuid::Uuid::from_u128(7))),
326            tools: vec![tool("inspect", "Inspect the workspace")],
327            max_iterations: Some(17),
328            parallel_tool_calls: Some(false),
329            mcp_servers: [(
330                "docs".into(),
331                server("https://docs.example.com/mcp", "docs-provider"),
332            )]
333            .into(),
334        }
335    }
336
337    fn assert_overlay(actual: AgentConfigOverlay, expected: AgentConfigOverlay) {
338        assert_eq!(actual.system_prompt, expected.system_prompt);
339        assert_eq!(actual.capabilities, expected.capabilities);
340        assert_eq!(actual.initial_files, expected.initial_files);
341        assert_eq!(actual.network_access, expected.network_access);
342        assert_eq!(actual.default_model_id, expected.default_model_id);
343        assert_eq!(
344            serde_json::to_value(actual.tools).unwrap(),
345            serde_json::to_value(expected.tools).unwrap()
346        );
347        assert_eq!(actual.max_iterations, expected.max_iterations);
348        assert_eq!(actual.parallel_tool_calls, expected.parallel_tool_calls);
349        assert_eq!(actual.mcp_servers, expected.mcp_servers);
350    }
351
352    #[test]
353    fn merge_prompts_trims_omits_empty_and_preserves_layer_order() {
354        for (base, overlay, expected) in [
355            (None, None, None),
356            (Some(" \n"), Some("\t"), None),
357            (Some(" Base. "), None, Some("Base.")),
358            (None, Some(" Overlay.\n"), Some("Overlay.")),
359            (Some("Base."), Some(" \t"), Some("Base.")),
360            (Some(" \t"), Some("Overlay."), Some("Overlay.")),
361            (
362                Some(" Base. "),
363                Some(" Overlay. "),
364                Some("Base.\n\nOverlay."),
365            ),
366        ] {
367            let result = AgentConfigOverlay {
368                system_prompt: base.map(str::to_owned),
369                ..Default::default()
370            }
371            .merge(AgentConfigOverlay {
372                system_prompt: overlay.map(str::to_owned),
373                ..Default::default()
374            });
375            assert_eq!(
376                result.system_prompt.as_deref(),
377                expected,
378                "{base:?} + {overlay:?}"
379            );
380        }
381    }
382
383    #[test]
384    fn merge_capabilities_replaces_full_config_and_preserves_unrelated_order() {
385        let retained =
386            AgentCapabilityConfig::with_config("session_file_system", json!({"readonly":true}));
387        let old = AgentCapabilityConfig::with_config("web_fetch", json!({"old":true}));
388        let replacement =
389            AgentCapabilityConfig::with_config("web_fetch", json!({"download":false}));
390        let added = AgentCapabilityConfig::with_config("current_time", json!({"zone":"UTC"}));
391        let result = AgentConfigOverlay {
392            capabilities: vec![retained.clone(), old],
393            ..Default::default()
394        }
395        .merge(AgentConfigOverlay {
396            capabilities: vec![
397                AgentCapabilityConfig::new("web_fetch"),
398                added.clone(),
399                replacement.clone(),
400            ],
401            ..Default::default()
402        });
403        assert_eq!(result.capabilities, vec![retained, replacement, added]);
404    }
405
406    #[test]
407    fn one_selected_environment_displaces_inherited_compute() {
408        let result = merge_capabilities(
409            &[
410                AgentCapabilityConfig::new("current_time"),
411                AgentCapabilityConfig::new("bashkit_shell"),
412                AgentCapabilityConfig::new("daytona"),
413            ],
414            &[AgentCapabilityConfig::with_config(
415                "session_sandbox",
416                json!({"provider":"daytona"}),
417            )],
418        );
419        assert_eq!(
420            result
421                .iter()
422                .map(AgentCapabilityConfig::capability_id)
423                .collect::<Vec<_>>(),
424            vec!["current_time", "session_sandbox"]
425        );
426    }
427
428    #[test]
429    fn legacy_multi_compute_layer_is_not_silently_rewritten() {
430        let result = merge_capabilities(
431            &[AgentCapabilityConfig::new("current_time")],
432            &[
433                AgentCapabilityConfig::new("bashkit_shell"),
434                AgentCapabilityConfig::new("daytona"),
435            ],
436        );
437        assert_eq!(result.len(), 3);
438    }
439
440    #[test]
441    fn merge_initial_files_replaces_full_file_by_normalized_path() {
442        let retained = file("/workspace/config.txt", "parent-config", true);
443        let replacement = InitialFile {
444            path: "README.md".into(),
445            content: "Y2hpbGQ=".into(),
446            encoding: "base64".into(),
447            is_readonly: true,
448        };
449        let added = file("/notes.txt", "notes", false);
450        let result = AgentConfigOverlay {
451            initial_files: vec![
452                file("/workspace/README.md", "parent", false),
453                retained.clone(),
454            ],
455            ..Default::default()
456        }
457        .merge(AgentConfigOverlay {
458            initial_files: vec![replacement.clone(), added.clone()],
459            ..Default::default()
460        });
461        assert_eq!(result.initial_files, vec![replacement, retained, added]);
462    }
463
464    #[test]
465    fn merge_network_access_keeps_intersection_and_both_block_lists() {
466        let result = AgentConfigOverlay {
467            network_access: Some(NetworkAccessList {
468                allowed: vec!["*.example.com".into(), "*.github.com".into()],
469                blocked: vec!["private.example.com".into()],
470            }),
471            ..Default::default()
472        }
473        .merge(AgentConfigOverlay {
474            network_access: Some(NetworkAccessList {
475                allowed: vec![
476                    "api.example.com".into(),
477                    "private.example.com".into(),
478                    "child.example.com".into(),
479                    "outside.net".into(),
480                ],
481                blocked: vec!["child.example.com".into(), "private.example.com".into()],
482            }),
483            ..Default::default()
484        });
485        let policy = result.network_access.unwrap();
486        assert_eq!(
487            policy,
488            NetworkAccessList {
489                allowed: vec![
490                    "api.example.com".into(),
491                    "private.example.com".into(),
492                    "child.example.com".into()
493                ],
494                blocked: vec!["private.example.com".into(), "child.example.com".into()],
495            }
496        );
497        assert!(policy.is_url_allowed("https://api.example.com/data"));
498        for url in [
499            "https://private.example.com",
500            "https://child.example.com",
501            "https://outside.net",
502            "https://github.com",
503        ] {
504            assert!(!policy.is_url_allowed(url), "{url}");
505        }
506    }
507
508    #[test]
509    fn merge_scalar_options_inherits_missing_and_keeps_explicit_zero_or_false() {
510        // Each tuple is base, overlay, expected. Distinct values expose reversed precedence.
511        for (base, overlay, expected, base_parallel, overlay_parallel, expected_parallel) in [
512            (None, None, None, None, None, None),
513            (Some(7), None, Some(7), Some(true), None, Some(true)),
514            (None, Some(9), Some(9), None, Some(false), Some(false)),
515            (
516                Some(7),
517                Some(0),
518                Some(0),
519                Some(true),
520                Some(false),
521                Some(false),
522            ),
523            (Some(7), None, Some(7), Some(false), None, Some(false)),
524        ] {
525            let model = |n: u128| ModelId::from_uuid(uuid::Uuid::from_u128(n));
526            let result = AgentConfigOverlay {
527                default_model_id: base.map(model),
528                max_iterations: base.map(|n| n as usize),
529                parallel_tool_calls: base_parallel,
530                ..Default::default()
531            }
532            .merge(AgentConfigOverlay {
533                default_model_id: overlay.map(model),
534                max_iterations: overlay.map(|n| n as usize),
535                parallel_tool_calls: overlay_parallel,
536                ..Default::default()
537            });
538            assert_eq!(result.default_model_id, expected.map(model));
539            assert_eq!(result.max_iterations, expected.map(|n| n as usize));
540            assert_eq!(result.parallel_tool_calls, expected_parallel);
541        }
542    }
543
544    #[test]
545    fn merge_tools_preserves_full_definitions_and_defers_deduplication() {
546        let base = tool("inspect", "base schema owner");
547        let overlay = tool("inspect", "overlay schema owner");
548        let added = tool("search", "new tool");
549        let expected =
550            serde_json::to_value([base.clone(), overlay.clone(), added.clone()]).unwrap();
551        let result = AgentConfigOverlay {
552            tools: vec![base],
553            ..Default::default()
554        }
555        .merge(AgentConfigOverlay {
556            tools: vec![overlay, added],
557            ..Default::default()
558        });
559        assert_eq!(serde_json::to_value(result.tools).unwrap(), expected);
560    }
561
562    #[test]
563    fn merge_mcp_servers_replaces_credentials_and_keeps_unrelated_entries() {
564        let retained = server("https://retained.example.com/mcp", "retained");
565        let replacement = server("https://overlay.example.com/mcp", "new-provider");
566        let added = server("https://search.example.com/mcp", "search-provider");
567        let result = AgentConfigOverlay {
568            mcp_servers: [
569                (
570                    "docs".into(),
571                    server("https://base.example.com/mcp", "old-provider"),
572                ),
573                ("retained".into(), retained.clone()),
574            ]
575            .into(),
576            ..Default::default()
577        }
578        .merge(AgentConfigOverlay {
579            mcp_servers: [
580                ("docs".into(), replacement.clone()),
581                ("search".into(), added.clone()),
582            ]
583            .into(),
584            ..Default::default()
585        });
586        assert_eq!(
587            result.mcp_servers,
588            [
589                ("docs".into(), replacement),
590                ("search".into(), added),
591                ("retained".into(), retained)
592            ]
593            .into()
594        );
595    }
596
597    #[test]
598    fn fold_three_layers_preserves_every_overlay_field() {
599        let harness = sample_overlay();
600        let mut expected = sample_overlay();
601        expected.system_prompt = Some("Base prompt.\n\nAgent prompt.\n\nSession prompt.".into());
602        let extra_capability =
603            AgentCapabilityConfig::with_config("current_time", json!({"zone":"UTC"}));
604        expected.capabilities.push(extra_capability.clone());
605        expected.initial_files = vec![file("config.txt", "agent", false)];
606        expected.max_iterations = Some(50);
607        let agent = AgentConfigOverlay {
608            system_prompt: Some("Agent prompt.".into()),
609            capabilities: vec![extra_capability],
610            initial_files: vec![file("config.txt", "agent", false)],
611            max_iterations: Some(200),
612            ..Default::default()
613        };
614        let session = AgentConfigOverlay {
615            system_prompt: Some("Session prompt.".into()),
616            max_iterations: Some(50),
617            ..Default::default()
618        };
619        assert_overlay(
620            AgentConfigOverlay::fold([harness, agent, session]),
621            expected,
622        );
623        assert_overlay(AgentConfigOverlay::fold([]), AgentConfigOverlay::default());
624    }
625
626    #[test]
627    fn normalize_workspace_prefix_preserves_other_namespaces() {
628        for (input, expected) in [
629            ("/workspace/README.md", "/README.md"),
630            ("/workspace", "/"),
631            ("README.md", "/README.md"),
632            ("/README.md", "/README.md"),
633            ("/workspace//nested/file", "/nested/file"),
634            ("/workspace/", "/"),
635            ("/workspace-other/file", "/workspace-other/file"),
636            ("", "/"),
637        ] {
638            assert_eq!(normalize_initial_file_path(input), expected, "{input:?}");
639        }
640    }
641
642    #[test]
643    fn harness_projection_preserves_all_supported_overlay_fields() {
644        let mut expected = sample_overlay();
645        expected.tools.clear();
646        expected.max_iterations = None;
647        let harness = HarnessDefinition {
648            name: "harness".into(),
649            system_prompt: expected.system_prompt.clone(),
650            capabilities: expected.capabilities.clone(),
651            initial_files: expected.initial_files.clone(),
652            network_access: expected.network_access.clone(),
653            default_model_id: expected.default_model_id,
654            parallel_tool_calls: expected.parallel_tool_calls,
655            mcp_servers: expected.mcp_servers.clone(),
656            ..Default::default()
657        };
658        assert_overlay(AgentConfigOverlay::from(&harness), expected);
659    }
660
661    #[test]
662    fn agent_projection_preserves_all_overlay_fields() {
663        let expected = sample_overlay();
664        let mut agent =
665            AgentDefinition::new(crate::typed_id::AgentId::new(), "agent", "Base prompt.");
666        agent.capabilities = expected.capabilities.clone();
667        agent.initial_files = expected.initial_files.clone();
668        agent.network_access = expected.network_access.clone();
669        agent.default_model_id = expected.default_model_id;
670        agent.tools = expected.tools.clone();
671        agent.max_iterations = expected.max_iterations;
672        agent.parallel_tool_calls = expected.parallel_tool_calls;
673        agent.mcp_servers = expected.mcp_servers.clone();
674        assert_overlay(AgentConfigOverlay::from(&agent), expected);
675    }
676
677    #[test]
678    fn session_projection_preserves_fields_and_appends_only_nonempty_goal() {
679        for (prompt, goal, expected_prompt) in [
680            (
681                Some(" Session prompt. "),
682                Some(" goal text \n"),
683                Some("Session prompt.\n\n<session-goal>\ngoal text\n</session-goal>"),
684            ),
685            (
686                None,
687                Some("goal"),
688                Some("<session-goal>\ngoal\n</session-goal>"),
689            ),
690            (Some("prompt"), Some(" \t"), Some("prompt")),
691            (None, None, None),
692        ] {
693            let mut expected = sample_overlay();
694            expected.system_prompt = expected_prompt.map(str::to_owned);
695            let mut session = ExecutionSession::new(
696                crate::typed_id::SessionId::new(),
697                crate::typed_id::WorkspaceId::new(),
698                crate::typed_id::HarnessId::new(),
699            );
700            session.system_prompt = prompt.map(str::to_owned);
701            session.goal = goal.map(str::to_owned);
702            session.capabilities = expected.capabilities.clone();
703            session.initial_files = expected.initial_files.clone();
704            session.network_access = expected.network_access.clone();
705            session.model_id = expected.default_model_id;
706            session.tools = expected.tools.clone();
707            session.max_iterations = expected.max_iterations;
708            session.parallel_tool_calls = expected.parallel_tool_calls;
709            session.mcp_servers = expected.mcp_servers.clone();
710            assert_overlay(AgentConfigOverlay::from(&session), expected);
711        }
712    }
713}