Synced from monorepo

Changes:
- Non-blocking coding-data sharing upsell banner
- Consolidate remediation in Doctor
- Auto mode defers fail-closed gate asks to the classifier
- Coalesce marketplace list fetches
- Allow removing a marketplace source by name
- Contain hung git marketplace sources (timeouts, non-blocking refresh, unbrick modal)
- Label failed workspace RPCs with error_kind
- Drop redundant explicit tonic/prost deps from xai-grok-shell
- Report real exit codes for completed background shells
- Narrow the date-rollover reminder to date-bearing templates
- Wire toolOverrides through the session and agent
- Security: Bash(git:*) allowlist matches whole command chain by prefix
- Split prompt-trigger telemetry and record classifier provenance
- Raise connectors-manager timeout to 60s
- Auto classifier honors recorded approvals for repeat actions
- Apply doctor fixes in the TUI
- Auto-mode classifier timeouts prompt instead of silently denying
- Scope subagent completion drains to the owning session
- Add the toolOverrides wire types
- Set client_identifier=grok-agent-sdk
- Accept both spellings of the workspace-teleport kill switch
- Persist one-shot occurrence journal
- Stop turns that poll the exact same tool call 16x in a row
- Copy compaction checkpoint files when forking sessions
- Auto-focus permission prompt from scrollback
- Esc cancels the running turn in non-vim and minimal modes
- List Ctrl+Z undo and redo in keyboard shortcuts
- Out-of-process macOS mic capture
- Show active auth mode on session-info
- Install the npm binary under $GROK_HOME
- Remove hover/click dead zones between dashboard items
- Route startup warnings to doctor
- Document [feedback.user] author identity config
- Extend bang command timeout
- Close combine-queued edit-hold race
- Integrate relocation recovery
- Expose privacy notice rollout flag
- Break harness discovery ref cycle so connections can idle-evict
- Shift/Alt+Enter inserts newline when editing a queued prompt
- Gate project Claude permissions on folder trust
- Echo response.create.event_id on response.created
- Toast when session creation fails from disk full
- Add shared test process lifecycle
- Enable dynamic workflows by default
- Add relocation transaction state machine
- Add shared test sandbox
- Surface auth failures on model-switch compact
- Persist durable scheduler expiry
- Confirm before removing extensions-modal items
- Re-run compact and prompt after login when compact hit expired auth
- Recap sends hosted tools under backend search
This commit is contained in:
grokkybara[bot] 2026-07-22 19:18:53 +01:00
commit a5727c5960
482 changed files with 37627 additions and 13402 deletions

View file

