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

@ -246,6 +246,12 @@ impl crate::types::resources::ResourceType for BashParams {
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
/// Product default advertised in the model-facing schema (FG). Not applied as a
/// serde default: omit/`None` must remain "use host/FG policy, BG unbounded".
fn schema_default_timeout_ms() -> Option<u64> {
Some(120_000)
}
/// Input for the bash/terminal command tool.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct BashToolInput {
@ -258,11 +264,14 @@ pub struct BashToolInput {
/// the task runs until it exits or is killed via the kill task tool.
// keep in sync with the rustdoc above
#[schemars(
description = "Optional timeout in milliseconds (max 300000). Default: 120000 (2 minutes). `timeout: 0` in background mode disables the wrapper timeout entirely; the task runs until it exits or is killed via the kill task tool."
description = "Optional timeout in milliseconds (max 300000). Default: 120000 (2 minutes). `timeout: 0` in background mode disables the wrapper timeout entirely; the task runs until it exits or is killed via the kill task tool.",
default = "schema_default_timeout_ms"
)]
// Some models serialize numeric tool args
// as JSON strings (`"120000"`), which a plain `Option<u64>` rejects. Accept
// string-or-number here; the schema still advertises an integer.
// Serde default stays None so omit ≠ Some(120000): background omit must stay
// unbounded (see resolve_effective_timeout). Schema still advertises 120000.
#[serde(
default,
deserialize_with = "crate::types::schema::deserialize_lenient_u64",
@ -2276,6 +2285,34 @@ impl xai_tool_runtime::Tool for BashTool {
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bash_timeout_schema_defaults_to_120s() {
let schema = serde_json::to_value(schemars::schema_for!(BashToolInput)).unwrap();
let timeout = &schema["properties"]["timeout"];
assert_eq!(
timeout.get("default"),
Some(&serde_json::json!(120_000)),
"timeout schema should advertise default 120000, got {timeout}"
);
// Serde omit stays None so background without timeout remains unbounded.
let missing: BashToolInput =
serde_json::from_str(r#"{"command":"ls","description":"list"}"#).unwrap();
assert_eq!(missing.timeout, None);
let zero: BashToolInput =
serde_json::from_str(r#"{"command":"ls","description":"list","timeout":0}"#).unwrap();
assert_eq!(zero.timeout, Some(0));
// Explicit BG omit still resolves unbounded.
assert_eq!(
BashTool::resolve_effective_timeout(
missing.timeout,
true,
DEFAULT_TIMEOUT,
DEFAULT_MAX_TIMEOUT_MS,
),
std::time::Duration::MAX
);
}
use crate::computer::types::{
BackgroundHandle, ComputerError, KillOutcome, TaskSnapshot, TerminalBackend,
TerminalRunRequest, TerminalRunResult,
@ -2288,8 +2325,7 @@ mod tests {
/// Models occasionally serialize numeric tool args as JSON strings. The
/// `timeout` field must accept both `120000` and `"120000"`, stay `None`
/// when omitted or null. Regression for the `invalid type: string
/// "120000", expected u64` failure seen with some models.
/// when omitted or null (FG host policy / BG unbounded).
#[test]
fn timeout_accepts_string_or_integer() {
let from_int: BashToolInput =

View file

@ -93,16 +93,13 @@ pub struct GrepSearchInput {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context: Option<usize>,
#[schemars(
rename = "-i",
description = "Case insensitive search (rg -i). Defaults to false."
)]
#[schemars(rename = "-i", description = "Case insensitive search (rg -i).")]
#[serde(
rename = "-i",
default,
deserialize_with = "crate::types::schema::deserialize_lenient_option_bool"
deserialize_with = "crate::types::schema::deserialize_lenient_bool"
)]
pub case_insensitive: Option<bool>,
pub case_insensitive: bool,
#[schemars(
description = "File type to search (rg --type). Common types: js, py, rust, go, java, etc. More efficient than glob for standard file types."
@ -117,14 +114,13 @@ pub struct GrepSearchInput {
pub head_limit: Option<usize>,
#[schemars(
description = "Enable multiline mode where . matches newlines and patterns can span lines (rg -U --multiline-dotall). Default: false."
description = "Enable multiline mode where . matches newlines and patterns can span lines (rg -U --multiline-dotall)."
)]
#[serde(
default,
deserialize_with = "crate::types::schema::deserialize_lenient_option_bool",
skip_serializing_if = "Option::is_none"
deserialize_with = "crate::types::schema::deserialize_lenient_bool"
)]
pub multiline: Option<bool>,
pub multiline: bool,
}
// ───────────────────────────────────────────────────────────────────────────
@ -766,7 +762,7 @@ async fn prepare_grep(
.arg("1000")
.arg("--max-columns-preview");
if input.case_insensitive.unwrap_or(false) {
if input.case_insensitive {
cmd.arg("--ignore-case");
}
@ -792,7 +788,7 @@ async fn prepare_grep(
cmd.arg("--type").arg(t);
}
if input.multiline.unwrap_or(false) {
if input.multiline {
cmd.arg("-U").arg("--multiline-dotall");
}
@ -1483,13 +1479,55 @@ mod tests {
before_context: None,
after_context: None,
context: None,
case_insensitive: None,
case_insensitive: false,
r#type: None,
head_limit: None,
multiline: None,
multiline: false,
}
}
/// Boolean flags must be non-optional in the model-facing schema so the
/// default is unambiguous (`false`, not `null` + "Default: false" prose).
#[test]
fn grep_bool_flags_schema_is_plain_boolean_with_default_false() {
let schema = serde_json::to_value(schemars::schema_for!(GrepSearchInput)).unwrap();
let props = &schema["properties"];
// Field is renamed to "-i" for the model-facing name.
let case = &props["-i"];
assert_eq!(case["type"], "boolean", "case_insensitive schema: {case}");
assert_eq!(case["default"], false, "case_insensitive schema: {case}");
assert!(
case.get("anyOf").is_none(),
"must not use nullable anyOf: {case}"
);
let multi = &props["multiline"];
assert_eq!(multi["type"], "boolean", "multiline schema: {multi}");
assert_eq!(multi["default"], false, "multiline schema: {multi}");
assert!(
multi.get("anyOf").is_none(),
"must not use nullable anyOf: {multi}"
);
}
#[test]
fn grep_bool_flags_deserialize_missing_and_null_as_false() {
let missing: GrepSearchInput = serde_json::from_str(r#"{"pattern":"foo"}"#).unwrap();
assert!(!missing.case_insensitive);
assert!(!missing.multiline);
let nulls: GrepSearchInput =
serde_json::from_str(r#"{"pattern":"foo","-i":null,"multiline":null}"#).unwrap();
assert!(!nulls.case_insensitive);
assert!(!nulls.multiline);
let truths: GrepSearchInput =
serde_json::from_str(r#"{"pattern":"foo","-i":"yes","multiline":1}"#).unwrap();
assert!(truths.case_insensitive);
assert!(truths.multiline);
}
#[test]
fn grep_timeout_secs_platform_defaults() {
assert_eq!(grep_timeout_secs(false), 20);
@ -2049,10 +2087,10 @@ mod tests {
before_context: None,
after_context: None,
context: None,
case_insensitive: None,
case_insensitive: false,
r#type: None,
head_limit: None,
multiline: None,
multiline: false,
}
},
)
@ -2087,10 +2125,10 @@ mod tests {
before_context: None,
after_context: None,
context: None,
case_insensitive: None,
case_insensitive: false,
r#type: None,
head_limit: None,
multiline: None,
multiline: false,
}
},
)
@ -2123,10 +2161,10 @@ mod tests {
before_context: None,
after_context: None,
context: None,
case_insensitive: None,
case_insensitive: false,
r#type: None,
head_limit: None,
multiline: None,
multiline: false,
},
)
.await

View file

@ -29,6 +29,10 @@ pub const MAX_TIMEOUT_MS: u64 = 36_000_000; // 10 hours
/// Max result size for the tool_result response.
pub const MAX_RESULT_SIZE_CHARS: usize = 10_000;
fn default_timeout_ms() -> Option<u64> {
Some(DEFAULT_TIMEOUT_MS)
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
pub struct MonitorInput {
/// Shell command or script. Each stdout line is an event; exit ends the watch.
@ -45,9 +49,10 @@ pub struct MonitorInput {
/// Kill the monitor after this deadline (ms). Ignored when persistent is true.
/// Default: 36000000 (10 hr). Max: 36000000 (10 hr).
#[serde(default)]
#[serde(default = "default_timeout_ms")]
#[schemars(
description = "Kill the monitor after this deadline (ms). Default: 36000000 (10 hr)."
description = "Kill the monitor after this deadline (ms). Default: 36000000 (10 hr). Max: 36000000 (10 hr).",
default = "default_timeout_ms"
)]
pub timeout_ms: Option<u64>,
@ -55,12 +60,12 @@ pub struct MonitorInput {
/// Stop with kill_command_or_subagent.
#[serde(
default,
deserialize_with = "crate::types::schema::deserialize_lenient_option_bool"
deserialize_with = "crate::types::schema::deserialize_lenient_bool"
)]
#[schemars(
description = "Run for the lifetime of the session (no timeout).${%- if tools.by_kind.kill_task_action %} Stop with ${{ tools.by_kind.kill_task_action }}.${%- endif %}"
)]
pub persistent: Option<bool>,
pub persistent: bool,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
@ -85,7 +90,7 @@ pub enum MonitorError {
impl MonitorInput {
/// Validate input constraints.
pub fn validate(&self) -> Result<(), MonitorError> {
let persistent = self.persistent.unwrap_or(false);
let persistent = self.persistent;
if let Some(timeout) = self.timeout_ms
&& !persistent
&& timeout > MAX_TIMEOUT_MS
@ -97,7 +102,7 @@ impl MonitorInput {
/// Resolved timeout in milliseconds (0 for persistent / no-deadline monitors).
pub fn resolved_timeout_ms(&self) -> u64 {
if self.persistent.unwrap_or(false) {
if self.persistent {
0
} else {
self.timeout_ms.unwrap_or(DEFAULT_TIMEOUT_MS)
@ -115,7 +120,7 @@ mod tests {
command: "tail -f log".into(),
description: "watch log".into(),
timeout_ms: None,
persistent: None,
persistent: false,
};
assert_eq!(input.resolved_timeout_ms(), DEFAULT_TIMEOUT_MS);
assert!(input.validate().is_ok());
@ -128,7 +133,7 @@ mod tests {
command: "tail -f log".into(),
description: "watch log".into(),
timeout_ms: None,
persistent: Some(true),
persistent: true,
};
assert_eq!(input.resolved_timeout_ms(), 0);
assert!(input.validate().is_ok());
@ -140,7 +145,7 @@ mod tests {
command: "cmd".into(),
description: "desc".into(),
timeout_ms: Some(600_000),
persistent: None,
persistent: false,
};
assert_eq!(input.resolved_timeout_ms(), 600_000);
assert!(input.validate().is_ok());
@ -152,7 +157,7 @@ mod tests {
command: "cmd".into(),
description: "desc".into(),
timeout_ms: Some(MAX_TIMEOUT_MS + 1),
persistent: Some(false),
persistent: false,
};
assert!(input.validate().is_err());
}
@ -163,7 +168,7 @@ mod tests {
command: "cmd".into(),
description: "desc".into(),
timeout_ms: Some(MAX_TIMEOUT_MS + 1),
persistent: Some(true),
persistent: true,
};
assert!(input.validate().is_ok());
}

View file

@ -108,6 +108,10 @@ Usage:
- Results are returned with line numbers starting at 1. The format is: LINE_NUMBERLINE_CONTENT
- This tool can read PDF files (.pdf), PowerPoint files (.pptx), Jupyter notebooks (.ipynb files), and image files (e.g. PNG, JPG, etc).
- When reading an image file the contents are presented visually as this tool uses multimodal LLMs."#;
/// Schema-only advertised default (runtime still treats omit as line 1 via unwrap_or).
fn schema_default_offset() -> Option<i64> {
Some(1)
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
pub struct ReadFileInput {
#[serde(rename = "target_file")]
@ -122,6 +126,7 @@ pub struct ReadFileInput {
)]
#[schemars(
with = "GrokIntegerSchema",
default = "schema_default_offset",
description = "The line number to start reading from. Only provide if the file is too large to read at once."
)]
pub offset: Option<i64>,
@ -444,9 +449,10 @@ pub(crate) async fn run_read_file(
}
if crate::util::binary::is_binary(&extension, &file_bytes) {
tracing::info!(
path = % path.display(), extension = % extension, detected_by = if crate
::util::binary::BINARY_EXTENSIONS.binary_search(& extension.as_str()).is_ok()
{ "extension" } else { "content_inspection" },
path = %path.display(),
extension = %extension,
detected_by = if crate::util::binary::BINARY_EXTENSIONS
.binary_search(&extension.as_str()).is_ok() { "extension" } else { "content_inspection" },
"binary file rejected by read_file"
);
return Ok(ReadFileOutput::FileReadError(format!(
@ -625,26 +631,51 @@ impl xai_tool_runtime::Tool for ReadFileTool {
let Some(spec) = admitted_spec else {
let this = ReadFileTool;
return Box::pin(async_stream::stream! {
yield xai_tool_runtime::ToolStreamItem::Terminal(this.run(ctx, input)
. await);
yield xai_tool_runtime::ToolStreamItem::Terminal(this.run(ctx, input).await);
});
};
Box::pin(async_stream::stream! {
match ReadFileTool::read_with_streamability(& ctx, input). await {
Ok((output, streamable)) => { if streamable && let
ReadFileOutput::FileContent(fc) = & output && ! fc.content.is_empty() {
let content = fc.content.as_bytes(); let mut last_total : u64 = 0; let
mut window_start = 0usize; while window_start < content.len() { let mut
window_end = (window_start + STREAM_DELTA_TARGET_BYTES).min(content
.len()); while window_end > window_start && ! fc.content
.is_char_boundary(window_end) { window_end -= 1; }
if let Some(p) =
xai_tool_runtime::stream_chunk(spec, & content[..window_end], window_end
as u64, & mut last_total, false,) { yield
xai_tool_runtime::ToolStreamItem::Progress(p); } window_start =
window_end; } } yield
xai_tool_runtime::ToolStreamItem::Terminal(Ok(output)); } Err(e) => yield
xai_tool_runtime::ToolStreamItem::Terminal(Err(e)), }
// `streamable` is call-local to this read.
match ReadFileTool::read_with_streamability(&ctx, input).await {
Ok((output, streamable)) => {
if streamable
&& let ReadFileOutput::FileContent(fc) = &output
&& !fc.content.is_empty()
{
// Replay char-aligned slices of the final `content`
// (each below the 16 KiB cap; see
// STREAM_DELTA_TARGET_BYTES).
let content = fc.content.as_bytes();
let mut last_total: u64 = 0;
let mut window_start = 0usize;
while window_start < content.len() {
let mut window_end =
(window_start + STREAM_DELTA_TARGET_BYTES).min(content.len());
// Align DOWN to a char boundary (a char is ≤ 4
// bytes vs the 4 KiB target: never a zero-width
// window).
while window_end > window_start
&& !fc.content.is_char_boundary(window_end)
{
window_end -= 1;
}
if let Some(p) = xai_tool_runtime::stream_chunk(
spec,
&content[..window_end],
window_end as u64,
&mut last_total,
// Full replay, no streaming loss ⇒ never truncated.
false,
) {
yield xai_tool_runtime::ToolStreamItem::Progress(p);
}
window_start = window_end;
}
}
yield xai_tool_runtime::ToolStreamItem::Terminal(Ok(output));
}
Err(e) => yield xai_tool_runtime::ToolStreamItem::Terminal(Err(e)),
}
})
}
#[tracing::instrument(name = "tool.read_file", skip_all, fields(path = %input.path))]
@ -2385,12 +2416,17 @@ pub fn verify(req: &HttpRequest) -> Result<Claims, Error> {
}
}
#[test]
fn read_file_offset_description_unchanged() {
fn read_file_offset_schema_advertises_start_default() {
let src = include_str!("mod.rs");
assert!(
src
.contains("description = \"The line number to start reading from. Only provide if the file is too large to read at once.\""),
"offset schemars description must not change"
src.contains(
"description = \"The line number to start reading from. Only provide if the file is too large to read at once.\""
),
"offset schemars description must remain the pre-PR wording"
);
assert!(
src.contains("default = \"schema_default_offset\""),
"offset must advertise schema_default_offset"
);
}
#[test]

View file

@ -1,3 +1,4 @@
use std::collections::HashSet;
use std::time::Duration;
use chrono::Utc;
@ -31,6 +32,12 @@ enum LoopFireOutcome {
Skipped,
}
enum ExpiryPersistenceOutcome {
Committed,
NotCommitted(std::io::Error),
Unknown(SchedulerError),
}
pub(crate) struct PendingDurableRemoval {
task_id: String,
reservation: super::types::SchedulerReservation,
@ -97,6 +104,7 @@ pub struct SchedulerActor {
pub(crate) cancel_token: CancellationToken,
pub(crate) clock: SchedulerClock,
pub(crate) pending_removal: Option<PendingDurableRemoval>,
pub(crate) blocked_expiries: HashSet<String>,
}
impl SchedulerActor {
@ -141,11 +149,18 @@ impl SchedulerActor {
async fn complete_pending_removal(&mut self) -> Result<bool, SchedulerError> {
if self.notification_handle.durable_targets() == DurableNotificationTargets::None {
// Immutable targets cannot recover; abandon only the uncommitted reservation.
self.pending_removal = None;
// Targets are immutable, so retaining the reservation would wedge all later commands.
let task_id = self
.pending_removal
.take()
.expect("pending durable removal exists")
.task_id;
tracing::error!(%task_id, "Durable scheduler removal unavailable");
return Err(SchedulerError::NoDurableNotificationConsumer);
}
self.persist_resources().await?;
let (task_id, version) = {
let pending = self
.pending_removal
@ -188,13 +203,11 @@ impl SchedulerActor {
}
}
let task_ids = {
let mut res = self.resources.lock().await;
res.get_or_default::<State<SchedulerState>>()
.tasks
.drain(..)
.map(|task| task.id)
.collect::<Vec<_>>()
let task_ids: Vec<String> = {
let res = self.resources.lock().await;
res.get::<State<SchedulerState>>()
.map(|state| state.tasks.iter().map(|task| task.id.clone()).collect())
.unwrap_or_default()
};
if task_ids.is_empty() {
return;
@ -246,7 +259,8 @@ impl SchedulerActor {
.map(|s| {
s.tasks
.iter()
.map(|t| t.next_fire_at())
.filter(|task| !self.blocked_expiries.contains(&task.id))
.map(ScheduledTask::next_fire_at)
.min()
.map(|next| {
let now = Utc::now();
@ -266,7 +280,9 @@ impl SchedulerActor {
let now = Utc::now();
let mut res = self.resources.lock().await;
let state = res.get_or_default::<State<SchedulerState>>();
let idx = state.tasks.iter().position(|t| t.next_fire_at() <= now);
let idx = state.tasks.iter().position(|task| {
task.next_fire_at() <= now && !self.blocked_expiries.contains(&task.id)
});
let Some(idx) = idx else {
return;
@ -278,6 +294,7 @@ impl SchedulerActor {
let should_remove = !task.recurring;
let prompt = task.prompt.clone();
let human_schedule = interval_to_human(task.interval_secs);
let is_durable = task.durable;
let foreground = task.foreground;
let last_subagent_id = task.last_subagent_id.clone();
let iterations_since_fresh = task.iterations_since_fresh;
@ -290,6 +307,89 @@ impl SchedulerActor {
1
};
let transition = if is_expired { "expiry" } else { "fire" };
if is_expired && is_durable {
if self.notification_handle.durable_targets() == DurableNotificationTargets::None {
tracing::error!(%task_id, "Durable scheduler expiry unavailable");
self.blocked_expiries.insert(task_id);
return;
}
let mut reservation = self.clock.prepare_transition(1);
let expired_task = state.tasks.remove(idx);
let acknowledgement = self
.resources_persistence
.enqueue_save_and_flush(res.serialize());
drop(res);
tracing::info!(task_id = %task_id, "Scheduled task expired; removing without firing");
let persistence = match acknowledgement {
Ok(acknowledgement) => {
let deadline = tokio::time::Instant::now() + DURABILITY_BARRIER_TIMEOUT;
tokio::select! {
_ = self.cancel_token.cancelled() => {
ExpiryPersistenceOutcome::Unknown(SchedulerError::Cancelled)
}
result = tokio::time::timeout_at(deadline, acknowledgement) => {
match result {
Ok(Ok(Ok(()))) => ExpiryPersistenceOutcome::Committed,
Ok(Ok(Err(error))) => {
ExpiryPersistenceOutcome::NotCommitted(error)
}
Ok(Err(_)) => ExpiryPersistenceOutcome::Unknown(
SchedulerError::Persistence(std::io::Error::new(
std::io::ErrorKind::BrokenPipe,
"resources persistence writer dropped acknowledgement",
)),
),
Err(_) => {
ExpiryPersistenceOutcome::Unknown(SchedulerError::Timeout)
}
}
}
}
}
Err(error) => ExpiryPersistenceOutcome::NotCommitted(error),
};
match persistence {
ExpiryPersistenceOutcome::Committed => {}
ExpiryPersistenceOutcome::NotCommitted(error) => {
tracing::warn!(
%task_id,
%error,
"Durable scheduler expiry was not persisted"
);
let mut resources = self.resources.lock().await;
resources
.get_or_default::<State<SchedulerState>>()
.tasks
.insert(idx, expired_task);
self.blocked_expiries.insert(task_id);
return;
}
ExpiryPersistenceOutcome::Unknown(error) => {
tracing::warn!(
%task_id,
%error,
"Durable scheduler expiry persistence outcome is unknown"
);
return;
}
}
let version = reservation.version_at(0);
if let Err(error) = self.publish_durable_removal(task_id.clone(), version).await {
tracing::warn!(
%task_id,
%error,
"Failed to acknowledge durable scheduler expiry"
);
return;
}
let commit = reservation.commit_next(&mut self.clock);
log_rollover(transition, Some(&task_id), commit.rollover);
return;
}
let mut reservation = self.clock.prepare_transition(transition_count);
if is_expired {
@ -825,6 +925,7 @@ mod tests {
cancel_token: cancel_token.clone(),
clock: SchedulerClock::new(),
pending_removal: None,
blocked_expiries: HashSet::new(),
}
.run(),
);
@ -852,6 +953,22 @@ mod tests {
.expect("notification channel closed")
}
fn expired_task(id: &str, durable: bool) -> ScheduledTask {
let mut task = ScheduledTask::new(1, id.into(), true, durable);
task.id = id.into();
task.created_at = Utc::now() - chrono::Duration::seconds(10);
task.expires_at = Some(Utc::now() - chrono::Duration::seconds(1));
task.foreground = true;
task
}
fn due_one_shot(id: &str) -> ScheduledTask {
let mut task = ScheduledTask::new(1, id.into(), false, false);
task.id = id.into();
task.created_at = Utc::now() - chrono::Duration::seconds(10);
task
}
fn auto_acknowledged_notifications() -> (
ToolNotificationHandle,
mpsc::UnboundedReceiver<ToolNotification>,
@ -889,6 +1006,7 @@ mod tests {
cancel_token: CancellationToken::new(),
clock: SchedulerClock::at_revision_for_test(revision),
pending_removal: None,
blocked_expiries: HashSet::new(),
},
notifications,
)
@ -915,6 +1033,7 @@ mod tests {
cancel_token: cancel_token.clone(),
clock: SchedulerClock::new(),
pending_removal: None,
blocked_expiries: HashSet::new(),
};
tokio::spawn(actor.run());
@ -1232,6 +1351,7 @@ mod tests {
cancel_token: cancel_token.clone(),
clock: SchedulerClock::new(),
pending_removal: None,
blocked_expiries: HashSet::new(),
};
tokio::spawn(actor.run());
@ -1318,6 +1438,7 @@ mod tests {
cancel_token: cancel_token.clone(),
clock: SchedulerClock::new(),
pending_removal: None,
blocked_expiries: HashSet::new(),
};
tokio::spawn(actor.run());
@ -1523,7 +1644,7 @@ mod tests {
}
#[tokio::test]
async fn cancel_sends_removed_for_remaining_tasks_and_drains_state() {
async fn cancel_sends_removed_for_remaining_tasks_without_draining_state() {
let mut resources = Resources::new();
resources.register_state::<SchedulerState>();
@ -1548,6 +1669,7 @@ mod tests {
cancel_token: cancel_token.clone(),
clock: SchedulerClock::new(),
pending_removal: None,
blocked_expiries: HashSet::new(),
};
let handle = tokio::spawn(actor.run());
@ -1568,14 +1690,15 @@ mod tests {
}
removed_ids.sort();
assert_eq!(removed_ids, vec!["cancel-A", "cancel-B"]);
assert!(
assert_eq!(
shared
.lock()
.await
.get::<State<SchedulerState>>()
.unwrap()
.tasks
.is_empty()
.len(),
2
);
}
@ -1611,6 +1734,7 @@ mod tests {
cancel_token: cancel_token.clone(),
clock: SchedulerClock::at_revision_for_test(revision),
pending_removal: None,
blocked_expiries: HashSet::new(),
};
tokio::spawn(actor.run());
@ -2091,6 +2215,7 @@ mod tests {
cancel_token: cancel_token.clone(),
clock: SchedulerClock::new(),
pending_removal: None,
blocked_expiries: HashSet::new(),
}
.run(),
);
@ -2433,6 +2558,233 @@ mod tests {
);
}
#[tokio::test]
async fn durable_expiry_persists_before_ack_and_commits_version() {
let (persistence, mut saves) = crate::persistence::ResourcesPersistence::controlled();
let mut actor = make_boundary_actor(vec![expired_task("expired", true)], 0).0;
actor.resources_persistence = Arc::new(persistence);
let (notification_handle, mut notifications) =
ToolNotificationHandle::acknowledged_channel();
actor.notification_handle = notification_handle;
{
let expiry = actor.fire_next_task();
tokio::pin!(expiry);
let (snapshot, persisted) = tokio::select! {
_ = expiry.as_mut() => panic!("expiry must wait for resource persistence"),
save = next_event(&mut saves) => save,
};
assert_eq!(
snapshot["state"]["grok_build.Scheduler"]["tasks"],
serde_json::json!([])
);
assert!(notifications.try_recv().is_err());
persisted.send(Ok(())).unwrap();
let delivery = tokio::select! {
_ = expiry.as_mut() => panic!("expiry must wait for tombstone acknowledgement"),
delivery = next_acknowledged(&mut notifications) => delivery,
};
let removed = notification!(delivery.notification, ScheduledTaskRemoved);
assert_eq!(removed.revision, 1);
delivery.acknowledgement.unwrap().send(Ok(())).unwrap();
tokio::time::timeout(Duration::from_secs(1), expiry.as_mut())
.await
.unwrap();
}
assert_eq!(actor.clock.snapshot().revision(), 1);
assert!(
actor
.resources
.lock()
.await
.get::<State<SchedulerState>>()
.unwrap()
.tasks
.is_empty()
);
}
#[tokio::test]
async fn expiry_persistence_failure_restores_and_blocks_while_plain_task_fires() {
let (persistence, mut saves) = crate::persistence::ResourcesPersistence::controlled();
let tasks = vec![expired_task("expired", true), due_one_shot("plain")];
let (mut actor, mut notifications) = make_boundary_actor(tasks, 0);
actor.resources_persistence = Arc::new(persistence);
{
let expiry = actor.fire_next_task();
tokio::pin!(expiry);
let (_, persisted) = tokio::select! {
_ = expiry.as_mut() => panic!("expiry must wait for resource persistence"),
save = next_event(&mut saves) => save,
};
persisted
.send(Err(std::io::Error::other("disk unavailable")))
.unwrap();
tokio::time::timeout(Duration::from_secs(1), expiry.as_mut())
.await
.unwrap();
}
assert!(actor.blocked_expiries.contains("expired"));
assert_eq!(actor.clock.snapshot().revision(), 0);
assert!(notifications.try_recv().is_err());
actor.fire_next_task().await;
assert_eq!(
(
notification!(notifications.try_recv().unwrap(), ScheduledTaskFired).task_id,
notification!(notifications.try_recv().unwrap(), ScheduledTaskRemoved).task_id,
),
("plain".to_string(), "plain".to_string())
);
actor.fire_next_task().await;
assert!(saves.try_recv().is_err());
assert!(notifications.try_recv().is_err());
}
#[tokio::test]
async fn expiry_without_durable_target_blocks_only_expiry() {
let tasks = vec![expired_task("expired", true), due_one_shot("plain")];
let (mut actor, _) = make_boundary_actor(tasks, 0);
let (notification_handle, mut notifications) = ToolNotificationHandle::channel();
actor.notification_handle = notification_handle;
actor.fire_next_task().await;
assert!(actor.blocked_expiries.contains("expired"));
assert!(notifications.try_recv().is_err());
actor.fire_next_task().await;
assert_eq!(
(
notification!(notifications.try_recv().unwrap(), ScheduledTaskFired).task_id,
notification!(notifications.try_recv().unwrap(), ScheduledTaskRemoved).task_id,
),
("plain".to_string(), "plain".to_string())
);
actor.fire_next_task().await;
assert!(notifications.try_recv().is_err());
}
#[tokio::test]
async fn expiry_ack_failure_leaves_absent_and_continues_without_version_commit() {
let tasks = vec![expired_task("expired", true), due_one_shot("plain")];
let mut actor = make_boundary_actor(tasks, 0).0;
let dir = tempfile::tempdir().unwrap();
let state_path = dir.path().join("resources_state.json");
actor.resources_persistence = Arc::new(crate::persistence::ResourcesPersistence::new(
state_path.clone(),
));
let (notification_handle, mut notifications) =
ToolNotificationHandle::acknowledged_channel();
actor.notification_handle = notification_handle;
let removed_revision = {
let expiry = actor.fire_next_task();
tokio::pin!(expiry);
let delivery = tokio::select! {
_ = expiry.as_mut() => panic!("expiry must wait for tombstone acknowledgement"),
delivery = next_acknowledged(&mut notifications) => delivery,
};
let removed = notification!(delivery.notification, ScheduledTaskRemoved);
let removed_revision = removed.revision;
delivery
.acknowledgement
.unwrap()
.send(Err("append failed".into()))
.unwrap();
tokio::time::timeout(Duration::from_secs(1), expiry.as_mut())
.await
.unwrap();
removed_revision
};
assert_eq!(actor.clock.snapshot().revision(), 0);
assert!(
actor
.resources
.lock()
.await
.get::<State<SchedulerState>>()
.unwrap()
.tasks
.iter()
.all(|task| task.id != "expired")
);
let persisted: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(state_path).unwrap()).unwrap();
assert!(
persisted["state"]["grok_build.Scheduler"]["tasks"]
.as_array()
.unwrap()
.iter()
.all(|task| task["id"] != "expired")
);
actor.fire_next_task().await;
assert_eq!(
notification!(
next_acknowledged(&mut notifications).await.notification,
ScheduledTaskFired
)
.task_id,
"plain"
);
let plain_removed = next_acknowledged(&mut notifications).await;
assert!(plain_removed.acknowledgement.is_none());
assert_eq!(
notification!(plain_removed.notification, ScheduledTaskRemoved).revision,
removed_revision + 1
);
actor.fire_next_task().await;
assert!(notifications.try_recv().is_err());
}
#[tokio::test]
async fn expiry_persistence_cancellation_leaves_absent_and_stops_actor() {
let (persistence, mut saves) = crate::persistence::ResourcesPersistence::controlled();
let mut resources = Resources::new();
resources.register_state::<SchedulerState>();
resources.get_or_default::<State<SchedulerState>>().tasks =
vec![expired_task("expired", true)];
let shared = Arc::new(Mutex::new(resources));
let (notification_handle, mut notifications) =
ToolNotificationHandle::acknowledged_channel();
let (_cmd_tx, cmd_rx) = mpsc::unbounded_channel();
let cancel_token = CancellationToken::new();
let actor = SchedulerActor {
resources: shared.clone(),
resources_persistence: Arc::new(persistence),
notification_handle,
cmd_rx,
cancel_token: cancel_token.clone(),
clock: SchedulerClock::new(),
pending_removal: None,
blocked_expiries: HashSet::new(),
};
let actor_task = tokio::spawn(actor.run());
let (_, _withheld) = next_event(&mut saves).await;
let announced = next_acknowledged(&mut notifications).await;
assert!(announced.acknowledgement.is_none());
assert!(matches!(
announced.notification,
ToolNotification::ScheduledTaskCreated(_)
));
assert!(!actor_task.is_finished());
cancel_token.cancel();
tokio::time::timeout(Duration::from_secs(1), actor_task)
.await
.unwrap()
.unwrap();
assert!(
shared
.lock()
.await
.get::<State<SchedulerState>>()
.unwrap()
.tasks
.is_empty()
);
assert!(notifications.try_recv().is_err());
}
#[tokio::test]
async fn cancel_with_no_tasks_sends_no_removed() {
let mut resources = Resources::new();
@ -2451,6 +2803,7 @@ mod tests {
cancel_token: cancel_token.clone(),
clock: SchedulerClock::new(),
pending_removal: None,
blocked_expiries: HashSet::new(),
};
let handle = tokio::spawn(actor.run());

View file

@ -302,6 +302,7 @@ mod tests {
cancel_token: cancel_token.clone(),
clock: Default::default(),
pending_removal: None,
blocked_expiries: Default::default(),
};
tokio::spawn(actor.run());
(shared, cancel_token)

View file

@ -3,4 +3,5 @@ pub mod create;
pub mod delete;
pub mod interval;
pub mod list;
pub(crate) mod occurrence_journal;
pub mod types;

View file

@ -0,0 +1,566 @@
//! Persisted one-shot removal receipts and restart reconciliation.
//!
//! A receipt records task absence and exact fire/removal versions in one JSON resources
//! snapshot. Recovery is a pure plan: it reports removals requiring persistence and
//! timer suppression while all state mutation/publication remains in the actor layer.
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use super::types::{ScheduledTask, SchedulerState, SchedulerVersion};
pub(super) const MAX_PENDING_ONE_SHOTS: usize = 50;
const MAX_QUARANTINED_TASK_IDS: usize = 50;
const MAX_TASK_ID_BYTES: usize = 256;
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
#[serde(transparent)]
pub(crate) struct ScheduledOccurrenceId(uuid::Uuid);
impl ScheduledOccurrenceId {
fn new() -> Self {
Self(uuid::Uuid::now_v7())
}
}
impl<'de> Deserialize<'de> for ScheduledOccurrenceId {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let id = uuid::Uuid::deserialize(deserializer)?;
if id.get_version() != Some(uuid::Version::SortRand)
|| id.get_variant() != uuid::Variant::RFC4122
{
return Err(serde::de::Error::custom(
"scheduled occurrence identity must be an RFC UUIDv7",
));
}
Ok(Self(id))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ScheduledOccurrenceVersions {
fire: SchedulerVersion,
removal: SchedulerVersion,
}
impl ScheduledOccurrenceVersions {
pub(super) fn try_new(
fire: SchedulerVersion,
removal: SchedulerVersion,
) -> Result<Self, OccurrenceJournalError> {
let generation = fire.generation_id();
if generation.get_version() != Some(uuid::Version::SortRand)
|| generation.get_variant() != uuid::Variant::RFC4122
|| fire.revision() == 0
|| removal.generation_id() != generation
|| fire
.revision()
.checked_add(1)
.is_none_or(|revision| removal.revision() != revision)
{
return Err(OccurrenceJournalError::InvalidVersions);
}
Ok(Self { fire, removal })
}
pub(super) fn fire(self) -> SchedulerVersion {
self.fire
}
pub(super) fn removal(self) -> SchedulerVersion {
self.removal
}
fn contains(self, version: SchedulerVersion) -> bool {
self.fire == version || self.removal == version
}
}
impl<'de> Deserialize<'de> for ScheduledOccurrenceVersions {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct PersistedVersions {
fire: SchedulerVersion,
removal: SchedulerVersion,
}
let persisted = PersistedVersions::deserialize(deserializer)?;
Self::try_new(persisted.fire, persisted.removal).map_err(serde::de::Error::custom)
}
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct OneShotOccurrence {
occurrence_id: ScheduledOccurrenceId,
task: ScheduledTask,
versions: ScheduledOccurrenceVersions,
}
impl<'de> Deserialize<'de> for OneShotOccurrence {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct PersistedOccurrence {
occurrence_id: ScheduledOccurrenceId,
task: ScheduledTask,
versions: ScheduledOccurrenceVersions,
}
let persisted = PersistedOccurrence::deserialize(deserializer)?;
if persisted.task.recurring || !persisted.task.durable {
return Err(serde::de::Error::custom(
OccurrenceJournalError::NotDurableOneShot(persisted.task.id),
));
}
Ok(Self {
occurrence_id: persisted.occurrence_id,
task: persisted.task,
versions: persisted.versions,
})
}
}
#[derive(Debug, Clone, Default, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct OccurrenceJournal {
entries: Vec<OneShotOccurrence>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
quarantined_task_ids: Vec<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
block_all_one_shots: bool,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
overflowed: bool,
}
impl OccurrenceJournal {
pub(super) fn is_empty(&self) -> bool {
self.entries.is_empty()
&& self.quarantined_task_ids.is_empty()
&& !self.block_all_one_shots
&& !self.overflowed
}
#[cfg_attr(
not(test),
expect(dead_code, reason = "wired by durable one-shot actor layer")
)]
pub(super) fn quarantine_diagnostics(&self) -> (&[String], bool, bool) {
(
&self.quarantined_task_ids,
self.block_all_one_shots,
self.overflowed,
)
}
}
/// JSON-only because Resources persistence stores this state as `serde_json::Value`.
impl<'de> Deserialize<'de> for OccurrenceJournal {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = serde_json::Value::deserialize(deserializer)?;
Ok(Self::decode_json(value))
}
}
impl OccurrenceJournal {
fn decode_json(value: serde_json::Value) -> Self {
let (entries, task_ids, block_all, overflowed, is_malformed) = match value {
serde_json::Value::Array(entries) => (entries, Vec::new(), false, false, false),
serde_json::Value::Object(mut object) => {
let (entries, bad_entries) = parse_json_array(object.remove("entries"));
let (task_values, bad_task_ids) =
parse_json_array(object.remove("quarantinedTaskIds"));
let bad_task_element = task_values.iter().any(|value| !value.is_string());
let task_ids: Vec<String> = task_values
.into_iter()
.filter_map(|value| value.as_str().map(str::to_owned))
.collect();
let (block_all, bad_block) = parse_json_bool(object.remove("blockAllOneShots"));
let (overflowed, bad_overflow) = parse_json_bool(object.remove("overflowed"));
(
entries,
task_ids,
block_all,
overflowed,
bad_entries || bad_task_ids || bad_task_element || bad_block || bad_overflow,
)
}
_ => (Vec::new(), Vec::new(), true, false, true),
};
let mut journal = Self {
block_all_one_shots: block_all || overflowed || is_malformed,
overflowed,
..Self::default()
};
for task_id in task_ids {
journal.quarantine_task_id(task_id);
}
if entries.len() > MAX_PENDING_ONE_SHOTS {
journal.block_all_one_shots = true;
journal.overflowed = true;
}
for value in entries.into_iter().take(MAX_PENDING_ONE_SHOTS) {
match serde_json::from_value(value.clone()) {
Ok(occurrence) => journal.entries.push(occurrence),
Err(_) => match quarantined_task_id(&value) {
Some(task_id) => journal.quarantine_task_id(task_id),
None => journal.block_all_one_shots = true,
},
}
}
journal
}
fn quarantine_task_id(&mut self, task_id: String) {
if task_id.is_empty() || task_id.len() > MAX_TASK_ID_BYTES {
self.block_all_one_shots = true;
} else if !self.quarantined_task_ids.contains(&task_id) {
if self.quarantined_task_ids.len() == MAX_QUARANTINED_TASK_IDS {
self.block_all_one_shots = true;
} else {
self.quarantined_task_ids.push(task_id);
}
}
}
}
fn parse_json_array(value: Option<serde_json::Value>) -> (Vec<serde_json::Value>, bool) {
value.map_or((Vec::new(), false), |value| match value {
serde_json::Value::Array(values) => (values, false),
_ => (Vec::new(), true),
})
}
fn parse_json_bool(value: Option<serde_json::Value>) -> (bool, bool) {
value.map_or((false, false), |value| match value {
serde_json::Value::Bool(value) => (value, false),
_ => (true, true),
})
}
fn quarantined_task_id(value: &serde_json::Value) -> Option<String> {
value.get("task")?.get("id")?.as_str().map(str::to_owned)
}
#[derive(thiserror::Error, Debug, PartialEq, Eq)]
pub(crate) enum OccurrenceJournalError {
#[error("scheduled task {0} was not found")]
TaskNotFound(String),
#[error("scheduled task {0} is not a durable one-shot")]
NotDurableOneShot(String),
#[error("scheduled task {0} already has a pending occurrence")]
TaskAlreadyJournaled(String),
#[error("maximum of {MAX_PENDING_ONE_SHOTS} pending one-shot occurrences reached")]
JournalFull,
#[error("one-shot fire/removal versions must be nonzero consecutive RFC UUIDv7 transitions")]
InvalidVersions,
#[error("scheduler transition version is already journaled")]
DuplicateTransitionVersion,
#[error("one-shot journal requires manual recovery before new occurrences can be prepared")]
RecoveryRequired,
#[error("scheduled occurrence was not found")]
OccurrenceNotFound,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum OneShotJournalConflict {
OccurrenceId,
TaskId,
TransitionVersion,
}
#[must_use = "loaded one-shot receipts must suppress timers and reconcile resources"]
pub(crate) struct SchedulerLoadReconciliation {
requires_resources_persistence: bool,
task_ids_to_remove: Vec<String>,
blocked_task_ids: HashSet<String>,
block_all_one_shots: bool,
recovery_required: bool,
conflicts: Vec<OneShotJournalConflict>,
overflow_error: Option<OccurrenceJournalError>,
}
impl SchedulerLoadReconciliation {
#[cfg_attr(
not(test),
expect(dead_code, reason = "wired by durable one-shot actor layer")
)]
pub(super) fn requires_resources_persistence(&self) -> bool {
self.requires_resources_persistence
}
#[cfg_attr(
not(test),
expect(dead_code, reason = "wired by durable one-shot actor layer")
)]
pub(super) fn task_ids_to_remove(&self) -> &[String] {
&self.task_ids_to_remove
}
#[cfg_attr(
not(test),
expect(dead_code, reason = "wired by durable one-shot actor layer")
)]
pub(super) fn blocked_task_ids(&self) -> &HashSet<String> {
&self.blocked_task_ids
}
#[cfg_attr(
not(test),
expect(dead_code, reason = "wired by durable one-shot actor layer")
)]
pub(super) fn block_all_one_shots(&self) -> bool {
self.block_all_one_shots
}
#[cfg_attr(
not(test),
expect(dead_code, reason = "wired by durable one-shot actor layer")
)]
pub(super) fn recovery_required(&self) -> bool {
self.recovery_required
}
#[cfg_attr(
not(test),
expect(dead_code, reason = "wired by durable one-shot actor layer")
)]
pub(super) fn conflicts(&self) -> &[OneShotJournalConflict] {
&self.conflicts
}
#[cfg_attr(
not(test),
expect(dead_code, reason = "wired by durable one-shot actor layer")
)]
pub(super) fn overflow_error(&self) -> Option<&OccurrenceJournalError> {
self.overflow_error.as_ref()
}
}
impl SchedulerState {
#[cfg_attr(
not(test),
expect(dead_code, reason = "wired by durable one-shot actor layer")
)]
pub(super) fn prepare_one_shot_occurrence(
&mut self,
task_id: &str,
versions: ScheduledOccurrenceVersions,
) -> Result<OneShotOccurrence, OccurrenceJournalError> {
self.prepare_one_shot_occurrence_with_id(ScheduledOccurrenceId::new(), task_id, versions)
}
fn prepare_one_shot_occurrence_with_id(
&mut self,
occurrence_id: ScheduledOccurrenceId,
task_id: &str,
versions: ScheduledOccurrenceVersions,
) -> Result<OneShotOccurrence, OccurrenceJournalError> {
if !self.occurrence_journal.quarantined_task_ids.is_empty()
|| self.occurrence_journal.block_all_one_shots
|| self.occurrence_journal.overflowed
|| has_conflict(&self.occurrence_journal.entries)
{
return Err(OccurrenceJournalError::RecoveryRequired);
}
if self.occurrence_journal.entries.len() >= MAX_PENDING_ONE_SHOTS {
return Err(OccurrenceJournalError::JournalFull);
}
if self
.occurrence_journal
.entries
.iter()
.any(|occurrence| occurrence.task.id == task_id)
{
return Err(OccurrenceJournalError::TaskAlreadyJournaled(
task_id.to_owned(),
));
}
if self.occurrence_journal.entries.iter().any(|occurrence| {
occurrence.versions.contains(versions.fire())
|| occurrence.versions.contains(versions.removal())
}) {
return Err(OccurrenceJournalError::DuplicateTransitionVersion);
}
let index = self
.tasks
.iter()
.position(|task| task.id == task_id)
.ok_or_else(|| OccurrenceJournalError::TaskNotFound(task_id.to_owned()))?;
if self.tasks[index].recurring || !self.tasks[index].durable {
return Err(OccurrenceJournalError::NotDurableOneShot(
task_id.to_owned(),
));
}
let occurrence = OneShotOccurrence {
occurrence_id,
task: self.tasks.remove(index),
versions,
};
self.occurrence_journal.entries.push(occurrence.clone());
Ok(occurrence)
}
#[must_use = "the exact removal receipt must be durably cleared"]
#[cfg_attr(
not(test),
expect(dead_code, reason = "wired by durable one-shot actor layer")
)]
pub(super) fn finish_one_shot_removal(
&mut self,
occurrence_id: &ScheduledOccurrenceId,
) -> Result<OneShotOccurrence, OccurrenceJournalError> {
let index = self
.occurrence_journal
.entries
.iter()
.position(|occurrence| occurrence.occurrence_id == *occurrence_id)
.ok_or(OccurrenceJournalError::OccurrenceNotFound)?;
Ok(self.occurrence_journal.entries.remove(index))
}
#[cfg_attr(
not(test),
expect(dead_code, reason = "wired by durable one-shot actor layer")
)]
pub(super) fn reconcile_one_shot_occurrences(&self) -> SchedulerLoadReconciliation {
let occurrence_counts = count_by(self.occurrence_journal.entries.iter(), |entry| {
entry.occurrence_id.clone()
});
let task_counts = count_by(self.occurrence_journal.entries.iter(), |entry| {
entry.task.id.clone()
});
let mut version_counts = HashMap::new();
for occurrence in &self.occurrence_journal.entries {
for version in [occurrence.versions.fire(), occurrence.versions.removal()] {
*version_counts.entry(version).or_insert(0usize) += 1;
}
}
let conflict_for = |occurrence: &OneShotOccurrence| {
if occurrence_counts[&occurrence.occurrence_id] > 1 {
Some(OneShotJournalConflict::OccurrenceId)
} else if task_counts[&occurrence.task.id] > 1 {
Some(OneShotJournalConflict::TaskId)
} else if version_counts[&occurrence.versions.fire()] > 1
|| version_counts[&occurrence.versions.removal()] > 1
{
Some(OneShotJournalConflict::TransitionVersion)
} else {
None
}
};
let mut blocked_task_ids: HashSet<String> = self
.occurrence_journal
.quarantined_task_ids
.iter()
.cloned()
.collect();
let block_all_one_shots = self.occurrence_journal.block_all_one_shots;
let overflowed = self.occurrence_journal.overflowed;
let conflicts: Vec<_> = self
.occurrence_journal
.entries
.iter()
.filter_map(conflict_for)
.collect();
let recovery_required = block_all_one_shots
|| overflowed
|| !self.occurrence_journal.quarantined_task_ids.is_empty()
|| !conflicts.is_empty();
blocked_task_ids.extend(
self.occurrence_journal
.entries
.iter()
.map(|occurrence| occurrence.task.id.clone()),
);
if block_all_one_shots {
blocked_task_ids.extend(
self.tasks
.iter()
.filter(|task| !task.recurring)
.map(|task| task.id.clone()),
);
}
let task_ids_to_remove: Vec<String> = if recovery_required {
Vec::new()
} else {
let journaled: HashSet<&str> = self
.occurrence_journal
.entries
.iter()
.map(|occurrence| occurrence.task.id.as_str())
.collect();
self.tasks
.iter()
.filter(|task| journaled.contains(task.id.as_str()))
.map(|task| task.id.clone())
.collect()
};
SchedulerLoadReconciliation {
requires_resources_persistence: !task_ids_to_remove.is_empty(),
task_ids_to_remove,
blocked_task_ids,
block_all_one_shots,
recovery_required,
conflicts,
overflow_error: overflowed.then_some(OccurrenceJournalError::JournalFull),
}
}
}
fn has_conflict(entries: &[OneShotOccurrence]) -> bool {
let occurrence_ids: HashSet<_> = entries.iter().map(|entry| &entry.occurrence_id).collect();
let task_ids: HashSet<_> = entries.iter().map(|entry| entry.task.id.as_str()).collect();
let versions: HashSet<_> = entries
.iter()
.flat_map(|entry| [entry.versions.fire(), entry.versions.removal()])
.collect();
occurrence_ids.len() != entries.len()
|| task_ids.len() != entries.len()
|| versions.len() != entries.len() * 2
}
fn count_by<'a, T, K>(
values: impl Iterator<Item = &'a T>,
key: impl Fn(&T) -> K,
) -> HashMap<K, usize>
where
T: 'a,
K: Eq + std::hash::Hash,
{
let mut counts = HashMap::new();
for value in values {
*counts.entry(key(value)).or_insert(0) += 1;
}
counts
}
#[cfg(test)]
#[path = "occurrence_journal_tests.rs"]
mod tests;

