1900 lines
77 KiB
Rust
1900 lines
77 KiB
Rust
//! Background task and subagent completion reminder.
|
||
//!
|
||
//! On each tool call, [`TaskCompletionReminder`] queries the
|
||
//! [`TerminalBackend`] (already on `SharedResources`) via `list_tasks()`
|
||
//! and reports any newly-completed background tasks as plain reminder
|
||
//! text. It also queries the subagent coordinator via
|
||
//! [`SubagentEventSender`] for newly-completed subagents.
|
||
//!
|
||
//! The tool pipeline wraps each string in `<system-reminder>` tags
|
||
//! inside the tool result so the model learns about completions without
|
||
//! polling `get_task_output`.
|
||
//!
|
||
//! A [`ReportedTaskCompletions`] state set tracks which task/subagent IDs
|
||
//! have already been surfaced, preventing duplicate reminders.
|
||
use crate::bridge::ToolBridge;
|
||
use crate::implementations::grok_build::task::types::{
|
||
SubagentCompletionSummary, SubagentCompletionsRequest, SubagentEvent, SubagentEventSender,
|
||
};
|
||
use crate::types::TaskSnapshot;
|
||
use crate::types::output::ToolOutput;
|
||
use crate::types::resources::{SharedResources, State, Terminal};
|
||
use crate::types::tool::{Reminder, ToolKind};
|
||
use crate::util::truncate::{PREVIEW_SIZE, truncate_with_preview};
|
||
use std::collections::HashSet;
|
||
use std::sync::Arc;
|
||
use xai_tool_types::KillTaskOutput;
|
||
use xai_tool_types::SubagentCompletedOutput;
|
||
use xai_tool_types::TaskOutputOutput;
|
||
/// Default tool name used in auto-wake completion messages.
|
||
pub const DEFAULT_TASK_OUTPUT_TOOL: &str = "get_task_output";
|
||
/// Inline preview cap applied ONLY to bash completion reminders that ship
|
||
/// with a disk-pointer footer. Subagent completions (which have no
|
||
/// disk-backed output file) are never truncated -- the inline branch is
|
||
/// their only chance to see the output.
|
||
const MAX_INLINE_COMPLETION_BYTES: usize = 4_000;
|
||
/// Shared set of IDs that have already been delivered via auto-wake synthetic
|
||
/// prompts. `TaskCompletionReminder` drains this set on each reminder pass
|
||
/// and extends its suppress list, preventing duplicate reminders for
|
||
/// completions that already triggered an auto-wake turn.
|
||
#[derive(Clone, Debug, Default)]
|
||
pub struct AutoWakeDeliveredIds(pub Arc<std::sync::Mutex<HashSet<String>>>);
|
||
impl AutoWakeDeliveredIds {
|
||
/// Insert an ID into the delivered set.
|
||
pub fn insert(&self, id: String) {
|
||
self.0.lock().unwrap_or_else(|e| e.into_inner()).insert(id);
|
||
}
|
||
/// Remove a single ID from the delivered set (e.g. when a synthetic
|
||
/// prompt is preempted or cancelled before being processed).
|
||
pub fn remove(&self, id: &str) {
|
||
self.0.lock().unwrap_or_else(|e| e.into_inner()).remove(id);
|
||
}
|
||
/// Return `true` if `id` is currently marked as delivered, without
|
||
/// draining the set. Preferred over [`snapshot`](Self::snapshot) for a
|
||
/// single-membership check on a hot path (e.g. per monitor stdout event):
|
||
/// it avoids cloning every ID into a `Vec`.
|
||
pub fn contains(&self, id: &str) -> bool {
|
||
self.0
|
||
.lock()
|
||
.unwrap_or_else(|e| e.into_inner())
|
||
.contains(id)
|
||
}
|
||
/// Drain all IDs from the set, returning them.
|
||
pub fn drain(&self) -> Vec<String> {
|
||
let mut guard = self.0.lock().unwrap_or_else(|e| e.into_inner());
|
||
guard.drain().collect()
|
||
}
|
||
/// Return a snapshot of the currently-marked IDs **without** draining them.
|
||
///
|
||
/// Used by the between-turn completion drain in `xai-grok-shell` to
|
||
/// suppress completions already delivered via auto-wake synthetic prompts.
|
||
/// Unlike [`drain`](Self::drain) (the per-tool-call surface's consumption
|
||
/// point), this is read-only so the existing drain/un-mark lifecycle —
|
||
/// `TaskCompletionReminder` draining on each tool call and the
|
||
/// preempt/cancel paths un-marking dropped synthetic prompts — stays the
|
||
/// single source of truth for the set's contents.
|
||
pub fn snapshot(&self) -> Vec<String> {
|
||
self.0
|
||
.lock()
|
||
.unwrap_or_else(|e| e.into_inner())
|
||
.iter()
|
||
.cloned()
|
||
.collect()
|
||
}
|
||
}
|
||
crate::register_resource!("grok_build", "AutoWakeDeliveredIds", AutoWakeDeliveredIds);
|
||
/// Set of task IDs whose completion has already been surfaced as a
|
||
/// `<system-reminder>`. Persisted via `State<T>` so it survives across
|
||
/// tool calls within a session.
|
||
#[derive(Debug, Default, serde::Serialize, serde::Deserialize)]
|
||
pub struct ReportedTaskCompletions {
|
||
reported: HashSet<String>,
|
||
}
|
||
impl ReportedTaskCompletions {
|
||
/// Returns `true` if the ID was newly inserted.
|
||
pub fn mark_reported(&mut self, id: &str) -> bool {
|
||
if self.reported.contains(id) {
|
||
return false;
|
||
}
|
||
self.reported.insert(id.to_owned())
|
||
}
|
||
}
|
||
crate::register_resource!(
|
||
"grok_build",
|
||
"ReportedTaskCompletions",
|
||
ReportedTaskCompletions
|
||
);
|
||
/// Format a model-facing message from a [`TaskSnapshot`].
|
||
///
|
||
/// `task_output_name` controls the pointer-vs-inline rendering of the output
|
||
/// section (see [`render_completion_output_delivery`]). When the inline
|
||
/// branch fires and `read_tool_name` is set, the output is truncated and
|
||
/// followed by a footer pointing the model at `task.output_file` so the
|
||
/// full log is still recoverable from disk.
|
||
pub fn format_bash_completion(
|
||
task: &TaskSnapshot,
|
||
task_output_name: Option<&str>,
|
||
read_tool_name: Option<&str>,
|
||
) -> String {
|
||
let command = task.display_command.as_deref().unwrap_or(&task.command);
|
||
let duration_secs = task.duration_secs();
|
||
let status_str = match task.signal.as_deref() {
|
||
Some(sig) => format!("terminated by signal {sig}"),
|
||
None => {
|
||
let exit_code_str = task
|
||
.exit_code
|
||
.map(|c| c.to_string())
|
||
.unwrap_or_else(|| "unknown".into());
|
||
format!("exit code: {exit_code_str}")
|
||
}
|
||
};
|
||
let mut msg = format!(
|
||
"Background task \"{}\" completed ({}).\n\
|
||
Command: {} | Duration: {:.1}s\n",
|
||
task.task_id, status_str, command, duration_secs,
|
||
);
|
||
if task.signal.is_some() && duration_secs < 1.0 {
|
||
msg.push_str(
|
||
"Note: this is much shorter than expected for a backgrounded command. \
|
||
The wrapper bash may have been killed by signal (e.g. `pkill -f <pat>` \
|
||
matching its own argv) before the inner command ran. Re-check the \
|
||
command for self-matching kill patterns, signals sent by the script \
|
||
itself, or upstream sources of SIGTERM/SIGHUP.\n",
|
||
);
|
||
}
|
||
let disk_pointer_footer = read_tool_name.map(|name| {
|
||
format!(
|
||
"Use {} on {} for full content",
|
||
name,
|
||
task.output_file.display()
|
||
)
|
||
});
|
||
render_completion_output_delivery(
|
||
&mut msg,
|
||
&task.task_id,
|
||
&task.output,
|
||
task_output_name,
|
||
disk_pointer_footer.as_deref(),
|
||
);
|
||
msg
|
||
}
|
||
/// Format a model-facing auto-wake message for a completed **monitor** task.
|
||
///
|
||
/// Uses the same `[monitor ended: <reason>]` wording as the monitor pipeline's
|
||
/// terminal `MonitorEvent`, but is delivered via the immediate `TaskCompleted`
|
||
/// synthetic-prompt path (same as bash). That path does not depend on the
|
||
/// idle-gated `InjectNotification` drain, which was easy to miss when a monitor
|
||
/// exited while the agent was idle and produced no further stdout.
|
||
pub fn format_monitor_completion(task: &TaskSnapshot, task_output_name: Option<&str>) -> String {
|
||
let reason = match task.signal.as_deref() {
|
||
Some(sig) => format!("killed by signal {sig}"),
|
||
None => match task.exit_code {
|
||
Some(code) => format!("exited (code {code})"),
|
||
None => "ended".to_string(),
|
||
},
|
||
};
|
||
let description = task
|
||
.display_command
|
||
.as_deref()
|
||
.and_then(|d| d.strip_prefix("[monitor] "))
|
||
.unwrap_or("monitor");
|
||
let tool = task_output_name.unwrap_or(DEFAULT_TASK_OUTPUT_TOOL);
|
||
format!(
|
||
"Monitor \"{id}\" ended: [monitor ended: {reason}].\n\
|
||
Description: {description}\n\
|
||
Command: {cmd}\n\
|
||
Duration: {dur:.1}s\n\
|
||
Use {tool}(\"{id}\") for full output.",
|
||
id = task.task_id,
|
||
cmd = task.command,
|
||
dur = task.duration_secs(),
|
||
)
|
||
}
|
||
/// Warn the model about other background tasks that are still running.
|
||
fn format_running_tasks_warning(running: &[&TaskSnapshot], kill_task_name: Option<&str>) -> String {
|
||
use std::fmt::Write as _;
|
||
let n = running.len();
|
||
let label = if n == 1 { "task is" } else { "tasks are" };
|
||
let mut buf = format!("Note: {n} other background {label} still running:\n");
|
||
for task in running {
|
||
let cmd = task.display_command.as_deref().unwrap_or(&task.command);
|
||
let _ = writeln!(
|
||
buf,
|
||
"- \"{}\" (running for {:.0}s): {}",
|
||
task.task_id,
|
||
task.duration_secs(),
|
||
cmd,
|
||
);
|
||
}
|
||
let kill_name = kill_task_name.unwrap_or("kill_command_or_subagent");
|
||
let _ = write!(
|
||
buf,
|
||
"Consider killing duplicate tasks with {kill_name} before launching new ones."
|
||
);
|
||
buf
|
||
}
|
||
/// Split a pre-wrapped `<monitor-event description="…" task_id="…">…</monitor-event>`
|
||
/// into `(description, inner_text)`. `wrap_monitor_event` is the single
|
||
/// writer with fixed attribute order; the `rfind` of `" task_id="`
|
||
/// tolerates quotes inside the model-supplied description. `None` =>
|
||
/// caller includes the text verbatim.
|
||
fn split_wrapped_monitor_event(event_text: &str) -> Option<(&str, &str)> {
|
||
let rest = event_text.strip_prefix("<monitor-event description=\"")?;
|
||
let open_end = rest.find(">\n")?;
|
||
let open_tag = &rest[..open_end];
|
||
let desc_end = open_tag.rfind("\" task_id=\"")?;
|
||
let description = &open_tag[..desc_end];
|
||
let inner = rest[open_end + 2..].strip_suffix("\n</monitor-event>")?;
|
||
Some((description, inner))
|
||
}
|
||
/// Format drained [`MonitorEventNotification`]s for the turn loop's hidden
|
||
/// synthetic user message. Model-facing only — the pager renders monitor
|
||
/// events from the structured `x.ai/monitor_event` notification, never by
|
||
/// parsing this text.
|
||
///
|
||
/// One event (also the common case for slow monitors, which drain one
|
||
/// event per loop-top pass) renders the lean form:
|
||
///
|
||
/// ```text
|
||
/// <monitor-event task_id="0199…">
|
||
/// [heartbeat] beat 1
|
||
/// </monitor-event>
|
||
/// ```
|
||
///
|
||
/// Multiple events batch under one count preamble, grouped per monitor
|
||
/// (first-seen order, within-monitor order kept). Identity — description
|
||
/// AND task id — is stated once on the group tag; tick lines carry only
|
||
/// ordinals (a 100-tick monitor must not repeat its description 100×):
|
||
///
|
||
/// ```text
|
||
/// <monitor description="heartbeat" task_id="0199…">
|
||
/// [1] beat 1
|
||
/// [2] beat 2
|
||
/// </monitor>
|
||
/// ```
|
||
///
|
||
/// Buffered `event_text` arrives pre-wrapped (`wrap_monitor_event`); it is
|
||
/// unwrapped via [`split_wrapped_monitor_event`] with verbatim fallback.
|
||
pub fn format_monitor_events(
|
||
events: &[crate::implementations::grok_build::task::types::MonitorEventNotification],
|
||
task_output_name: Option<&str>,
|
||
) -> Option<String> {
|
||
use std::fmt::Write as _;
|
||
let tool_hint = task_output_name.unwrap_or("get_command_or_subagent_output");
|
||
match events {
|
||
[] => None,
|
||
[event] => {
|
||
let (label, inner) = match split_wrapped_monitor_event(&event.event_text) {
|
||
Some((desc, inner)) if !desc.is_empty() => (desc, inner),
|
||
Some((_, inner)) => ("event", inner),
|
||
None => ("event", event.event_text.as_str()),
|
||
};
|
||
let label =
|
||
crate::implementations::grok_build::monitor::event::sanitize_monitor_description(
|
||
label,
|
||
);
|
||
Some(format!(
|
||
"<monitor-event task_id=\"{}\">\n[{}] {}\n</monitor-event>",
|
||
event.task_id, label, inner,
|
||
))
|
||
}
|
||
_ => {
|
||
type Event = crate::implementations::grok_build::task::types::MonitorEventNotification;
|
||
let mut groups: Vec<(&str, Vec<&Event>)> = Vec::new();
|
||
for event in events {
|
||
match groups.iter_mut().find(|(id, _)| *id == event.task_id) {
|
||
Some((_, group)) => group.push(event),
|
||
None => groups.push((&event.task_id, vec![event])),
|
||
}
|
||
}
|
||
let mut buf = format!(
|
||
"{} monitor events from {} {} (use {} to identify each monitor):",
|
||
events.len(),
|
||
groups.len(),
|
||
if groups.len() == 1 {
|
||
"monitor"
|
||
} else {
|
||
"monitors"
|
||
},
|
||
tool_hint,
|
||
);
|
||
for (task_id, group) in &groups {
|
||
let description = group
|
||
.iter()
|
||
.find_map(|e| split_wrapped_monitor_event(&e.event_text))
|
||
.map(|(desc, _)| desc)
|
||
.filter(|d| !d.is_empty())
|
||
.unwrap_or("event");
|
||
let description = crate::implementations::grok_build::monitor::event::sanitize_monitor_description(
|
||
description,
|
||
);
|
||
let _ = write!(
|
||
buf,
|
||
"\n\n<monitor description=\"{description}\" task_id=\"{task_id}\">"
|
||
);
|
||
for (n, event) in group.iter().enumerate() {
|
||
let inner = split_wrapped_monitor_event(&event.event_text)
|
||
.map(|(_, inner)| inner)
|
||
.unwrap_or(&event.event_text);
|
||
let _ = write!(buf, "\n[{}] {}", n + 1, inner);
|
||
}
|
||
buf.push_str("\n</monitor>");
|
||
}
|
||
Some(buf)
|
||
}
|
||
}
|
||
}
|
||
/// Whether a background task should be surfaced to the session whose owner id
|
||
/// is `my_owner`.
|
||
///
|
||
/// Subagents share the parent's terminal backend, so `list_tasks()` returns
|
||
/// tasks owned by other sessions (the parent, sibling subagents). A task is in
|
||
/// scope only when it has no recorded owner (legacy / non-grok-build backends)
|
||
/// or its owner matches the current session; cross-session tasks are filtered
|
||
/// out so their completions surface in the owning session, not here.
|
||
pub(crate) fn task_owned_by_session(task: &TaskSnapshot, my_owner: Option<&str>) -> bool {
|
||
match (my_owner, task.owner_session_id.as_deref()) {
|
||
(Some(me), Some(owner)) => me == owner,
|
||
_ => true,
|
||
}
|
||
}
|
||
/// Append the completion-output delivery section for a bash task or subagent.
|
||
///
|
||
/// - `Some(name)` writes `Use {name}("{subagent_id}") to see the full output.`
|
||
/// (polling tool available; the model can pull the full output via that
|
||
/// tool on demand).
|
||
/// - `None` writes `response:\n{output}`. When `disk_pointer_footer` is
|
||
/// `Some(line)`, the output is capped at [`MAX_INLINE_COMPLETION_BYTES`]
|
||
/// and the footer line is appended so the model can recover the full log
|
||
/// from disk -- this is the bash-completion path. When
|
||
/// `disk_pointer_footer` is `None`, the full output is inlined verbatim
|
||
/// -- this is the subagent path (there is no disk-backed output file and
|
||
/// this notification is the model's only chance to see the output).
|
||
///
|
||
/// The pointer-vs-inline decision is centralised here so all completion
|
||
/// notification surfaces stay in lock-step. Callers control any leading
|
||
/// indentation or newlines around the section.
|
||
pub fn render_completion_output_delivery(
|
||
buf: &mut String,
|
||
subagent_id: &str,
|
||
output: &str,
|
||
task_output_name: Option<&str>,
|
||
disk_pointer_footer: Option<&str>,
|
||
) {
|
||
use std::fmt::Write as _;
|
||
match task_output_name {
|
||
Some(name) => {
|
||
let _ = write!(buf, "Use {name}(\"{subagent_id}\") to see the full output.");
|
||
}
|
||
None => match disk_pointer_footer {
|
||
Some(footer) => {
|
||
let (output, _) = truncate_with_preview(
|
||
output,
|
||
MAX_INLINE_COMPLETION_BYTES,
|
||
PREVIEW_SIZE,
|
||
Some(footer),
|
||
);
|
||
let _ = write!(buf, "response:\n{output}");
|
||
}
|
||
None => {
|
||
let _ = write!(buf, "response:\n{output}");
|
||
}
|
||
},
|
||
}
|
||
}
|
||
/// Resolve the active toolset's `BackgroundTaskAction` tool name (e.g.
|
||
/// `"get_command_or_subagent_output"`), or `None` when no such tool is registered.
|
||
///
|
||
/// Centralises the structural "is a polling tool available?" check so all
|
||
/// callers route the same answer into [`render_completion_output_delivery`].
|
||
pub async fn resolve_task_output_tool_name(bridge: &ToolBridge) -> Option<String> {
|
||
bridge.tool_for_kind(ToolKind::BackgroundTaskAction).await
|
||
}
|
||
/// Resolve the active toolset's `Read` tool name, used for the bash
|
||
/// completion disk-pointer footer in [`render_completion_output_delivery`].
|
||
pub async fn resolve_read_tool_name(bridge: &ToolBridge) -> Option<String> {
|
||
bridge.tool_for_kind(ToolKind::Read).await
|
||
}
|
||
/// Format a model-facing message from a [`SubagentCompletionSummary`] for
|
||
/// the next-tool-call reminder surface.
|
||
///
|
||
/// `task_output_name`: the resolved name of the BackgroundTaskAction tool
|
||
/// in the current agent's toolset. When `None`, the subagent's full
|
||
/// `output` is inlined verbatim -- this notification is the only place
|
||
/// the model will see it (no disk-backed output file exists for subagents).
|
||
pub fn format_subagent_completion(
|
||
c: &SubagentCompletionSummary,
|
||
task_output_name: Option<&str>,
|
||
) -> String {
|
||
let status = if c.success {
|
||
"successfully"
|
||
} else {
|
||
"with failure"
|
||
};
|
||
let mut out = format!(
|
||
"Background subagent \"{}\" ({}: \"{}\") completed {}.\n\
|
||
Duration: {:.1}s | Tool calls: {} | Turns: {}",
|
||
c.subagent_id,
|
||
c.subagent_type,
|
||
c.description,
|
||
status,
|
||
c.duration_ms as f64 / 1000.0,
|
||
c.tool_calls,
|
||
c.turns,
|
||
);
|
||
out.push_str(match task_output_name {
|
||
Some(_) => "\n",
|
||
None => "\n\n",
|
||
});
|
||
render_completion_output_delivery(&mut out, &c.subagent_id, &c.output, task_output_name, None);
|
||
out
|
||
}
|
||
/// Format buffered between-turn subagent completions into a system-reminder
|
||
/// string. When `task_output_name` is `None` each subagent's full output is
|
||
/// inlined verbatim; see [`render_completion_output_delivery`].
|
||
pub fn format_between_turn_completions(
|
||
completions: &[SubagentCompletionSummary],
|
||
task_output_name: Option<&str>,
|
||
) -> String {
|
||
use std::fmt::Write as _;
|
||
let n = completions.len();
|
||
let label = if n == 1 { "subagent" } else { "subagents" };
|
||
let mut buf = format!("While you were idle, {n} background {label} completed:\n");
|
||
for c in completions {
|
||
let status = if c.success {
|
||
"completed successfully"
|
||
} else {
|
||
"failed"
|
||
};
|
||
let secs = c.duration_ms as f64 / 1000.0;
|
||
let _ = write!(
|
||
buf,
|
||
"- [{}] {:?} \u{2014} {status} ({secs:.1}s, {} tool calls)\n subagent_id: {}",
|
||
c.subagent_type, c.description, c.tool_calls, c.subagent_id,
|
||
);
|
||
match task_output_name {
|
||
Some(_) => buf.push_str(". "),
|
||
None => buf.push_str("\n "),
|
||
}
|
||
render_completion_output_delivery(
|
||
&mut buf,
|
||
&c.subagent_id,
|
||
&c.output,
|
||
task_output_name,
|
||
None,
|
||
);
|
||
buf.push('\n');
|
||
}
|
||
buf
|
||
}
|
||
/// Format buffered between-turn subagent completions, resolving the
|
||
/// `BackgroundTaskAction` tool name from the supplied bridge in one place.
|
||
///
|
||
/// Wraps [`resolve_task_output_tool_name`] +
|
||
/// [`format_between_turn_completions`] so callers don't repeat the lookup
|
||
/// at every emission site.
|
||
pub async fn format_between_turn_completion_reminder(
|
||
completions: &[SubagentCompletionSummary],
|
||
bridge: &ToolBridge,
|
||
) -> String {
|
||
let task_output_name = resolve_task_output_tool_name(bridge).await;
|
||
format_between_turn_completions(completions, task_output_name.as_deref())
|
||
}
|
||
/// Format between-turn bash task completions into a system-reminder string.
|
||
pub fn format_between_turn_bash_completions(
|
||
tasks: &[TaskSnapshot],
|
||
task_output_name: Option<&str>,
|
||
read_tool_name: Option<&str>,
|
||
) -> String {
|
||
let n = tasks.len();
|
||
let label = if n == 1 {
|
||
"background task"
|
||
} else {
|
||
"background tasks"
|
||
};
|
||
let mut buf = format!("While you were idle, {n} {label} completed:\n");
|
||
for task in tasks {
|
||
buf.push_str(&format_bash_completion(
|
||
task,
|
||
task_output_name,
|
||
read_tool_name,
|
||
));
|
||
buf.push('\n');
|
||
}
|
||
buf
|
||
}
|
||
/// Extract task / subagent IDs whose completion the model already
|
||
/// learned about from this tool result. Used by:
|
||
/// - `TaskCompletionReminder::collect_reminders` to suppress the
|
||
/// per-tool-call `<system-reminder>` for the same ID.
|
||
/// - `xai-grok-shell`'s `SessionActor` to sweep matching synthetic
|
||
/// auto-wake prompts and notifications out of `pending_inputs` /
|
||
/// `pending_notifications` (closes the TOCTOU race that produces
|
||
/// trailing `<system-reminder>` items in `chat_history.jsonl`).
|
||
///
|
||
/// Centralising this here means the two consumer surfaces cannot drift:
|
||
/// both call the same function, and the exhaustive `match` below
|
||
/// forces every new `ToolOutput` variant to opt in or out at compile
|
||
/// time.
|
||
///
|
||
/// Returns borrowed `&str` slices (no allocation) — the strings live in
|
||
/// `output` for the duration of the call.
|
||
/// Extract the subagent UUID from the body of a Task-tool-formatted
|
||
/// text output. The shape is exactly:
|
||
///
|
||
/// ```text
|
||
/// This is the output of the subagent:
|
||
///
|
||
/// response:
|
||
/// <response>...</response>
|
||
///
|
||
/// Agent ID: <uuid> (can be used with the `resume` parameter to send a follow-up after it completes)
|
||
/// ```
|
||
///
|
||
/// We anchor on `"Agent ID: "` and consume up to (but excluding) the next
|
||
/// whitespace character. Returns `None` if the text doesn't match.
|
||
fn task_text_agent_id(text: &str) -> Option<&str> {
|
||
if !text.starts_with("This is the output of the subagent:") {
|
||
return None;
|
||
}
|
||
let after = text.split_once("\nAgent ID: ")?.1;
|
||
let end = after
|
||
.find(|c: char| c.is_whitespace())
|
||
.unwrap_or(after.len());
|
||
if end == 0 { None } else { Some(&after[..end]) }
|
||
}
|
||
pub fn consumed_completion_ids(output: &ToolOutput) -> Vec<&str> {
|
||
let mut ids = Vec::new();
|
||
if let ToolOutput::Text(t) = output
|
||
&& let Some(uuid) = task_text_agent_id(&t.text)
|
||
{
|
||
ids.push(uuid);
|
||
}
|
||
match output {
|
||
ToolOutput::TaskOutput(TaskOutputOutput::Result(r)) if r.status == "completed" => {
|
||
ids.push(r.task_id.as_str());
|
||
}
|
||
ToolOutput::TaskOutput(TaskOutputOutput::Result(_)) => {}
|
||
ToolOutput::TaskOutput(TaskOutputOutput::MultiResult(mr)) => {
|
||
for r in &mr.results {
|
||
if r.status == "completed" {
|
||
ids.push(r.task_id.as_str());
|
||
}
|
||
}
|
||
}
|
||
ToolOutput::TaskOutput(TaskOutputOutput::TaskNotFound(_)) => {}
|
||
ToolOutput::KillTask(KillTaskOutput::Result(r)) => {
|
||
ids.push(r.task_id.as_str());
|
||
}
|
||
ToolOutput::KillTask(KillTaskOutput::TaskNotFound(_)) => {}
|
||
ToolOutput::SubagentCompleted(SubagentCompletedOutput { subagent_id, .. }) => {
|
||
ids.push(subagent_id.as_str());
|
||
}
|
||
ToolOutput::Text(text) => {
|
||
if let Some(id) = text.consumed_completion_task_id.as_deref() {
|
||
ids.push(id);
|
||
}
|
||
}
|
||
ToolOutput::Bash(_)
|
||
| ToolOutput::BackgroundTaskStarted(_)
|
||
| ToolOutput::GrepSearch(_)
|
||
| ToolOutput::ReadFile(_)
|
||
| ToolOutput::ListDir(_)
|
||
| ToolOutput::SearchReplace(_)
|
||
| ToolOutput::Todo(_)
|
||
| ToolOutput::WebSearch(_)
|
||
| ToolOutput::WebFetch(_)
|
||
| ToolOutput::MCP(_)
|
||
| ToolOutput::Skill(_)
|
||
| ToolOutput::ApplyPatch(_)
|
||
| ToolOutput::CodexGrepFiles(_)
|
||
| ToolOutput::SearchTool(_)
|
||
| ToolOutput::EnterPlanMode(_)
|
||
| ToolOutput::ExitPlanMode(_)
|
||
| ToolOutput::AskUserQuestion(_)
|
||
| ToolOutput::Monitor(_)
|
||
| ToolOutput::SchedulerCreate(_)
|
||
| ToolOutput::SchedulerDelete(_)
|
||
| ToolOutput::SchedulerList(_)
|
||
| ToolOutput::UpdateGoal(_)
|
||
| ToolOutput::ImageGen(_)
|
||
| ToolOutput::ImageToVideo(_)
|
||
| ToolOutput::ReferenceToVideo(_)
|
||
| ToolOutput::ImageEdit(_)
|
||
| ToolOutput::Dynamic(_) => {}
|
||
}
|
||
ids
|
||
}
|
||
/// Cross-cutting reminder that queries the terminal backend for completed
|
||
/// background tasks and the subagent coordinator for completed subagents,
|
||
/// surfacing newly-completed ones as `<system-reminder>` text inside the
|
||
/// next tool result.
|
||
///
|
||
/// Registered on `FinalizedToolset` as a cross-cutting reminder.
|
||
/// Returns plain strings; the tool pipeline wraps
|
||
/// each one in `<system-reminder>` tags automatically.
|
||
pub struct TaskCompletionReminder;
|
||
#[async_trait::async_trait]
|
||
impl Reminder for TaskCompletionReminder {
|
||
async fn collect_reminders(
|
||
&self,
|
||
resources: SharedResources,
|
||
tool_output: &ToolOutput,
|
||
) -> Vec<String> {
|
||
let mut suppress: Vec<String> = consumed_completion_ids(tool_output)
|
||
.into_iter()
|
||
.map(str::to_string)
|
||
.collect();
|
||
{
|
||
let res = resources.lock().await;
|
||
if let Some(auto_wake) = res.get::<AutoWakeDeliveredIds>() {
|
||
suppress.extend(auto_wake.drain());
|
||
}
|
||
}
|
||
let (terminal, event_sender) = {
|
||
let res = resources.lock().await;
|
||
(
|
||
res.get::<Terminal>().map(|t| t.0.clone()),
|
||
res.get::<SubagentEventSender>().cloned(),
|
||
)
|
||
};
|
||
let mut reminders = Vec::new();
|
||
if let Some(terminal) = terminal {
|
||
let all_tasks = terminal.list_tasks().await;
|
||
let mut res = resources.lock().await;
|
||
let my_owner = res
|
||
.get::<crate::types::resources::OwnerSessionId>()
|
||
.map(|o| o.0.clone());
|
||
let tasks: Vec<TaskSnapshot> = all_tasks
|
||
.into_iter()
|
||
.filter(|t| task_owned_by_session(t, my_owner.as_deref()))
|
||
.collect();
|
||
let goal_loop_active = res
|
||
.get::<crate::implementations::grok_build::task::types::GoalLoopActive>()
|
||
.is_some_and(|g| g.0);
|
||
let surface_reminders = !goal_loop_active
|
||
&& res
|
||
.get::<crate::types::resources::Params<
|
||
crate::implementations::grok_build::bash::BashParams,
|
||
>>()
|
||
.map(|p| p.0.surface_bg_completion_reminders)
|
||
.unwrap_or(true);
|
||
let renderer = res.get::<crate::types::template_renderer::TemplateRenderer>();
|
||
let task_output_name: Option<String> = renderer.and_then(|r| {
|
||
r.tool_for_kind(crate::types::tool::ToolKind::BackgroundTaskAction)
|
||
.map(str::to_string)
|
||
});
|
||
let read_tool_name: Option<String> = renderer.and_then(|r| {
|
||
r.tool_for_kind(crate::types::tool::ToolKind::Read)
|
||
.map(str::to_string)
|
||
});
|
||
let kill_task_name: Option<String> = renderer.and_then(|r| {
|
||
r.tool_for_kind(crate::types::tool::ToolKind::KillTaskAction)
|
||
.map(str::to_string)
|
||
});
|
||
let state = res.get_or_default::<State<ReportedTaskCompletions>>();
|
||
for id in &suppress {
|
||
state.reported.insert(id.clone());
|
||
}
|
||
if surface_reminders {
|
||
reminders.extend(
|
||
tasks
|
||
.iter()
|
||
.filter(|task| {
|
||
task.completed && state.reported.insert(task.task_id.clone())
|
||
})
|
||
.map(|task| {
|
||
format_bash_completion(
|
||
task,
|
||
task_output_name.as_deref(),
|
||
read_tool_name.as_deref(),
|
||
)
|
||
}),
|
||
);
|
||
} else {
|
||
for task in &tasks {
|
||
if task.completed {
|
||
state.reported.insert(task.task_id.clone());
|
||
}
|
||
}
|
||
}
|
||
if let ToolOutput::BackgroundTaskStarted(bg) = tool_output {
|
||
let running: Vec<&TaskSnapshot> = tasks
|
||
.iter()
|
||
.filter(|t| !t.completed && t.task_id != bg.task_id)
|
||
.collect();
|
||
if !running.is_empty() {
|
||
reminders.push(format_running_tasks_warning(
|
||
&running,
|
||
kill_task_name.as_deref(),
|
||
));
|
||
}
|
||
}
|
||
}
|
||
if let Some(sender) = event_sender {
|
||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||
if sender
|
||
.0
|
||
.send(SubagentEvent::Completions(SubagentCompletionsRequest {
|
||
suppress_ids: suppress,
|
||
respond_to: tx,
|
||
}))
|
||
.is_err()
|
||
{
|
||
tracing::debug!("SubagentEventSender: receiver dropped, skipping");
|
||
} else if let Ok(completions) = rx.await {
|
||
let mut res = resources.lock().await;
|
||
let goal_loop_active = res
|
||
.get::<crate::implementations::grok_build::task::types::GoalLoopActive>()
|
||
.is_some_and(|g| g.0);
|
||
let task_output_name: Option<String> = res
|
||
.get::<crate::types::template_renderer::TemplateRenderer>()
|
||
.and_then(|r| {
|
||
r.tool_for_kind(crate::types::tool::ToolKind::BackgroundTaskAction)
|
||
.map(str::to_string)
|
||
});
|
||
let state = res.get_or_default::<State<ReportedTaskCompletions>>();
|
||
for c in &completions {
|
||
if state.reported.insert(c.subagent_id.clone()) && !goal_loop_active {
|
||
reminders.push(format_subagent_completion(c, task_output_name.as_deref()));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
reminders
|
||
}
|
||
}
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::types::output::TextOutput;
|
||
#[test]
|
||
fn consumed_completion_ids_from_text_with_consumed_id() {
|
||
let output = ToolOutput::Text(TextOutput {
|
||
text: "Task completed in 100ms with exit code: 0.".into(),
|
||
consumed_completion_task_id: Some("bg-uuid-42".into()),
|
||
});
|
||
let ids = consumed_completion_ids(&output);
|
||
assert_eq!(ids, vec!["bg-uuid-42"]);
|
||
}
|
||
#[test]
|
||
fn consumed_completion_ids_from_text_without_consumed_id() {
|
||
let output = ToolOutput::Text(TextOutput {
|
||
text: "Task completed in 100ms with exit code: 0.".into(),
|
||
consumed_completion_task_id: None,
|
||
});
|
||
assert!(consumed_completion_ids(&output).is_empty());
|
||
}
|
||
#[test]
|
||
fn format_bash_completion_basic() {
|
||
let task = TaskSnapshot {
|
||
task_id: "abc-123".into(),
|
||
command: "cargo test".into(),
|
||
display_command: None,
|
||
cwd: String::new(),
|
||
start_time: std::time::SystemTime::now(),
|
||
end_time: Some(std::time::SystemTime::now()),
|
||
output: String::new(),
|
||
output_file: std::path::PathBuf::new(),
|
||
truncated: false,
|
||
exit_code: Some(0),
|
||
signal: None,
|
||
completed: true,
|
||
kind: Default::default(),
|
||
block_waited: false,
|
||
explicitly_killed: false,
|
||
owner_session_id: None,
|
||
};
|
||
let msg = format_bash_completion(&task, Some("get_command_or_subagent_output"), None);
|
||
assert!(msg.contains("abc-123"));
|
||
assert!(msg.contains("exit code: 0"));
|
||
assert!(msg.contains("cargo test"));
|
||
assert!(msg.contains("get_command_or_subagent_output(\"abc-123\")"));
|
||
}
|
||
#[test]
|
||
fn format_monitor_completion_exit_zero() {
|
||
let task = TaskSnapshot {
|
||
task_id: "mon-1".into(),
|
||
command: "tail -f /var/log/app".into(),
|
||
display_command: Some("[monitor] app logs".into()),
|
||
cwd: String::new(),
|
||
start_time: std::time::SystemTime::now(),
|
||
end_time: Some(std::time::SystemTime::now()),
|
||
output: String::new(),
|
||
output_file: std::path::PathBuf::new(),
|
||
truncated: false,
|
||
exit_code: Some(0),
|
||
signal: None,
|
||
completed: true,
|
||
kind: crate::computer::types::TaskKind::Monitor,
|
||
block_waited: false,
|
||
explicitly_killed: false,
|
||
owner_session_id: None,
|
||
};
|
||
let msg = format_monitor_completion(&task, Some("get_command_or_subagent_output"));
|
||
assert!(
|
||
msg.contains("[monitor ended: exited (code 0)]"),
|
||
"expected ended wording: {msg}"
|
||
);
|
||
assert!(msg.contains("app logs"), "description: {msg}");
|
||
assert!(msg.contains("tail -f /var/log/app"), "command: {msg}");
|
||
assert!(
|
||
msg.contains("get_command_or_subagent_output(\"mon-1\")"),
|
||
"poll tool pointer: {msg}"
|
||
);
|
||
}
|
||
#[test]
|
||
fn format_monitor_completion_signal() {
|
||
let task = TaskSnapshot {
|
||
task_id: "mon-sig".into(),
|
||
command: "sleep 999".into(),
|
||
display_command: Some("[monitor] sleep".into()),
|
||
cwd: String::new(),
|
||
start_time: std::time::SystemTime::now(),
|
||
end_time: Some(std::time::SystemTime::now()),
|
||
output: String::new(),
|
||
output_file: std::path::PathBuf::new(),
|
||
truncated: false,
|
||
exit_code: None,
|
||
signal: Some("SIGTERM".into()),
|
||
completed: true,
|
||
kind: crate::computer::types::TaskKind::Monitor,
|
||
block_waited: false,
|
||
explicitly_killed: false,
|
||
owner_session_id: None,
|
||
};
|
||
let msg = format_monitor_completion(&task, None);
|
||
assert!(
|
||
msg.contains("[monitor ended: killed by signal SIGTERM]"),
|
||
"expected signal wording: {msg}"
|
||
);
|
||
assert!(msg.contains("get_task_output(\"mon-sig\")"), "{msg}");
|
||
}
|
||
#[test]
|
||
fn format_bash_completion_prefers_display_command() {
|
||
let task = TaskSnapshot {
|
||
task_id: "t1".into(),
|
||
command: "unshare --mount -- cargo test".into(),
|
||
display_command: Some("cargo test".into()),
|
||
cwd: String::new(),
|
||
start_time: std::time::SystemTime::now(),
|
||
end_time: Some(std::time::SystemTime::now()),
|
||
output: String::new(),
|
||
output_file: std::path::PathBuf::new(),
|
||
truncated: false,
|
||
exit_code: Some(0),
|
||
signal: None,
|
||
completed: true,
|
||
kind: Default::default(),
|
||
block_waited: false,
|
||
explicitly_killed: false,
|
||
owner_session_id: None,
|
||
};
|
||
let msg = format_bash_completion(&task, Some("get_command_or_subagent_output"), None);
|
||
assert!(msg.contains("cargo test"));
|
||
assert!(!msg.contains("unshare"));
|
||
}
|
||
#[test]
|
||
fn format_bash_completion_unknown_exit_code() {
|
||
let task = TaskSnapshot {
|
||
task_id: "t1".into(),
|
||
command: "server".into(),
|
||
display_command: None,
|
||
cwd: String::new(),
|
||
start_time: std::time::SystemTime::now(),
|
||
end_time: Some(std::time::SystemTime::now()),
|
||
output: String::new(),
|
||
output_file: std::path::PathBuf::new(),
|
||
truncated: false,
|
||
exit_code: None,
|
||
signal: None,
|
||
completed: true,
|
||
kind: Default::default(),
|
||
block_waited: false,
|
||
explicitly_killed: false,
|
||
owner_session_id: None,
|
||
};
|
||
let msg = format_bash_completion(&task, Some("get_command_or_subagent_output"), None);
|
||
assert!(msg.contains("exit code: unknown"));
|
||
}
|
||
/// A long-running task killed by signal renders `terminated by
|
||
/// signal SIGTERM`, *not* `exit code: ...`, mirroring the foreground
|
||
/// `[killed by signal {sig}]` convention.
|
||
#[test]
|
||
fn format_bash_completion_signal_renders_signal_name() {
|
||
let start = std::time::SystemTime::now() - std::time::Duration::from_secs(5);
|
||
let task = TaskSnapshot {
|
||
task_id: "sig-1".into(),
|
||
command: "./server".into(),
|
||
display_command: None,
|
||
cwd: String::new(),
|
||
start_time: start,
|
||
end_time: Some(std::time::SystemTime::now()),
|
||
output: String::new(),
|
||
output_file: std::path::PathBuf::new(),
|
||
truncated: false,
|
||
exit_code: None,
|
||
signal: Some("SIGTERM".into()),
|
||
completed: true,
|
||
kind: Default::default(),
|
||
block_waited: false,
|
||
explicitly_killed: false,
|
||
owner_session_id: None,
|
||
};
|
||
let msg = format_bash_completion(&task, Some("get_command_or_subagent_output"), None);
|
||
assert!(
|
||
msg.contains("terminated by signal SIGTERM"),
|
||
"expected signal phrase, got: {msg}"
|
||
);
|
||
assert!(
|
||
!msg.contains("exit code:"),
|
||
"signal must take precedence: {msg}"
|
||
);
|
||
assert!(
|
||
!msg.contains("wrapper bash may have been killed"),
|
||
"long-running task should not get the wrapper-killed hint: {msg}"
|
||
);
|
||
}
|
||
/// A signalled task with sub-second duration triggers the
|
||
/// wrapper-killed hint -- this is the diagnostic for the
|
||
/// self-matching pkill footgun.
|
||
#[test]
|
||
fn format_bash_completion_signal_short_duration_adds_hint() {
|
||
let now = std::time::SystemTime::now();
|
||
let task = TaskSnapshot {
|
||
task_id: "sig-short".into(),
|
||
command: "pkill -f ./server && ./server".into(),
|
||
display_command: None,
|
||
cwd: String::new(),
|
||
start_time: now,
|
||
end_time: Some(now),
|
||
output: String::new(),
|
||
output_file: std::path::PathBuf::new(),
|
||
truncated: false,
|
||
exit_code: None,
|
||
signal: Some("SIGTERM".into()),
|
||
completed: true,
|
||
kind: Default::default(),
|
||
block_waited: false,
|
||
explicitly_killed: false,
|
||
owner_session_id: None,
|
||
};
|
||
let msg = format_bash_completion(&task, Some("get_command_or_subagent_output"), None);
|
||
assert!(
|
||
msg.contains("terminated by signal SIGTERM"),
|
||
"still includes signal phrase: {msg}"
|
||
);
|
||
assert!(
|
||
msg.contains("wrapper bash may have been killed"),
|
||
"expected wrapper-killed hint for short-duration signalled task: {msg}"
|
||
);
|
||
assert!(
|
||
msg.contains("`pkill -f <pat>`"),
|
||
"hint should mention the pkill footgun: {msg}"
|
||
);
|
||
}
|
||
/// Short-duration tasks that exited cleanly (no signal) are normal
|
||
/// (`true`, `:`, etc.) — the hint must NOT fire.
|
||
#[test]
|
||
fn format_bash_completion_no_signal_short_duration_no_hint() {
|
||
let now = std::time::SystemTime::now();
|
||
let task = TaskSnapshot {
|
||
task_id: "fast".into(),
|
||
command: "true".into(),
|
||
display_command: None,
|
||
cwd: String::new(),
|
||
start_time: now,
|
||
end_time: Some(now),
|
||
output: String::new(),
|
||
output_file: std::path::PathBuf::new(),
|
||
truncated: false,
|
||
exit_code: Some(0),
|
||
signal: None,
|
||
completed: true,
|
||
kind: Default::default(),
|
||
block_waited: false,
|
||
explicitly_killed: false,
|
||
owner_session_id: None,
|
||
};
|
||
let msg = format_bash_completion(&task, Some("get_command_or_subagent_output"), None);
|
||
assert!(msg.contains("exit code: 0"));
|
||
assert!(
|
||
!msg.contains("wrapper bash may have been killed"),
|
||
"no-signal short-duration task must not get the hint: {msg}"
|
||
);
|
||
}
|
||
#[test]
|
||
fn reported_state_deduplicates() {
|
||
let mut state = ReportedTaskCompletions::default();
|
||
assert!(state.mark_reported("t1"));
|
||
assert!(!state.mark_reported("t1"));
|
||
assert!(state.mark_reported("t2"));
|
||
}
|
||
#[test]
|
||
fn format_between_turn_bash_single() {
|
||
let tasks = vec![make_completed("bg-1")];
|
||
let msg = format_between_turn_bash_completions(
|
||
&tasks,
|
||
Some("get_command_or_subagent_output"),
|
||
Some("read_file"),
|
||
);
|
||
assert!(msg.starts_with("While you were idle, 1 background task completed:"));
|
||
assert!(msg.contains("bg-1"));
|
||
assert!(msg.contains(r#"get_command_or_subagent_output("bg-1")"#));
|
||
assert!(!msg.contains("response:"));
|
||
}
|
||
#[test]
|
||
fn format_between_turn_bash_multiple() {
|
||
let tasks = vec![make_completed("bg-1"), make_completed("bg-2")];
|
||
let msg = format_between_turn_bash_completions(
|
||
&tasks,
|
||
Some("get_command_or_subagent_output"),
|
||
Some("read_file"),
|
||
);
|
||
assert!(msg.starts_with("While you were idle, 2 background tasks completed:"));
|
||
assert!(msg.contains("bg-1"));
|
||
assert!(msg.contains("bg-2"));
|
||
}
|
||
#[test]
|
||
fn format_bash_completion_pointer_form_is_small() {
|
||
let mut task = make_completed("big-bg");
|
||
task.output = "x".repeat(5_000_000);
|
||
let msg = format_bash_completion(
|
||
&task,
|
||
Some("get_command_or_subagent_output"),
|
||
Some("read_file"),
|
||
);
|
||
assert!(msg.len() < 500, "pointer reminder was {} bytes", msg.len());
|
||
assert!(msg.contains(r#"get_command_or_subagent_output("big-bg")"#));
|
||
assert!(!msg.contains(&"x".repeat(100)));
|
||
}
|
||
/// Without a polling tool AND without a Read-tool fallback, there is no
|
||
/// disk-pointer footer to anchor a truncated preview against, so the
|
||
/// full output text is inlined verbatim. The bash truncation cap only
|
||
/// fires when the caller supplies a disk-pointer footer.
|
||
#[test]
|
||
fn format_bash_completion_inline_form_without_footer_is_verbatim() {
|
||
let mut task = make_completed("big-bg");
|
||
let large_output = "x".repeat(5_000_000);
|
||
task.output = large_output.clone();
|
||
let msg = format_bash_completion(&task, None, None);
|
||
assert!(msg.contains("response:\n"));
|
||
assert!(
|
||
msg.contains(&large_output),
|
||
"expected full output verbatim, got len={}",
|
||
msg.len()
|
||
);
|
||
assert!(!msg.contains("[Output truncated"));
|
||
}
|
||
#[test]
|
||
fn format_bash_completion_inline_points_at_output_file_when_available() {
|
||
let mut task = make_completed("big-bg");
|
||
task.output = "x".repeat(5_000_000);
|
||
task.output_file = std::path::PathBuf::from("/tmp/bg.log");
|
||
let msg = format_bash_completion(&task, None, Some("read_file"));
|
||
assert!(
|
||
msg.contains("Use read_file on /tmp/bg.log for full content"),
|
||
"expected disk-pointer footer in inline reminder: {msg}"
|
||
);
|
||
assert!(msg.len() < 4_500, "inline reminder was {} bytes", msg.len());
|
||
assert!(msg.contains("response:\n"));
|
||
}
|
||
#[test]
|
||
fn format_between_turn_bash_completions_uses_pointer() {
|
||
let mut first = make_completed("bg-1");
|
||
first.output = "a".repeat(5_000_000);
|
||
let mut second = make_completed("bg-2");
|
||
second.output = "b".repeat(5_000_000);
|
||
let msg = format_between_turn_bash_completions(
|
||
&[first, second],
|
||
Some("get_command_or_subagent_output"),
|
||
Some("read_file"),
|
||
);
|
||
assert!(
|
||
msg.len() < 1_000,
|
||
"batched reminder was {} bytes",
|
||
msg.len()
|
||
);
|
||
assert!(msg.contains(r#"get_command_or_subagent_output("bg-1")"#));
|
||
assert!(msg.contains(r#"get_command_or_subagent_output("bg-2")"#));
|
||
assert!(!msg.contains("response:"));
|
||
}
|
||
use crate::computer::types::{
|
||
BackgroundHandle, KillOutcome, TerminalBackend, TerminalRunRequest, TerminalRunResult,
|
||
};
|
||
use crate::types::resources::Resources;
|
||
use std::sync::Arc;
|
||
use std::time::Duration;
|
||
use xai_tool_types::KillTaskResult;
|
||
use xai_tool_types::{MultiTaskOutputResult, TaskOutputResult};
|
||
struct MockTerminal {
|
||
tasks: Vec<TaskSnapshot>,
|
||
}
|
||
#[async_trait::async_trait]
|
||
impl TerminalBackend for MockTerminal {
|
||
async fn run(
|
||
&self,
|
||
_: TerminalRunRequest,
|
||
) -> Result<TerminalRunResult, crate::computer::types::ComputerError> {
|
||
unimplemented!()
|
||
}
|
||
async fn run_background(
|
||
&self,
|
||
_: TerminalRunRequest,
|
||
) -> Result<BackgroundHandle, crate::computer::types::ComputerError> {
|
||
unimplemented!()
|
||
}
|
||
async fn kill_task(&self, _: &str) -> KillOutcome {
|
||
KillOutcome::NotFound
|
||
}
|
||
async fn get_task(&self, _: &str) -> Option<TaskSnapshot> {
|
||
None
|
||
}
|
||
async fn wait_for_completion(&self, _: &str, _: Option<Duration>) -> Option<TaskSnapshot> {
|
||
None
|
||
}
|
||
async fn list_tasks(&self) -> Vec<TaskSnapshot> {
|
||
self.tasks.clone()
|
||
}
|
||
}
|
||
fn make_completed(id: &str) -> TaskSnapshot {
|
||
TaskSnapshot {
|
||
task_id: id.into(),
|
||
command: "echo test".into(),
|
||
display_command: None,
|
||
cwd: String::new(),
|
||
start_time: std::time::SystemTime::now(),
|
||
end_time: Some(std::time::SystemTime::now()),
|
||
output: String::new(),
|
||
output_file: std::path::PathBuf::new(),
|
||
truncated: false,
|
||
exit_code: Some(0),
|
||
signal: None,
|
||
completed: true,
|
||
kind: Default::default(),
|
||
block_waited: false,
|
||
explicitly_killed: false,
|
||
owner_session_id: None,
|
||
}
|
||
}
|
||
fn make_running(id: &str) -> TaskSnapshot {
|
||
TaskSnapshot {
|
||
task_id: id.into(),
|
||
command: "python3 scripts/add_clips.py".into(),
|
||
display_command: None,
|
||
cwd: String::new(),
|
||
start_time: std::time::SystemTime::now(),
|
||
end_time: None,
|
||
output: String::new(),
|
||
output_file: std::path::PathBuf::new(),
|
||
truncated: false,
|
||
exit_code: None,
|
||
signal: None,
|
||
completed: false,
|
||
kind: Default::default(),
|
||
block_waited: false,
|
||
explicitly_killed: false,
|
||
owner_session_id: None,
|
||
}
|
||
}
|
||
fn make_bg_started(id: &str) -> crate::types::output::BackgroundTaskStarted {
|
||
crate::types::output::BackgroundTaskStarted {
|
||
task_id: id.into(),
|
||
task_type: "bash".into(),
|
||
output_file: String::new(),
|
||
status: "running".into(),
|
||
command: "echo hello".into(),
|
||
summary: String::new(),
|
||
retrieval_hint: String::new(),
|
||
pre_formatted: None,
|
||
pid: None,
|
||
}
|
||
}
|
||
fn shared_with(tasks: Vec<TaskSnapshot>) -> SharedResources {
|
||
let mut res = Resources::new();
|
||
let backend: Arc<dyn TerminalBackend> = Arc::new(MockTerminal { tasks });
|
||
res.insert(Terminal(backend));
|
||
res.register_state::<ReportedTaskCompletions>();
|
||
res.into_shared()
|
||
}
|
||
/// Like `shared_with` but inserts `BashParams` with
|
||
/// `surface_bg_completion_reminders = false` so the
|
||
/// reminder is suppressed.
|
||
fn shared_with_reminders_disabled(tasks: Vec<TaskSnapshot>) -> SharedResources {
|
||
let mut res = Resources::new();
|
||
let backend: Arc<dyn TerminalBackend> = Arc::new(MockTerminal { tasks });
|
||
res.insert(Terminal(backend));
|
||
res.register_state::<ReportedTaskCompletions>();
|
||
let params = crate::implementations::grok_build::bash::BashParams {
|
||
surface_bg_completion_reminders: false,
|
||
..Default::default()
|
||
};
|
||
res.insert(crate::types::resources::Params(params));
|
||
res.into_shared()
|
||
}
|
||
/// Bash completions are scoped to the session that OWNS the task. A
|
||
/// subagent shares the parent's terminal backend, so `list_tasks()` returns
|
||
/// the parent's (and sibling subagents') tasks too; only this session's
|
||
/// (and unowned) tasks may surface. Regression guard for the parent →
|
||
/// subagent background-task completion leak.
|
||
#[tokio::test]
|
||
async fn bash_completions_scoped_to_owning_session() {
|
||
let mine = TaskSnapshot {
|
||
owner_session_id: Some("subagent-1".into()),
|
||
..make_completed("mine-task")
|
||
};
|
||
let parents = TaskSnapshot {
|
||
owner_session_id: Some("parent-0".into()),
|
||
..make_completed("parent-task")
|
||
};
|
||
let unowned = make_completed("unowned-task");
|
||
let mut res = Resources::new();
|
||
let backend: Arc<dyn TerminalBackend> = Arc::new(MockTerminal {
|
||
tasks: vec![mine, parents, unowned],
|
||
});
|
||
res.insert(Terminal(backend));
|
||
res.register_state::<ReportedTaskCompletions>();
|
||
res.insert(crate::types::resources::OwnerSessionId("subagent-1".into()));
|
||
let shared = res.into_shared();
|
||
let output = ToolOutput::Text(crate::types::output::TextOutput {
|
||
text: "ok".into(),
|
||
consumed_completion_task_id: None,
|
||
});
|
||
let reminders = TaskCompletionReminder
|
||
.collect_reminders(shared, &output)
|
||
.await;
|
||
let joined = reminders.join("\n\n");
|
||
assert!(
|
||
joined.contains("mine-task"),
|
||
"this session's own task must surface: {joined}"
|
||
);
|
||
assert!(
|
||
joined.contains("unowned-task"),
|
||
"unowned task must surface (backwards compat): {joined}"
|
||
);
|
||
assert!(
|
||
!joined.contains("parent-task"),
|
||
"another session's task must NOT leak into this session: {joined}"
|
||
);
|
||
}
|
||
#[tokio::test]
|
||
async fn suppressed_after_kill_task() {
|
||
let shared = shared_with(vec![make_completed("t1")]);
|
||
let reminder = TaskCompletionReminder;
|
||
let output = ToolOutput::KillTask(KillTaskOutput::Result(KillTaskResult {
|
||
task_id: "t1".into(),
|
||
outcome: "killed".into(),
|
||
message: "Task was terminated successfully".into(),
|
||
}));
|
||
let r = reminder.collect_reminders(shared, &output).await;
|
||
assert!(r.is_empty(), "kill_task result should suppress reminder");
|
||
}
|
||
#[tokio::test]
|
||
async fn suppressed_after_await_text_with_consumed_id() {
|
||
let shared = shared_with(vec![make_completed("t1")]);
|
||
let reminder = TaskCompletionReminder;
|
||
let output = ToolOutput::Text(TextOutput {
|
||
text: "Task completed in 100ms with exit code: 0.".into(),
|
||
consumed_completion_task_id: Some("t1".into()),
|
||
});
|
||
let r = reminder.collect_reminders(shared, &output).await;
|
||
assert!(
|
||
r.is_empty(),
|
||
"Await Text with consumed_completion_task_id should suppress reminder"
|
||
);
|
||
}
|
||
#[tokio::test]
|
||
async fn suppressed_after_get_task_output_completed() {
|
||
let shared = shared_with(vec![make_completed("t1")]);
|
||
let reminder = TaskCompletionReminder;
|
||
let output = ToolOutput::TaskOutput(TaskOutputOutput::Result(TaskOutputResult {
|
||
task_id: "t1".into(),
|
||
command: "echo test".into(),
|
||
status: "completed".into(),
|
||
exit_code: Some(0),
|
||
started: "2026-01-01T00:00:00Z".into(),
|
||
ended: Some("2026-01-01T00:00:01Z".into()),
|
||
duration_secs: 1.0,
|
||
output: "test".into(),
|
||
output_file: "/tmp/out.log".into(),
|
||
truncated: false,
|
||
truncation_hint: String::new(),
|
||
raw_output_bytes: 4,
|
||
}));
|
||
let r = reminder.collect_reminders(shared, &output).await;
|
||
assert!(
|
||
r.is_empty(),
|
||
"get_task_output(completed) should suppress reminder"
|
||
);
|
||
}
|
||
#[tokio::test]
|
||
async fn not_suppressed_for_unrelated_output() {
|
||
let shared = shared_with(vec![make_completed("t1")]);
|
||
let reminder = TaskCompletionReminder;
|
||
let output = ToolOutput::Dynamic(serde_json::Value::Null.into());
|
||
let r = reminder.collect_reminders(shared, &output).await;
|
||
assert_eq!(
|
||
r.len(),
|
||
1,
|
||
"unrelated tool output should not suppress reminder"
|
||
);
|
||
assert!(r[0].contains("t1"));
|
||
}
|
||
#[tokio::test]
|
||
async fn dedup_across_calls() {
|
||
let shared = shared_with(vec![make_completed("t1")]);
|
||
let reminder = TaskCompletionReminder;
|
||
let output = ToolOutput::Dynamic(serde_json::Value::Null.into());
|
||
let first = reminder.collect_reminders(shared.clone(), &output).await;
|
||
assert_eq!(first.len(), 1);
|
||
let second = reminder.collect_reminders(shared, &output).await;
|
||
assert!(second.is_empty(), "should not repeat");
|
||
}
|
||
/// In a toolset that opts out of bash-completion reminders via
|
||
/// `BashParams.surface_bg_completion_reminders = false` (compat
|
||
/// namespace), the reminders are silently dropped so the model
|
||
/// does not see `Use get_task_output(...)` text referring to a
|
||
/// non-existent tool. The completed-task IDs are still marked as
|
||
/// reported, so a subsequent call won't surface them either.
|
||
#[tokio::test]
|
||
async fn bash_completion_suppressed_when_reminders_flag_disabled() {
|
||
let shared = shared_with_reminders_disabled(vec![
|
||
make_completed("bash-1"),
|
||
make_completed("bash-2"),
|
||
]);
|
||
let reminder = TaskCompletionReminder;
|
||
let output = ToolOutput::Dynamic(serde_json::Value::Null.into());
|
||
let reminders = reminder.collect_reminders(shared, &output).await;
|
||
assert!(
|
||
reminders.is_empty(),
|
||
"expected no bash completion reminders when flag is disabled, got: {reminders:?}"
|
||
);
|
||
}
|
||
fn shared_with_subagent_completions(
|
||
tasks: Vec<TaskSnapshot>,
|
||
completions: Vec<SubagentCompletionSummary>,
|
||
) -> SharedResources {
|
||
let mut res = Resources::new();
|
||
let backend: Arc<dyn TerminalBackend> = Arc::new(MockTerminal { tasks });
|
||
res.insert(Terminal(backend));
|
||
res.register_state::<ReportedTaskCompletions>();
|
||
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
|
||
res.insert(SubagentEventSender(tx));
|
||
tokio::spawn(async move {
|
||
while let Some(event) = rx.recv().await {
|
||
if let SubagentEvent::Completions(req) = event {
|
||
let filtered: Vec<_> = completions
|
||
.iter()
|
||
.filter(|c| !req.suppress_ids.contains(&c.subagent_id))
|
||
.cloned()
|
||
.collect();
|
||
let _ = req.respond_to.send(filtered);
|
||
}
|
||
}
|
||
});
|
||
res.into_shared()
|
||
}
|
||
fn make_subagent_completion(id: &str, success: bool) -> SubagentCompletionSummary {
|
||
SubagentCompletionSummary {
|
||
subagent_id: id.into(),
|
||
subagent_type: "general-purpose".into(),
|
||
description: "test task".into(),
|
||
success,
|
||
duration_ms: 5000,
|
||
tool_calls: 3,
|
||
turns: 2,
|
||
output: std::sync::Arc::from(format!("output for {id}")),
|
||
}
|
||
}
|
||
#[tokio::test]
|
||
async fn subagent_completion_surfaced() {
|
||
let shared =
|
||
shared_with_subagent_completions(vec![], vec![make_subagent_completion("sub-1", true)]);
|
||
let reminder = TaskCompletionReminder;
|
||
let output = ToolOutput::Dynamic(serde_json::Value::Null.into());
|
||
let r = reminder.collect_reminders(shared, &output).await;
|
||
assert_eq!(r.len(), 1);
|
||
assert!(r[0].contains("sub-1"));
|
||
assert!(r[0].contains("successfully"));
|
||
assert!(r[0].contains("general-purpose"));
|
||
assert!(r[0].contains("5.0s"));
|
||
}
|
||
#[tokio::test]
|
||
async fn subagent_completion_suppressed_by_task_output() {
|
||
let shared =
|
||
shared_with_subagent_completions(vec![], vec![make_subagent_completion("sub-1", true)]);
|
||
let reminder = TaskCompletionReminder;
|
||
let output = ToolOutput::TaskOutput(TaskOutputOutput::Result(TaskOutputResult {
|
||
task_id: "sub-1".into(),
|
||
command: String::new(),
|
||
status: "completed".into(),
|
||
exit_code: None,
|
||
started: String::new(),
|
||
ended: None,
|
||
duration_secs: 0.0,
|
||
output: "done".into(),
|
||
output_file: String::new(),
|
||
truncated: false,
|
||
truncation_hint: String::new(),
|
||
raw_output_bytes: 0,
|
||
}));
|
||
let r = reminder.collect_reminders(shared, &output).await;
|
||
assert!(
|
||
r.is_empty(),
|
||
"completed get_task_output should suppress subagent reminder"
|
||
);
|
||
}
|
||
#[tokio::test]
|
||
async fn subagent_completion_suppressed_by_wait_tasks_multi_result() {
|
||
let shared = shared_with_subagent_completions(
|
||
vec![],
|
||
vec![
|
||
make_subagent_completion("sub-1", true),
|
||
make_subagent_completion("sub-2", true),
|
||
],
|
||
);
|
||
let reminder = TaskCompletionReminder;
|
||
let output = ToolOutput::TaskOutput(TaskOutputOutput::MultiResult(MultiTaskOutputResult {
|
||
mode: "wait_all".into(),
|
||
results: vec![
|
||
TaskOutputResult {
|
||
task_id: "sub-1".into(),
|
||
command: String::new(),
|
||
status: "completed".into(),
|
||
exit_code: None,
|
||
started: String::new(),
|
||
ended: None,
|
||
duration_secs: 0.0,
|
||
output: "done".into(),
|
||
output_file: String::new(),
|
||
truncated: false,
|
||
truncation_hint: String::new(),
|
||
raw_output_bytes: 0,
|
||
},
|
||
TaskOutputResult {
|
||
task_id: "sub-2".into(),
|
||
command: String::new(),
|
||
status: "completed".into(),
|
||
exit_code: None,
|
||
started: String::new(),
|
||
ended: None,
|
||
duration_secs: 0.0,
|
||
output: "done".into(),
|
||
output_file: String::new(),
|
||
truncated: false,
|
||
truncation_hint: String::new(),
|
||
raw_output_bytes: 0,
|
||
},
|
||
],
|
||
summary: "2/2 tasks completed (wait_all)".into(),
|
||
}));
|
||
let r = reminder.collect_reminders(shared, &output).await;
|
||
assert!(
|
||
r.is_empty(),
|
||
"wait_tasks MultiResult should suppress reminders for completed subagents"
|
||
);
|
||
}
|
||
#[tokio::test]
|
||
async fn subagent_completion_dedup_across_calls() {
|
||
let shared =
|
||
shared_with_subagent_completions(vec![], vec![make_subagent_completion("sub-1", true)]);
|
||
let reminder = TaskCompletionReminder;
|
||
let output = ToolOutput::Dynamic(serde_json::Value::Null.into());
|
||
let first = reminder.collect_reminders(shared.clone(), &output).await;
|
||
assert_eq!(first.len(), 1);
|
||
let second = reminder.collect_reminders(shared, &output).await;
|
||
assert!(
|
||
second.is_empty(),
|
||
"same subagent completion should not repeat"
|
||
);
|
||
}
|
||
/// While a `/goal` loop is active the per-tool-call reminder must not
|
||
/// surface bash or subagent completions (they would derail a weak model
|
||
/// mid-goal), but the IDs must still be marked reported so they never
|
||
/// resurface once the goal ends.
|
||
#[tokio::test]
|
||
async fn completions_suppressed_when_goal_loop_active() {
|
||
let mut res = Resources::new();
|
||
let backend: Arc<dyn TerminalBackend> = Arc::new(MockTerminal {
|
||
tasks: vec![make_completed("bash-1")],
|
||
});
|
||
res.insert(Terminal(backend));
|
||
res.register_state::<ReportedTaskCompletions>();
|
||
res.insert(crate::implementations::grok_build::task::types::GoalLoopActive(true));
|
||
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
|
||
res.insert(SubagentEventSender(tx));
|
||
tokio::spawn(async move {
|
||
while let Some(SubagentEvent::Completions(req)) = rx.recv().await {
|
||
let _ = req
|
||
.respond_to
|
||
.send(vec![make_subagent_completion("sub-1", true)]);
|
||
}
|
||
});
|
||
let shared = res.into_shared();
|
||
let reminder = TaskCompletionReminder;
|
||
let output = ToolOutput::Dynamic(serde_json::Value::Null.into());
|
||
let first = reminder.collect_reminders(shared.clone(), &output).await;
|
||
assert!(
|
||
first.is_empty(),
|
||
"goal loop active should suppress bash + subagent reminders, got: {first:?}"
|
||
);
|
||
shared
|
||
.lock()
|
||
.await
|
||
.insert(crate::implementations::grok_build::task::types::GoalLoopActive(false));
|
||
let second = reminder.collect_reminders(shared, &output).await;
|
||
assert!(
|
||
second.is_empty(),
|
||
"suppressed completions must stay reported after goal ends, got: {second:?}"
|
||
);
|
||
}
|
||
#[tokio::test]
|
||
async fn subagent_and_bash_completions_together() {
|
||
let shared = shared_with_subagent_completions(
|
||
vec![make_completed("bash-1")],
|
||
vec![make_subagent_completion("sub-1", false)],
|
||
);
|
||
let reminder = TaskCompletionReminder;
|
||
let output = ToolOutput::Dynamic(serde_json::Value::Null.into());
|
||
let r = reminder.collect_reminders(shared, &output).await;
|
||
assert_eq!(r.len(), 2);
|
||
assert!(r[0].contains("bash-1"));
|
||
assert!(r[1].contains("sub-1"));
|
||
assert!(r[1].contains("with failure"));
|
||
}
|
||
#[tokio::test]
|
||
async fn warns_about_running_tasks_on_bg_launch() {
|
||
let shared = shared_with(vec![make_running("old-bg"), make_completed("done-1")]);
|
||
let reminder = TaskCompletionReminder;
|
||
let output = ToolOutput::BackgroundTaskStarted(make_bg_started("new-bg"));
|
||
let r = reminder.collect_reminders(shared, &output).await;
|
||
assert_eq!(
|
||
r.len(),
|
||
2,
|
||
"expected completion + running warning, got: {r:?}"
|
||
);
|
||
assert!(r[0].contains("done-1"), "first should be the completion");
|
||
assert!(
|
||
r[1].contains("old-bg"),
|
||
"second should warn about old-bg still running"
|
||
);
|
||
assert!(r[1].contains("Consider killing"));
|
||
}
|
||
#[tokio::test]
|
||
async fn no_warning_when_no_other_running_tasks() {
|
||
let shared = shared_with(vec![make_completed("done-1")]);
|
||
let reminder = TaskCompletionReminder;
|
||
let output = ToolOutput::BackgroundTaskStarted(make_bg_started("new-bg"));
|
||
let r = reminder.collect_reminders(shared, &output).await;
|
||
assert_eq!(r.len(), 1);
|
||
assert!(r[0].contains("done-1"));
|
||
}
|
||
#[test]
|
||
fn format_subagent_completion_success_with_poll_tool() {
|
||
let c = make_subagent_completion("sub-abc", true);
|
||
let msg = format_subagent_completion(&c, Some("get_task_output"));
|
||
assert!(msg.contains("sub-abc"));
|
||
assert!(msg.contains("successfully"));
|
||
assert!(msg.contains("general-purpose"));
|
||
assert!(msg.contains("test task"));
|
||
assert!(msg.contains("5.0s"));
|
||
assert!(msg.contains("Tool calls: 3"));
|
||
assert!(msg.contains("Turns: 2"));
|
||
assert!(msg.contains(r#"get_task_output("sub-abc")"#));
|
||
}
|
||
#[test]
|
||
fn format_subagent_completion_failure() {
|
||
let c = make_subagent_completion("sub-fail", false);
|
||
let msg = format_subagent_completion(&c, Some("get_task_output"));
|
||
assert!(msg.contains("with failure"));
|
||
}
|
||
#[test]
|
||
fn format_subagent_completion_inlines_output_when_no_poll_tool() {
|
||
let c = make_subagent_completion("sub-abc", true);
|
||
let msg = format_subagent_completion(&c, None);
|
||
assert!(msg.contains("sub-abc"));
|
||
assert!(msg.contains("successfully"));
|
||
assert!(
|
||
!msg.contains("get_task_output"),
|
||
"must not mention a polling tool when none is available"
|
||
);
|
||
assert!(
|
||
!msg.contains("to see the full output"),
|
||
"must omit the polling hint line entirely"
|
||
);
|
||
assert!(
|
||
msg.contains("response:\noutput for sub-abc"),
|
||
"must inline the subagent's output text: {msg}"
|
||
);
|
||
}
|
||
#[test]
|
||
fn auto_wake_delivered_ids_insert_and_drain() {
|
||
let ids = AutoWakeDeliveredIds::default();
|
||
ids.insert("t1".into());
|
||
ids.insert("t2".into());
|
||
let drained = ids.drain();
|
||
assert_eq!(drained.len(), 2);
|
||
assert!(drained.contains(&"t1".to_string()));
|
||
assert!(drained.contains(&"t2".to_string()));
|
||
assert!(ids.drain().is_empty());
|
||
}
|
||
#[test]
|
||
fn auto_wake_delivered_ids_dedup() {
|
||
let ids = AutoWakeDeliveredIds::default();
|
||
ids.insert("t1".into());
|
||
ids.insert("t1".into());
|
||
let drained = ids.drain();
|
||
assert_eq!(drained.len(), 1);
|
||
}
|
||
#[test]
|
||
fn auto_wake_delivered_ids_snapshot_is_non_destructive() {
|
||
let ids = AutoWakeDeliveredIds::default();
|
||
ids.insert("t1".into());
|
||
ids.insert("t2".into());
|
||
let snap = ids.snapshot();
|
||
assert_eq!(snap.len(), 2);
|
||
assert!(snap.contains(&"t1".to_string()));
|
||
assert!(snap.contains(&"t2".to_string()));
|
||
assert_eq!(ids.drain().len(), 2);
|
||
}
|
||
#[tokio::test]
|
||
async fn auto_wake_delivered_ids_suppress_reminders() {
|
||
let mut res = Resources::new();
|
||
let backend: Arc<dyn TerminalBackend> = Arc::new(MockTerminal {
|
||
tasks: vec![make_completed("t1"), make_completed("t2")],
|
||
});
|
||
res.insert(Terminal(backend));
|
||
res.register_state::<ReportedTaskCompletions>();
|
||
let auto_wake = AutoWakeDeliveredIds::default();
|
||
auto_wake.insert("t1".into());
|
||
res.insert(auto_wake);
|
||
let shared = res.into_shared();
|
||
let reminder = TaskCompletionReminder;
|
||
let output = ToolOutput::Dynamic(serde_json::Value::Null.into());
|
||
let r = reminder.collect_reminders(shared, &output).await;
|
||
assert_eq!(
|
||
r.len(),
|
||
1,
|
||
"auto-wake delivered ID should suppress reminder"
|
||
);
|
||
assert!(r[0].contains("t2"));
|
||
}
|
||
/// Regression: subagent inline output larger than the bash-completion
|
||
/// inline cap MUST be preserved verbatim. The inline branch is the
|
||
/// model's only chance to see subagent output (no disk file exists),
|
||
/// so the bash-only [`MAX_INLINE_COMPLETION_BYTES`] cap must not leak
|
||
/// into the subagent path.
|
||
#[test]
|
||
fn format_subagent_completion_no_poll_tool_preserves_large_output_verbatim() {
|
||
let mut c = make_subagent_completion("sub-large", true);
|
||
let large_output = "y".repeat(MAX_INLINE_COMPLETION_BYTES * 5);
|
||
c.output = std::sync::Arc::from(large_output.as_str());
|
||
let msg = format_subagent_completion(&c, None);
|
||
assert!(
|
||
msg.contains(&large_output),
|
||
"subagent inline output must be preserved verbatim, got len={}",
|
||
msg.len()
|
||
);
|
||
assert!(
|
||
!msg.contains("[Output truncated"),
|
||
"subagent inline output must not be truncated: {msg}"
|
||
);
|
||
}
|
||
/// Same invariant for the between-turn batched reminder surface.
|
||
#[test]
|
||
fn format_between_turn_completions_no_poll_tool_preserves_large_output_verbatim() {
|
||
let mut c = make_subagent_completion("sub-batch", true);
|
||
let large_output = "z".repeat(MAX_INLINE_COMPLETION_BYTES * 3);
|
||
c.output = std::sync::Arc::from(large_output.as_str());
|
||
let msg = format_between_turn_completions(&[c], None);
|
||
assert!(
|
||
msg.contains(&large_output),
|
||
"between-turn subagent inline output must be preserved verbatim"
|
||
);
|
||
assert!(!msg.contains("[Output truncated"));
|
||
}
|
||
/// The reminder pipeline ignores `MonitorEventBuffer` — the turn loop
|
||
/// owns the drain. Guards against the tool-result append path being
|
||
/// reintroduced.
|
||
#[tokio::test]
|
||
async fn reminder_pipeline_ignores_monitor_event_buffer() {
|
||
use crate::implementations::grok_build::task::types::{
|
||
MonitorEventBuffer, MonitorEventNotification,
|
||
};
|
||
use crate::types::resources::Resources;
|
||
let session_buffer = MonitorEventBuffer::default();
|
||
session_buffer.push(MonitorEventNotification {
|
||
task_id: "own-1".into(),
|
||
event_text: "own line".into(),
|
||
owner_session_id: None,
|
||
});
|
||
let mut res = Resources::new();
|
||
res.insert(session_buffer.clone());
|
||
let shared = res.into_shared();
|
||
let output = ToolOutput::Text(crate::types::output::TextOutput {
|
||
text: "ok".into(),
|
||
consumed_completion_task_id: None,
|
||
});
|
||
let reminders = TaskCompletionReminder
|
||
.collect_reminders(shared, &output)
|
||
.await;
|
||
assert!(
|
||
reminders.is_empty(),
|
||
"monitor events must not surface as tool-result reminders: {reminders:?}"
|
||
);
|
||
assert_eq!(
|
||
session_buffer.len(),
|
||
1,
|
||
"the reminder pipeline must leave the buffer for the turn loop to drain"
|
||
);
|
||
}
|
||
/// `drain_owned` leader-mode partition: the draining session takes its
|
||
/// own + owner-less legacy events; foreign events stay buffered.
|
||
#[test]
|
||
fn drain_owned_partitions_by_session_owner() {
|
||
use crate::implementations::grok_build::task::types::{
|
||
MonitorEventBuffer, MonitorEventNotification, drain_owned,
|
||
};
|
||
let shared_buffer = MonitorEventBuffer::default();
|
||
shared_buffer.push(MonitorEventNotification {
|
||
task_id: "mine-1".into(),
|
||
event_text: "mine line".into(),
|
||
owner_session_id: Some("session-B".into()),
|
||
});
|
||
shared_buffer.push(MonitorEventNotification {
|
||
task_id: "foreign-1".into(),
|
||
event_text: "foreign line".into(),
|
||
owner_session_id: Some("session-A".into()),
|
||
});
|
||
shared_buffer.push(MonitorEventNotification {
|
||
task_id: "legacy-1".into(),
|
||
event_text: "legacy line".into(),
|
||
owner_session_id: None,
|
||
});
|
||
let mine = drain_owned(&shared_buffer, Some("session-B"));
|
||
let ids: Vec<&str> = mine.iter().map(|e| e.task_id.as_str()).collect();
|
||
assert_eq!(
|
||
ids,
|
||
vec!["mine-1", "legacy-1"],
|
||
"own + legacy events drain, in arrival order"
|
||
);
|
||
assert_eq!(shared_buffer.len(), 1, "foreign event must remain buffered");
|
||
let foreign = drain_owned(&shared_buffer, Some("session-A"));
|
||
assert_eq!(foreign.len(), 1);
|
||
assert_eq!(foreign[0].task_id, "foreign-1");
|
||
assert!(shared_buffer.is_empty());
|
||
}
|
||
/// Single event => lean `<monitor-event>` form; multiple => count-led
|
||
/// batch with per-monitor `<monitor>` groups and numbered labels;
|
||
/// empty => `None`.
|
||
#[test]
|
||
fn format_monitor_events_single_vs_batched() {
|
||
use crate::implementations::grok_build::task::types::MonitorEventNotification;
|
||
let event = |task: &str, desc: &str, text: &str| MonitorEventNotification {
|
||
task_id: task.to_string(),
|
||
event_text: format!(
|
||
"<monitor-event description=\"{desc}\" task_id=\"{task}\">\n{text}\n</monitor-event>"
|
||
),
|
||
owner_session_id: None,
|
||
};
|
||
assert_eq!(format_monitor_events(&[], Some("get_task_output")), None);
|
||
let single = format_monitor_events(
|
||
&[event("task-0", "alpha", "line 0")],
|
||
Some("get_task_output"),
|
||
)
|
||
.expect("single event formats");
|
||
assert_eq!(
|
||
single, "<monitor-event task_id=\"task-0\">\n[alpha] line 0\n</monitor-event>",
|
||
"single event must use the lean monitor-event form"
|
||
);
|
||
let bare = crate::implementations::grok_build::task::types::MonitorEventNotification {
|
||
task_id: "task-9".into(),
|
||
event_text: "bare text, no wrapper".into(),
|
||
owner_session_id: None,
|
||
};
|
||
let single_bare =
|
||
format_monitor_events(std::slice::from_ref(&bare), None).expect("bare event formats");
|
||
assert_eq!(
|
||
single_bare,
|
||
"<monitor-event task_id=\"task-9\">\n[event] bare text, no wrapper\n</monitor-event>"
|
||
);
|
||
let batched = format_monitor_events(
|
||
&[
|
||
event("task-0", "alpha", "a first"),
|
||
event("task-1", "beta", "b first"),
|
||
event("task-0", "alpha", "a second"),
|
||
],
|
||
None,
|
||
)
|
||
.expect("multiple events format");
|
||
assert!(
|
||
batched.starts_with(
|
||
"3 monitor events from 2 monitors \
|
||
(use get_command_or_subagent_output to identify each monitor):"
|
||
),
|
||
"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>"),
|
||
"task-0 group: description once on the tag, ordinal tick labels: {batched}"
|
||
);
|
||
assert!(
|
||
batched.contains(
|
||
"<monitor description=\"beta\" task_id=\"task-1\">\n[1] b first\n</monitor>"
|
||
),
|
||
"task-1 group must carry its own description: {batched}"
|
||
);
|
||
assert!(
|
||
!batched.contains("<monitor-event "),
|
||
"per-event attribute wrappers must be hoisted into the group: {batched}"
|
||
);
|
||
assert_eq!(
|
||
batched.matches("to identify each monitor").count(),
|
||
1,
|
||
"exactly one preamble: {batched}"
|
||
);
|
||
}
|
||
/// `split_wrapped_monitor_event`: parses the exact `wrap_monitor_event`
|
||
/// shape (including quotes inside the description), and returns `None`
|
||
/// for non-conforming text so the batch falls back to verbatim.
|
||
#[test]
|
||
fn split_wrapped_monitor_event_parses_and_rejects() {
|
||
let wrapped = "<monitor-event description=\"watch \\\"prod\\\" logs\" task_id=\"t-1\">\nline a\nline b\n</monitor-event>";
|
||
let (desc, inner) = split_wrapped_monitor_event(wrapped).expect("conforming text parses");
|
||
assert_eq!(desc, "watch \\\"prod\\\" logs");
|
||
assert_eq!(inner, "line a\nline b");
|
||
assert_eq!(split_wrapped_monitor_event("bare text, no wrapper"), None);
|
||
assert_eq!(
|
||
split_wrapped_monitor_event("<monitor-event task_id=\"t\">\nx\n</monitor-event>"),
|
||
None,
|
||
"missing description attribute must not parse"
|
||
);
|
||
let multibyte = "<monitor-event description=\"日本語ログ 监视 🚨\" task_id=\"t-utf8\">\n警告: ライン①\n二行目 — ürgent 🚨\n</monitor-event>";
|
||
let (desc, inner) = split_wrapped_monitor_event(multibyte).expect("multibyte parses");
|
||
assert_eq!(desc, "日本語ログ 监视 🚨");
|
||
assert_eq!(inner, "警告: ライン①\n二行目 — ürgent 🚨");
|
||
}
|
||
/// Writer↔parser round-trip through the REAL `wrap_monitor_event`,
|
||
/// including a hostile description (quotes + newline + `">\n` sequence).
|
||
/// The writer sanitizes, so the parser always recovers cleanly — if the
|
||
/// writer's shape ever drifts from the parser, this fails loudly.
|
||
#[test]
|
||
fn wrap_monitor_event_round_trips_through_split() {
|
||
use crate::implementations::grok_build::monitor::event::wrap_monitor_event;
|
||
let wrapped = wrap_monitor_event("plain watcher", "tick 1\ntick 2", "t-1");
|
||
let (desc, inner) = split_wrapped_monitor_event(&wrapped).expect("plain round-trip");
|
||
assert_eq!(desc, "plain watcher");
|
||
assert_eq!(inner, "tick 1\ntick 2");
|
||
let wrapped = wrap_monitor_event("evil\">\nfake task_id=\"x", "payload", "t-2");
|
||
let (desc, inner) = split_wrapped_monitor_event(&wrapped).expect("hostile round-trip");
|
||
assert_eq!(desc, "evil'> fake task_id='x");
|
||
assert_eq!(inner, "payload");
|
||
}
|
||
/// End-to-end multibyte safety through the formatter (single + batch).
|
||
#[test]
|
||
fn format_monitor_events_handles_multibyte_content() {
|
||
use crate::implementations::grok_build::task::types::MonitorEventNotification;
|
||
let event = |task: &str, desc: &str, text: &str| MonitorEventNotification {
|
||
task_id: task.to_string(),
|
||
event_text: format!(
|
||
"<monitor-event description=\"{desc}\" task_id=\"{task}\">\n{text}\n</monitor-event>"
|
||
),
|
||
owner_session_id: None,
|
||
};
|
||
let single = format_monitor_events(&[event("t-1", "журнал 🚨", "строка №1 ✓")], None)
|
||
.expect("single formats");
|
||
assert!(single.contains("[журнал 🚨] строка №1 ✓"), "{single}");
|
||
let batched = format_monitor_events(
|
||
&[
|
||
event("t-1", "журнал 🚨", "строка №1"),
|
||
event("t-1", "журнал 🚨", "строка №2"),
|
||
],
|
||
None,
|
||
)
|
||
.expect("batch formats");
|
||
assert!(batched.contains("description=\"журнал 🚨\""), "{batched}");
|
||
assert!(batched.contains("[1] строка №1"), "{batched}");
|
||
assert!(batched.contains("[2] строка №2"), "{batched}");
|
||
}
|
||
}
|