@ -887,10 +887,7 @@ impl AgentBuilder {
}
let matched = removed.iter().any(|&id| tool_id_eq(d, id));
if !matched {
tracing::warn!(
agent = % definition.name, tool = % d,
"disallowedTools entry matched nothing"
);
tracing::warn!(agent = %definition.name, tool = %d, "disallowedTools entry matched nothing");
}
}
}
@ -933,8 +930,8 @@ impl AgentBuilder {
}
if !recognized_but_unavailable.is_empty() {
tracing::debug!(
agent = % definition.name, recognized_but_unavailable = ?
recognized_but_unavailable,
agent = %definition.name,
recognized_but_unavailable = ?recognized_but_unavailable,
"tools allowlist named recognized tools that aren't enabled; ignoring them"
);
}
@ -945,14 +942,12 @@ impl AgentBuilder {
|| (has_agent_entry && task_deps.contains(&short_tool_name(&tc.id)))
|| matches!(tc.kind, Some(ToolKind::SearchTool | ToolKind::UseTool))
});
tracing::debug!(
agent = % definition.name, allowed = ? definition.tools,
"tools allowlist applied"
);
tracing::debug!(agent = %definition.name, allowed = ?definition.tools, "tools allowlist applied");
} else {
tracing::warn!(
agent = % definition.name, unresolved = ? unresolved, allowed = ?
definition.tools,
agent = %definition.name,
unresolved = ?unresolved,
allowed = ?definition.tools,
"tools allowlist had unmappable entries; keeping full grok toolset"
);
}
@ -1195,13 +1190,15 @@ impl AgentBuilder {
let mut hosted_tools = Vec::new();
if use_backend_search {
if web_search_enabled && definition.hosted_tool_allowed("web_search") {
hosted_tools.push(xai_grok_sampling_types::HostedTool::WebSearch {
allowed_domains: None,
});
hosted_tools.push(xai_grok_sampling_types::HostedTool::WebSearch { options: None });
}
if definition.hosted_tool_allowed("x_search") {
hosted_tools.push(xai_grok_sampling_types::HostedTool::XSearch);
hosted_tools.push(xai_grok_sampling_types::HostedTool::XSearch { options: None });
}
xai_grok_sampling_types::apply_tool_overrides(
&mut hosted_tools,
definition.tool_overrides.as_ref(),
);
}
#[allow(clippy::arc_with_non_send_sync)]
let tool_bridge = Arc::new(tool_bridge);
@ -1485,16 +1482,14 @@ mod tests {
&subagents,
&["zeta".to_string(), "alpha".to_string(), "alpha".to_string()],
);
assert!(
desc
.contains("If the user explicitly asks for the model of a subagent/task, you may ONLY use model slugs from this list:\n\
assert!(desc.contains(
"If the user explicitly asks for the model of a subagent/task, you may ONLY use model slugs from this list:\n\
- alpha\n\
- zeta")
);
assert!(
desc
.contains("If the user does not explicitly request a model, omit `${{ params.task.model }}` to inherit the parent model.")
);
- zeta"
));
assert!(desc.contains(
"If the user does not explicitly request a model, omit `${{ params.task.model }}` to inherit the parent model."
));
assert!(!desc.contains("Available model slugs:"));
assert!(!desc.contains(concat!("grok", " models")));
}
@ -1946,10 +1941,11 @@ mod tests {
use xai_grok_tools::types::resources::Params;
let mut definition = crate::config::AgentDefinition::default_grok_build();
definition.tools = vec!["run_terminal_cmd".into()];
let bash_params = serde_json::json!(
{ "max_timeout_secs" : 36_000.0, "auto_background_on_timeout" : true,
"allow_background_operator" : false, }
)
let bash_params = serde_json::json!({
"max_timeout_secs": 36_000.0,
"auto_background_on_timeout": true,
"allow_background_operator": false,
})
.as_object()
.unwrap()
.clone();
@ -2313,6 +2309,7 @@ mod tests {
web_search_enabled: bool,
backend_search_enabled: bool,
disallowed_tools: &[&str],
tool_overrides: Option<xai_grok_sampling_types::ToolOverrides>,
) -> crate::agent::Agent {
use xai_grok_tools::computer::local::LocalTerminalBackend;
use xai_grok_tools::implementations::web_search::WebSearchConfig;
@ -2330,6 +2327,7 @@ mod tests {
};
let mut def = crate::config::AgentDefinition::default_grok_build();
def.disallowed_tools = disallowed_tools.iter().map(|s| s.to_string()).collect();
def.tool_overrides = tool_overrides;
AgentBuilder::new(
std::env::temp_dir(),
Arc::new(LocalTerminalBackend::new()),
@ -2344,7 +2342,7 @@ mod tests {
}
#[tokio::test]
async fn disallowed_web_search_strips_function_and_hosted_tools() {
let agent = build_with_web_search(true, true, &["web_search"]).await;
let agent = build_with_web_search(true, true, &["web_search"], None).await;
let hosted = agent.hosted_tools();
assert!(
!hosted
@ -2355,7 +2353,7 @@ mod tests {
assert!(
hosted
.iter()
.any(|t| matches!(t, xai_grok_sampling_types::HostedTool::XSearch)),
.any(|t| matches!(t, xai_grok_sampling_types::HostedTool::XSearch { .. })),
"XSearch must remain when only web_search is disallowed, got: {hosted:?}"
);
let has_web_search_fn = agent
@ -2372,7 +2370,7 @@ mod tests {
/// hosted tools appear and `backend_search_enabled()` is true.
#[tokio::test]
async fn hosted_tools_populated_when_backend_search_and_web_search_enabled() {
let agent = build_with_web_search(true, true, &[]).await;
let agent = build_with_web_search(true, true, &[], None).await;
assert!(agent.backend_search_enabled());
let hosted = agent.hosted_tools();
assert!(
@ -2384,7 +2382,7 @@ mod tests {
assert!(
hosted
.iter()
.any(|t| matches!(t, xai_grok_sampling_types::HostedTool::XSearch)),
.any(|t| matches!(t, xai_grok_sampling_types::HostedTool::XSearch { .. })),
"expected XSearch hosted tool, got: {hosted:?}"
);
}
@ -2392,7 +2390,7 @@ mod tests {
/// WebSearch requires the web-search config.
#[tokio::test]
async fn hosted_tools_only_xsearch_when_web_search_disabled() {
let agent = build_with_web_search(false, true, &[]).await;
let agent = build_with_web_search(false, true, &[], None).await;
let hosted = agent.hosted_tools();
assert!(
!hosted
@ -2403,7 +2401,7 @@ mod tests {
assert!(
hosted
.iter()
.any(|t| matches!(t, xai_grok_sampling_types::HostedTool::XSearch)),
.any(|t| matches!(t, xai_grok_sampling_types::HostedTool::XSearch { .. })),
"expected XSearch hosted tool, got: {hosted:?}"
);
}
@ -2411,8 +2409,35 @@ mod tests {
/// of web-search config.
#[tokio::test]
async fn hosted_tools_empty_when_backend_search_disabled() {
let agent = build_with_web_search(true, false, &[]).await;
let agent = build_with_web_search(true, false, &[], None).await;
assert!(!agent.backend_search_enabled());
assert!(agent.hosted_tools().is_empty());
}
#[tokio::test]
async fn hosted_tools_bake_definition_tool_overrides_into_options() {
let x_search = xai_grok_sampling_types::XSearchOptions {
date_bound: Some(
xai_grok_sampling_types::SearchDateBound::new(None, Some("2024-03-15".into()))
.unwrap(),
),
};
let agent = build_with_web_search(
true,
true,
&[],
Some(xai_grok_sampling_types::ToolOverrides {
x_search: Some(x_search.clone()),
web_search: None,
}),
)
.await;
assert!(
agent
.hosted_tools()
.contains(&xai_grok_sampling_types::HostedTool::XSearch {
options: Some(x_search),
}),
"definition tool_overrides must be applied to HostedTool options"
);
}
}

View file

@ -383,7 +383,9 @@ fn plan_toolset() -> ToolServerConfig {
(&grok_build::ReadFileTool).into(),
(&grok_build::ListDirTool).into(),
(&grok_build::GrepTool).into(),
// (&grok_build::SkillTool).into(),
(&grok_build::TodoWriteTool).into(),
// search_replace + run_terminal_command intentionally omitted (read-only)
],
behavior_preset: None,
}
@ -397,6 +399,7 @@ fn plan_toolset() -> ToolServerConfig {
fn grok_build_plan_toolset() -> ToolServerConfig {
ToolServerConfig {
tools: vec![
// Standard grok-build tools
bash_tool_config(),
(&grok_build::ReadFileTool).into(),
(&grok_build::SearchReplaceTool).into(),
@ -414,6 +417,7 @@ fn grok_build_plan_toolset() -> ToolServerConfig {
(&use_tool::UseTool).into(),
(&grok_build::UpdateGoalTool).into(),
(&grok_build::WorkflowTool).into(),
// Plan mode tools
(&grok_build::EnterPlanModeTool).into(),
(&grok_build::ExitPlanModeTool).into(),
(&grok_build::AskUserQuestionTool).into(),
@ -430,33 +434,44 @@ fn grok_build_plan_toolset() -> ToolServerConfig {
fn orchestrator_toolset() -> ToolServerConfig {
ToolServerConfig {
tools: vec![
// Research tools
bash_tool_config(),
(&grok_build::ReadFileTool).into(),
(&grok_build::ListDirTool).into(),
(&grok_build::GrepTool).into(),
// Subagent orchestration
task_tool_config(),
task_output_tool_config(),
wait_tasks_tool_config(),
kill_task_tool_config(),
// Skills and MCP
(&search_tool::SearchTool).into(),
(&use_tool::UseTool).into(),
// Planning and user interaction
(&grok_build::TodoWriteTool).into(),
(&grok_build::EnterPlanModeTool).into(),
(&grok_build::ExitPlanModeTool).into(),
(&grok_build::AskUserQuestionTool).into(),
(&grok_build::UpdateGoalTool).into(),
(&grok_build::WorkflowTool).into(),
// Scheduling and monitoring
(&grok_build::SchedulerCreateTool).into(),
(&grok_build::SchedulerDeleteTool).into(),
(&grok_build::SchedulerListTool).into(),
(&grok_build::MonitorTool).into(),
// Web tools
(&grok_build::WebSearchTool).into(),
(&grok_build::WebFetchTool).into(),
// Imagine
(&grok_build::ImageGenTool).into(),
(&grok_build::ImageToVideoTool).into(),
(&grok_build::ReferenceToVideoTool).into(),
// Memory
(&memory::MemorySearchImpl).into(),
(&memory::MemoryGetImpl).into(),
// Intentionally excluded:
// - SearchReplaceTool (no file editing — delegate to subagents)
// - OpenCodeWriteTool (no file writing — delegate to subagents)
],
behavior_preset: None,
}
@ -469,6 +484,8 @@ fn orchestrator_toolset() -> ToolServerConfig {
fn grok_build_plan_no_subagents_toolset() -> ToolServerConfig {
ToolServerConfig {
tools: vec![
// Standard grok-build tools (minus TaskTool only — KillTaskTool and
// TaskOutputTool are kept because BashTool's background mode requires them)
bash_tool_config(),
(&grok_build::ReadFileTool).into(),
(&grok_build::SearchReplaceTool).into(),
@ -485,6 +502,7 @@ fn grok_build_plan_no_subagents_toolset() -> ToolServerConfig {
(&use_tool::UseTool).into(),
(&grok_build::UpdateGoalTool).into(),
(&grok_build::WorkflowTool).into(),
// Plan mode tools
(&grok_build::EnterPlanModeTool).into(),
(&grok_build::ExitPlanModeTool).into(),
(&grok_build::AskUserQuestionTool).into(),
@ -517,6 +535,7 @@ fn grok_build_ask_user_toolset() -> ToolServerConfig {
(&use_tool::UseTool).into(),
(&grok_build::UpdateGoalTool).into(),
(&grok_build::WorkflowTool).into(),
// Ask user tool (without plan mode)
(&grok_build::AskUserQuestionTool).into(),
],
behavior_preset: None,
@ -792,6 +811,8 @@ pub struct AgentDefinition {
/// specific tool before the turn ends.
#[serde(default)]
pub completion_requirement: Option<CompletionRequirement>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_overrides: Option<xai_grok_sampling_types::ToolOverrides>,
/// Subagent types this agent can spawn (derived by builder from `tools`).
/// `None` = unrestricted, `Some([t1])` = restricted, `Some([])` = blocked.
#[serde(skip)]
@ -1458,6 +1479,7 @@ impl AgentDefinition {
session_tools_denylist: None,
model: ModelOverride::Inherit,
completion_requirement: None,
tool_overrides: None,
prompt_body: None,
system_prompt: TemplateOverride::None,
source_path: None,
@ -2026,20 +2048,16 @@ description: Minimal agent
let v: McpServerRef = serde_json::from_value(serde_json::json!("slack")).unwrap();
assert_eq!(v, McpServerRef::Named("slack".to_string()));
let v: McpServerRef =
serde_json::from_value(serde_json::json!({ "s" : { "type" : "stdio" } })).unwrap();
serde_json::from_value(serde_json::json!({"s": {"type": "stdio"}})).unwrap();
assert!(matches!(v, McpServerRef::Inline { ref name, .. } if name == "s"));
let v: McpServerRef =
serde_json::from_value(serde_json::json!({ "name" : "s", "type" : "stdio" })).unwrap();
serde_json::from_value(serde_json::json!({"name": "s", "type": "stdio"})).unwrap();
assert!(matches!(v, McpServerRef::Inline { ref name, .. } if name == "s"));
assert!(
serde_json::from_value::<McpServerRef>(serde_json::json!({ "type" :
"stdio" }))
.is_err()
serde_json::from_value::<McpServerRef>(serde_json::json!({"type": "stdio"})).is_err()
);
assert!(serde_json::from_value::<McpServerRef>(serde_json::json!(42)).is_err());
assert!(
serde_json::from_value::<McpServerRef>(serde_json::json!({ "s" : "bad" })).is_err()
);
assert!(serde_json::from_value::<McpServerRef>(serde_json::json!({"s": "bad"})).is_err());
}
#[test]
fn memory_scope_resolve_dir() {
@ -2255,9 +2273,10 @@ description: Test default tool config
}
#[test]
fn test_from_json_minimal() {
let json = serde_json::json!(
{ "name" : "acp-agent", "description" : "An agent from ACP" }
);
let json = serde_json::json!({
"name": "acp-agent",
"description": "An agent from ACP"
});
let def = AgentDefinition::from_json(&json).unwrap();
assert_eq!(def.name, "acp-agent");
assert_eq!(def.description, "An agent from ACP");
@ -2268,11 +2287,14 @@ description: Test default tool config
}
#[test]
fn test_from_json_has_default_toolset_with_task_tool() {
let json = serde_json::json!(
{ "name" : "grok-build", "description" : "Multi-surface coding agent.",
"promptMode" : "extend", "permissionMode" : "dontAsk", "agentsMd" : true,
"promptBody" : "You are a coding assistant." }
);
let json = serde_json::json!({
"name": "grok-build",
"description": "Multi-surface coding agent.",
"promptMode": "extend",
"permissionMode": "dontAsk",
"agentsMd": true,
"promptBody": "You are a coding assistant."
});
let def = AgentDefinition::from_json(&json).unwrap();
let task_tool_id = "GrokBuild:task";
assert!(
@ -2288,10 +2310,11 @@ description: Test default tool config
}
#[test]
fn test_from_json_with_prompt_body() {
let json = serde_json::json!(
{ "name" : "custom-agent", "description" : "Agent with prompt body",
"promptBody" : "You are a specialized coding assistant.\n\nFocus on Rust." }
);
let json = serde_json::json!({
"name": "custom-agent",
"description": "Agent with prompt body",
"promptBody": "You are a specialized coding assistant.\n\nFocus on Rust."
});
let def = AgentDefinition::from_json(&json).unwrap();
assert_eq!(def.name, "custom-agent");
assert_eq!(
@ -2301,20 +2324,23 @@ description: Test default tool config
}
#[test]
fn test_from_json_with_permission_mode() {
let json = serde_json::json!(
{ "name" : "auto-accept-agent", "description" :
"Agent with dontAsk permission mode", "permissionMode" : "dontAsk",
"promptBody" : "## Auto-accept Mode" }
);
let json = serde_json::json!({
"name": "auto-accept-agent",
"description": "Agent with dontAsk permission mode",
"permissionMode": "dontAsk",
"promptBody": "## Auto-accept Mode"
});
let def = AgentDefinition::from_json(&json).unwrap();
assert_eq!(def.permission_mode, PermissionMode::DontAsk);
assert_eq!(def.prompt_body.as_deref(), Some("## Auto-accept Mode"));
}
#[test]
fn test_from_json_empty_prompt_body_is_none() {
let json = serde_json::json!(
{ "name" : "test", "description" : "Test", "promptBody" : " " }
);
let json = serde_json::json!({
"name": "test",
"description": "Test",
"promptBody": " "
});
let def = AgentDefinition::from_json(&json).unwrap();
assert!(
def.prompt_body.is_none(),
@ -2323,16 +2349,20 @@ description: Test default tool config
}
#[test]
fn test_from_json_missing_required_fields() {
let json = serde_json::json!({ "description" : "Missing name" });
let json = serde_json::json!({
"description": "Missing name"
});
let result = AgentDefinition::from_json(&json);
assert!(result.is_err());
}
#[test]
fn test_from_json_ignores_unknown_fields() {
let json = serde_json::json!(
{ "name" : "test", "description" : "Test", "unknownField" : "value",
"futureFeature" : true }
);
let json = serde_json::json!({
"name": "test",
"description": "Test",
"unknownField": "value",
"futureFeature": true
});
let def = AgentDefinition::from_json(&json).unwrap();
assert_eq!(def.name, "test");
}
@ -2410,9 +2440,11 @@ description: Test default tool config
}
#[test]
fn test_model_override_in_json() {
let json = serde_json::json!(
{ "name" : "test", "description" : "Test", "model" : "grok-code-fast-1" }
);
let json = serde_json::json!({
"name": "test",
"description": "Test",
"model": "grok-code-fast-1"
});
let def = AgentDefinition::from_json(&json).unwrap();
assert_eq!(
def.model,
@ -2518,10 +2550,11 @@ description: Test default tool config
}
#[test]
fn mcp_inheritance_round_trips_via_json() {
let json = serde_json::json!(
{ "name" : "t", "description" : "t", "mcpInheritance" : { "named" : ["a",
"b"] } }
);
let json = serde_json::json!({
"name": "t",
"description": "t",
"mcpInheritance": {"named": ["a", "b"]}
});
let def = AgentDefinition::from_json(&json).unwrap();
assert_eq!(
def.mcp_inheritance,

View file

@ -235,18 +235,19 @@ impl PromptContext {
/// These are the agent-specific values that get merged with the
/// tool context in `TemplateRenderer::render_with_extra()`.
pub fn placeholders(&self) -> serde_json::Value {
serde_json::json!(
{ "memory_enabled" : self.memory_enabled, "memory_global_path" : self
.memory_global_path.as_deref().unwrap_or(""), "memory_workspace_path" : self
.memory_workspace_path.as_deref().unwrap_or(""), "role_instructions" : self
.role_instructions.as_deref().unwrap_or(""), "persona_instructions" : self
.persona_instructions.as_deref().unwrap_or(""), "os_name" : self.os_name
.as_deref().unwrap_or(""), "shell_path" : self.shell_path.as_deref()
.unwrap_or(""), "working_directory" : self.working_directory.as_deref()
.unwrap_or(""), "current_date" : self.current_date.as_deref().unwrap_or(""),
"is_non_interactive" : self.is_non_interactive, "system_prompt_label" : self
.system_prompt_label.as_str(), }
)
serde_json::json!({
"memory_enabled": self.memory_enabled,
"memory_global_path": self.memory_global_path.as_deref().unwrap_or(""),
"memory_workspace_path": self.memory_workspace_path.as_deref().unwrap_or(""),
"role_instructions": self.role_instructions.as_deref().unwrap_or(""),
"persona_instructions": self.persona_instructions.as_deref().unwrap_or(""),
"os_name": self.os_name.as_deref().unwrap_or(""),
"shell_path": self.shell_path.as_deref().unwrap_or(""),
"working_directory": self.working_directory.as_deref().unwrap_or(""),
"current_date": self.current_date.as_deref().unwrap_or(""),
"is_non_interactive": self.is_non_interactive,
"system_prompt_label": self.system_prompt_label.as_str(),
})
}
/// Render the full system prompt via `ToolBridge`.
///
@ -779,13 +780,24 @@ mod tests {
}
fn base_template_ctx() -> minijinja::Value {
minijinja::context! {
os_name => "linux", shell_path => "/bin/bash", working_directory =>
"/workspace", current_date => "2026-03-26", memory_enabled => true,
role_instructions => "", persona_instructions => "", tools =>
minijinja::context! { by_kind => minijinja::context! { read =>
"hashline_read", edit => "hashline_edit", search => "hashline_grep", execute
=> "run_terminal_cmd", background_task_action => "get_task_output",
memory_search => "memory_search", memory_get => "memory_get", } },
os_name => "linux",
shell_path => "/bin/bash",
working_directory => "/workspace",
current_date => "2026-03-26",
memory_enabled => true,
role_instructions => "",
persona_instructions => "",
tools => minijinja::context! {
by_kind => minijinja::context! {
read => "hashline_read",
edit => "hashline_edit",
search => "hashline_grep",
execute => "run_terminal_cmd",
background_task_action => "get_task_output",
memory_search => "memory_search",
memory_get => "memory_get",
}
},
}
}
#[test]
@ -842,13 +854,23 @@ mod tests {
#[test]
fn child_rendered_prompt_includes_role_and_persona_sections() {
let ctx = minijinja::context! {
os_name => "linux", shell_path => "/bin/bash", working_directory =>
"/workspace", current_date => "2026-03-26", memory_enabled => false,
role_instructions => "Follow Rust conventions", persona_instructions =>
"You are a code reviewer", tools => minijinja::context! { by_kind =>
minijinja::context! { read => "hashline_read", edit => "hashline_edit",
search => "hashline_grep", execute => "run_terminal_cmd",
background_task_action => "get_task_output", } },
os_name => "linux",
shell_path => "/bin/bash",
working_directory => "/workspace",
current_date => "2026-03-26",
memory_enabled => false,
role_instructions => "Follow Rust conventions",
persona_instructions => "You are a code reviewer",
tools => minijinja::context! {
by_kind => minijinja::context! {
read => "hashline_read",
edit => "hashline_edit",
search => "hashline_grep",
execute => "run_terminal_cmd",
background_task_action => "get_task_output",
}
},
};
let rendered = render_subagent_template(ctx);
assert!(rendered.contains("<role-instructions>"));
@ -899,11 +921,20 @@ mod tests {
#[test]
fn child_rendered_prompt_omits_background_tasks_without_execute() {
let ctx = minijinja::context! {
os_name => "linux", shell_path => "/bin/bash", working_directory =>
"/workspace", current_date => "2026-03-26", memory_enabled => false,
role_instructions => "", persona_instructions => "", tools =>
minijinja::context! { by_kind => minijinja::context! { read =>
"hashline_read", edit => "hashline_edit", search => "hashline_grep", } },
os_name => "linux",
shell_path => "/bin/bash",
working_directory => "/workspace",
current_date => "2026-03-26",
memory_enabled => false,
role_instructions => "",
persona_instructions => "",
tools => minijinja::context! {
by_kind => minijinja::context! {
read => "hashline_read",
edit => "hashline_edit",
search => "hashline_grep",
}
},
};
let rendered = render_subagent_template(ctx);
assert!(
@ -927,12 +958,22 @@ mod tests {
#[test]
fn child_rendered_prompt_omits_code_change_rules_without_edit_tools() {
let ctx = minijinja::context! {
os_name => "linux", shell_path => "/bin/bash", working_directory =>
"/workspace", current_date => "2026-03-26", memory_enabled => false,
role_instructions => "", persona_instructions => "", tools =>
minijinja::context! { by_kind => minijinja::context! { read =>
"hashline_read", search => "hashline_grep", execute => "run_terminal_cmd",
background_task_action => "get_task_output", } },
os_name => "linux",
shell_path => "/bin/bash",
working_directory => "/workspace",
current_date => "2026-03-26",
memory_enabled => false,
role_instructions => "",
persona_instructions => "",
tools => minijinja::context! {
by_kind => minijinja::context! {
read => "hashline_read",
search => "hashline_grep",
execute => "run_terminal_cmd",
background_task_action => "get_task_output",
}
},
};
let rendered = render_subagent_template(ctx);
assert!(
@ -955,12 +996,22 @@ mod tests {
#[test]
fn rendered_prompt_size_read_only() {
let ctx = minijinja::context! {
os_name => "linux", shell_path => "/bin/bash", working_directory =>
"/workspace", current_date => "2026-03-26", memory_enabled => false,
role_instructions => "", persona_instructions => "", tools =>
minijinja::context! { by_kind => minijinja::context! { read =>
"hashline_read", search => "hashline_grep", execute => "run_terminal_cmd",
background_task_action => "get_task_output", } },
os_name => "linux",
shell_path => "/bin/bash",
working_directory => "/workspace",
current_date => "2026-03-26",
memory_enabled => false,
role_instructions => "",
persona_instructions => "",
tools => minijinja::context! {
by_kind => minijinja::context! {
read => "hashline_read",
search => "hashline_grep",
execute => "run_terminal_cmd",
background_task_action => "get_task_output",
}
},
};
let rendered = render_subagent_template(ctx);
assert!(
@ -979,12 +1030,22 @@ mod tests {
#[test]
fn child_rendered_prompt_omits_edit_references_without_edit_tool() {
let ctx = minijinja::context! {
os_name => "linux", shell_path => "/bin/bash", working_directory =>
"/workspace", current_date => "2026-03-26", memory_enabled => false,
role_instructions => "", persona_instructions => "", tools =>
minijinja::context! { by_kind => minijinja::context! { read => "read_file",
search => "grep", execute => "run_terminal_cmd", background_task_action =>
"get_task_output", } },
os_name => "linux",
shell_path => "/bin/bash",
working_directory => "/workspace",
current_date => "2026-03-26",
memory_enabled => false,
role_instructions => "",
persona_instructions => "",
tools => minijinja::context! {
by_kind => minijinja::context! {
read => "read_file",
search => "grep",
execute => "run_terminal_cmd",
background_task_action => "get_task_output",
}
},
};
let rendered = render_subagent_template(ctx);
assert!(
@ -995,11 +1056,20 @@ mod tests {
#[test]
fn child_rendered_prompt_omits_execute_references_without_execute_tool() {
let ctx = minijinja::context! {
os_name => "linux", shell_path => "/bin/bash", working_directory =>
"/workspace", current_date => "2026-03-26", memory_enabled => false,
role_instructions => "", persona_instructions => "", tools =>
minijinja::context! { by_kind => minijinja::context! { read => "read_file",
edit => "search_replace", search => "grep", } },
os_name => "linux",
shell_path => "/bin/bash",
working_directory => "/workspace",
current_date => "2026-03-26",
memory_enabled => false,
role_instructions => "",
persona_instructions => "",
tools => minijinja::context! {
by_kind => minijinja::context! {
read => "read_file",
edit => "search_replace",
search => "grep",
}
},
};
let rendered = render_subagent_template(ctx);
assert!(
@ -1014,11 +1084,19 @@ mod tests {
#[test]
fn child_rendered_prompt_omits_both_edit_and_execute_references() {
let ctx = minijinja::context! {
os_name => "linux", shell_path => "/bin/bash", working_directory =>
"/workspace", current_date => "2026-03-26", memory_enabled => false,
role_instructions => "", persona_instructions => "", tools =>
minijinja::context! { by_kind => minijinja::context! { read => "read_file",
search => "grep", } },
os_name => "linux",
shell_path => "/bin/bash",
working_directory => "/workspace",
current_date => "2026-03-26",
memory_enabled => false,
role_instructions => "",
persona_instructions => "",
tools => minijinja::context! {
by_kind => minijinja::context! {
read => "read_file",
search => "grep",
}
},
};
let rendered = render_subagent_template(ctx);
assert!(

View file

@ -5,9 +5,9 @@
//! workspace overview, optional rules / skills / MCP listings).
//!
//! `UserMessageTemplate` selects the rendering strategy:
//! - `Default` -- the legacy Grok Build prefix (built by the shell layer).
//! - `Custom` -- caller-supplied template string (MiniJinja, same delimiters
//! as the system prompt templates).
//! - `Default`: the legacy Grok Build prefix (built by the shell layer).
//! - `Custom`: caller-supplied MiniJinja template string (same delimiters as
//! the system prompt templates).
//!
//! The shell layer gathers session-scoped inputs (cwd, vcs status, rule
//! files, skill registry, MCP servers) and hands them to
@ -63,10 +63,8 @@ pub fn normalize_git_status(status: &str) -> Option<String> {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum UserMessageTemplate {
/// Legacy Grok Build prefix: `<user_info>` + optional `<git_status>`.
/// Built directly by the shell layer; this
/// renderer returns `None` for `Default` and the caller falls back to
/// its own legacy path.
/// Legacy Grok Build prefix (`<user_info>` + optional `<git_status>`), built directly by the
/// shell layer; the renderer returns `None` and the caller uses its own legacy path.
#[default]
Default,
/// Caller-supplied MiniJinja template string.
@ -76,6 +74,15 @@ impl UserMessageTemplate {
pub fn is_cursor(&self) -> bool {
false
}
/// Whether this template surfaces the session's local date, scoping the date-rollover reminder.
/// A `Custom` template omitting [`TODAY_LOCAL_PLACEHOLDER`] is date-free. The substring check can
/// only over-keep the reminder, never wrongly suppress a dated session.
pub fn surfaces_local_date(&self) -> bool {
match self {
Self::Default => true,
Self::Custom(body) => body.contains(TODAY_LOCAL_PLACEHOLDER),
}
}
}
/// Backward-compatible deserialization: accepts both the new tagged format
/// (`"default"`, `{"custom": "..."}`) and a bare string (treated
@ -205,6 +212,9 @@ pub struct UserMessageContext {
/// Used in the skill section's instructional text. Defaults to `"Read"`.
pub read_tool_name: String,
}
/// MiniJinja variable a `Custom` template renders the local date under (pinned to the serialized
/// field by `placeholders_carry_today_local_key`).
pub const TODAY_LOCAL_PLACEHOLDER: &str = "today_local";
/// Typed placeholder bag handed to MiniJinja.
///
/// Field names here must match `${{ … }}` references in any caller-supplied
@ -318,6 +328,30 @@ mod tests {
assert_eq!(v, UserMessageTemplate::Custom("my custom".into()));
}
#[test]
fn placeholders_carry_today_local_key() {
let placeholders = UserMessagePlaceholders {
workspace_path: "/w".into(),
os_family: "macos",
shell: "zsh",
vcs_root: None,
vcs_status: None,
today_local: Some("Friday Apr 24, 2026".into()),
terminals_folder: None,
has_rules: false,
workspace_rules: &[],
user_rules: &[],
skill_listing: String::new(),
read_tool_name: "Read".into(),
mcp_servers: &[],
mcps_root: None,
};
let json = serde_json::to_value(&placeholders).unwrap();
assert!(
json.get(TODAY_LOCAL_PLACEHOLDER).is_some(),
"the local-date placeholder must serialize under TODAY_LOCAL_PLACEHOLDER"
);
}
#[test]
fn template_override_deserialize_custom_map() {
let v: UserMessageTemplate =
serde_json::from_str(r#"{"custom": "my template body"}"#).unwrap();