View file

@ -0,0 +1,391 @@
use super::*;
use crate::persistence::ResourcesPersistence;
use crate::types::resources::{Resources, State};
use chrono::{TimeZone, Utc};
const GENERATION: &str = "01890f42-7d5c-7c00-8000-000000000001";
fn uuid(suffix: u64) -> uuid::Uuid {
uuid::Uuid::parse_str(&format!("01890f42-7d5c-7c00-8000-{suffix:012x}")).unwrap()
}
fn task(id: &str, recurring: bool, durable: bool) -> ScheduledTask {
ScheduledTask {
id: id.into(),
interval_secs: 300,
prompt: format!("run {id}"),
recurring,
durable,
foreground: true,
created_at: Utc.timestamp_opt(1_700_000_000, 0).unwrap(),
last_fired_at: None,
expires_at: None,
last_subagent_id: None,
iterations_since_fresh: 0,
chain_reset_pending: false,
}
}
fn version(generation: &str, revision: u64) -> SchedulerVersion {
SchedulerVersion::from_parts(uuid::Uuid::parse_str(generation).unwrap(), revision)
}
fn versions(revision: u64) -> ScheduledOccurrenceVersions {
ScheduledOccurrenceVersions::try_new(
version(GENERATION, revision),
version(GENERATION, revision + 1),
)
.unwrap()
}
fn occurrence_json(
id: &str,
task: serde_json::Value,
versions: serde_json::Value,
) -> serde_json::Value {
serde_json::json!({ "occurrenceId": id, "task": task, "versions": versions })
}
fn valid_occurrence_json(id_suffix: u64, task_id: &str, revision: u64) -> serde_json::Value {
occurrence_json(
&uuid(id_suffix).to_string(),
serde_json::to_value(task(task_id, false, true)).unwrap(),
serde_json::json!({
"fire": { "generation": GENERATION, "revision": revision },
"removal": { "generation": GENERATION, "revision": revision + 1 },
}),
)
}
fn state(tasks: Vec<ScheduledTask>, journal: serde_json::Value) -> SchedulerState {
serde_json::from_value(serde_json::json!({
"tasks": tasks,
"occurrenceJournal": journal
}))
.unwrap()
}
fn prepare(state: &mut SchedulerState, task_id: &str, revision: u64) -> OneShotOccurrence {
state
.prepare_one_shot_occurrence_with_id(
ScheduledOccurrenceId(uuid(100 + revision)),
task_id,
versions(revision),
)
.unwrap()
}
#[test]
fn prepare_finish_and_mutation_failures_preserve_state() {
let mut state = SchedulerState {
tasks: vec![task("one-shot", false, true), task("second", false, true)],
..Default::default()
};
let occurrence = prepare(&mut state, "one-shot", 7);
assert_eq!(occurrence.task.id, "one-shot");
state
.finish_one_shot_removal(&occurrence.occurrence_id)
.unwrap();
prepare(&mut state, "second", 1);
state.tasks.push(task("duplicate", false, true));
assert_eq!(
state
.prepare_one_shot_occurrence("duplicate", versions(1))
.unwrap_err(),
OccurrenceJournalError::DuplicateTransitionVersion
);
for invalid in [
task("recurring", true, true),
task("ephemeral", false, false),
] {
let mut state = SchedulerState {
tasks: vec![invalid.clone()],
..Default::default()
};
assert!(matches!(
state.prepare_one_shot_occurrence(&invalid.id, versions(3)),
Err(OccurrenceJournalError::NotDurableOneShot(_))
));
}
}
#[test]
fn validation_rejects_impossible_versions_and_non_rfc_identity() {
for (fire_generation, removal_generation, fire, removal) in [
(GENERATION, GENERATION, 0, 1),
(GENERATION, GENERATION, 1, 3),
(GENERATION, "01890f42-7d5c-7c00-8000-000000000002", 1, 2),
("01890f42-7d5c-7c00-c000-000000000001", GENERATION, 1, 2),
] {
assert_eq!(
ScheduledOccurrenceVersions::try_new(
version(fire_generation, fire),
version(removal_generation, removal),
),
Err(OccurrenceJournalError::InvalidVersions)
);
}
let invalid = occurrence_json(
"01890f42-7d5c-7c00-c000-000000000001",
serde_json::to_value(task("bad-id", false, true)).unwrap(),
serde_json::json!({
"fire": { "generation": GENERATION, "revision": 1 },
"removal": { "generation": GENERATION, "revision": 2 },
}),
);
let state = state(Vec::new(), serde_json::Value::Array(vec![invalid]));
let plan = state.reconcile_one_shot_occurrences();
assert!(plan.recovery_required() && plan.blocked_task_ids().contains("bad-id"));
}
#[test]
fn exactly_fifty_round_trips_and_mutation_reports_journal_full() {
let entries: Vec<_> = (0..MAX_PENDING_ONE_SHOTS)
.map(|index| {
valid_occurrence_json(
100 + index as u64,
&format!("task-{index}"),
index as u64 * 2 + 1,
)
})
.collect();
let mut state = state(Vec::new(), serde_json::Value::Array(entries));
assert_eq!(
state.occurrence_journal.entries.len(),
MAX_PENDING_ONE_SHOTS
);
let encoded = serde_json::to_value(&state).unwrap();
let reloaded: SchedulerState = serde_json::from_value(encoded).unwrap();
assert_eq!(
reloaded.occurrence_journal.entries.len(),
MAX_PENDING_ONE_SHOTS
);
state.tasks.push(task("new", false, true));
assert_eq!(
state
.prepare_one_shot_occurrence("new", versions(3))
.unwrap_err(),
OccurrenceJournalError::JournalFull
);
}
#[test]
fn overflow_tail_suppresses_globally_and_never_serializes_a_fifty_first_entry() {
let mut entries: Vec<_> = (0..MAX_PENDING_ONE_SHOTS)
.map(|index| valid_occurrence_json(200 + index as u64, &format!("task-{index}"), 1))
.collect();
entries.push(valid_occurrence_json(999, "tail-task", 3));
let state = state(
vec![task("tail-task", false, true), task("other", false, true)],
serde_json::Value::Array(entries),
);
let plan = state.reconcile_one_shot_occurrences();
assert!(plan.block_all_one_shots() && plan.recovery_required());
assert!(!plan.requires_resources_persistence());
assert!(plan.blocked_task_ids().contains("tail-task"));
assert!(plan.overflow_error().is_some());
let encoded = serde_json::to_value(&state).unwrap();
assert_eq!(
encoded["occurrenceJournal"]["entries"]
.as_array()
.unwrap()
.len(),
MAX_PENDING_ONE_SHOTS
);
let mut reloaded: SchedulerState = serde_json::from_value(encoded).unwrap();
let reloaded_plan = reloaded.reconcile_one_shot_occurrences();
assert!(reloaded_plan.block_all_one_shots() && reloaded_plan.recovery_required());
reloaded.tasks.push(task("new", false, true));
let before = reloaded.tasks.len();
assert_eq!(
reloaded
.prepare_one_shot_occurrence("new", versions(5))
.unwrap_err(),
OccurrenceJournalError::RecoveryRequired
);
assert_eq!(reloaded.tasks.len(), before);
}
#[test]
fn malformed_missing_task_identity_blocks_all_one_shots_across_reload() {
let malformed = occurrence_json(
&uuid(20).to_string(),
serde_json::json!({ "prompt": "missing id" }),
serde_json::json!({
"fire": { "generation": GENERATION, "revision": 1 },
"removal": { "generation": GENERATION, "revision": 2 },
}),
);
let state = state(
vec![task("due", false, true), task("recurring", true, true)],
serde_json::Value::Array(vec![malformed]),
);
let plan = state.reconcile_one_shot_occurrences();
assert!(plan.block_all_one_shots() && plan.recovery_required());
assert!(plan.blocked_task_ids().contains("due"));
let encoded = serde_json::to_value(&state).unwrap();
let reloaded: SchedulerState = serde_json::from_value(encoded).unwrap();
let reloaded_plan = reloaded.reconcile_one_shot_occurrences();
assert!(reloaded_plan.block_all_one_shots() && reloaded_plan.recovery_required());
}
#[test]
fn inconsistent_current_overflow_metadata_normalizes_and_round_trips() {
let current = serde_json::json!({
"entries": [],
"overflowed": true,
"blockAllOneShots": false,
});
let state = state(vec![task("due", false, true)], current);
let plan = state.reconcile_one_shot_occurrences();
assert!(plan.block_all_one_shots() && plan.recovery_required());
let encoded = serde_json::to_value(&state).unwrap();
assert!(encoded["occurrenceJournal"]["blockAllOneShots"] == true);
let reloaded: SchedulerState = serde_json::from_value(encoded).unwrap();
assert!(
reloaded
.reconcile_one_shot_occurrences()
.recovery_required()
);
}
#[tokio::test]
async fn production_loader_preserves_tasks_and_quarantine_metadata() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("resources_state.json");
let invalid = occurrence_json(
&uuid(30).to_string(),
serde_json::to_value(task("bad", true, true)).unwrap(),
serde_json::json!({
"fire": { "generation": GENERATION, "revision": 1 },
"removal": { "generation": GENERATION, "revision": 2 },
}),
);
std::fs::write(
&path,
serde_json::to_vec(&serde_json::json!({
"state": { "grok_build.Scheduler": {
"tasks": [task("recurring", true, true)],
"occurrenceJournal": [invalid]
} }
}))
.unwrap(),
)
.unwrap();
let mut resources = Resources::new();
resources.register_state::<SchedulerState>();
assert!(ResourcesPersistence::new(path.clone()).load(&mut resources));
let state = resources.get::<State<SchedulerState>>().unwrap();
assert_eq!(state.tasks[0].id, "recurring");
let (task_ids, is_global_block, is_overflowed) =
state.occurrence_journal.quarantine_diagnostics();
assert_eq!(task_ids, ["bad"]);
assert!(!is_global_block && !is_overflowed);
for journal in [
serde_json::json!({ "entries": "bad", "blockAllOneShots": [] }),
serde_json::json!({ "quarantinedTaskIds": ["kept-id", 7] }),
serde_json::json!("wrong-shape"),
] {
std::fs::write(
&path,
serde_json::to_vec(&serde_json::json!({
"state": { "grok_build.Scheduler": {
"tasks": [task("kept", true, true)],
"occurrenceJournal": journal
} }
}))
.unwrap(),
)
.unwrap();
let mut resources = Resources::new();
resources.register_state::<SchedulerState>();
assert!(ResourcesPersistence::new(path.clone()).load(&mut resources));
let state = resources.get::<State<SchedulerState>>().unwrap();
assert_eq!(state.tasks[0].id, "kept");
assert!(state.occurrence_journal.block_all_one_shots);
}
}
#[test]
fn reconciliation_exposes_only_persistence_and_suppression_foundation() {
let state = state(
vec![task("resurrected", false, true)],
serde_json::Value::Array(vec![valid_occurrence_json(10, "resurrected", 1)]),
);
let plan = state.reconcile_one_shot_occurrences();
assert!(plan.requires_resources_persistence());
assert_eq!(plan.task_ids_to_remove(), ["resurrected"]);
assert_eq!(state.tasks[0].id, "resurrected");
}
#[test]
fn conflict_receipts_produce_diagnostics_and_suppress_every_task() {
for (entries, expected) in [
(
vec![
valid_occurrence_json(10, "first", 1),
valid_occurrence_json(10, "second", 3),
],
OneShotJournalConflict::OccurrenceId,
),
(
vec![
valid_occurrence_json(10, "same", 1),
valid_occurrence_json(11, "same", 3),
],
OneShotJournalConflict::TaskId,
),
(
vec![
valid_occurrence_json(10, "first", 1),
valid_occurrence_json(11, "second", 1),
],
OneShotJournalConflict::TransitionVersion,
),
] {
let ids: Vec<_> = entries
.iter()
.map(|entry| entry["task"]["id"].as_str().unwrap().to_owned())
.collect();
let mut state = state(
ids.iter().map(|id| task(id, false, true)).collect(),
serde_json::Value::Array(entries),
);
let plan = state.reconcile_one_shot_occurrences();
assert!(plan.recovery_required());
assert!(plan.task_ids_to_remove().is_empty());
assert_eq!(plan.conflicts(), &[expected, expected]);
assert!(ids.iter().all(|id| plan.blocked_task_ids().contains(id)));
assert_eq!(state.tasks.len(), ids.len());
let unrelated = "unrelated";
state.tasks.push(task(unrelated, false, true));
let before = state.tasks.len();
assert_eq!(
state
.prepare_one_shot_occurrence(unrelated, versions(9))
.unwrap_err(),
OccurrenceJournalError::RecoveryRequired
);
assert_eq!(state.tasks.len(), before);
}
}
#[test]
fn empty_journal_omits_legacy_field() {
let serialized = serde_json::to_value(SchedulerState {
tasks: vec![task("legacy", true, true)],
..Default::default()
})
.unwrap();
assert!(serialized.get("occurrenceJournal").is_none());
}

