use std::collections::BTreeMap;
use shuvarie_config::{
Hooks, SceneConfig, SceneToolVerb, SceneToolsConfig, ScenesConfig, SubagentConfig,
SubagentsConfig, ToolOverride,
};
use shuvarie_llm::{ChatMsg, Role};
pub const DEFAULT_SCENE_NAME: &str = "Default";
pub const DEFAULT_SCENE_DESCRIPTION: &str = "Built-in behavior, no scene configured";
pub const DEFAULT_INTERLUDE: &str = "The earlier turns of this session may have run under a different scene, \
possibly with its own instructions or tool restrictions. Treat any scene-specific \
constraints in the history as belonging to those earlier turns, and continue \
under the default behavior.";
#[derive(Debug, Clone, PartialEq)]
pub struct SceneListEntry {
pub id: Option<String>,
pub name: String,
pub description: Option<String>,
pub switchable: bool,
}
pub fn is_switchable(config: &SceneConfig) -> bool {
config
.system_prompts
.interlude
.as_deref()
.is_some_and(|interlude| !interlude.trim().is_empty())
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Scene {
config: Option<SceneConfig>,
name: Option<String>,
}
impl Scene {
pub fn resolve(scenes: &ScenesConfig, stored: Option<&str>) -> Self {
let config = stored.and_then(|name| scenes.scene(name)).cloned();
Self {
name: config
.as_ref()
.map(|_| stored.expect("resolvable scene has a name").to_string()),
config,
}
}
pub fn is_default(&self) -> bool {
self.config.is_none()
}
pub fn display_name(&self) -> &str {
self.name.as_deref().unwrap_or(DEFAULT_SCENE_NAME)
}
pub fn description(&self) -> Option<&str> {
self.config.as_ref().and_then(|c| c.description.as_deref())
}
pub fn prelude(&self) -> Option<&str> {
self.config
.as_ref()
.and_then(|c| c.system_prompts.prelude.as_deref())
}
pub fn interlude(&self) -> Option<&str> {
match &self.config {
Some(c) => c.system_prompts.interlude.as_deref(),
None => Some(DEFAULT_INTERLUDE),
}
}
pub fn before_each(&self) -> Option<&Hooks> {
self.config
.as_ref()
.and_then(|c| c.system_prompts.before_each.as_ref())
}
pub fn after_each(&self) -> Option<&Hooks> {
self.config
.as_ref()
.and_then(|c| c.system_prompts.after_each.as_ref())
}
pub fn tools(&self) -> ToolScene {
ToolScene::build(self.config.as_ref().map(|c| &c.tools))
}
pub fn subagents(&self) -> Option<&SubagentsConfig> {
self.config.as_ref().map(|c| &c.subagents)
}
pub fn worker(&self, name: &str) -> Option<&SubagentConfig> {
self.config
.as_ref()
.and_then(|c| c.subagents.workers.get(name))
}
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct ToolScene {
verb: Option<SceneToolVerb>,
overrides: BTreeMap<String, ToolOverride>,
}
impl ToolScene {
pub fn build(config: Option<&SceneToolsConfig>) -> Self {
match config {
Some(cfg) => Self {
verb: cfg.verb,
overrides: cfg.tools.clone(),
},
None => Self::default(),
}
}
pub fn allows(&self, tool: &str) -> bool {
let disabled = self.overrides.get(tool).and_then(|o| o.disabled);
match self.verb {
Some(SceneToolVerb::DisableAll) => disabled == Some(false),
_ => disabled != Some(true),
}
}
pub fn forces_ask(&self, tool: &str) -> bool {
self.allows(tool)
&& (self.verb == Some(SceneToolVerb::AskAll)
|| self
.overrides
.get(tool)
.and_then(|o| o.ask)
.unwrap_or(false))
}
}
pub fn inject_history(
scene: &Scene,
prior: &[ChatMsg],
pending_user: Option<&str>,
announce_scene: bool,
) -> Vec<ChatMsg> {
let before = scene.before_each();
let after = scene.after_each();
let mut out = Vec::with_capacity(prior.len() * 2 + 4);
if announce_scene && let Some(interlude) = scene.interlude() {
out.push(ChatMsg::system(interlude));
}
for msg in prior {
match msg.role {
Role::User => {
if let Some(hooks) = before {
if let Some(turn) = &hooks.turn {
out.push(ChatMsg::system(turn));
}
if let Some(user) = &hooks.user_prompt {
out.push(ChatMsg::system(user));
}
}
out.push(msg.clone());
if let Some(hooks) = after
&& let Some(user) = &hooks.user_prompt
{
out.push(ChatMsg::system(user));
}
}
Role::Assistant => {
if let Some(hooks) = before
&& let Some(assistant) = &hooks.assistant_prompt
{
out.push(ChatMsg::system(assistant));
}
out.push(msg.clone());
if let Some(hooks) = after {
if let Some(assistant) = &hooks.assistant_prompt {
out.push(ChatMsg::system(assistant));
}
if let Some(turn) = &hooks.turn {
out.push(ChatMsg::system(turn));
}
}
}
Role::System => out.push(msg.clone()),
}
}
if pending_user.is_some()
&& let Some(hooks) = before
{
if let Some(turn) = &hooks.turn {
out.push(ChatMsg::system(turn));
}
if let Some(user) = &hooks.user_prompt {
out.push(ChatMsg::system(user));
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use shuvarie_config::ScenesConfig;
fn scene_config(text: &str) -> ScenesConfig {
ScenesConfig::from_kdl(text).unwrap()
}
fn plan_scene(text: &str) -> Scene {
let config = scene_config(text);
Scene::resolve(&config, Some("Plan"))
}
#[test]
fn resolves_the_persisted_scene() {
let config = ScenesConfig::default();
let resolved = Scene::resolve(&config, None);
assert!(resolved.is_default());
assert_eq!(resolved.display_name(), DEFAULT_SCENE_NAME);
assert_eq!(resolved.description(), None);
assert_eq!(resolved.prelude(), None);
assert_eq!(resolved.interlude(), Some(DEFAULT_INTERLUDE));
assert!(resolved.tools().allows("run_shell"));
assert!(!resolved.tools().forces_ask("run_shell"));
}
#[test]
fn switchability_needs_a_non_empty_interlude() {
let config = scene_config(
r#"
scenes {
scene name="Plan" {
system-prompts {
interlude "We switched to Plan mode."
}
}
scene name="Draft" {
}
}
"#,
);
assert!(
shuvarie_config::ScenesConfig::scene(&config, "Plan")
.map(crate::scenes::is_switchable)
.unwrap_or(false)
);
assert_eq!(
shuvarie_config::ScenesConfig::scene(&config, "Draft")
.map(crate::scenes::is_switchable),
Some(false)
);
let blank = shuvarie_config::SceneConfig {
system_prompts: shuvarie_config::SystemPromptsConfig {
interlude: Some(" ".into()),
..Default::default()
},
..Default::default()
};
assert!(
!crate::scenes::is_switchable(&blank),
"a whitespace-only interlude is absent"
);
}
#[test]
fn unknown_scene_falls_back_to_builtin() {
let config = ScenesConfig::default();
let resolved = Scene::resolve(&config, Some("Ghost"));
assert!(resolved.is_default(), "unresolvable name falls back");
assert_eq!(resolved.display_name(), DEFAULT_SCENE_NAME);
}
#[test]
fn a_configured_default_resolves_over_the_builtin() {
let config = scene_config(
r#"
scenes {
scene name="Default" {
description "custom default"
tools {
ask-all
}
}
}
"#,
);
let resolved = Scene::resolve(&config, Some("Default"));
assert!(!resolved.is_default(), "the configured scene wins");
assert_eq!(resolved.display_name(), "Default");
assert_eq!(resolved.description(), Some("custom default"));
assert!(resolved.tools().forces_ask("run_shell"));
}
#[test]
fn resolution_reads_the_configured_scene() {
let resolved = plan_scene(
r#"
scenes {
scene name="Plan" {
description "plan first"
system-prompts {
prelude "You are a planner."
interlude "We switched to Plan mode."
}
}
}
"#,
);
assert!(!resolved.is_default());
assert_eq!(resolved.display_name(), "Plan");
assert_eq!(resolved.description(), Some("plan first"));
assert_eq!(resolved.prelude(), Some("You are a planner."));
assert_eq!(resolved.interlude(), Some("We switched to Plan mode."));
}
#[test]
fn tool_scene_verbs_and_overrides() {
let tools_cfg = SceneToolsConfig {
verb: Some(SceneToolVerb::DisableAll),
tools: BTreeMap::from([
(
"read_file".to_string(),
ToolOverride {
disabled: Some(false),
ask: None,
},
),
(
"grep".to_string(),
ToolOverride {
disabled: Some(false),
ask: None,
},
),
(
"run_shell".to_string(),
ToolOverride {
disabled: Some(false),
ask: Some(true),
},
),
]),
};
let tools = ToolScene::build(Some(&tools_cfg));
assert!(!tools.allows("write_file"), "disable-all removes tools");
assert!(tools.allows("read_file"), "explicit re-enable survives");
assert!(tools.allows("grep"));
assert!(tools.forces_ask("run_shell"), "re-enabled and asking");
assert!(!tools.forces_ask("read_file"));
let scene = plan_scene(
r#"
scenes {
scene name="Plan" {
tools {
ask-all
tool "read_file" {
disabled
}
}
}
}
"#,
);
let tools = scene.tools();
assert!(
!tools.allows("read_file"),
"per-tool disabled beats ask-all"
);
assert!(tools.forces_ask("write_file"), "ask-all asks everything");
assert!(tools.allows("write_file"));
let scene = plan_scene(
r#"
scenes {
scene name="Plan" {
tools {
tool "run_shell" {
ask #true
}
}
}
}
"#,
);
let tools = scene.tools();
assert!(tools.allows("run_shell"));
assert!(tools.forces_ask("run_shell"));
assert!(tools.allows("write_file"));
assert!(!tools.forces_ask("write_file"));
}
#[test]
fn injection_passes_default_history_through() {
let prior = vec![ChatMsg::user("hi"), ChatMsg::assistant("hello")];
let injected = inject_history(&Scene::default(), &prior, Some("next"), false);
assert_eq!(
injected,
vec![ChatMsg::user("hi"), ChatMsg::assistant("hello")],
"without the announce nothing is injected, not even the built-in interlude"
);
let announced = inject_history(&Scene::default(), &prior, Some("next"), true);
assert_eq!(
announced,
vec![
ChatMsg::system(DEFAULT_INTERLUDE),
ChatMsg::user("hi"),
ChatMsg::assistant("hello"),
],
"the built-in scene injects only its default interlude, on the announce"
);
let empty = inject_history(&Scene::default(), &[], Some("next"), true);
assert_eq!(empty, vec![ChatMsg::system(DEFAULT_INTERLUDE)]);
}
#[test]
fn injection_wraps_messages_and_appends_the_pending_hooks() {
let scene = plan_scene(
r#"
scenes {
scene name="Plan" {
system-prompts {
prelude "p"
interlude "switched"
before-each {
user-prompt "BU"
assistant-prompt "BA"
turn "BT"
}
after-each {
user-prompt "AU"
assistant-prompt "AA"
turn "AT"
}
}
}
}
"#,
);
let prior = vec![
ChatMsg::user("u1"),
ChatMsg::assistant("a1"),
ChatMsg::user("u2"),
];
let injected = inject_history(&scene, &prior, Some("u3"), true);
let rendered: Vec<(char, &str)> = injected
.iter()
.map(|m| {
(
match m.role {
Role::System => 's',
Role::User => 'u',
Role::Assistant => 'a',
},
m.content.as_str(),
)
})
.collect();
assert_eq!(
rendered,
vec![
('s', "switched"),
('s', "BT"),
('s', "BU"),
('u', "u1"),
('s', "AU"),
('s', "BA"),
('a', "a1"),
('s', "AA"),
('s', "AT"),
('s', "BT"),
('s', "BU"),
('u', "u2"),
('s', "AU"),
('s', "BT"),
('s', "BU"),
]
);
let later = inject_history(&scene, &prior, Some("u3"), false);
assert!(
later.iter().all(|m| m.content != "switched"),
"the interlude rides only the announce request"
);
assert_eq!(
later.first().map(|m| m.content.as_str()),
Some("BT"),
"hooks still wrap the first turn on later requests"
);
}
}