From c1cd22840d008c10b7e6474d0ab2552cf573a0df Mon Sep 17 00:00:00 2001 From: Victor Sumner Date: Thu, 26 Feb 2026 15:26:33 -0500 Subject: [PATCH 1/7] feat(channel): add deterministic temporal context --- docs/content/docs/(configuration)/config.mdx | 12 + prompts/en/branch.md.j2 | 1 + prompts/en/channel.md.j2 | 2 + src/agent/channel.rs | 221 +++++++++++++++++-- src/agent/status.rs | 21 ++ src/api/agents.rs | 1 + src/config.rs | 175 ++++++++++++++- 7 files changed, 409 insertions(+), 24 deletions(-) diff --git a/docs/content/docs/(configuration)/config.mdx b/docs/content/docs/(configuration)/config.mdx index 098b3dd12..7c76066cd 100644 --- a/docs/content/docs/(configuration)/config.mdx +++ b/docs/content/docs/(configuration)/config.mdx @@ -62,6 +62,7 @@ context_window = 128000 # context window size in tokens history_backfill_count = 50 # messages to fetch from platform on new channel worker_log_mode = "errors_only" # "errors_only", "all_separate", or "all_combined" cron_timezone = "UTC" # optional default timezone for cron active hours +user_timezone = "UTC" # optional default timezone for channel/worker time context # Model routing per process type. [defaults.routing] @@ -116,6 +117,7 @@ id = "main" default = true workspace = "/custom/workspace/path" # optional, defaults to ~/.spacebot/agents/{id}/workspace cron_timezone = "America/Los_Angeles" # optional per-agent cron timezone override +user_timezone = "America/Los_Angeles" # optional per-agent timezone override for channel/worker time context # Per-agent routing overrides (merges with defaults). [agents.routing] @@ -417,6 +419,7 @@ At least one provider (legacy key or custom provider) must be configured. | `history_backfill_count` | integer | 50 | Messages to fetch from platform on new channel | | `worker_log_mode` | string | `"errors_only"` | Worker log persistence: `"errors_only"`, `"all_separate"`, or `"all_combined"` | | `cron_timezone` | string | None | Default timezone for cron active-hours evaluation (IANA name like `UTC` or `America/New_York`) | +| `user_timezone` | string | inherits `cron_timezone` | Default timezone for channel/worker temporal context (IANA name) | ### `[defaults.routing]` @@ -522,6 +525,7 @@ When branch/worker/cron dispatch happens before readiness is satisfied, Spacebot | `default` | bool | false | Whether this is the default agent | | `workspace` | string | `~/.spacebot/agents/{id}/workspace` | Custom workspace path | | `cron_timezone` | string | inherits | Per-agent timezone override for cron active-hours evaluation | +| `user_timezone` | string | inherits | Per-agent timezone override for channel/worker temporal context | | `max_concurrent_branches` | integer | inherits | Override instance default | | `max_turns` | integer | inherits | Override instance default | | `context_window` | integer | inherits | Override instance default | @@ -577,6 +581,14 @@ Cron timezone precedence is: If a configured timezone is invalid, Spacebot logs a warning and falls back to server local time. +Channel/worker temporal context timezone precedence is: + +1. `agents.user_timezone` +2. `defaults.user_timezone` +3. `SPACEBOT_USER_TIMEZONE` +4. resolved cron timezone (from `agents.cron_timezone` / `defaults.cron_timezone` / `SPACEBOT_CRON_TIMEZONE`) +5. server local timezone + ### `[messaging.discord]` | Key | Type | Default | Description | diff --git a/prompts/en/branch.md.j2 b/prompts/en/branch.md.j2 index a60d9097f..56b262120 100644 --- a/prompts/en/branch.md.j2 +++ b/prompts/en/branch.md.j2 @@ -63,3 +63,4 @@ Refine a task. Update the description as the user clarifies scope — append sec - **observation** — a pattern noticed over time ("the user usually asks for code-first answers") - **goal** — something the user or agent wants to achieve ("migrate to the new API by Q3"). Goals are aspirational and may span multiple conversations. - **todo** — a concrete actionable task or reminder ("update the auth tests", "remind me to check the deploy tomorrow"). Todos are specific and completable. +7. For time-sensitive conclusions, anchor on explicit timestamps/context and include concrete dates when ambiguity is possible. diff --git a/prompts/en/channel.md.j2 b/prompts/en/channel.md.j2 index 62843da3a..38b06e30f 100644 --- a/prompts/en/channel.md.j2 +++ b/prompts/en/channel.md.j2 @@ -33,6 +33,7 @@ You have a soul, an identity, and a personality. These are loaded separately and ## How You Work Every turn, you receive the user's message along with a live status block showing active workers, branches, and recently completed work. Use this to stay aware of what's happening without asking. +The status block includes a current date/time line with timezone and UTC. Treat that as the source of truth for words like "today", "tomorrow", "yesterday", "now", and "later today". When a background process (branch or worker) completes, you will receive a system message containing the full result text, tagged with the process type and ID. The user has NOT seen any of it — you must relay the substance to them using the reply tool. Include actual content and details, not just a summary teaser. Do not mention internal processes (branch, worker, process IDs). If a result is background work the user didn't ask about, incorporate it silently. @@ -105,6 +106,7 @@ When in doubt, skip. Being a lurker who speaks when it matters is better than be 9. Save important information to memory. Be selective. When the user asks to forget something, branch to find and delete the relevant memories. 10. One worker per task. Never spawn multiple workers for the same request. If a worker is already handling something, wait for it to finish or route follow-ups to it. Check your status block before spawning. 11. On Discord and Slack, prefer rich responses when output is structured or multi-part (task outcomes, summaries, comparisons, checklists, incident/debug updates, plans). Use `reply` with `cards`/interactive elements (Discord) or `blocks` (Slack) instead of plain text walls when it improves clarity. +12. For time-sensitive responses, prefer concrete dates (for example, "March 5, 2026") in addition to relative phrases. {%- if skills_prompt %} {{ skills_prompt }} diff --git a/src/agent/channel.rs b/src/agent/channel.rs index 71229d2a5..27f20e06f 100644 --- a/src/agent/channel.rs +++ b/src/agent/channel.rs @@ -13,6 +13,8 @@ use crate::{ AgentDeps, BranchId, ChannelId, InboundMessage, OutboundResponse, ProcessEvent, ProcessId, ProcessType, WorkerId, }; +use chrono::{DateTime, Local, Utc}; +use chrono_tz::Tz; use rig::agent::AgentBuilder; use rig::completion::{CompletionModel, Prompt}; use rig::message::{ImageMediaType, MimeType, UserContent}; @@ -33,6 +35,103 @@ const RETRIGGER_DEBOUNCE_MS: u64 = 500; /// infinite retrigger cascades where each retrigger spawns more work. const MAX_RETRIGGERS_PER_TURN: usize = 3; +#[derive(Debug, Clone)] +enum TemporalTimezone { + Named { timezone_name: String, timezone: Tz }, + SystemLocal, +} + +#[derive(Debug, Clone)] +struct TemporalContext { + now_utc: DateTime, + timezone: TemporalTimezone, +} + +impl TemporalContext { + fn from_runtime(runtime_config: &crate::config::RuntimeConfig) -> Self { + let configured_timezone = runtime_config + .user_timezone + .load() + .as_ref() + .clone() + .or_else(|| runtime_config.cron_timezone.load().as_ref().clone()); + let now_utc = Utc::now(); + + if let Some(timezone_name) = configured_timezone { + match timezone_name.parse::() { + Ok(timezone) => { + return Self { + now_utc, + timezone: TemporalTimezone::Named { + timezone_name, + timezone, + }, + }; + } + Err(_) => { + tracing::warn!( + timezone = %timezone_name, + "invalid runtime timezone for channel temporal context, falling back to system local" + ); + } + } + } + + Self { + now_utc, + timezone: TemporalTimezone::SystemLocal, + } + } + + fn format_timestamp(&self, timestamp: DateTime) -> String { + match &self.timezone { + TemporalTimezone::Named { + timezone_name, + timezone, + } => { + let local_timestamp = timestamp.with_timezone(timezone); + format!( + "{} ({}, UTC{})", + local_timestamp.format("%Y-%m-%d %H:%M:%S %Z"), + timezone_name, + local_timestamp.format("%:z") + ) + } + TemporalTimezone::SystemLocal => { + let local_timestamp = timestamp.with_timezone(&Local); + format!( + "{} (system local, UTC{})", + local_timestamp.format("%Y-%m-%d %H:%M:%S %Z"), + local_timestamp.format("%:z") + ) + } + } + } + + fn current_time_line(&self) -> String { + format!( + "{}; UTC {}", + self.format_timestamp(self.now_utc), + self.now_utc.format("%Y-%m-%d %H:%M:%S UTC") + ) + } + + fn worker_task_preamble(&self) -> String { + format!( + "## Time Context\n- Current local date/time: {}\n- Current UTC date/time: {}\n- Use this context for relative dates (today/tomorrow/yesterday/now) and include absolute dates when timing matters.", + self.format_timestamp(self.now_utc), + self.now_utc.format("%Y-%m-%d %H:%M:%S UTC") + ) + } +} + +fn build_worker_task_with_temporal_context( + task: &str, + temporal_context: &TemporalContext, +) -> String { + format!("{}\n\n{}", temporal_context.worker_task_preamble(), task) +} + /// A background process result waiting to be relayed to the user via retrigger. /// /// Instead of injecting raw result text into history as a fake "User" message @@ -528,6 +627,7 @@ impl Channel { // Persist each message to conversation log (individual audit trail) let mut user_contents: Vec = Vec::new(); let mut conversation_id = String::new(); + let temporal_context = TemporalContext::from_runtime(self.deps.runtime_config.as_ref()); for message in &messages { if message.source != "system" { @@ -561,11 +661,12 @@ impl Channel { conversation_id = message.conversation_id.clone(); - // Format with relative timestamp + // Include both absolute and relative time context. let relative_secs = message .timestamp .signed_duration_since(first_timestamp) - .num_seconds(); + .num_seconds() + .max(0); let relative_text = if relative_secs < 1 { "just now".to_string() } else if relative_secs < 60 { @@ -573,6 +674,7 @@ impl Channel { } else { format!("{}m ago", relative_secs / 60) }; + let absolute_timestamp = temporal_context.format_timestamp(message.timestamp); let display_name = message .metadata @@ -580,8 +682,12 @@ impl Channel { .and_then(|v| v.as_str()) .unwrap_or(&message.sender_id); - let formatted_text = - format!("[{}] ({}): {}", display_name, relative_text, raw_text); + let formatted_text = format_batched_user_message( + display_name, + &absolute_timestamp, + &relative_text, + &raw_text, + ); // Download attachments for this message if !attachments.is_empty() { @@ -669,9 +775,11 @@ impl Channel { opencode_enabled, )?; + let temporal_context = TemporalContext::from_runtime(rc.as_ref()); + let current_time_line = temporal_context.current_time_line(); let status_text = { let status = self.state.status_block.read().await; - status.render() + status.render_with_time_context(Some(¤t_time_line)) }; // Render coalesce hint @@ -726,7 +834,9 @@ impl Channel { crate::MessageContent::Interaction { .. } => (message.content.to_string(), Vec::new()), }; - let user_text = format_user_message(&raw_text, &message); + let temporal_context = TemporalContext::from_runtime(self.deps.runtime_config.as_ref()); + let message_timestamp = temporal_context.format_timestamp(message.timestamp); + let user_text = format_user_message(&raw_text, &message, &message_timestamp); let attachment_content = if !attachments.is_empty() { download_attachments(&self.deps, &attachments).await @@ -958,9 +1068,11 @@ impl Channel { opencode_enabled, )?; + let temporal_context = TemporalContext::from_runtime(rc.as_ref()); + let current_time_line = temporal_context.current_time_line(); let status_text = { let status = self.state.status_block.read().await; - status.render() + status.render_with_time_context(Some(¤t_time_line)) }; let available_channels = self.build_available_channels().await; @@ -1626,8 +1738,10 @@ impl Channel { /// Get the current status block as a string. pub async fn get_status(&self) -> String { + let temporal_context = TemporalContext::from_runtime(self.deps.runtime_config.as_ref()); + let current_time_line = temporal_context.current_time_line(); let status = self.state.status_block.read().await; - status.render() + status.render_with_time_context(Some(¤t_time_line)) } /// Check if a memory persistence branch should be spawned based on message count. @@ -1883,6 +1997,8 @@ pub async fn spawn_worker_from_state( let task = task.into(); let rc = &state.deps.runtime_config; + let temporal_context = TemporalContext::from_runtime(rc.as_ref()); + let worker_task = build_worker_task_with_temporal_context(&task, &temporal_context); let prompt_engine = rc.prompts.load(); let worker_system_prompt = prompt_engine .render_worker_prompt( @@ -1911,7 +2027,7 @@ pub async fn spawn_worker_from_state( let worker = if interactive { let (worker, input_tx) = Worker::new_interactive( Some(state.channel_id.clone()), - &task, + &worker_task, &system_prompt, state.deps.clone(), browser_config.clone(), @@ -1929,7 +2045,7 @@ pub async fn spawn_worker_from_state( } else { Worker::new( Some(state.channel_id.clone()), - &task, + &worker_task, &system_prompt, state.deps.clone(), browser_config, @@ -1996,6 +2112,8 @@ pub async fn spawn_opencode_worker_from_state( let directory = std::path::PathBuf::from(directory); let rc = &state.deps.runtime_config; + let temporal_context = TemporalContext::from_runtime(rc.as_ref()); + let worker_task = build_worker_task_with_temporal_context(&task, &temporal_context); let opencode_config = rc.opencode.load(); if !opencode_config.enabled { @@ -2010,7 +2128,7 @@ pub async fn spawn_opencode_worker_from_state( let (worker, input_tx) = crate::opencode::OpenCodeWorker::new_interactive( Some(state.channel_id.clone()), state.deps.agent_id.clone(), - &task, + &worker_task, directory, server_pool, state.deps.event_tx.clone(), @@ -2026,7 +2144,7 @@ pub async fn spawn_opencode_worker_from_state( crate::opencode::OpenCodeWorker::new( Some(state.channel_id.clone()), state.deps.agent_id.clone(), - &task, + &worker_task, directory, server_pool, state.deps.event_tx.clone(), @@ -2195,7 +2313,7 @@ fn extract_reply_from_tool_syntax(text: &str) -> Option { /// /// In multi-user channels, this lets the LLM distinguish who said what. /// System-generated messages (re-triggers) are passed through as-is. -fn format_user_message(raw_text: &str, message: &InboundMessage) -> String { +fn format_user_message(raw_text: &str, message: &InboundMessage, timestamp_text: &str) -> String { if message.source == "system" { // System messages should never be empty, but guard against it return if raw_text.trim().is_empty() { @@ -2255,7 +2373,16 @@ fn format_user_message(raw_text: &str, message: &InboundMessage) -> String { raw_text }; - format!("{display_name}{bot_tag}{reply_context}: {text_content}") + format!("{display_name}{bot_tag}{reply_context} [{timestamp_text}]: {text_content}") +} + +fn format_batched_user_message( + display_name: &str, + absolute_timestamp: &str, + relative_text: &str, + raw_text: &str, +) -> String { + format!("[{display_name}] ({absolute_timestamp}; {relative_text}): {raw_text}") } fn extract_discord_message_id(message: &InboundMessage) -> Option { @@ -3126,7 +3253,7 @@ mod tests { timestamp: Utc::now(), }; - let formatted = format_user_message("", &message); + let formatted = format_user_message("", &message, "2026-02-26 12:00:00 UTC"); assert!( !formatted.trim().is_empty(), "formatted message should not be empty" @@ -3137,7 +3264,7 @@ mod tests { ); // Test whitespace-only text - let formatted_ws = format_user_message(" ", &message); + let formatted_ws = format_user_message(" ", &message, "2026-02-26 12:00:00 UTC"); assert!( formatted_ws.contains("[attachment or empty message]"), "should use placeholder for whitespace-only text" @@ -3156,21 +3283,79 @@ mod tests { timestamp: Utc::now(), }; - let formatted_sys = format_user_message("", &system_message); + let formatted_sys = format_user_message("", &system_message, "2026-02-26 12:00:00 UTC"); assert_eq!( formatted_sys, "[system event]", "system messages should use [system event] placeholder" ); // Test normal message with text - let formatted_normal = format_user_message("hello", &message); + let formatted_normal = format_user_message("hello", &message, "2026-02-26 12:00:00 UTC"); assert!( formatted_normal.contains("hello"), "normal messages should preserve text" ); + assert!( + formatted_normal.contains("[2026-02-26 12:00:00 UTC]"), + "normal messages should include absolute timestamp context" + ); assert!( !formatted_normal.contains("[attachment or empty message]"), "normal messages should not use placeholder" ); } + + #[test] + fn worker_task_temporal_context_preamble_includes_absolute_dates() { + let temporal_context = super::TemporalContext { + now_utc: chrono::DateTime::parse_from_rfc3339("2026-02-26T20:30:00Z") + .expect("valid RFC3339 timestamp") + .with_timezone(&chrono::Utc), + timezone: super::TemporalTimezone::Named { + timezone_name: "America/New_York".to_string(), + timezone: "America/New_York" + .parse() + .expect("valid timezone identifier"), + }, + }; + + let worker_task = super::build_worker_task_with_temporal_context( + "Run the migration checks", + &temporal_context, + ); + assert!( + worker_task.contains("Current local date/time:"), + "worker task should include local time context" + ); + assert!( + worker_task.contains("Current UTC date/time:"), + "worker task should include UTC time context" + ); + assert!( + worker_task.contains("Run the migration checks"), + "worker task should preserve the original task body" + ); + } + + #[test] + fn format_batched_message_includes_absolute_and_relative_time() { + let formatted = super::format_batched_user_message( + "alice", + "2026-02-26 15:04:05 PST (America/Los_Angeles, UTC-08:00)", + "12s ago", + "ship it", + ); + assert!( + formatted.contains("2026-02-26 15:04:05 PST"), + "batched formatting should include absolute timestamp" + ); + assert!( + formatted.contains("12s ago"), + "batched formatting should include relative timestamp hint" + ); + assert!( + formatted.contains("ship it"), + "batched formatting should include original message text" + ); + } } diff --git a/src/agent/status.rs b/src/agent/status.rs index f410e6e18..bb321a75d 100644 --- a/src/agent/status.rs +++ b/src/agent/status.rs @@ -158,8 +158,17 @@ impl StatusBlock { /// Render the status block as a string for context injection. pub fn render(&self) -> String { + self.render_with_time_context(None) + } + + /// Render the status block with optional current time context. + pub fn render_with_time_context(&self, current_time_line: Option<&str>) -> String { let mut output = String::new(); + if let Some(current_time_line) = current_time_line { + output.push_str(&format!("Current date/time: {current_time_line}\n\n")); + } + // Active workers if !self.active_workers.is_empty() { output.push_str("## Active Workers\n"); @@ -269,3 +278,15 @@ impl StatusBlock { .retain(|l| l.peer_agent != peer_agent); } } + +#[cfg(test)] +mod tests { + use super::StatusBlock; + + #[test] + fn render_with_time_context_renders_current_time_when_empty() { + let status = StatusBlock::new(); + let rendered = status.render_with_time_context(Some("2026-02-26 12:00:00 UTC")); + assert!(rendered.contains("Current date/time: 2026-02-26 12:00:00 UTC")); + } +} diff --git a/src/api/agents.rs b/src/api/agents.rs index 9a460675c..0d64da420 100644 --- a/src/api/agents.rs +++ b/src/api/agents.rs @@ -536,6 +536,7 @@ pub(super) async fn create_agent( mcp: None, brave_search_key: None, cron_timezone: None, + user_timezone: None, sandbox: None, cron: Vec::new(), }; diff --git a/src/config.rs b/src/config.rs index badc1b522..aa006fb96 100644 --- a/src/config.rs +++ b/src/config.rs @@ -11,6 +11,7 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; const CRON_TIMEZONE_ENV_VAR: &str = "SPACEBOT_CRON_TIMEZONE"; +const USER_TIMEZONE_ENV_VAR: &str = "SPACEBOT_USER_TIMEZONE"; /// OpenTelemetry export configuration. /// @@ -534,6 +535,8 @@ pub struct DefaultsConfig { pub brave_search_key: Option, /// Default timezone used when evaluating cron active hours. pub cron_timezone: Option, + /// Default timezone for channel/worker temporal context. + pub user_timezone: Option, pub history_backfill_count: usize, pub cron: Vec, pub opencode: OpenCodeConfig, @@ -562,6 +565,8 @@ impl std::fmt::Debug for DefaultsConfig { "brave_search_key", &self.brave_search_key.as_ref().map(|_| "[REDACTED]"), ) + .field("cron_timezone", &self.cron_timezone) + .field("user_timezone", &self.user_timezone) .field("history_backfill_count", &self.history_backfill_count) .field("cron", &self.cron) .field("opencode", &self.opencode) @@ -953,6 +958,8 @@ pub struct AgentConfig { pub brave_search_key: Option, /// Optional timezone override for cron active-hours evaluation. pub cron_timezone: Option, + /// Optional timezone override for channel/worker temporal context. + pub user_timezone: Option, /// Sandbox configuration for process containment. pub sandbox: Option, /// Cron job definitions for this agent. @@ -1001,6 +1008,7 @@ pub struct ResolvedAgentConfig { pub mcp: Vec, pub brave_search_key: Option, pub cron_timezone: Option, + pub user_timezone: Option, /// Sandbox configuration for process containment. pub sandbox: crate::sandbox::SandboxConfig, /// Number of messages to fetch from the platform when a new channel is created. @@ -1027,6 +1035,7 @@ impl Default for DefaultsConfig { mcp: Vec::new(), brave_search_key: None, cron_timezone: None, + user_timezone: None, history_backfill_count: 50, cron: Vec::new(), opencode: OpenCodeConfig::default(), @@ -1039,6 +1048,17 @@ impl AgentConfig { /// Resolve this agent config against instance defaults and base paths. pub fn resolve(&self, instance_dir: &Path, defaults: &DefaultsConfig) -> ResolvedAgentConfig { let agent_root = instance_dir.join("agents").join(&self.id); + let resolved_cron_timezone = resolve_cron_timezone( + &self.id, + self.cron_timezone.as_deref(), + defaults.cron_timezone.as_deref(), + ); + let resolved_user_timezone = resolve_user_timezone( + &self.id, + self.user_timezone.as_deref(), + defaults.user_timezone.as_deref(), + resolved_cron_timezone.as_deref(), + ); ResolvedAgentConfig { id: self.id.clone(), @@ -1080,11 +1100,8 @@ impl AgentConfig { .brave_search_key .clone() .or_else(|| defaults.brave_search_key.clone()), - cron_timezone: resolve_cron_timezone( - &self.id, - self.cron_timezone.as_deref(), - defaults.cron_timezone.as_deref(), - ), + cron_timezone: resolved_cron_timezone, + user_timezone: resolved_user_timezone, sandbox: self.sandbox.clone().unwrap_or_default(), history_backfill_count: defaults.history_backfill_count, cron: self.cron.clone(), @@ -1914,6 +1931,7 @@ struct TomlDefaultsConfig { mcp: Vec, brave_search_key: Option, cron_timezone: Option, + user_timezone: Option, opencode: Option, worker_log_mode: Option, } @@ -2059,6 +2077,7 @@ struct TomlAgentConfig { mcp: Option>, brave_search_key: Option, cron_timezone: Option, + user_timezone: Option, sandbox: Option, #[serde(default)] cron: Vec, @@ -2223,6 +2242,36 @@ fn resolve_cron_timezone( Some(timezone) } +fn resolve_user_timezone( + agent_id: &str, + agent_timezone: Option<&str>, + default_timezone: Option<&str>, + fallback_timezone: Option<&str>, +) -> Option { + let timezone = agent_timezone + .and_then(normalize_timezone) + .or_else(|| default_timezone.and_then(normalize_timezone)) + .or_else(|| { + std::env::var(USER_TIMEZONE_ENV_VAR) + .ok() + .and_then(|value| normalize_timezone(&value)) + }) + .or_else(|| fallback_timezone.and_then(normalize_timezone)); + + let timezone = timezone?; + + if timezone.parse::().is_err() { + tracing::warn!( + agent_id, + user_timezone = %timezone, + "invalid user timezone configured, falling back to cron/system timezone" + ); + return fallback_timezone.and_then(normalize_timezone); + } + + Some(timezone) +} + fn parse_otlp_headers(value: Option) -> Result> { let Some(raw) = value else { return Ok(HashMap::new()); @@ -2882,6 +2931,7 @@ impl Config { mcp: None, brave_search_key: None, cron_timezone: None, + user_timezone: None, sandbox: None, cron: Vec::new(), }]; @@ -3516,6 +3566,11 @@ impl Config { .cron_timezone .as_deref() .and_then(resolve_env_value), + user_timezone: toml + .defaults + .user_timezone + .as_deref() + .and_then(resolve_env_value), history_backfill_count: base_defaults.history_backfill_count, cron: Vec::new(), opencode: toml @@ -3699,6 +3754,7 @@ impl Config { }, brave_search_key: a.brave_search_key.as_deref().and_then(resolve_env_value), cron_timezone: a.cron_timezone.as_deref().and_then(resolve_env_value), + user_timezone: a.user_timezone.as_deref().and_then(resolve_env_value), sandbox: a.sandbox, cron, }) @@ -3728,6 +3784,7 @@ impl Config { mcp: None, brave_search_key: None, cron_timezone: None, + user_timezone: None, sandbox: None, cron: Vec::new(), }); @@ -4002,6 +4059,7 @@ pub struct RuntimeConfig { pub history_backfill_count: ArcSwap, pub brave_search_key: ArcSwap>, pub cron_timezone: ArcSwap>, + pub user_timezone: ArcSwap>, pub cortex: ArcSwap, pub warmup: ArcSwap, /// Current warmup lifecycle status for API and observability. @@ -4062,6 +4120,7 @@ impl RuntimeConfig { history_backfill_count: ArcSwap::from_pointee(agent_config.history_backfill_count), brave_search_key: ArcSwap::from_pointee(agent_config.brave_search_key.clone()), cron_timezone: ArcSwap::from_pointee(agent_config.cron_timezone.clone()), + user_timezone: ArcSwap::from_pointee(agent_config.user_timezone.clone()), cortex: ArcSwap::from_pointee(agent_config.cortex), warmup: ArcSwap::from_pointee(agent_config.warmup), warmup_status: ArcSwap::from_pointee(WarmupStatus::default()), @@ -4149,6 +4208,7 @@ impl RuntimeConfig { self.brave_search_key .store(Arc::new(resolved.brave_search_key)); self.cron_timezone.store(Arc::new(resolved.cron_timezone)); + self.user_timezone.store(Arc::new(resolved.user_timezone)); self.cortex.store(Arc::new(resolved.cortex)); self.warmup.store(Arc::new(resolved.warmup)); // sandbox config is not hot-reloaded here because the Sandbox instance @@ -4866,10 +4926,11 @@ mod tests { impl EnvGuard { fn new() -> Self { - const KEYS: [&str; 25] = [ + const KEYS: [&str; 26] = [ "SPACEBOT_DIR", "SPACEBOT_DEPLOYMENT", "SPACEBOT_CRON_TIMEZONE", + "SPACEBOT_USER_TIMEZONE", "ANTHROPIC_API_KEY", "ANTHROPIC_OAUTH_TOKEN", "OPENAI_API_KEY", @@ -5451,6 +5512,108 @@ id = "main" assert_eq!(resolved.cron_timezone, None); } + #[test] + fn test_user_timezone_resolution_precedence() { + let _lock = env_test_lock() + .lock() + .expect("failed to lock env test mutex"); + let _env = EnvGuard::new(); + + unsafe { + std::env::set_var(USER_TIMEZONE_ENV_VAR, "Asia/Tokyo"); + } + + let toml = r#" +[defaults] +user_timezone = "America/New_York" + +[[agents]] +id = "main" +user_timezone = "Europe/Berlin" +"#; + + let parsed: TomlConfig = toml::from_str(toml).expect("failed to parse test TOML"); + let config = Config::from_toml(parsed, PathBuf::from(".")).expect("failed to build Config"); + let resolved = config.agents[0].resolve(&config.instance_dir, &config.defaults); + assert_eq!(resolved.user_timezone.as_deref(), Some("Europe/Berlin")); + + let toml_without_agent_override = r#" +[defaults] +user_timezone = "America/New_York" + +[[agents]] +id = "main" +"#; + let parsed: TomlConfig = + toml::from_str(toml_without_agent_override).expect("failed to parse test TOML"); + let config = Config::from_toml(parsed, PathBuf::from(".")).expect("failed to build Config"); + let resolved = config.agents[0].resolve(&config.instance_dir, &config.defaults); + assert_eq!(resolved.user_timezone.as_deref(), Some("America/New_York")); + + let toml_without_default = r#" +[[agents]] +id = "main" +"#; + let parsed: TomlConfig = + toml::from_str(toml_without_default).expect("failed to parse test TOML"); + let config = Config::from_toml(parsed, PathBuf::from(".")).expect("failed to build Config"); + let resolved = config.agents[0].resolve(&config.instance_dir, &config.defaults); + assert_eq!(resolved.user_timezone.as_deref(), Some("Asia/Tokyo")); + } + + #[test] + fn test_user_timezone_falls_back_to_cron_timezone() { + let _lock = env_test_lock() + .lock() + .expect("failed to lock env test mutex"); + let _env = EnvGuard::new(); + + let toml = r#" +[defaults] +cron_timezone = "America/Los_Angeles" + +[[agents]] +id = "main" +"#; + + let parsed: TomlConfig = toml::from_str(toml).expect("failed to parse test TOML"); + let config = Config::from_toml(parsed, PathBuf::from(".")).expect("failed to build Config"); + let resolved = config.agents[0].resolve(&config.instance_dir, &config.defaults); + assert_eq!( + resolved.cron_timezone.as_deref(), + Some("America/Los_Angeles") + ); + assert_eq!( + resolved.user_timezone.as_deref(), + Some("America/Los_Angeles") + ); + } + + #[test] + fn test_user_timezone_invalid_falls_back_to_cron_timezone() { + let _lock = env_test_lock() + .lock() + .expect("failed to lock env test mutex"); + let _env = EnvGuard::new(); + + let toml = r#" +[defaults] +cron_timezone = "America/Los_Angeles" +user_timezone = "Not/A-Real-Tz" + +[[agents]] +id = "main" +"#; + + let parsed: TomlConfig = toml::from_str(toml).expect("failed to parse test TOML"); + let config = Config::from_toml(parsed, PathBuf::from(".")).expect("failed to build Config"); + let resolved = config.agents[0].resolve(&config.instance_dir, &config.defaults); + assert_eq!( + resolved.user_timezone.as_deref(), + Some("America/Los_Angeles") + ); + } + #[test] fn ollama_base_url_registers_provider() { let toml = r#" From 58bc4bea77730575294ee34f7e7b32923bba25c7 Mon Sep 17 00:00:00 2001 From: Victor Sumner Date: Thu, 26 Feb 2026 15:36:13 -0500 Subject: [PATCH 2/7] fix(channel): compute batched relative time from batch tail --- src/agent/channel.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/agent/channel.rs b/src/agent/channel.rs index 27f20e06f..d76fc18d2 100644 --- a/src/agent/channel.rs +++ b/src/agent/channel.rs @@ -662,9 +662,8 @@ impl Channel { conversation_id = message.conversation_id.clone(); // Include both absolute and relative time context. - let relative_secs = message - .timestamp - .signed_duration_since(first_timestamp) + let relative_secs = last_timestamp + .signed_duration_since(message.timestamp) .num_seconds() .max(0); let relative_text = if relative_secs < 1 { From 42509d796c51e971e60eabfc19bb45767fbcd863 Mon Sep 17 00:00:00 2001 From: Victor Sumner Date: Thu, 26 Feb 2026 15:39:38 -0500 Subject: [PATCH 3/7] fix(channel): harden batched time and timezone fallback --- src/agent/channel.rs | 41 +++++++++++++++++++++------- src/config.rs | 64 +++++++++++++++++++++++++++++++++----------- 2 files changed, 79 insertions(+), 26 deletions(-) diff --git a/src/agent/channel.rs b/src/agent/channel.rs index d76fc18d2..8516d134c 100644 --- a/src/agent/channel.rs +++ b/src/agent/channel.rs @@ -562,15 +562,17 @@ impl Channel { #[tracing::instrument(skip(self, messages), fields(channel_id = %self.id, agent_id = %self.deps.agent_id, message_count = messages.len()))] async fn handle_message_batch(&mut self, messages: Vec) -> Result<()> { let message_count = messages.len(); - let first_timestamp = messages - .first() - .map(|m| m.timestamp) + let batch_start_timestamp = messages + .iter() + .map(|message| message.timestamp) + .min() .unwrap_or_else(chrono::Utc::now); - let last_timestamp = messages - .last() - .map(|m| m.timestamp) - .unwrap_or(first_timestamp); - let elapsed = last_timestamp.signed_duration_since(first_timestamp); + let batch_tail_timestamp = messages + .iter() + .map(|message| message.timestamp) + .max() + .unwrap_or(batch_start_timestamp); + let elapsed = batch_tail_timestamp.signed_duration_since(batch_start_timestamp); let elapsed_secs = elapsed.num_milliseconds() as f64 / 1000.0; tracing::info!( @@ -662,7 +664,7 @@ impl Channel { conversation_id = message.conversation_id.clone(); // Include both absolute and relative time context. - let relative_secs = last_timestamp + let relative_secs = batch_tail_timestamp .signed_duration_since(message.timestamp) .num_seconds() .max(0); @@ -2381,7 +2383,12 @@ fn format_batched_user_message( relative_text: &str, raw_text: &str, ) -> String { - format!("[{display_name}] ({absolute_timestamp}; {relative_text}): {raw_text}") + let text_content = if raw_text.trim().is_empty() { + "[attachment or empty message]" + } else { + raw_text + }; + format!("[{display_name}] ({absolute_timestamp}; {relative_text}): {text_content}") } fn extract_discord_message_id(message: &InboundMessage) -> Option { @@ -3357,4 +3364,18 @@ mod tests { "batched formatting should include original message text" ); } + + #[test] + fn format_batched_message_uses_placeholder_for_empty_text() { + let formatted = super::format_batched_user_message( + "alice", + "2026-02-26 15:04:05 PST (America/Los_Angeles, UTC-08:00)", + "just now", + " ", + ); + assert!( + formatted.contains("[attachment or empty message]"), + "batched formatting should use placeholder for empty/whitespace text" + ); + } } diff --git a/src/config.rs b/src/config.rs index aa006fb96..687a76c17 100644 --- a/src/config.rs +++ b/src/config.rs @@ -2248,28 +2248,34 @@ fn resolve_user_timezone( default_timezone: Option<&str>, fallback_timezone: Option<&str>, ) -> Option { - let timezone = agent_timezone - .and_then(normalize_timezone) - .or_else(|| default_timezone.and_then(normalize_timezone)) - .or_else(|| { - std::env::var(USER_TIMEZONE_ENV_VAR) - .ok() - .and_then(|value| normalize_timezone(&value)) - }) - .or_else(|| fallback_timezone.and_then(normalize_timezone)); - - let timezone = timezone?; - - if timezone.parse::().is_err() { + let env_timezone = std::env::var(USER_TIMEZONE_ENV_VAR) + .ok() + .and_then(|value| normalize_timezone(&value)); + + for (source, timezone) in [ + ("agent", agent_timezone.and_then(normalize_timezone)), + ("defaults", default_timezone.and_then(normalize_timezone)), + ("env", env_timezone), + ( + "cron_or_system", + fallback_timezone.and_then(normalize_timezone), + ), + ] { + let Some(timezone) = timezone else { + continue; + }; + if timezone.parse::().is_ok() { + return Some(timezone); + } tracing::warn!( agent_id, user_timezone = %timezone, - "invalid user timezone configured, falling back to cron/system timezone" + user_timezone_source = source, + "invalid user timezone configured, trying next fallback" ); - return fallback_timezone.and_then(normalize_timezone); } - Some(timezone) + None } fn parse_otlp_headers(value: Option) -> Result> { @@ -5614,6 +5620,32 @@ id = "main" ); } + #[test] + fn test_user_timezone_invalid_config_uses_env_fallback() { + let _lock = env_test_lock() + .lock() + .expect("failed to lock env test mutex"); + let _env = EnvGuard::new(); + + unsafe { + std::env::set_var(USER_TIMEZONE_ENV_VAR, "Asia/Tokyo"); + } + + let toml = r#" +[defaults] +cron_timezone = "America/Los_Angeles" +user_timezone = "Not/A-Real-Tz" + +[[agents]] +id = "main" +"#; + + let parsed: TomlConfig = toml::from_str(toml).expect("failed to parse test TOML"); + let config = Config::from_toml(parsed, PathBuf::from(".")).expect("failed to build Config"); + let resolved = config.agents[0].resolve(&config.instance_dir, &config.defaults); + assert_eq!(resolved.user_timezone.as_deref(), Some("Asia/Tokyo")); + } + #[test] fn ollama_base_url_registers_provider() { let toml = r#" From e553b6bc62ea5a2eddd7f8bd9959df33d8c07799 Mon Sep 17 00:00:00 2001 From: Victor Sumner Date: Thu, 26 Feb 2026 15:45:54 -0500 Subject: [PATCH 4/7] fix(channel): render worker time preamble from prompt template --- .../system/worker_time_context.md.j2 | 4 + src/agent/channel.rs | 90 +++++++++++++------ src/prompts/engine.rs | 19 ++++ src/prompts/text.rs | 3 + 4 files changed, 88 insertions(+), 28 deletions(-) create mode 100644 prompts/en/fragments/system/worker_time_context.md.j2 diff --git a/prompts/en/fragments/system/worker_time_context.md.j2 b/prompts/en/fragments/system/worker_time_context.md.j2 new file mode 100644 index 000000000..fc06944c5 --- /dev/null +++ b/prompts/en/fragments/system/worker_time_context.md.j2 @@ -0,0 +1,4 @@ +## Time Context +- Current local date/time: {{ current_local_datetime }} +- Current UTC date/time: {{ current_utc_datetime }} +- Use this context for relative dates (today/tomorrow/yesterday/now) and include absolute dates when timing matters. diff --git a/src/agent/channel.rs b/src/agent/channel.rs index 8516d134c..12a962358 100644 --- a/src/agent/channel.rs +++ b/src/agent/channel.rs @@ -49,23 +49,26 @@ struct TemporalContext { impl TemporalContext { fn from_runtime(runtime_config: &crate::config::RuntimeConfig) -> Self { - let configured_timezone = runtime_config - .user_timezone - .load() - .as_ref() - .clone() - .or_else(|| runtime_config.cron_timezone.load().as_ref().clone()); let now_utc = Utc::now(); + let user_timezone = runtime_config.user_timezone.load().as_ref().clone(); + let cron_timezone = runtime_config.cron_timezone.load().as_ref().clone(); + + Self { + now_utc, + timezone: Self::resolve_timezone_from_names(user_timezone, cron_timezone), + } + } - if let Some(timezone_name) = configured_timezone { + fn resolve_timezone_from_names( + user_timezone: Option, + cron_timezone: Option, + ) -> TemporalTimezone { + if let Some(timezone_name) = user_timezone { match timezone_name.parse::() { Ok(timezone) => { - return Self { - now_utc, - timezone: TemporalTimezone::Named { - timezone_name, - timezone, - }, + return TemporalTimezone::Named { + timezone_name, + timezone, }; } Err(_) => { @@ -77,10 +80,16 @@ impl TemporalContext { } } - Self { - now_utc, - timezone: TemporalTimezone::SystemLocal, + if let Some(timezone_name) = cron_timezone + && let Ok(timezone) = timezone_name.parse::() + { + return TemporalTimezone::Named { + timezone_name, + timezone, + }; } + + TemporalTimezone::SystemLocal } fn format_timestamp(&self, timestamp: DateTime) -> String { @@ -116,20 +125,20 @@ impl TemporalContext { ) } - fn worker_task_preamble(&self) -> String { - format!( - "## Time Context\n- Current local date/time: {}\n- Current UTC date/time: {}\n- Use this context for relative dates (today/tomorrow/yesterday/now) and include absolute dates when timing matters.", - self.format_timestamp(self.now_utc), - self.now_utc.format("%Y-%m-%d %H:%M:%S UTC") - ) + fn worker_task_preamble(&self, prompt_engine: &crate::prompts::PromptEngine) -> Result { + let local_time = self.format_timestamp(self.now_utc); + let utc_time = self.now_utc.format("%Y-%m-%d %H:%M:%S UTC").to_string(); + prompt_engine.render_system_worker_time_context(&local_time, &utc_time) } } fn build_worker_task_with_temporal_context( task: &str, temporal_context: &TemporalContext, -) -> String { - format!("{}\n\n{}", temporal_context.worker_task_preamble(), task) + prompt_engine: &crate::prompts::PromptEngine, +) -> Result { + let preamble = temporal_context.worker_task_preamble(prompt_engine)?; + Ok(format!("{preamble}\n\n{task}")) } /// A background process result waiting to be relayed to the user via retrigger. @@ -1998,9 +2007,11 @@ pub async fn spawn_worker_from_state( let task = task.into(); let rc = &state.deps.runtime_config; - let temporal_context = TemporalContext::from_runtime(rc.as_ref()); - let worker_task = build_worker_task_with_temporal_context(&task, &temporal_context); let prompt_engine = rc.prompts.load(); + let temporal_context = TemporalContext::from_runtime(rc.as_ref()); + let worker_task = + build_worker_task_with_temporal_context(&task, &temporal_context, &prompt_engine) + .map_err(|error| AgentError::Other(anyhow::anyhow!("{error}")))?; let worker_system_prompt = prompt_engine .render_worker_prompt( &rc.instance_dir.display().to_string(), @@ -2113,8 +2124,11 @@ pub async fn spawn_opencode_worker_from_state( let directory = std::path::PathBuf::from(directory); let rc = &state.deps.runtime_config; + let prompt_engine = rc.prompts.load(); let temporal_context = TemporalContext::from_runtime(rc.as_ref()); - let worker_task = build_worker_task_with_temporal_context(&task, &temporal_context); + let worker_task = + build_worker_task_with_temporal_context(&task, &temporal_context, &prompt_engine) + .map_err(|error| AgentError::Other(anyhow::anyhow!("{error}")))?; let opencode_config = rc.opencode.load(); if !opencode_config.enabled { @@ -3313,6 +3327,8 @@ mod tests { #[test] fn worker_task_temporal_context_preamble_includes_absolute_dates() { + let prompt_engine = + crate::prompts::PromptEngine::new("en").expect("prompt engine should initialize"); let temporal_context = super::TemporalContext { now_utc: chrono::DateTime::parse_from_rfc3339("2026-02-26T20:30:00Z") .expect("valid RFC3339 timestamp") @@ -3328,7 +3344,9 @@ mod tests { let worker_task = super::build_worker_task_with_temporal_context( "Run the migration checks", &temporal_context, - ); + &prompt_engine, + ) + .expect("worker task preamble should render"); assert!( worker_task.contains("Current local date/time:"), "worker task should include local time context" @@ -3343,6 +3361,22 @@ mod tests { ); } + #[test] + fn temporal_context_uses_cron_timezone_when_user_timezone_is_invalid() { + let resolved = super::TemporalContext::resolve_timezone_from_names( + Some("Not/A-Real-Tz".to_string()), + Some("America/Los_Angeles".to_string()), + ); + match resolved { + super::TemporalTimezone::Named { timezone_name, .. } => { + assert_eq!(timezone_name, "America/Los_Angeles"); + } + super::TemporalTimezone::SystemLocal => { + panic!("expected cron timezone fallback, got system local") + } + } + } + #[test] fn format_batched_message_includes_absolute_and_relative_time() { let formatted = super::format_batched_user_message( diff --git a/src/prompts/engine.rs b/src/prompts/engine.rs index e21a60ef2..0b7846fd2 100644 --- a/src/prompts/engine.rs +++ b/src/prompts/engine.rs @@ -140,6 +140,10 @@ impl PromptEngine { "fragments/system/tool_syntax_correction", crate::prompts::text::get("fragments/system/tool_syntax_correction"), )?; + env.add_template( + "fragments/system/worker_time_context", + crate::prompts::text::get("fragments/system/worker_time_context"), + )?; env.add_template( "fragments/coalesce_hint", crate::prompts::text::get("fragments/coalesce_hint"), @@ -298,6 +302,21 @@ impl PromptEngine { self.render_static("fragments/system/tool_syntax_correction") } + /// Render worker task time-context preamble. + pub fn render_system_worker_time_context( + &self, + current_local_datetime: &str, + current_utc_datetime: &str, + ) -> Result { + self.render( + "fragments/system/worker_time_context", + context! { + current_local_datetime => current_local_datetime, + current_utc_datetime => current_utc_datetime, + }, + ) + } + /// Convenience method for rendering truncation marker. pub fn render_system_truncation(&self, remove_count: usize) -> Result { self.render( diff --git a/src/prompts/text.rs b/src/prompts/text.rs index 00a9c3d23..dd2d7f613 100644 --- a/src/prompts/text.rs +++ b/src/prompts/text.rs @@ -113,6 +113,9 @@ fn lookup(lang: &str, key: &str) -> &'static str { ("en", "fragments/system/tool_syntax_correction") => { include_str!("../../prompts/en/fragments/system/tool_syntax_correction.md.j2") } + ("en", "fragments/system/worker_time_context") => { + include_str!("../../prompts/en/fragments/system/worker_time_context.md.j2") + } // Agent Communication Fragments ("en", "fragments/org_context") => { From 7e313fcc434976c525204bfb54ada38e6d2ae687 Mon Sep 17 00:00:00 2001 From: Victor Sumner Date: Thu, 26 Feb 2026 17:50:07 -0500 Subject: [PATCH 5/7] fix(channel): tighten timezone fallback semantics --- src/agent/channel.rs | 28 ++++++++++++++++------- src/config.rs | 53 ++++++++++++++++++++++++++++++++++---------- 2 files changed, 61 insertions(+), 20 deletions(-) diff --git a/src/agent/channel.rs b/src/agent/channel.rs index 12a962358..d8f9f6b73 100644 --- a/src/agent/channel.rs +++ b/src/agent/channel.rs @@ -72,21 +72,33 @@ impl TemporalContext { }; } Err(_) => { + let cron_timezone_candidate = + cron_timezone.as_deref().unwrap_or("none configured"); tracing::warn!( timezone = %timezone_name, - "invalid runtime timezone for channel temporal context, falling back to system local" + cron_timezone = %cron_timezone_candidate, + "invalid runtime timezone for channel temporal context, will try cron_timezone then fall back to system local" ); } } } - if let Some(timezone_name) = cron_timezone - && let Ok(timezone) = timezone_name.parse::() - { - return TemporalTimezone::Named { - timezone_name, - timezone, - }; + if let Some(timezone_name) = cron_timezone { + match timezone_name.parse::() { + Ok(timezone) => { + return TemporalTimezone::Named { + timezone_name, + timezone, + }; + } + Err(error) => { + tracing::warn!( + timezone = %timezone_name, + error = %error, + "invalid cron_timezone for channel temporal context, falling back to system local" + ); + } + } } TemporalTimezone::SystemLocal diff --git a/src/config.rs b/src/config.rs index 687a76c17..d6b0afd9c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -2219,27 +2219,31 @@ fn resolve_cron_timezone( agent_timezone: Option<&str>, default_timezone: Option<&str>, ) -> Option { - let timezone = agent_timezone - .and_then(normalize_timezone) - .or_else(|| default_timezone.and_then(normalize_timezone)) - .or_else(|| { - std::env::var(CRON_TIMEZONE_ENV_VAR) - .ok() - .and_then(|value| normalize_timezone(&value)) - }); + let env_timezone = std::env::var(CRON_TIMEZONE_ENV_VAR) + .ok() + .and_then(|value| normalize_timezone(&value)); - let timezone = timezone?; + for timezone in [ + agent_timezone.and_then(normalize_timezone), + default_timezone.and_then(normalize_timezone), + env_timezone, + ] { + let Some(timezone) = timezone else { + continue; + }; + + if timezone.parse::().is_ok() { + return Some(timezone); + } - if timezone.parse::().is_err() { tracing::warn!( agent_id, cron_timezone = %timezone, "invalid cron timezone configured, falling back to system local timezone" ); - return None; } - Some(timezone) + None } fn resolve_user_timezone( @@ -5518,6 +5522,31 @@ id = "main" assert_eq!(resolved.cron_timezone, None); } + #[test] + fn test_cron_timezone_invalid_default_uses_env_fallback() { + let _lock = env_test_lock() + .lock() + .expect("failed to lock env test mutex"); + let _env = EnvGuard::new(); + + unsafe { + std::env::set_var(CRON_TIMEZONE_ENV_VAR, "Asia/Tokyo"); + } + + let toml = r#" +[defaults] +cron_timezone = "Not/A-Real-Tz" + +[[agents]] +id = "main" +"#; + + let parsed: TomlConfig = toml::from_str(toml).expect("failed to parse test TOML"); + let config = Config::from_toml(parsed, PathBuf::from(".")).expect("failed to build Config"); + let resolved = config.agents[0].resolve(&config.instance_dir, &config.defaults); + assert_eq!(resolved.cron_timezone.as_deref(), Some("Asia/Tokyo")); + } + #[test] fn test_user_timezone_resolution_precedence() { let _lock = env_test_lock() From 91770a743b0837f3efffceed0f5c9a3c2dcdf536 Mon Sep 17 00:00:00 2001 From: Victor Sumner Date: Thu, 26 Feb 2026 17:52:54 -0500 Subject: [PATCH 6/7] refactor(channel): unify author resolution for batched messages --- src/agent/channel.rs | 91 ++++++++++++++++++++++++++++++++++++-------- 1 file changed, 75 insertions(+), 16 deletions(-) diff --git a/src/agent/channel.rs b/src/agent/channel.rs index d8f9f6b73..dc642b26d 100644 --- a/src/agent/channel.rs +++ b/src/agent/channel.rs @@ -698,11 +698,7 @@ impl Channel { }; let absolute_timestamp = temporal_context.format_timestamp(message.timestamp); - let display_name = message - .metadata - .get("sender_display_name") - .and_then(|v| v.as_str()) - .unwrap_or(&message.sender_id); + let display_name = message_display_name(message); let formatted_text = format_batched_user_message( display_name, @@ -2340,6 +2336,19 @@ fn extract_reply_from_tool_syntax(text: &str) -> Option { /// /// In multi-user channels, this lets the LLM distinguish who said what. /// System-generated messages (re-triggers) are passed through as-is. +fn message_display_name(message: &InboundMessage) -> &str { + message + .formatted_author + .as_deref() + .or_else(|| { + message + .metadata + .get("sender_display_name") + .and_then(|v| v.as_str()) + }) + .unwrap_or(&message.sender_id) +} + fn format_user_message(raw_text: &str, message: &InboundMessage, timestamp_text: &str) -> String { if message.source == "system" { // System messages should never be empty, but guard against it @@ -2350,17 +2359,7 @@ fn format_user_message(raw_text: &str, message: &InboundMessage, timestamp_text: }; } - // Use platform-formatted author if available, fall back to metadata - let display_name = message - .formatted_author - .as_deref() - .or_else(|| { - message - .metadata - .get("sender_display_name") - .and_then(|v| v.as_str()) - }) - .unwrap_or(&message.sender_id); + let display_name = message_display_name(message); let bot_tag = if message .metadata @@ -3337,6 +3336,66 @@ mod tests { ); } + #[test] + fn message_display_name_uses_consistent_fallback_order() { + use super::message_display_name; + use crate::{Arc, InboundMessage}; + use chrono::Utc; + use std::collections::HashMap; + + let mut metadata_only = HashMap::new(); + metadata_only.insert( + "sender_display_name".to_string(), + serde_json::Value::String("Metadata User".to_string()), + ); + let metadata_message = InboundMessage { + id: "metadata".to_string(), + agent_id: Some(Arc::from("test_agent")), + sender_id: "sender123".to_string(), + conversation_id: "conv".to_string(), + content: crate::MessageContent::Text("hello".to_string()), + source: "discord".to_string(), + metadata: metadata_only, + formatted_author: None, + timestamp: Utc::now(), + }; + assert_eq!(message_display_name(&metadata_message), "Metadata User"); + + let mut both_metadata = HashMap::new(); + both_metadata.insert( + "sender_display_name".to_string(), + serde_json::Value::String("Metadata User".to_string()), + ); + let formatted_author_message = InboundMessage { + id: "formatted".to_string(), + agent_id: Some(Arc::from("test_agent")), + sender_id: "sender123".to_string(), + conversation_id: "conv".to_string(), + content: crate::MessageContent::Text("hello".to_string()), + source: "discord".to_string(), + metadata: both_metadata, + formatted_author: Some("Formatted Author".to_string()), + timestamp: Utc::now(), + }; + assert_eq!( + message_display_name(&formatted_author_message), + "Formatted Author" + ); + + let sender_fallback_message = InboundMessage { + id: "fallback".to_string(), + agent_id: Some(Arc::from("test_agent")), + sender_id: "sender123".to_string(), + conversation_id: "conv".to_string(), + content: crate::MessageContent::Text("hello".to_string()), + source: "discord".to_string(), + metadata: HashMap::new(), + formatted_author: None, + timestamp: Utc::now(), + }; + assert_eq!(message_display_name(&sender_fallback_message), "sender123"); + } + #[test] fn worker_task_temporal_context_preamble_includes_absolute_dates() { let prompt_engine = From 608895a63a0d1469ff92b3405edabceae6d63864 Mon Sep 17 00:00:00 2001 From: Victor Sumner Date: Thu, 26 Feb 2026 18:56:54 -0500 Subject: [PATCH 7/7] fix(prompts): remove stale link_context template registration --- src/prompts/engine.rs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/prompts/engine.rs b/src/prompts/engine.rs index 0b7846fd2..44479cb6a 100644 --- a/src/prompts/engine.rs +++ b/src/prompts/engine.rs @@ -94,10 +94,6 @@ impl PromptEngine { "fragments/org_context", crate::prompts::text::get("fragments/org_context"), )?; - env.add_template( - "fragments/link_context", - crate::prompts::text::get("fragments/link_context"), - )?; // System message fragments env.add_template(