View file

@ -2,7 +2,8 @@ use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use tokio::sync::{mpsc, oneshot};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct SchedulerVersion {
generation: uuid::Uuid,
revision: u64,
@ -16,6 +17,18 @@ impl SchedulerVersion {
pub(super) fn revision(self) -> u64 {
self.revision
}
pub(super) fn generation_id(self) -> uuid::Uuid {
self.generation
}
#[cfg(test)]
pub(super) fn from_parts(generation: uuid::Uuid, revision: u64) -> Self {
Self {
generation,
revision,
}
}
}
#[derive(Debug)]
@ -279,7 +292,14 @@ impl ScheduledTask {
/// Persisted state for the scheduler, stored via Resources + ResourcesPersistence.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SchedulerState {
#[serde(default)]
pub tasks: Vec<ScheduledTask>,
#[serde(
default,
rename = "occurrenceJournal",
skip_serializing_if = "super::occurrence_journal::OccurrenceJournal::is_empty"
)]
pub(crate) occurrence_journal: super::occurrence_journal::OccurrenceJournal,
}
crate::register_resource!("grok_build", "Scheduler", SchedulerState);

View file

@ -767,6 +767,8 @@ impl crate::types::tool_metadata::ToolMetadata for SearchReplaceTool {
}
fn requires_expr(&self) -> Expr<ToolRequirement> {
Expr::And(vec![
// Unless `skip_read_before_edit` is set, require a Read tool in the toolset
// (read-before-edit is encouraged via description and RL grading, not runtime-enforced).
Expr::Value(ToolRequirement::if_params(
Expr::Not(Box::new(Expr::Value(ToolParamsRequirement::new(
"skip_read_before_edit",
@ -774,6 +776,12 @@ impl crate::types::tool_metadata::ToolMetadata for SearchReplaceTool {
)))),
ToolRequirement::tool_kind(ToolKind::Read),
)),
// Description template references these input params via
// ${{ params.edit.old_string }}, ${{ params.edit.new_string }},
// ${{ params.edit.replace_all }}. They must remain visible.
// TODO: We can generate the schemas and requirement by enforcing
// it during the registry phase, since these are parts of the params which are
// tied to the tool
Expr::Value(ToolRequirement::input_param(ToolKind::Edit, "old_string")),
Expr::Value(ToolRequirement::input_param(ToolKind::Edit, "new_string")),
Expr::Value(ToolRequirement::input_param(ToolKind::Edit, "replace_all")),
@ -903,7 +911,7 @@ mod tests {
/// Harness configs still send this field; it must keep validating under `deny_unknown_fields`.
#[test]
fn harness_skip_read_before_edit_param_still_validates() {
let json = serde_json::json!({ "skip_read_before_edit" : true });
let json = serde_json::json!({ "skip_read_before_edit": true });
crate::types::params_validation::validate_params_json::<SearchReplaceParams>(&json).expect(
"harness skip_read_before_edit config must validate against SearchReplaceParams",
);

View file

@ -527,6 +527,7 @@ pub enum SubagentCancelOutcome {
#[derive(Debug, Clone)]
pub struct SubagentCompletionSummary {
pub subagent_id: String,
pub owner_session_id: String,
pub subagent_type: String,
pub description: String,
pub success: bool,
@ -559,6 +560,7 @@ pub struct SubagentMultiWaitRequest {
#[derive(Educe)]
#[educe(Debug)]
pub struct SubagentCompletionsRequest {
pub session_id: String,
pub suppress_ids: Vec<String>,
#[educe(Debug(ignore))]
pub respond_to: oneshot::Sender<Vec<SubagentCompletionSummary>>,
@ -1280,16 +1282,19 @@ mod tests {
let (respond_to, mut response_rx) = oneshot::channel();
tx.send(super::SubagentCompletionsRequest {
session_id: "session-1".into(),
suppress_ids: vec!["id-1".into(), "id-2".into()],
respond_to,
})
.unwrap();
let req = rx.try_recv().unwrap();
assert_eq!(req.session_id, "session-1");
assert_eq!(req.suppress_ids, vec!["id-1", "id-2"]);
let summaries = vec![super::SubagentCompletionSummary {
subagent_id: "sub-1".into(),
owner_session_id: "session-1".into(),
subagent_type: "general-purpose".into(),
description: "test task".into(),
success: true,
@ -1368,6 +1373,7 @@ mod tests {
.0
.send(super::SubagentEvent::Completions(
super::SubagentCompletionsRequest {
session_id: String::new(),
suppress_ids: vec![],
respond_to,
},
@ -1399,6 +1405,7 @@ mod tests {
.0
.send(super::SubagentEvent::Completions(
super::SubagentCompletionsRequest {
session_id: String::new(),
suppress_ids: vec![],
respond_to,
},

View file

@ -79,15 +79,13 @@ pub struct EditInput {
)]
pub new_string: String,
/// When true, replace every occurrence of `old_string` (default false).
/// When true, replace every occurrence of `old_string`.
#[serde(
default,
deserialize_with = "crate::types::schema::deserialize_lenient_option_bool"
deserialize_with = "crate::types::schema::deserialize_lenient_bool"
)]
#[schemars(
description = "Replace all occurrences of ${{ params.edit.oldString }} (default false)"
)]
pub replace_all: Option<bool>,
#[schemars(description = "Replace all occurrences of ${{ params.edit.oldString }}")]
pub replace_all: bool,
}
// ───────────────────────────────────────────────────────────────────────────
@ -197,7 +195,7 @@ impl xai_tool_runtime::Tool for EditTool {
};
let tool_call_id = ctx.call_id.as_str().to_owned();
let replace_all = input.replace_all.unwrap_or(false);
let replace_all = input.replace_all;
// Resolve the model-provided path.
let path = resolve_model_path(&cwd, display_cwd.as_deref(), &input.file_path);
@ -521,10 +519,30 @@ mod tests {
file_path: file_path.to_string(),
old_string: old_string.to_string(),
new_string: new_string.to_string(),
replace_all: None,
replace_all: false,
}
}
#[test]
fn replace_all_defaults_false_and_schema_is_boolean() {
let missing: EditInput =
serde_json::from_str(r#"{"filePath":"/f","oldString":"a","newString":"b"}"#).unwrap();
assert!(!missing.replace_all);
let nullv: EditInput = serde_json::from_str(
r#"{"filePath":"/f","oldString":"a","newString":"b","replaceAll":null}"#,
)
.unwrap();
assert!(!nullv.replace_all);
let schema = serde_json::to_value(schemars::schema_for!(EditInput)).unwrap();
// rename_all = camelCase → replaceAll
let p = &schema["properties"]["replaceAll"];
assert_eq!(p["type"], "boolean", "schema: {schema}");
assert_eq!(p["default"], false, "schema: {schema}");
assert!(p.get("anyOf").is_none(), "schema: {schema}");
}
// ── Tool metadata ───────────────────────────────────────────────
#[test]
@ -560,7 +578,7 @@ mod tests {
assert_eq!(input.file_path, "src/main.rs");
assert_eq!(input.old_string, "hello");
assert_eq!(input.new_string, "goodbye");
assert_eq!(input.replace_all, Some(true));
assert!(input.replace_all);
}
#[test]
@ -572,7 +590,7 @@ mod tests {
});
let input: EditInput = serde_json::from_value(json).unwrap();
assert_eq!(input.file_path, "test.txt");
assert_eq!(input.replace_all, None);
assert!(!input.replace_all);
}
// ── Validation ──────────────────────────────────────────────────
@ -816,7 +834,7 @@ mod tests {
file_path: "test.txt".to_string(),
old_string: "aaa".to_string(),
new_string: "ccc".to_string(),
replace_all: Some(true),
replace_all: true,
};
let result = xai_tool_runtime::Tool::run(&tool, test_ctx(resources.into_shared()), input)
.await
@ -990,7 +1008,7 @@ mod tests {
file_path: "test.txt".to_string(),
old_string: "foo".to_string(),
new_string: "qux".to_string(),
replace_all: Some(true),
replace_all: true,
};
let result = xai_tool_runtime::Tool::run(&tool, test_ctx(resources.into_shared()), input)
.await

View file

@ -155,7 +155,10 @@ impl WebSearchClient {
return Err(xai_tool_runtime::ToolError::unauthorized(format!(
"Responses API returned 401 Unauthorized: {body}"
))
.with_details(serde_json::json!({ "tool_id" : "web_search", "status" : 401, })));
.with_details(serde_json::json!({
"tool_id": "web_search",
"status": 401,
})));
}
if !status.is_success() {
let body = response
@ -243,7 +246,10 @@ impl WebSearchClient {
return Err(xai_tool_runtime::ToolError::unauthorized(format!(
"Responses API returned 401 Unauthorized: {body}"
))
.with_details(serde_json::json!({ "tool_id" : "web_search", "status" : 401, })));
.with_details(serde_json::json!({
"tool_id": "web_search",
"status": 401,
})));
}
if !status.is_success() {
let body = response
@ -411,26 +417,56 @@ mod tests {
}
#[test]
fn test_extract_citations_empty_response() {
let response = response_from_json(serde_json::json!(
{ "id" : "resp_test", "object" : "response", "created_at" : 1234567890,
"status" : "completed", "output" : [], "model" : "test-model" }
));
let response = response_from_json(serde_json::json!({
"id": "resp_test",
"object": "response",
"created_at": 1234567890,
"status": "completed",
"output": [],
"model": "test-model"
}));
let citations = extract_citations(&response);
assert!(citations.is_empty());
}
#[test]
fn test_extract_citations_with_url_citations() {
let response = response_from_json(serde_json::json!(
{ "id" : "resp_test", "object" : "response", "created_at" : 1234567890,
"status" : "completed", "model" : "test-model", "output" : [{ "type" :
"message", "id" : "msg_1", "status" : "completed", "role" : "assistant",
"content" : [{ "type" : "output_text", "text" :
"Here is some info about Rust.", "annotations" : [{ "type" :
"url_citation", "url" : "https://www.rust-lang.org/", "title" :
"Rust Programming Language", "start_index" : 0, "end_index" : 10 }, {
"type" : "url_citation", "url" : "https://docs.rs/", "title" : "Docs.rs",
"start_index" : 11, "end_index" : 20 }] }] }] }
));
let response = response_from_json(serde_json::json!({
"id": "resp_test",
"object": "response",
"created_at": 1234567890,
"status": "completed",
"model": "test-model",
"output": [
{
"type": "message",
"id": "msg_1",
"status": "completed",
"role": "assistant",
"content": [
{
"type": "output_text",
"text": "Here is some info about Rust.",
"annotations": [
{
"type": "url_citation",
"url": "https://www.rust-lang.org/",
"title": "Rust Programming Language",
"start_index": 0,
"end_index": 10
},
{
"type": "url_citation",
"url": "https://docs.rs/",
"title": "Docs.rs",
"start_index": 11,
"end_index": 20
}
]
}
]
}
]
}));
let citations = extract_citations(&response);
assert_eq!(citations.len(), 2);
assert_eq!(citations[0], "https://www.rust-lang.org/");
@ -438,19 +474,50 @@ mod tests {
}
#[test]
fn test_extract_citations_deduplicates() {
let response = response_from_json(serde_json::json!(
{ "id" : "resp_test", "object" : "response", "created_at" : 1234567890,
"status" : "completed", "model" : "test-model", "output" : [{ "type" :
"message", "id" : "msg_1", "status" : "completed", "role" : "assistant",
"content" : [{ "type" : "output_text", "text" :
"Info with duplicate citations.", "annotations" : [{ "type" :
"url_citation", "url" : "https://example.com/page1", "title" : "Page 1",
"start_index" : 0, "end_index" : 5 }, { "type" : "url_citation", "url" :
"https://example.com/page2", "title" : "Page 2", "start_index" : 6,
"end_index" : 10 }, { "type" : "url_citation", "url" :
"https://example.com/page1", "title" : "Page 1 Again", "start_index" :
11, "end_index" : 15 }] }] }] }
));
let response = response_from_json(serde_json::json!({
"id": "resp_test",
"object": "response",
"created_at": 1234567890,
"status": "completed",
"model": "test-model",
"output": [
{
"type": "message",
"id": "msg_1",
"status": "completed",
"role": "assistant",
"content": [
{
"type": "output_text",
"text": "Info with duplicate citations.",
"annotations": [
{
"type": "url_citation",
"url": "https://example.com/page1",
"title": "Page 1",
"start_index": 0,
"end_index": 5
},
{
"type": "url_citation",
"url": "https://example.com/page2",
"title": "Page 2",
"start_index": 6,
"end_index": 10
},
{
"type": "url_citation",
"url": "https://example.com/page1",
"title": "Page 1 Again",
"start_index": 11,
"end_index": 15
}
]
}
]
}
]
}));
let citations = extract_citations(&response);
assert_eq!(citations.len(), 2);
assert_eq!(citations[0], "https://example.com/page1");
@ -458,19 +525,57 @@ mod tests {
}
#[test]
fn test_extract_citations_multiple_messages() {
let response = response_from_json(serde_json::json!(
{ "id" : "resp_test", "object" : "response", "created_at" : 1234567890,
"status" : "completed", "model" : "test-model", "output" : [{ "type" :
"message", "id" : "msg_1", "status" : "completed", "role" : "assistant",
"content" : [{ "type" : "output_text", "text" : "First message",
"annotations" : [{ "type" : "url_citation", "url" : "https://first.com/",
"title" : "First", "start_index" : 0, "end_index" : 5 }] }] }, { "type" :
"message", "id" : "msg_2", "status" : "completed", "role" : "assistant",
"content" : [{ "type" : "output_text", "text" : "Second message",
"annotations" : [{ "type" : "url_citation", "url" :
"https://second.com/", "title" : "Second", "start_index" : 0, "end_index"
: 6 }] }] }] }
));
let response = response_from_json(serde_json::json!({
"id": "resp_test",
"object": "response",
"created_at": 1234567890,
"status": "completed",
"model": "test-model",
"output": [
{
"type": "message",
"id": "msg_1",
"status": "completed",
"role": "assistant",
"content": [
{
"type": "output_text",
"text": "First message",
"annotations": [
{
"type": "url_citation",
"url": "https://first.com/",
"title": "First",
"start_index": 0,
"end_index": 5
}
]
}
]
},
{
"type": "message",
"id": "msg_2",
"status": "completed",
"role": "assistant",
"content": [
{
"type": "output_text",
"text": "Second message",
"annotations": [
{
"type": "url_citation",
"url": "https://second.com/",
"title": "Second",
"start_index": 0,
"end_index": 6
}
]
}
]
}
]
}));
let citations = extract_citations(&response);
assert_eq!(citations.len(), 2);
assert_eq!(citations[0], "https://first.com/");
@ -478,14 +583,36 @@ mod tests {
}
#[test]
fn test_extract_citations_ignores_non_url_annotations() {
let response = response_from_json(serde_json::json!(
{ "id" : "resp_test", "object" : "response", "created_at" : 1234567890,
"status" : "completed", "model" : "test-model", "output" : [{ "type" :
"message", "id" : "msg_1", "status" : "completed", "role" : "assistant",
"content" : [{ "type" : "output_text", "text" : "Some text",
"annotations" : [{ "type" : "url_citation", "url" : "https://valid.com/",
"title" : "Valid", "start_index" : 0, "end_index" : 4 }] }] }] }
));
let response = response_from_json(serde_json::json!({
"id": "resp_test",
"object": "response",
"created_at": 1234567890,
"status": "completed",
"model": "test-model",
"output": [
{
"type": "message",
"id": "msg_1",
"status": "completed",
"role": "assistant",
"content": [
{
"type": "output_text",
"text": "Some text",
"annotations": [
{
"type": "url_citation",
"url": "https://valid.com/",
"title": "Valid",
"start_index": 0,
"end_index": 4
}
]
}
]
}
]
}));
let citations = extract_citations(&response);
assert_eq!(citations.len(), 1);
assert_eq!(citations[0], "https://valid.com/");
@ -510,14 +637,24 @@ mod tests {
Mock::given(method("POST"))
.and(path("/responses"))
.and(header("Authorization", "Bearer static-key-from-config"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!(
{ "id" : "resp_test", "object" : "response", "created_at" :
1234567890, "status" : "completed", "model" : "test-model",
"output" : [{ "type" : "message", "id" : "msg_1", "status" :
"completed", "role" : "assistant", "content" : [{ "type" :
"output_text", "text" : "search result", "annotations" : []
}] }] }
)))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "resp_test",
"object": "response",
"created_at": 1234567890,
"status": "completed",
"model": "test-model",
"output": [{
"type": "message",
"id": "msg_1",
"status": "completed",
"role": "assistant",
"content": [{
"type": "output_text",
"text": "search result",
"annotations": []
}]
}]
})))
.mount(&server)
.await;
let config = WebSearchConfig::Enabled {
@ -550,14 +687,24 @@ mod tests {
Mock::given(method("POST"))
.and(path("/responses"))
.and(header("Authorization", "Bearer fresh-key-from-provider"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!(
{ "id" : "resp_test", "object" : "response", "created_at" :
1234567890, "status" : "completed", "model" : "test-model",
"output" : [{ "type" : "message", "id" : "msg_1", "status" :
"completed", "role" : "assistant", "content" : [{ "type" :
"output_text", "text" : "fresh result", "annotations" : [] }]
}] }
)))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "resp_test",
"object": "response",
"created_at": 1234567890,
"status": "completed",
"model": "test-model",
"output": [{
"type": "message",
"id": "msg_1",
"status": "completed",
"role": "assistant",
"content": [{
"type": "output_text",
"text": "fresh result",
"annotations": []
}]
}]
})))
.mount(&server)
.await;
let config = WebSearchConfig::Enabled {
@ -577,13 +724,28 @@ mod tests {
}
#[test]
fn test_extract_citations_no_annotations() {
let response = response_from_json(serde_json::json!(
{ "id" : "resp_test", "object" : "response", "created_at" : 1234567890,
"status" : "completed", "model" : "test-model", "output" : [{ "type" :
"message", "id" : "msg_1", "status" : "completed", "role" : "assistant",
"content" : [{ "type" : "output_text", "text" :
"Plain text with no annotations", "annotations" : [] }] }] }
));
let response = response_from_json(serde_json::json!({
"id": "resp_test",
"object": "response",
"created_at": 1234567890,
"status": "completed",
"model": "test-model",
"output": [
{
"type": "message",
"id": "msg_1",
"status": "completed",
"role": "assistant",
"content": [
{
"type": "output_text",
"text": "Plain text with no annotations",
"annotations": []
}
]
}
]
}));
let citations = extract_citations(&response);
assert!(citations.is_empty());
}

View file

@ -140,7 +140,7 @@ mod tests {
}
#[test]
fn canonical_omits_absent_options_not_null() {
let grok = parse(serde_json::json!({ "variant" : "ReadFile", "target_file" : "/a" }));
let grok = parse(serde_json::json!({"variant":"ReadFile","target_file":"/a"}));
let g = canonical_input(&grok).unwrap();
let keys: Vec<&String> = g.as_object().unwrap().keys().collect();
assert_eq!(

View file

@ -29,6 +29,12 @@ pub struct ResourcesPersistence {
noop: bool,
}
#[cfg(test)]
pub(crate) type ControlledSave = (
serde_json::Value,
tokio::sync::oneshot::Sender<io::Result<()>>,
);
enum ResourcesPersistenceCommand {
/// Write this serialized Resources value to disk
Save(serde_json::Value),
@ -51,6 +57,37 @@ impl ResourcesPersistence {
}
}
#[cfg(test)]
pub(crate) fn controlled() -> (Self, tokio::sync::mpsc::UnboundedReceiver<ControlledSave>) {
let (tx, mut commands) =
tokio::sync::mpsc::unbounded_channel::<ResourcesPersistenceCommand>();
let (observed_tx, observed_rx) = tokio::sync::mpsc::unbounded_channel();
tokio::spawn(async move {
while let Some(command) = commands.recv().await {
match command {
ResourcesPersistenceCommand::Save(_) => {}
ResourcesPersistenceCommand::SaveAndFlush {
snapshot,
respond_to,
} => {
let _ = observed_tx.send((snapshot, respond_to));
}
ResourcesPersistenceCommand::Flush(done) => {
let _ = done.send(());
}
}
}
});
(
Self {
state_path: PathBuf::from("/dev/null"),
tx,
noop: false,
},
observed_rx,
)
}
/// Create a new persistence handle and spawn the background writer task.
pub fn new(state_path: PathBuf) -> Self {
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();

View file

@ -141,8 +141,7 @@ mod tests {
assert_eq!(err.field_path(), "tools[3].params_json");
assert!(matches!(
&err.kind,
ToolConfigEntryErrorKind::ParamsJsonParse { raw, .. }
if raw == "{not json"
ToolConfigEntryErrorKind::ParamsJsonParse { raw, .. } if raw == "{not json"
));
}
@ -182,8 +181,7 @@ if raw == "{not json"
assert!(
matches!(
&err.kind,
ToolConfigEntryErrorKind::NameOverrideInvalid { name: n, .. }
if n == name
ToolConfigEntryErrorKind::NameOverrideInvalid { name: n, .. } if n == name
),
"name={name:?} kind={:?}",
err.kind

View file

@ -103,9 +103,7 @@ where
};
let kind = ToolKind::deserialize(serde::de::value::StrDeserializer::<D::Error>::new(&raw))?;
if kind == ToolKind::Other && raw != "other" {
tracing::warn!(
kind = % raw, "unknown tool kind in config; treating as \"other\""
);
tracing::warn!(kind = %raw, "unknown tool kind in config; treating as \"other\"");
}
Ok(Some(kind))
}
@ -757,11 +755,14 @@ impl ToolRegistryBuilder {
.map(|(name, e)| {
(
name.as_str(),
serde_json::json!(
{ "namespace" : e.namespace, "id" : e.id, "kind" : e.kind,
"default_params" : e.default_params, "input_schema" : e.input_schema,
"requires" : e.requires, }
),
serde_json::json!({
"namespace": e.namespace,
"id": e.id,
"kind": e.kind,
"default_params": e.default_params,
"input_schema": e.input_schema,
"requires": e.requires,
}),
)
})
.collect();
@ -788,8 +789,8 @@ impl ToolRegistryBuilder {
for tool_config in &config.tools {
let Some(entry) = self.tools.get(tool_config.id.as_str()) else {
tracing::warn!(
tool_id = % tool_config.id, registered_keys = ? self.tools.keys()
.collect::< Vec < _ >> (),
tool_id = %tool_config.id,
registered_keys = ?self.tools.keys().collect::<Vec<_>>(),
"validate_config: tool NOT FOUND in registry"
);
errors.push(
@ -1192,6 +1193,7 @@ impl ToolRegistryBuilder {
cancel_token: cancel_token.clone(),
clock: Default::default(),
pending_removal: None,
blocked_expiries: Default::default(),
};
tokio::spawn(actor.run());
}
@ -1486,22 +1488,46 @@ impl FinalizedToolset {
let tool_name = tool_name.to_owned();
let tool_call_id = tool_call_id.to_owned();
Box::pin(async_stream::stream! {
let parts = match this.prepare_dispatch(& tool_name, tool_args, &
tool_call_id, cwd_override,) { Ok(parts) => parts, Err(e) => { yield
xai_tool_runtime::ToolStreamItem::Terminal(Err(e)); return; } }; let
DispatchParts { lr_handle, ctx, canonical_params, output_converter,
effective_tool_name, } = parts; let mut inner = lr_handle.execute(ctx,
canonical_params). await; while let Some(item) = inner.next(). await {
match item { xai_tool_runtime::ToolStreamItem::Progress(p) => { yield
xai_tool_runtime::ToolStreamItem::Progress(p); }
xai_tool_runtime::ToolStreamItem::Terminal(Err(e)) => { yield
xai_tool_runtime::ToolStreamItem::Terminal(Err(e)); return; }
xai_tool_runtime::ToolStreamItem::Terminal(Ok(typed)) => { let run_result
= this.finalize_output(typed.value, & output_converter,
effective_tool_name). await; yield
xai_tool_runtime::ToolStreamItem::Terminal(run_result); return; } } }
yield
xai_tool_runtime::ToolStreamItem::Terminal(Err(stream_no_terminal_error()));
let parts = match this.prepare_dispatch(
&tool_name,
tool_args,
&tool_call_id,
cwd_override,
) {
Ok(parts) => parts,
Err(e) => {
yield xai_tool_runtime::ToolStreamItem::Terminal(Err(e));
return;
}
};
let DispatchParts {
lr_handle,
ctx,
canonical_params,
output_converter,
effective_tool_name,
} = parts;
let mut inner = lr_handle.execute(ctx, canonical_params).await;
while let Some(item) = inner.next().await {
match item {
xai_tool_runtime::ToolStreamItem::Progress(p) => {
yield xai_tool_runtime::ToolStreamItem::Progress(p);
}
xai_tool_runtime::ToolStreamItem::Terminal(Err(e)) => {
yield xai_tool_runtime::ToolStreamItem::Terminal(Err(e));
return;
}
xai_tool_runtime::ToolStreamItem::Terminal(Ok(typed)) => {
let run_result = this
.finalize_output(typed.value, &output_converter, effective_tool_name)
.await;
yield xai_tool_runtime::ToolStreamItem::Terminal(run_result);
return;
}
}
}
yield xai_tool_runtime::ToolStreamItem::Terminal(Err(stream_no_terminal_error()));
})
}
/// Pre-dispatch setup shared by [`call`] / [`call_streaming`].
@ -1825,9 +1851,9 @@ fn explain_requirement_failure(
"unsatisfied requirements".to_string()
} else {
format!(
"enabled_background=true requires {} so background bash tasks can be observed and cancelled",
missing.join(" and ")
)
"enabled_background=true requires {} so background bash tasks can be observed and cancelled",
missing.join(" and ")
)
};
RequirementError::new(fq_tool_id, message)
.with_field_path("params.enabled_background")
@ -1848,9 +1874,9 @@ fn explain_requirement_failure(
RequirementError::new(
fq_tool_id,
format!(
"task requires {} so spawned background subagents can be monitored and cancelled",
missing.join(" and ")
),
"task requires {} so spawned background subagents can be monitored and cancelled",
missing.join(" and ")
),
)
.with_field_path("tools")
.with_expected("include get_task_output and kill_task")
@ -2060,11 +2086,10 @@ mod tests {
ToolConfig {
id: "GrokBuild:search_replace".to_string(),
params: Some(
serde_json::json!({
"skip_read_before_edit" : true })
.as_object()
.unwrap()
.clone(),
serde_json::json!({ "skip_read_before_edit": true })
.as_object()
.unwrap()
.clone(),
),
name_override: None,
params_name_overrides: None,
@ -2084,10 +2109,12 @@ mod tests {
let result = toolset
.call(
"search_replace",
serde_json::json!(
{ "file_path" : "test.txt", "old_string" : "aaa", "new_string" :
"ccc", "replace_all" : false, }
),
serde_json::json!({
"file_path": "test.txt",
"old_string": "aaa",
"new_string": "ccc",
"replace_all": false,
}),
"test-call",
None,
)
@ -2290,7 +2317,7 @@ mod tests {
});
let merged = merge_tool_meta(
&toolset,
Some(serde_json::json!({ "bash_mode" : true })),
Some(serde_json::json!({"bash_mode": true})),
"run_terminal_cmd",
Some(&bash),
)
@ -2300,7 +2327,7 @@ mod tests {
assert_eq!(merged[TOOL_META_KEY]["input"]["command"], "ls");
let unchanged = merge_tool_meta(
&toolset,
Some(serde_json::json!({ "backend" : true })),
Some(serde_json::json!({"backend": true})),
"not_a_registered_tool",
None,
)
@ -2340,11 +2367,11 @@ mod tests {
let parse = |v: serde_json::Value| -> ToolConfig {
serde_json::from_value(v).expect("ToolConfig deserializes")
};
let known = parse(serde_json::json!({ "id" : "GrokBuild:read_file", "kind" : "read" }));
let known = parse(serde_json::json!({"id": "GrokBuild:read_file", "kind": "read"}));
assert_eq!(known.kind, Some(ToolKind::Read));
let typo = parse(serde_json::json!({ "id" : "GrokBuild:read_file", "kind" : "raed" }));
let typo = parse(serde_json::json!({"id": "GrokBuild:read_file", "kind": "raed"}));
assert_eq!(typo.kind, Some(ToolKind::Other));
let absent = parse(serde_json::json!({ "id" : "GrokBuild:read_file" }));
let absent = parse(serde_json::json!({"id": "GrokBuild:read_file"}));
assert_eq!(absent.kind, None);
}
/// End-to-end: a `params_name_overrides` rename of `old_string` must flow
@ -2355,6 +2382,7 @@ mod tests {
let builder = ToolRegistryBuilder::new();
let config = ToolServerConfig {
tools: vec![
// read_file satisfies search_replace's Read requirement.
ToolConfig {
id: "GrokBuild:read_file".to_string(),
params: None,
@ -2438,7 +2466,7 @@ mod tests {
},
ToolConfig {
id: "GrokBuild:search_replace".to_string(),
params: None,
params: None, // default: skip_read_before_edit = false
name_override: None,
params_name_overrides: None,
description_override: None,
@ -2458,7 +2486,7 @@ mod tests {
toolset
.call(
"read_file",
serde_json::json!({ "target_file" : * fname }),
serde_json::json!({ "target_file": *fname }),
"read-call",
None,
)
@ -2468,10 +2496,12 @@ mod tests {
let result = toolset
.call(
"search_replace",
serde_json::json!(
{ "file_path" : "dup.txt", "old_string" : "aaa", "new_string" :
"ccc", "replace_all" : false, }
),
serde_json::json!({
"file_path": "dup.txt",
"old_string": "aaa",
"new_string": "ccc",
"replace_all": false,
}),
"call-2",
None,
)
@ -2493,10 +2523,11 @@ mod tests {
let result = toolset
.call(
"search_replace",
serde_json::json!(
{ "file_path" : "no_match.txt", "old_string" : "nonexistent_string",
"new_string" : "replacement", }
),
serde_json::json!({
"file_path": "no_match.txt",
"old_string": "nonexistent_string",
"new_string": "replacement",
}),
"call-3",
None,
)
@ -2542,7 +2573,7 @@ mod tests {
ToolConfig {
id: "GrokBuildConcise:run_terminal_cmd".to_string(),
params: Some(
serde_json::json!({ "enabled_background" : true })
serde_json::json!({ "enabled_background": true })
.as_object()
.unwrap()
.clone(),
@ -2577,7 +2608,7 @@ mod tests {
let result = toolset
.call(
"read_file",
serde_json::json!({ "target_file" : "hello.txt" }),
serde_json::json!({ "target_file": "hello.txt" }),
"call-concise-1",
None,
)
@ -2673,7 +2704,7 @@ mod tests {
ToolConfig {
id: "Codex:read_file".to_string(),
params: None,
name_override: None,
name_override: None, // both resolve to "read_file"
params_name_overrides: None,
description_override: None,
behavior_version: None,
@ -2698,8 +2729,9 @@ mod tests {
tools: vec![ToolConfig {
id: "GrokBuild:run_terminal_cmd".to_string(),
params: Some(
serde_json::from_value(serde_json::json!({ "enabled_background" :
"yes" }))
serde_json::from_value(serde_json::json!({
"enabled_background": "yes"
}))
.unwrap(),
),
name_override: None,
@ -2728,7 +2760,10 @@ mod tests {
tools: vec![ToolConfig {
id: "GrokBuildHashline:hashline_read".to_string(),
params: Some(
serde_json::from_value(serde_json::json!({ "hash_len" : 0 })).unwrap(),
serde_json::from_value(serde_json::json!({
"hash_len": 0
}))
.unwrap(),
),
name_override: None,
params_name_overrides: None,
@ -2756,7 +2791,7 @@ mod tests {
ToolConfig {
id: "GrokBuild:read_file".to_string(),
params: None,
name_override: None,
name_override: None, // client_name = "read_file"
params_name_overrides: None,
description_override: None,
behavior_version: None,
@ -2765,7 +2800,7 @@ mod tests {
ToolConfig {
id: "Codex:read_file".to_string(),
params: None,
name_override: Some("codex_read_file".to_string()),
name_override: Some("codex_read_file".to_string()), // disambiguated
params_name_overrides: None,
description_override: None,
behavior_version: None,
@ -2879,16 +2914,16 @@ mod tests {
FakeMcpTool {
description: "Create or update a Linear issue".into(),
},
Some(serde_json::json!({ "type" : "object", "properties" : {} })),
Some(serde_json::json!({"type": "object", "properties": {}})),
)
.unwrap();
let result = toolset
.call(
"use_tool",
serde_json::json!(
{ "tool_name" : "linear__save_issue", "tool_input" : { "title" :
"hello" } }
),
serde_json::json!({
"tool_name": "linear__save_issue",
"tool_input": {"title": "hello"}
}),
"call-1",
None,
)
@ -3003,7 +3038,7 @@ mod tests {
.register_tool(
"stub".to_string(),
NonStreamingStub,
Some(serde_json::json!({ "type" : "object", "properties" : {} })),
Some(serde_json::json!({"type": "object", "properties": {}})),
)
.unwrap();
let result = toolset
@ -3036,7 +3071,7 @@ mod tests {
.register_tool(
"streamer".to_string(),
StreamingStub,
Some(serde_json::json!({ "type" : "object", "properties" : {} })),
Some(serde_json::json!({"type": "object", "properties": {}})),
)
.unwrap();
let mut stream = toolset.call_streaming("streamer", serde_json::json!({}), "call-b", None);
@ -3150,7 +3185,7 @@ mod tests {
.register_tool(
"no_terminal".to_string(),
NoTerminalStub,
Some(serde_json::json!({ "type" : "object", "properties" : {} })),
Some(serde_json::json!({"type": "object", "properties": {}})),
)
.unwrap();
let err = toolset
@ -3182,7 +3217,7 @@ mod tests {
FakeMcpTool {
description: "Create or update a Linear issue".into(),
},
Some(serde_json::json!({ "type" : "object", "properties" : {} })),
Some(serde_json::json!({"type": "object", "properties": {}})),
)
.unwrap();
assert_eq!(toolset.tool_definitions().len(), 3);
@ -3359,7 +3394,7 @@ mod tests {
tools: vec![ToolConfig {
id: "GrokBuild:run_terminal_cmd".to_string(),
params: Some(
serde_json::json!({ "enabled_background" : false })
serde_json::json!({ "enabled_background": false })
.as_object()
.unwrap()
.clone(),
@ -3410,7 +3445,7 @@ mod tests {
ToolConfig {
id: "GrokBuild:run_terminal_cmd".to_string(),
params: Some(
serde_json::json!({ "enabled_background" : true })
serde_json::json!({ "enabled_background": true })
.as_object()
.unwrap()
.clone(),
@ -3550,11 +3585,10 @@ mod tests {
tools: vec![ToolConfig {
id: "GrokBuild:run_terminal_cmd".to_string(),
params: Some(
serde_json::json!({ "enabled_background" : false,
"auto_background_on_timeout" : true })
.as_object()
.unwrap()
.clone(),
serde_json::json!({ "enabled_background": false, "auto_background_on_timeout": true })
.as_object()
.unwrap()
.clone(),
),
name_override: None,
params_name_overrides: None,
@ -3587,11 +3621,10 @@ mod tests {
tools: vec![ToolConfig {
id: "GrokBuild:run_terminal_cmd".to_string(),
params: Some(
serde_json::json!({ "enabled_background" : false,
"auto_background_on_timeout" : false })
.as_object()
.unwrap()
.clone(),
serde_json::json!({ "enabled_background": false, "auto_background_on_timeout": false })
.as_object()
.unwrap()
.clone(),
),
name_override: None,
params_name_overrides: None,
@ -3633,11 +3666,10 @@ mod tests {
tools: vec![ToolConfig {
id: "GrokBuild:run_terminal_cmd".to_string(),
params: Some(
serde_json::json!({ "enabled_background" : false,
"auto_background_on_timeout" : false })
.as_object()
.unwrap()
.clone(),
serde_json::json!({ "enabled_background": false, "auto_background_on_timeout": false })
.as_object()
.unwrap()
.clone(),
),
name_override: None,
params_name_overrides: None,
@ -4084,11 +4116,10 @@ mod tests {
ToolConfig {
id: "GrokBuildHashline:hashline_read".to_owned(),
params: Some(
serde_json::json!({ "scheme" : "chunk", "hash_len" : 2, "chunk_size"
: 16 })
.as_object()
.unwrap()
.clone(),
serde_json::json!({"scheme": "chunk", "hash_len": 2, "chunk_size": 16})
.as_object()
.unwrap()
.clone(),
),
name_override: None,
params_name_overrides: None,
@ -4124,7 +4155,7 @@ mod tests {
ToolConfig {
id: "GrokBuild:run_terminal_cmd".to_owned(),
params: Some(
serde_json::json!({ "enabled_background" : true })
serde_json::json!({ "enabled_background": true })
.as_object()
.unwrap()
.clone(),
@ -4164,7 +4195,7 @@ mod tests {
let result = bridge
.call(
"list_dir",
serde_json::json!({ "target_directory" : tmp.path().to_str().unwrap() }),
serde_json::json!({ "target_directory": tmp.path().to_str().unwrap() }),
"test-call-id",
)
.await
@ -4183,9 +4214,7 @@ mod tests {
let test_dir = tmp.path().join("testdir");
std::fs::create_dir_all(&test_dir).unwrap();
std::fs::write(test_dir.join("parity.txt"), "test").unwrap();
let args = serde_json::json!(
{ "target_directory" : test_dir.to_str().unwrap() }
);
let args = serde_json::json!({ "target_directory": test_dir.to_str().unwrap() });
let hub_bridge = grok_build_bridge(&tmp).await;
let hub_result = hub_bridge
.call("list_dir", args.clone(), "hub-call")
@ -4195,9 +4224,8 @@ mod tests {
let legacy_test_dir = legacy_tmp.path().join("testdir");
std::fs::create_dir_all(&legacy_test_dir).unwrap();
std::fs::write(legacy_test_dir.join("parity.txt"), "test").unwrap();
let legacy_args = serde_json::json!(
{ "target_directory" : legacy_test_dir.to_str().unwrap() }
);
let legacy_args =
serde_json::json!({ "target_directory": legacy_test_dir.to_str().unwrap() });
let builder = ToolRegistryBuilder::new();
let config = ToolServerConfig {
tools: vec![ToolConfig::for_tool::<grok_build::ListDirTool>()],
@ -4231,7 +4259,7 @@ mod tests {
bridge
.call(
"read_file",
serde_json::json!({ "target_file" : file.to_str().unwrap() }),
serde_json::json!({ "target_file": file.to_str().unwrap() }),
"read-call",
)
.await
@ -4239,10 +4267,11 @@ mod tests {
let result = bridge
.call(
"search_replace",
serde_json::json!(
{ "file_path" : file.to_str().unwrap(), "old_string" : "hello",
"new_string" : "goodbye" }
),
serde_json::json!({
"file_path": file.to_str().unwrap(),
"old_string": "hello",
"new_string": "goodbye"
}),
"edit-call",
)
.await
@ -4263,10 +4292,10 @@ mod tests {
let result = bridge
.call(
"run_terminal_cmd",
serde_json::json!(
{ "command" : "echo hub_dispatch_test_sentinel", "description" :
"test" }
),
serde_json::json!({
"command": "echo hub_dispatch_test_sentinel",
"description": "test"
}),
"bash-call",
)
.await
@ -4423,7 +4452,7 @@ mod tests {
let parts = toolset
.prepare_dispatch(
"read_file",
serde_json::json!({ "target_file" : "noop" }),
serde_json::json!({"target_file": "noop"}),
"test-call",
None,
)
@ -4441,7 +4470,7 @@ mod tests {
let parts = toolset
.prepare_dispatch(
"read_file",
serde_json::json!({ "target_file" : "noop" }),
serde_json::json!({"target_file": "noop"}),
"test-call",
None,
)
@ -4466,7 +4495,7 @@ mod tests {
tools: vec![ToolConfig {
id: "GrokBuild:run_terminal_cmd".to_string(),
params: Some(
serde_json::json!({ "enabled_background" : false })
serde_json::json!({"enabled_background": false})
.as_object()
.unwrap()
.clone(),
@ -4494,10 +4523,10 @@ mod tests {
);
let mut stream = toolset.call_streaming(
"run_terminal_cmd",
serde_json::json!(
{ "command" : "for i in 1 2 3; do echo $i; sleep 0.1; done",
"description" : "stream progress test" }
),
serde_json::json!({
"command": "for i in 1 2 3; do echo $i; sleep 0.1; done",
"description": "stream progress test"
}),
"test-call",
None,
);

View file

@ -643,11 +643,14 @@ impl Reminder for TaskCompletionReminder {
.chain(&reserved_ids)
.cloned()
.collect::<Vec<_>>();
let (terminal, event_sender) = {
let (terminal, event_sender, session_id) = {
let res = resources.lock().await;
(
res.get::<Terminal>().map(|t| t.0.clone()),
res.get::<SubagentEventSender>().cloned(),
res.get::<crate::implementations::grok_build::task::types::SessionIdResource>()
.map(|s| s.0.clone())
.unwrap_or_default(),
)
};
let mut reminders = Vec::new();
@ -730,6 +733,7 @@ impl Reminder for TaskCompletionReminder {
if sender
.0
.send(SubagentEvent::Completions(SubagentCompletionsRequest {
session_id,
suppress_ids,
respond_to: tx,
}))
@ -1434,6 +1438,7 @@ mod tests {
fn make_subagent_completion(id: &str, success: bool) -> SubagentCompletionSummary {
SubagentCompletionSummary {
subagent_id: id.into(),
owner_session_id: String::new(),
subagent_type: "general-purpose".into(),
description: "test task".into(),
success,
@ -1915,8 +1920,9 @@ mod tests {
"batch must lead with event + monitor counts and default tool hint: {batched}"
);
assert!(
batched
.contains("<monitor description=\"alpha\" task_id=\"task-0\">\n[1] a first\n[2] a second\n</monitor>"),
batched.contains(
"<monitor description=\"alpha\" task_id=\"task-0\">\n[1] a first\n[2] a second\n</monitor>"
),
"task-0 group: description once on the tag, ordinal tick labels: {batched}"
);
assert!(

View file

@ -126,13 +126,14 @@ impl schemars::JsonSchema for ToolKind {
.filter_map(|v| v.as_str().map(|s| format!("`{s}`")))
.collect::<Vec<_>>()
.join(", ");
schemars::json_schema!(
{ "type" : "string", "description" :
format!("Categorizes what a tool does at a high level. Open set — consumers must \
schemars::json_schema!({
"type": "string",
"description": format!(
"Categorizes what a tool does at a high level. Open set — consumers must \
tolerate unknown values (Rust deserializes them to `other` via \
`#[serde(other)]`). Known values: {known}."),
}
)
`#[serde(other)]`). Known values: {known}."
),
})
}
}
/// Canonical identity for a tool call, resolved from a tool's registered
@ -313,7 +314,7 @@ mod tests {
let meta = CanonicalToolMeta::new(
"read_file",
&identity(ToolKind::Read),
Some(serde_json::json!({ "path" : "/a" })),
Some(serde_json::json!({ "path": "/a" })),
);
let t = serde_json::to_value(&meta).unwrap();
assert_eq!(t["version"], serde_json::json!(TOOL_META_VERSION));
@ -367,7 +368,7 @@ mod tests {
#[test]
fn merge_into_nests_under_one_key_and_preserves_existing() {
let meta = CanonicalToolMeta::new("run_terminal_cmd", &identity(ToolKind::Execute), None);
let merged = meta.merge_into(Some(serde_json::json!({ "bash_mode" : true })));
let merged = meta.merge_into(Some(serde_json::json!({"bash_mode": true})));
let o = merged.as_object().unwrap();
assert_eq!(o["bash_mode"], true, "existing meta must be preserved");
let t = &o[TOOL_META_KEY];

View file

@ -115,10 +115,12 @@ impl MediaGenOutput {
let message = format!(
"{action} and saved to {path}. Do not read or re-display it, and do not describe how it appears to the user."
);
serde_json::json!(
{ "path" : path, "filename" : & self.filename, "session_folder" : & self
.session_folder, "message" : message, }
)
serde_json::json!({
"path": path,
"filename": &self.filename,
"session_folder": &self.session_folder,
"message": message,
})
.to_string()
}
}
@ -1413,8 +1415,7 @@ mod tests {
to_json(ReadFileOutput::FileNotFound("Error: /tmp/x does not exist.".into()).into());
assert_eq!(
json,
json!({ "type" : "ReadFile", "FileNotFound" :
"Error: /tmp/x does not exist." })
json!({"type": "ReadFile", "FileNotFound": "Error: /tmp/x does not exist."})
);
}
#[test]
@ -1423,8 +1424,7 @@ mod tests {
to_json(ReadFileOutput::IsADirectory("Error: /tmp is a directory.".into()).into());
assert_eq!(
json,
json!({ "type" : "ReadFile", "IsADirectory" :
"Error: /tmp is a directory." })
json!({"type": "ReadFile", "IsADirectory": "Error: /tmp is a directory."})
);
}
#[test]
@ -1434,8 +1434,7 @@ mod tests {
);
assert_eq!(
json,
json!({ "type" : "ReadFile", "PermissionDenied" :
"Permission denied: /etc/shadow" })
json!({"type": "ReadFile", "PermissionDenied": "Permission denied: /etc/shadow"})
);
}
#[test]
@ -1448,9 +1447,7 @@ mod tests {
);
assert_eq!(
json,
json!({ "type" : "ReadFile", "FileTooLarge" :
"File content (37044 tokens) exceeds maximum allowed tokens (25000 tokens)."
})
json!({"type": "ReadFile", "FileTooLarge": "File content (37044 tokens) exceeds maximum allowed tokens (25000 tokens)."})
);
}
#[test]
@ -1458,7 +1455,7 @@ mod tests {
let json = to_json(ReadFileOutput::FileReadError("Failed to read file".into()).into());
assert_eq!(
json,
json!({ "type" : "ReadFile", "FileReadError" : "Failed to read file" })
json!({"type": "ReadFile", "FileReadError": "Failed to read file"})
);
}
#[test]
@ -1466,7 +1463,7 @@ mod tests {
let json = to_json(ReadFileOutput::ImageSizeError("Image too large".into()).into());
assert_eq!(
json,
json!({ "type" : "ReadFile", "ImageSizeError" : "Image too large" })
json!({"type": "ReadFile", "ImageSizeError": "Image too large"})
);
}
#[test]
@ -1474,20 +1471,20 @@ mod tests {
let json = to_json(ListDirOutput::NotFound("does not exist".into()).into());
assert_eq!(
json,
json!({ "type" : "ListDir", "NotFound" : "does not exist" })
json!({"type": "ListDir", "NotFound": "does not exist"})
);
}
#[test]
fn list_dir_is_a_file_json() {
let json = to_json(ListDirOutput::IsAFile("is a file".into()).into());
assert_eq!(json, json!({ "type" : "ListDir", "IsAFile" : "is a file" }));
assert_eq!(json, json!({"type": "ListDir", "IsAFile": "is a file"}));
}
#[test]
fn list_dir_not_a_directory_json() {
let json = to_json(ListDirOutput::NotADirectory("is not a directory".into()).into());
assert_eq!(
json,
json!({ "type" : "ListDir", "NotADirectory" : "is not a directory" })
json!({"type": "ListDir", "NotADirectory": "is not a directory"})
);
}
#[test]
@ -1495,20 +1492,20 @@ mod tests {
let json = to_json(ListDirOutput::PermissionDenied("Permission denied".into()).into());
assert_eq!(
json,
json!({ "type" : "ListDir", "PermissionDenied" : "Permission denied" })
json!({"type": "ListDir", "PermissionDenied": "Permission denied"})
);
}
#[test]
fn list_dir_generic_error_json() {
let json = to_json(ListDirOutput::Error("Some error".into()).into());
assert_eq!(json, json!({ "type" : "ListDir", "Error" : "Some error" }));
assert_eq!(json, json!({"type": "ListDir", "Error": "Some error"}));
}
#[test]
fn search_replace_file_not_found_json() {
let json = to_json(SearchReplaceOutput::FileNotFound("not found".into()).into());
assert_eq!(
json,
json!({ "type" : "SearchReplace", "FileNotFound" : "not found" })
json!({"type": "SearchReplace", "FileNotFound": "not found"})
);
}
#[test]
@ -1523,8 +1520,13 @@ mod tests {
);
assert_eq!(
json,
json!({ "type" : "SearchReplace", "NoMatchesFound" : { "message" :
"no matches", "file_path" : "/project/src/main.c" } })
json!({
"type": "SearchReplace",
"NoMatchesFound": {
"message": "no matches",
"file_path": "/project/src/main.c"
}
})
);
}
#[test]
@ -1548,8 +1550,7 @@ mod tests {
let json = to_json(SearchReplaceOutput::MultipleMatchesFound("3 matches".into()).into());
assert_eq!(
json,
json!({ "type" : "SearchReplace", "MultipleMatchesFound" : "3 matches"
})
json!({"type": "SearchReplace", "MultipleMatchesFound": "3 matches"})
);
}
#[test]
@ -1557,7 +1558,7 @@ mod tests {
let json = to_json(SearchReplaceOutput::FileAlreadyExists("exists".into()).into());
assert_eq!(
json,
json!({ "type" : "SearchReplace", "FileAlreadyExists" : "exists" })
json!({"type": "SearchReplace", "FileAlreadyExists": "exists"})
);
}
#[test]
@ -1565,7 +1566,7 @@ mod tests {
let json = to_json(SearchReplaceOutput::InvalidInput("same strings".into()).into());
assert_eq!(
json,
json!({ "type" : "SearchReplace", "InvalidInput" : "same strings" })
json!({"type": "SearchReplace", "InvalidInput": "same strings"})
);
}
#[test]
@ -1573,8 +1574,7 @@ mod tests {
let json = to_json(SearchReplaceOutput::FilenameTooLong("name too long".into()).into());
assert_eq!(
json,
json!({ "type" : "SearchReplace", "FilenameTooLong" : "name too long"
})
json!({"type": "SearchReplace", "FilenameTooLong": "name too long"})
);
}
#[test]
@ -1602,8 +1602,10 @@ mod tests {
);
assert_eq!(
json,
json!({ "type" : "KillTask", "TaskNotFound" :
"Task abc not found. No background tasks exist in this session." })
json!({
"type": "KillTask",
"TaskNotFound": "Task abc not found. No background tasks exist in this session."
})
);
}
#[test]
@ -1612,8 +1614,7 @@ mod tests {
let serialized = serde_json::to_value(&original).unwrap();
let deserialized: KillTaskOutput = serde_json::from_value(serialized).unwrap();
assert!(
matches!(deserialized, KillTaskOutput::TaskNotFound(ref msg) if msg ==
"not found")
matches!(deserialized, KillTaskOutput::TaskNotFound(ref msg) if msg == "not found")
);
}
#[test]
@ -1722,8 +1723,10 @@ mod tests {
);
assert_eq!(
json,
json!({ "type" : "TaskOutput", "TaskNotFound" :
"Task xyz not found. Known task IDs: [task-1, task-2]" })
json!({
"type": "TaskOutput",
"TaskNotFound": "Task xyz not found. Known task IDs: [task-1, task-2]"
})
);
}
#[test]
@ -1732,8 +1735,7 @@ mod tests {
let serialized = serde_json::to_value(&original).unwrap();
let deserialized: TaskOutputOutput = serde_json::from_value(serialized).unwrap();
assert!(
matches!(deserialized, TaskOutputOutput::TaskNotFound(ref msg) if msg ==
"not found")
matches!(deserialized, TaskOutputOutput::TaskNotFound(ref msg) if msg == "not found")
);
}
#[test]
@ -1774,8 +1776,9 @@ mod tests {
);
assert_eq!(
json,
json!({ "type" : "Todo", "DuplicateId" :
"Duplicate todo ID in request: \"dup\". Each todo item must have a unique ID."
json!({
"type": "Todo",
"DuplicateId": "Duplicate todo ID in request: \"dup\". Each todo item must have a unique ID."
})
);
}
@ -1784,10 +1787,7 @@ mod tests {
let original = TodoWriteOutput::DuplicateId("dup id".into());
let serialized = serde_json::to_value(&original).unwrap();
let deserialized: TodoWriteOutput = serde_json::from_value(serialized).unwrap();
assert!(
matches!(deserialized, TodoWriteOutput::DuplicateId(ref msg) if msg ==
"dup id")
);
assert!(matches!(deserialized, TodoWriteOutput::DuplicateId(ref msg) if msg == "dup id"));
}
#[test]
fn todo_write_success_round_trip() {
@ -2063,10 +2063,12 @@ mod tests {
}
#[test]
fn enter_plan_mode_output_serde_defaults_tool_hints_when_absent() {
let json = json!(
{ "Entered" : { "message" : "Entered plan mode.", "plan_file_path" :
"/tmp/plan.md" } }
);
let json = json!({
"Entered": {
"message": "Entered plan mode.",
"plan_file_path": "/tmp/plan.md"
}
});
let deserialized: EnterPlanModeOutput = serde_json::from_value(json).unwrap();
match deserialized {
EnterPlanModeOutput::Entered {
@ -2115,10 +2117,12 @@ mod tests {
}
#[test]
fn enter_plan_mode_absent_seed_field_prompt_is_missing() {
let json = json!(
{ "Entered" : { "message" : "Entered plan mode.", "plan_file_path" :
"/tmp/plan.md" } }
);
let json = json!({
"Entered": {
"message": "Entered plan mode.",
"plan_file_path": "/tmp/plan.md"
}
});
let deserialized: EnterPlanModeOutput = serde_json::from_value(json).unwrap();
let prompt = ToolOutput::EnterPlanMode(deserialized).to_prompt_format();
assert!(
@ -2170,7 +2174,7 @@ mod tests {
let json = serde_json::to_value(&output).unwrap();
assert_eq!(
json["Entered"]["plan_file_seed"],
json!({ "missing" : "not_a_file" })
json!({ "missing": "not_a_file" })
);
let back: EnterPlanModeOutput = serde_json::from_value(json).unwrap();
let EnterPlanModeOutput::Entered { plan_file_seed, .. } = back;

View file

@ -1109,14 +1109,12 @@ mod tests {
let mut state_map = HashMap::new();
state_map.insert(
"grok_build.ReadFile".to_string(),
serde_json::json!({ "files_read" : ["loaded.rs"] }),
serde_json::json!({"files_read": ["loaded.rs"]}),
);
let mut params_map = HashMap::new();
params_map.insert(
"grok_build.Edit".to_string(),
serde_json::json!(
{ "skip_read_before_edit" : true, "max_file_size" : 512 }
),
serde_json::json!({"skip_read_before_edit": true, "max_file_size": 512}),
);
let mut data = HashMap::new();
data.insert("state".to_string(), state_map);
@ -1135,11 +1133,11 @@ mod tests {
let mut state_map = HashMap::new();
state_map.insert(
"unknown.Type".to_string(),
serde_json::json!({ "foo" : "bar" }),
serde_json::json!({"foo": "bar"}),
);
state_map.insert(
"grok_build.ReadFile".to_string(),
serde_json::json!({ "files_read" : ["ok.rs"] }),
serde_json::json!({"files_read": ["ok.rs"]}),
);
let mut data = HashMap::new();
data.insert("state".to_string(), state_map);
@ -1176,7 +1174,7 @@ mod tests {
let ok = res.set_json(
"params",
"grok_build.Edit",
serde_json::json!({ "skip_read_before_edit" : true }),
serde_json::json!({"skip_read_before_edit": true}),
);
assert!(ok);
let config = res.get::<Params<EditConfig>>().unwrap();

View file

@ -10,7 +10,7 @@ impl schemars::JsonSchema for GrokIntegerSchema {
"grok_integer_schema".into()
}
fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
schemars::json_schema!({ "type" : "integer" })
schemars::json_schema!({ "type": "integer" })
}
}
/// Largest whole value exactly representable as `f64` (2^53). JSON floats above this

View file

@ -174,9 +174,9 @@ mod tests {
before_context: None,
after_context: None,
context: None,
case_insensitive: None,
case_insensitive: false,
head_limit: None,
multiline: None,
multiline: false,
r#type: None,
})
.try_into();
@ -195,7 +195,7 @@ mod tests {
}
#[test]
fn dynamic_input_holds_arbitrary_json() {
let input = ToolInput::Dynamic(serde_json::json!({ "custom" : "data" }));
let input = ToolInput::Dynamic(serde_json::json!({"custom": "data"}));
match input {
ToolInput::Dynamic(v) => {
assert_eq!(v["custom"], "data");