mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
ACP harnesses that name a session from the first text they receive all land in the same place: every managed Buzz agent opens with the identical `[Base] You are operating inside the Buzz platform…` framing, so the harness session list shows a wall of indistinguishable rows. Because sessions are keyed per channel, one agent active in several channels produces several of them. This sends the name out of band instead. `session/new` carries `_meta.sessionTitle` with `Agent · #channel`, composed from the agent's `display_name` (or its unique `name` handle) and the channel it is serving. The prompt is untouched — no tokens spent, no perturbation of the prompt contract, and nothing new for the desktop observer's section parsing to handle. The mechanism is harness-agnostic: Buzz sends the field on every ACP `session/new` regardless of which harness is behind it, and adapters that don't read it ignore it per spec. ## Inert until a consuming adapter ships ACP adapters ignore `_meta` members they do not recognize, so against an adapter with no reader a Buzz session gets no title and nothing else changes. Three adapter halves consume it — Codex, Goose, and Claude Code (linked below); this half and each reader are only useful together, and each reader lands independently. No version floor is added. `codex_adapter_is_outdated_with_path` already gates codex-acp on major version `>= 1` (`desktop/src-tauri/src/managed_agents/discovery.rs:1276-1284`) and this feature needs nothing above that — an older adapter is not broken by the extra member, it simply ignores it. ## What changes **`crates/buzz-acp`** owns sanitization and composition. `sanitize_session_title` collapses whitespace, drops control characters, and caps at `SESSION_TITLE_MAX_CHARS` (80) by character, not byte, so a multi-byte character cannot be split. `compose_session_title` truncates only the channel part against that cap, so the agent name always survives; when the agent name alone fills the cap the channel is dropped rather than the name. `session_new_full` sets `_meta.sessionTitle` when a title exists and omits `_meta` entirely when it does not, since an adapter may distinguish an absent member from a null one. **`desktop/src-tauri`** only resolves and exports. `resolve_session_title` picks `display_name` or falls back to `name`, and `spawn_agent_child` writes it to `BUZZ_ACP_SESSION_TITLE` — or removes the variable when neither candidate yields anything printable. DMs, unresolved channels, and heartbeat sessions get the bare agent name with no channel suffix. ## Four properties that are easy to remove by accident **Control characters are stripped at the desktop boundary, not in the harness.** An interior NUL cannot cross the environment boundary at all — `Command::env` fails the entire spawn rather than passing it through. Deferring the strip to `buzz-acp` would let a corrupted display name turn display chrome into a spawn failure. A display name that is *only* control characters falls back to `name`. **The title is hashed into `spawn_config_hash`.** Without it, renaming an agent left the running process with a stale title and no restart badge. The hash runs the same `resolve_session_title` the spawn writes, and skips it when a user env override shadows `BUZZ_ACP_SESSION_TITLE` — spawn writes the title *before* the layered user env, so the override is what actually runs, and it already reaches the hash through `descriptor.env`. Hashing the record-derived value under an override would badge a rename that changes nothing. **One channel resolve serves both consumers.** `resolve_new_session_channel_context` returns `(is_dm, title_channel)` from a single metadata lookup, feeding both the canvas block's DM check and the title. `ChannelInfoResolver` caches only `Some`, so two independent calls against an unresolvable channel pay the full `fetch_channel_info` retry sequence twice — two timeouts plus a retry delay each — directly in front of `session/new`, precisely when the relay is already degraded. **The `"unknown"` channel name is treated as absent.** `fetch_channel_info` substitutes the literal `"unknown"` for a metadata event with no `name` tag. Composing that sentinel would title every unnamed channel `Agent · #unknown`, reintroducing the exact collision the suffix exists to remove while naming a channel something it isn't. The startup cache already refuses `channel_type == "unknown"` for the same reason. Closes #2334 Related — the adapter halves that consume `_meta.sessionTitle`: - [codex-acp#338](https://github.com/agentclientprotocol/codex-acp/pull/338) — Codex - [aaif-goose/goose#10712](https://github.com/aaif-goose/goose/pull/10712) — Goose - [claude-agent-acp#920](https://github.com/agentclientprotocol/claude-agent-acp/pull/920) — Claude Code --------- Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
5822 lines
226 KiB
Rust
5822 lines
226 KiB
Rust
//! Agent pool — owns N AcpClient instances and dispatches prompt tasks.
|
||
//!
|
||
//! # Mental model
|
||
//!
|
||
//! ```text
|
||
//! AgentPool
|
||
//! ├── agents: Vec<Option<OwnedAgent>> ← idle agents sit here
|
||
//! ├── join_set: JoinSet<()> ← in-flight tasks
|
||
//! ├── task_map: HashMap<Id, TaskMeta> ← panic recovery metadata
|
||
//! └── result_tx/rx: mpsc channel ← tasks return agents here
|
||
//!
|
||
//! Dispatch:
|
||
//! try_claim() → OwnedAgent (removed from slot)
|
||
//! spawn run_prompt_task(agent, ...) into join_set
|
||
//! task sends PromptResult { agent, outcome } via result_tx
|
||
//! rx_and_join_set() → poll result_rx for PromptResult
|
||
//! return_agent(agent) → puts agent back in slot
|
||
//! ```
|
||
//!
|
||
//! `AcpClient` is NOT Clone — ownership moves out on claim and back on return.
|
||
|
||
use std::collections::{HashMap, HashSet};
|
||
use std::sync::{Arc, Mutex};
|
||
use std::time::Duration;
|
||
|
||
use tokio::sync::mpsc;
|
||
use tokio::task::{JoinHandle, JoinSet};
|
||
use tokio::time::timeout;
|
||
use uuid::Uuid;
|
||
|
||
use crate::acp::{
|
||
extract_model_config_options, extract_model_state, model_in_catalog,
|
||
resolve_model_switch_method, AcpClient, AcpError, McpServer, ModelSwitchMethod, StopReason,
|
||
};
|
||
use crate::config::{compose_session_title, DedupMode, PermissionMode};
|
||
use crate::observer;
|
||
use crate::queue::{
|
||
CancelReason, ContextMessage, ConversationContext, FlushBatch, PromptChannelInfo,
|
||
PromptProfile, PromptProfileLookup, ThreadTags,
|
||
};
|
||
use crate::relay::{ChannelInfo, RestClient};
|
||
|
||
/// Window within which agent activity before a hard-cap death qualifies
|
||
/// the turn as "recently active" (eligible for requeue instead of dead-letter).
|
||
const RECENT_ACTIVITY_WINDOW: Duration = Duration::from_secs(60);
|
||
|
||
// FlushBatch and BatchEvent derive Clone (added in queue.rs) so we can store
|
||
// a recoverable copy in TaskMeta for panic recovery in Queue mode.
|
||
|
||
/// Metadata stored per in-flight task for panic recovery.
|
||
pub struct TaskMeta {
|
||
pub agent_index: usize,
|
||
pub channel_id: Option<Uuid>,
|
||
/// Identifies terminal events when the task panics before returning a result.
|
||
pub turn_id: String,
|
||
/// Clone of batch for Queue mode panic recovery.
|
||
pub recoverable_batch: Option<FlushBatch>,
|
||
/// Control signal for the in-flight prompt task.
|
||
/// `None` for heartbeat tasks (not controllable) and after signal is consumed.
|
||
pub control_tx: Option<tokio::sync::oneshot::Sender<ControlSignal>>,
|
||
/// Steer request channel for non-cancelling mid-turn delivery.
|
||
/// Capacity-1; `try_send` from the main loop fails on `Full`/`Closed`,
|
||
/// in which case the caller must fall back to the universal
|
||
/// `ControlSignal::Steer` cancel+merge path. `None` for heartbeat
|
||
/// tasks only — all prompt tasks install a steer channel regardless
|
||
/// of the agent's name.
|
||
pub steer_tx: Option<tokio::sync::mpsc::Sender<SteerRequest>>,
|
||
}
|
||
|
||
/// Agent-level model capabilities. Populated on first session creation.
|
||
/// The catalog is the same across all sessions for a given agent process.
|
||
/// Fields are read by the desktop's `get_agent_models` Tauri command (Phase 3).
|
||
#[allow(dead_code)] // Scaffolding for desktop integration — fields read via serde.
|
||
pub struct AgentModelCapabilities {
|
||
/// Stable: configOptions with category "model" from session/new.
|
||
pub config_options_raw: Vec<serde_json::Value>,
|
||
/// Unstable: SessionModelState from session/new.
|
||
pub available_models_raw: Option<serde_json::Value>,
|
||
}
|
||
|
||
/// Per-channel session IDs and turn counters.
|
||
///
|
||
/// Separated from `OwnedAgent` so the state machine is testable without
|
||
/// spawning a real agent subprocess.
|
||
#[derive(Default)]
|
||
pub struct SessionState {
|
||
/// channel_id → session_id
|
||
pub sessions: HashMap<Uuid, String>,
|
||
pub heartbeat_session: Option<String>,
|
||
/// Per-channel turn counters for proactive session rotation.
|
||
/// Incremented on each successful prompt; reset when the session is rotated.
|
||
pub turn_counts: HashMap<Uuid, u32>,
|
||
/// Turn counter for the heartbeat session.
|
||
pub heartbeat_turn_count: u32,
|
||
/// channel_id → rendered NIP-AE core prompt section, populated once at
|
||
/// session creation per Tyler's spec (no mid-session refresh).
|
||
pub core_sections: HashMap<Uuid, String>,
|
||
/// channel_id → rendered `[Channel Canvas]` metadata section.
|
||
///
|
||
/// Populated once before session creation (same lifecycle as `core_sections`).
|
||
/// Absent when the channel has no canvas, the canvas content is blank, or the
|
||
/// fetch fails — all fail open. Cleared on session invalidation alongside
|
||
/// `core_sections` so the next session picks up any canvas change.
|
||
pub canvas_sections: HashMap<Uuid, String>,
|
||
}
|
||
|
||
impl SessionState {
|
||
/// Invalidate the session (and turn counter) for a specific prompt source.
|
||
pub fn invalidate(&mut self, source: &PromptSource) {
|
||
match source {
|
||
PromptSource::Channel(cid) => {
|
||
self.invalidate_channel(cid);
|
||
}
|
||
PromptSource::Heartbeat => {
|
||
self.heartbeat_session = None;
|
||
self.heartbeat_turn_count = 0;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Invalidate a single channel's session and turn counter.
|
||
/// Returns `true` if the channel had an active session.
|
||
pub fn invalidate_channel(&mut self, channel_id: &Uuid) -> bool {
|
||
self.turn_counts.remove(channel_id);
|
||
self.core_sections.remove(channel_id);
|
||
self.canvas_sections.remove(channel_id);
|
||
self.sessions.remove(channel_id).is_some()
|
||
}
|
||
|
||
/// Invalidate all sessions and turn counters (e.g. after agent exit).
|
||
pub fn invalidate_all(&mut self) {
|
||
self.sessions.clear();
|
||
self.turn_counts.clear();
|
||
self.heartbeat_session = None;
|
||
self.heartbeat_turn_count = 0;
|
||
self.core_sections.clear();
|
||
self.canvas_sections.clear();
|
||
}
|
||
|
||
#[cfg(test)]
|
||
fn has_channel_state(&self, channel_id: &Uuid) -> bool {
|
||
self.sessions.contains_key(channel_id)
|
||
|| self.turn_counts.contains_key(channel_id)
|
||
|| self.core_sections.contains_key(channel_id)
|
||
|| self.canvas_sections.contains_key(channel_id)
|
||
}
|
||
}
|
||
|
||
/// An agent with its session state, owned by the pool or a running task.
|
||
pub struct OwnedAgent {
|
||
pub index: usize,
|
||
pub acp: AcpClient,
|
||
pub state: SessionState,
|
||
/// Model catalog from first session/new. None until first session created.
|
||
pub model_capabilities: Option<AgentModelCapabilities>,
|
||
/// Desired model ID (from `Config.model`). Applied after every `session_new_full()`.
|
||
pub desired_model: Option<String>,
|
||
/// Whether `desired_model` was set by a live `SwitchModel` control signal
|
||
/// (as opposed to being derived from config/persona at spawn). Used by the
|
||
/// desktop reader to distinguish a genuine runtime override from a stale
|
||
/// session whose persona model was edited. Reset on spawn/restart.
|
||
pub model_overridden: bool,
|
||
/// Normalized agent name from initialize (`agentInfo.name`/`serverInfo.name`).
|
||
pub agent_name: String,
|
||
/// Whether Goose accepted its custom system-prompt method. `None` probes on
|
||
/// the first session; method-not-found is cached as `Some(false)` so legacy
|
||
/// user-message framing is used for this process thereafter.
|
||
pub goose_system_prompt_supported: Option<bool>,
|
||
/// Protocol version reported by the agent in its initialize response.
|
||
pub protocol_version: u32,
|
||
}
|
||
|
||
fn has_system_prompt_support(
|
||
protocol_version: u32,
|
||
agent_name: &str,
|
||
goose_system_prompt_supported: Option<bool>,
|
||
) -> bool {
|
||
if agent_name == "goose" {
|
||
goose_system_prompt_supported == Some(true)
|
||
} else {
|
||
protocol_version >= 2
|
||
}
|
||
}
|
||
|
||
fn session_new_system_prompt(
|
||
is_goose: bool,
|
||
protocol_version: u32,
|
||
prompt: Option<&str>,
|
||
) -> Option<&str> {
|
||
if is_goose || protocol_version < 2 {
|
||
None
|
||
} else {
|
||
prompt
|
||
}
|
||
}
|
||
|
||
impl OwnedAgent {
|
||
pub(crate) fn has_system_prompt_support(&self) -> bool {
|
||
has_system_prompt_support(
|
||
self.protocol_version,
|
||
&self.agent_name,
|
||
self.goose_system_prompt_supported,
|
||
)
|
||
}
|
||
}
|
||
|
||
/// Pool of agents with take-and-return ownership semantics.
|
||
///
|
||
/// Agents are either idle (sitting in `agents[i]`) or checked out
|
||
/// (running inside a spawned task). The `task_map` tracks in-flight
|
||
/// tasks for panic recovery.
|
||
pub struct AgentPool {
|
||
agents: Vec<Option<OwnedAgent>>,
|
||
result_tx: mpsc::UnboundedSender<PromptResult>,
|
||
result_rx: mpsc::UnboundedReceiver<PromptResult>,
|
||
pub join_set: JoinSet<()>,
|
||
task_map: HashMap<tokio::task::Id, TaskMeta>,
|
||
}
|
||
|
||
/// Result returned by a completed prompt task.
|
||
pub struct PromptResult {
|
||
pub agent: OwnedAgent,
|
||
pub source: PromptSource,
|
||
/// Identifies the completed turn for observer terminal events.
|
||
pub turn_id: String,
|
||
pub outcome: PromptOutcome,
|
||
/// Present on failure in Queue mode, for requeue.
|
||
pub batch: Option<FlushBatch>,
|
||
}
|
||
|
||
/// Whether the prompt came from a channel event or a heartbeat.
|
||
#[derive(Debug)]
|
||
pub enum PromptSource {
|
||
Channel(Uuid),
|
||
Heartbeat,
|
||
}
|
||
|
||
/// Apply state effects for Race 1, where a control signal arrives just after the
|
||
/// prompt completed naturally. The prompt result has already been consumed by
|
||
/// `select!`, so the harness must synthesize a successful result while still
|
||
/// honoring any load-bearing control signal semantics.
|
||
fn apply_completed_before_control_signal(
|
||
state: &mut SessionState,
|
||
source: &PromptSource,
|
||
control_signal: &ControlSignal,
|
||
) {
|
||
// Rotate and SwitchModel both invalidate so the next turn creates a fresh
|
||
// session. For SwitchModel the caller has already set `desired_model`, so
|
||
// the fresh session applies the new model on its next creation.
|
||
if matches!(
|
||
control_signal,
|
||
ControlSignal::Rotate | ControlSignal::SwitchModel(_)
|
||
) {
|
||
state.invalidate(source);
|
||
}
|
||
}
|
||
|
||
/// Control signal for an in-flight channel turn.
|
||
///
|
||
/// Not `Copy`: `SwitchModel` carries an owned `String`. Callers must clone when
|
||
/// a value is needed after a move, or match by reference.
|
||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||
pub enum ControlSignal {
|
||
/// Stop the current turn and drop its triggering batch.
|
||
Cancel,
|
||
/// Stop the current turn and requeue its triggering batch for a merged
|
||
/// re-prompt framed as a **supersede**: the new request replaces the old.
|
||
Interrupt,
|
||
/// Stop the current turn and requeue its triggering batch for a merged
|
||
/// re-prompt framed as a **steer**: a message arrived while the agent was
|
||
/// working; it should continue its work and incorporate the message if
|
||
/// relevant, not treat it as a replacement task. This is the default
|
||
/// mid-turn delivery path (see [`MultipleEventHandling::Steer`]).
|
||
Steer,
|
||
/// Stop the current turn and drop its triggering batch. The session is
|
||
/// invalidated just like cancel; the next turn creates a fresh session.
|
||
Rotate,
|
||
/// Switch the agent's model, then requeue the triggering batch so it
|
||
/// re-runs on a fresh session under the new model. The model lands by
|
||
/// setting `OwnedAgent::desired_model` before invalidation; the requeued
|
||
/// turn re-creates the session and re-applies `desired_model`. Runtime-only
|
||
/// — never persisted, gone on restart/respawn.
|
||
SwitchModel(String),
|
||
}
|
||
|
||
/// Goose-native non-cancelling steer request, sent from the main loop to an
|
||
/// in-flight prompt task's read loop via a capacity-1 mpsc channel.
|
||
///
|
||
/// The read loop owns the `AcpClient`'s reader/writer for the duration of the
|
||
/// turn, so we cannot drive a steer write from the main thread directly. The
|
||
/// main loop carries the steer prompt body (already framed by
|
||
/// `queue::native_steer_framing()` + `queue::format_event_block`); the read
|
||
/// loop completes `sessionId` (lexical) and `expectedRunId`
|
||
/// (`AcpClient::active_run_id` at write time) when it actually emits the
|
||
/// JSON-RPC request. The main loop awaits a `SteerAck` on the `ack_tx`
|
||
/// oneshot.
|
||
///
|
||
/// ## Why the read loop fills params, not the main loop
|
||
///
|
||
/// `expectedRunId` is a *moving target*: the read loop updates
|
||
/// `self.active_run_id` as goose emits `session/update` notifications, and
|
||
/// the steer is rejected if the supplied id doesn't match the *current* run.
|
||
/// A snapshot taken at dispatch (or at mode-gate time) can be stale by the
|
||
/// time the read loop actually writes the steer line. Filling params at
|
||
/// write time uses the freshest possible run id and is correct-by-
|
||
/// construction on the one field whose freshness the protocol checks.
|
||
/// `sessionId` is in lexical scope inside the read loop's caller
|
||
/// (`session_prompt_blocks_with_idle_timeout`), so no plumbing is required
|
||
/// for that — only a function parameter pass-through.
|
||
///
|
||
/// If `active_run_id` is `None` at write time (no `session/update` seen yet
|
||
/// — e.g. agents that never emit run-id metadata), the steer cannot form a
|
||
/// valid `expectedRunId` and the read loop acks
|
||
/// [`SteerError::ExpectedRunIdMissing`]. The main loop maps this to the
|
||
/// "Err-before-pending" bucket: no withhold/mark was established at
|
||
/// `pool::send_steer` time because the request was rejected before any
|
||
/// write, so the watcher only needs to release nothing and fall back to the
|
||
/// universal `ControlSignal::Steer` cancel+merge path.
|
||
pub struct SteerRequest {
|
||
/// Prompt body text blocks. Each entry becomes one `text` content
|
||
/// block in `params.prompt`. Built by the main loop via
|
||
/// `queue::native_steer_framing()` + `queue::format_event_block` so
|
||
/// the wording cannot drift from the cancel+merge fallback path.
|
||
pub prompt_blocks: Vec<String>,
|
||
/// Oneshot for the read loop to report the outcome.
|
||
pub ack_tx: tokio::sync::oneshot::Sender<SteerAck>,
|
||
}
|
||
|
||
/// Why a goose-native steer failed.
|
||
///
|
||
/// String and integer fields are intentionally `Debug`-only — read by
|
||
/// `tracing` macros in the main loop's `PoolEvent::SteerAck` arm via
|
||
/// `?ack`. The dead-code lint can't see that path because it doesn't
|
||
/// trace through `Debug` derives, hence the `#[allow]`.
|
||
#[allow(dead_code)]
|
||
#[derive(Debug)]
|
||
pub enum SteerError {
|
||
/// The agent returned a JSON-RPC error response to the steer request.
|
||
///
|
||
/// `code` is the JSON-RPC error code:
|
||
/// - `-32601` (`method_not_found`): the agent does not implement the
|
||
/// steer extension. The main loop should fire the cancel+merge
|
||
/// fallback so the message still reaches the agent.
|
||
/// - Any other code: the write landed and the agent rejected it at the
|
||
/// application level (e.g. wrong run id). Release the withheld event
|
||
/// for normal dispatch; do NOT fire the fallback — the turn is still
|
||
/// running or just ended.
|
||
AgentError { code: i64, message: String },
|
||
/// Transport-level failure: write error, read EOF, JSON-RPC framing
|
||
/// violation, etc. The string carries the underlying `AcpError`'s display.
|
||
Transport(String),
|
||
/// At steer-write time `AcpClient::active_run_id` was `None`, so the
|
||
/// read loop couldn't form a valid `expectedRunId`. The read loop drops
|
||
/// the request without writing anything; the main loop should release
|
||
/// any withheld event and fall back to the universal cancel+merge
|
||
/// `ControlSignal::Steer` path. This is in the same "Err-before-pending"
|
||
/// bucket as `Transport` write failures: no in-process state was
|
||
/// established, so no in-process cleanup is needed.
|
||
ExpectedRunIdMissing,
|
||
/// The read loop never got to dispatch the steer because the prompt
|
||
/// completed first. Delivery state for the underlying message is
|
||
/// unknown after prompt completion — the main loop must treat this as
|
||
/// "release the withheld event so normal dispatch handles it" with no
|
||
/// claims that the agent did or did not incorporate it.
|
||
///
|
||
/// Returned synchronously by `send_steer` when no task is in flight
|
||
/// for the channel. Never sent through the ack channel — the ack
|
||
/// watcher is only spawned on `send_steer` success.
|
||
PromptCompleted,
|
||
}
|
||
|
||
/// Outcome of a goose-native steer, sent from the read loop back to the
|
||
/// main loop's ack watcher.
|
||
#[derive(Debug)]
|
||
pub enum SteerAck {
|
||
/// The agent returned a successful response to the steer request.
|
||
/// The main loop must drop the withheld event (`remove_event`) — it
|
||
/// has been delivered via the non-cancelling path.
|
||
Success,
|
||
/// The steer was attempted but failed. Delivery state for the
|
||
/// underlying message is unknown after prompt completion; the main
|
||
/// loop must release the withheld event and fall back to the
|
||
/// universal `Steer` cancel+merge path so the message still reaches
|
||
/// the agent.
|
||
Err(SteerError),
|
||
/// The prompt completed before the read loop selected the steer arm.
|
||
/// Treated as a benign no-op: release the withheld event for normal
|
||
/// dispatch. Do not fire the fallback `Steer` signal — there is no
|
||
/// in-flight turn to signal, and normal dispatch handles delivery.
|
||
PromptCompletedNeutral,
|
||
}
|
||
|
||
/// Whether a turn was cut by the idle clock or the hard wall-clock cap.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum TimeoutKind {
|
||
/// No ACP wire activity for `idle_timeout` seconds.
|
||
Idle,
|
||
/// Turn ran for `max_turn_duration` seconds of wall-clock time.
|
||
/// `recently_active` is true when the agent produced output within
|
||
/// `RECENT_ACTIVITY_WINDOW` of the hard-cap firing.
|
||
Hard { recently_active: bool },
|
||
}
|
||
|
||
/// Outcome of a prompt task.
|
||
#[allow(dead_code)]
|
||
pub enum PromptOutcome {
|
||
Ok(StopReason),
|
||
Error(AcpError),
|
||
AgentExited,
|
||
Timeout(TimeoutKind),
|
||
/// Intentional cancel via `!cancel` command or interrupt mode.
|
||
/// Agent is healthy — no respawn, no retry penalty.
|
||
Cancelled,
|
||
/// The agent did not stop within `grace` after `session/cancel` was sent
|
||
/// for a control-signal cancellation (steer fallback, interrupt, or
|
||
/// explicit stop). Distinct from [`TimeoutKind::Hard`]: this is a bounded
|
||
/// cleanup deadline, not the turn's configured max-turn wall clock, so it
|
||
/// must never be reported or dead-lettered as a hard-cap breach. The
|
||
/// agent process is uncertain — treated as poisoned and respawned, same
|
||
/// as a hard timeout, but the triggering batch's fate follows the
|
||
/// `CancelReason` on the batch (steer/interrupt requeue, explicit cancel
|
||
/// drops) rather than the hard-cap's unconditional dead-letter.
|
||
CancelDrainTimeout(Duration),
|
||
}
|
||
|
||
/// Immutable config subset shared (via `Arc`) by all spawned prompt tasks.
|
||
///
|
||
/// Built once from `Config` at startup. Avoids cloning the full config
|
||
/// into every task.
|
||
/// Shared channel-metadata resolver for startup-known and dynamically joined channels.
|
||
///
|
||
/// Successful lazy lookups are cached for every consumer (author gate, prompt
|
||
/// context, canvas, and setup mode). Unknown metadata is never cached as a
|
||
/// non-DM: callers can fail closed and a later event retries resolution.
|
||
#[derive(Debug, Clone)]
|
||
pub struct ChannelInfoResolver {
|
||
cache: std::sync::Arc<std::sync::RwLock<std::collections::HashMap<Uuid, PromptChannelInfo>>>,
|
||
rest_client: RestClient,
|
||
}
|
||
|
||
impl ChannelInfoResolver {
|
||
pub fn new(
|
||
startup: std::collections::HashMap<Uuid, ChannelInfo>,
|
||
rest_client: RestClient,
|
||
) -> Self {
|
||
let cache = startup
|
||
.into_iter()
|
||
.filter_map(|(id, info)| {
|
||
(info.channel_type != "unknown").then_some((
|
||
id,
|
||
PromptChannelInfo {
|
||
name: info.name,
|
||
channel_type: info.channel_type,
|
||
},
|
||
))
|
||
})
|
||
.collect();
|
||
Self {
|
||
cache: std::sync::Arc::new(std::sync::RwLock::new(cache)),
|
||
rest_client,
|
||
}
|
||
}
|
||
|
||
pub async fn resolve(&self, channel_id: Uuid) -> Option<PromptChannelInfo> {
|
||
if let Some(info) = self
|
||
.cache
|
||
.read()
|
||
.ok()
|
||
.and_then(|cache| cache.get(&channel_id).cloned())
|
||
{
|
||
return Some(info);
|
||
}
|
||
|
||
let info = fetch_channel_info(channel_id, &self.rest_client).await?;
|
||
if let Ok(mut cache) = self.cache.write() {
|
||
cache.insert(channel_id, info.clone());
|
||
}
|
||
Some(info)
|
||
}
|
||
}
|
||
|
||
pub struct PromptContext {
|
||
pub mcp_servers: Vec<McpServer>,
|
||
pub initial_message: Option<String>,
|
||
pub idle_timeout: Duration,
|
||
pub max_turn_duration: Duration,
|
||
/// Interval between per-turn `turn_liveness` observer pings. `Duration::ZERO`
|
||
/// disables emission. This is the desktop crash-backstop signal — distinct
|
||
/// from `heartbeat_prompt` (agent self-prompting).
|
||
pub turn_liveness_interval: Duration,
|
||
pub dedup_mode: DedupMode,
|
||
pub system_prompt: Option<String>,
|
||
/// Sanitized title for each new ACP session, sent as `_meta.sessionTitle`
|
||
/// on `session/new`. Never part of the prompt.
|
||
pub session_title: Option<String>,
|
||
pub team_instructions: Option<String>,
|
||
pub heartbeat_prompt: Option<String>,
|
||
/// Base prompt content, or `None` if `--no-base-prompt` was passed.
|
||
///
|
||
/// `'static` because `PromptContext` is `Arc`-shared across async tasks.
|
||
/// Content from `--base-prompt-file` is promoted via `Box::leak` in `main.rs`
|
||
/// after validated file read in `Config::from_cli()`. The compiled-in default
|
||
/// (`include_str!`) is inherently `'static`.
|
||
pub base_prompt: Option<&'static str>,
|
||
pub cwd: String,
|
||
/// REST client for pre-prompt context fetches (thread/DM history).
|
||
pub rest_client: RestClient,
|
||
/// Shared channel metadata for startup-known and dynamically joined channels.
|
||
pub channel_info: ChannelInfoResolver,
|
||
/// Max messages to include in thread/DM context. 0 = disabled.
|
||
pub context_message_limit: u32,
|
||
/// Max turns per session before proactive rotation. 0 = disabled.
|
||
pub max_turns_per_session: u32,
|
||
/// Permission mode to apply after session creation. `Default` = skip.
|
||
pub permission_mode: PermissionMode,
|
||
/// Agent identity — used to derive the NIP-AE conversation key at
|
||
/// session creation for core injection.
|
||
pub agent_keys: nostr::Keys,
|
||
/// Owner pubkey (hex), if resolved at startup. When unset, NIP-AE core
|
||
/// injection is skipped entirely (no owner = no `(agent, owner)` pair).
|
||
pub agent_owner_pubkey: Option<nostr::PublicKey>,
|
||
/// Whether NIP-AE agent core memory injection is enabled. When false,
|
||
/// the per-session core engram fetch is skipped and `core_sections`
|
||
/// remains empty for every channel, so `format_prompt` renders no
|
||
/// `[Agent Memory — core]` section. On by default; disabled via
|
||
/// `--no-memory` / `BUZZ_ACP_NO_MEMORY`.
|
||
pub memory_enabled: bool,
|
||
/// Harness identity string for NIP-AM `harness` field. Derived from the
|
||
/// configured `agent_command` at startup (e.g. `"goose"`, `"buzz-agent"`).
|
||
pub harness_name: String,
|
||
/// Relay URL this harness is connected to. Rides in observer payloads that
|
||
/// the desktop keys per (agent, relay) pair, e.g. `session_config_captured`,
|
||
/// mirroring the `managed_agent_runtime_lifecycle` frames.
|
||
pub relay_url: String,
|
||
}
|
||
|
||
impl AgentPool {
|
||
/// Create a pool from pre-indexed slots (may contain None for failed startups).
|
||
///
|
||
/// Slot positions are preserved so that `agent.index` always matches the
|
||
/// index into `self.agents`. Use this instead of `new()` when the startup
|
||
/// loop skips failed agents — `new()` would pack agents densely and break
|
||
/// the index invariant.
|
||
pub fn from_slots(slots: Vec<Option<OwnedAgent>>) -> Self {
|
||
let (result_tx, result_rx) = mpsc::unbounded_channel();
|
||
Self {
|
||
agents: slots,
|
||
result_tx,
|
||
result_rx,
|
||
join_set: JoinSet::new(),
|
||
task_map: HashMap::new(),
|
||
}
|
||
}
|
||
|
||
/// Try to claim an idle agent for the given channel (or heartbeat if `None`).
|
||
///
|
||
/// Pass 1: prefer an agent that already has a session for `channel_id`.
|
||
/// Pass 2: any idle agent.
|
||
///
|
||
/// Returns `None` if all agents are checked out.
|
||
pub fn try_claim(&mut self, channel_id: Option<Uuid>) -> Option<OwnedAgent> {
|
||
// Pass 1: prefer agent with existing session for this channel.
|
||
if let Some(cid) = channel_id {
|
||
let idx = self.agents.iter().position(|slot| {
|
||
slot.as_ref()
|
||
.map(|a| a.state.sessions.contains_key(&cid))
|
||
.unwrap_or(false)
|
||
});
|
||
if let Some(i) = idx {
|
||
return self.agents[i].take();
|
||
}
|
||
}
|
||
|
||
// Pass 2: first idle agent.
|
||
let idx = self.agents.iter().position(|slot| slot.is_some());
|
||
idx.map(|i| self.agents[i].take().unwrap())
|
||
}
|
||
|
||
/// Return an agent to its slot after a task completes.
|
||
pub fn return_agent(&mut self, agent: OwnedAgent) {
|
||
let idx = agent.index;
|
||
if self.agents[idx].is_some() {
|
||
// This is a bug: two tasks returned the same agent index. Log it
|
||
// loudly so it shows up in production logs, then overwrite — the
|
||
// alternative (dropping the incoming agent) would permanently leak
|
||
// the slot.
|
||
tracing::error!(
|
||
idx,
|
||
"BUG: return_agent called for slot {idx} which is already occupied — overwriting"
|
||
);
|
||
}
|
||
self.agents[idx] = Some(agent);
|
||
}
|
||
|
||
/// Whether any agent is currently idle (sitting in its slot).
|
||
pub fn any_idle(&self) -> bool {
|
||
self.agents.iter().any(|slot| slot.is_some())
|
||
}
|
||
|
||
/// Whether any idle agent already has a session for `channel_id`.
|
||
/// Used to compute `affinity_hit` before calling `try_claim`.
|
||
pub fn has_session_for(&self, channel_id: Uuid) -> bool {
|
||
self.agents.iter().any(|slot| {
|
||
slot.as_ref()
|
||
.map(|a| a.state.sessions.contains_key(&channel_id))
|
||
.unwrap_or(false)
|
||
})
|
||
}
|
||
|
||
/// Count of agents that are alive: idle OR checked out (have a task_map entry).
|
||
///
|
||
/// Used to detect when all agents have exited so the caller can respawn.
|
||
pub fn live_count(&self) -> usize {
|
||
let idle = self.agents.iter().filter(|s| s.is_some()).count();
|
||
let checked_out = self.task_map.len();
|
||
idle + checked_out
|
||
}
|
||
|
||
pub fn task_map(&self) -> &HashMap<tokio::task::Id, TaskMeta> {
|
||
&self.task_map
|
||
}
|
||
|
||
pub fn task_map_mut(&mut self) -> &mut HashMap<tokio::task::Id, TaskMeta> {
|
||
&mut self.task_map
|
||
}
|
||
|
||
/// Try to send a goose-native steer request to the in-flight task for
|
||
/// `channel_id`.
|
||
///
|
||
/// Returns `Ok(())` if the request was accepted by the read loop's
|
||
/// receiver (capacity-1 mpsc; one slot is the single in-flight steer
|
||
/// write). Returns `Err(SteerError::Transport(_))` on `Full`/`Closed`
|
||
/// (already-in-flight write, or read loop torn down). Callers must
|
||
/// fall back to the universal `ControlSignal::Steer` cancel+merge path
|
||
/// on `Err`.
|
||
///
|
||
/// This does **not** spawn the ack watcher — the caller owns the
|
||
/// oneshot `ack_tx` inside `SteerRequest` and is responsible for
|
||
/// awaiting it and applying the locked Success / Err / PromptCompletedNeutral
|
||
/// semantics. Caller is also responsible for the synchronous
|
||
/// `queue.mark_native_steer_pending(...)` *before* spawning the
|
||
/// watcher, to close the result-vs-ack race.
|
||
///
|
||
/// Returns `Err(SteerError::PromptCompleted)` if no task is in flight
|
||
/// for `channel_id` (the prompt completed between the mode-gate check
|
||
/// and this call, or the channel was never in flight). This is
|
||
/// semantically a soft no-op — the caller should release any withheld
|
||
/// event and let normal dispatch handle delivery.
|
||
pub fn send_steer(
|
||
&mut self,
|
||
channel_id: Uuid,
|
||
request: SteerRequest,
|
||
) -> Result<(), SteerError> {
|
||
let meta = self
|
||
.task_map
|
||
.values_mut()
|
||
.find(|m| m.channel_id == Some(channel_id))
|
||
.ok_or(SteerError::PromptCompleted)?;
|
||
let tx = meta
|
||
.steer_tx
|
||
.as_ref()
|
||
.ok_or_else(|| SteerError::Transport("steer_tx not installed".into()))?;
|
||
tx.try_send(request)
|
||
.map_err(|e| SteerError::Transport(e.to_string()))
|
||
}
|
||
|
||
pub fn result_tx(&self) -> mpsc::UnboundedSender<PromptResult> {
|
||
self.result_tx.clone()
|
||
}
|
||
|
||
/// Split-borrow: returns mutable refs to `result_rx` and `join_set`
|
||
/// simultaneously. This lets callers poll both in a single `select!`
|
||
/// without a double-borrow error on `&mut AgentPool`.
|
||
pub fn rx_and_join_set(
|
||
&mut self,
|
||
) -> (&mut mpsc::UnboundedReceiver<PromptResult>, &mut JoinSet<()>) {
|
||
(&mut self.result_rx, &mut self.join_set)
|
||
}
|
||
|
||
/// Non-blocking drain of the result channel. Used during shutdown to
|
||
/// collect agents that completed while join_set was being drained.
|
||
pub fn result_rx_try_recv(&mut self) -> Result<PromptResult, mpsc::error::TryRecvError> {
|
||
self.result_rx.try_recv()
|
||
}
|
||
|
||
/// Check whether a slot is alive: either idle in the pool or checked out
|
||
/// for an in-flight task. Returns `false` only when the slot is truly
|
||
/// empty and available for refill.
|
||
pub fn slot_alive(&self, index: usize) -> bool {
|
||
let idle = self.agents.get(index).is_some_and(|s| s.is_some());
|
||
if idle {
|
||
return true;
|
||
}
|
||
// Check if the agent is checked out (in-flight on a task).
|
||
self.task_map.values().any(|m| m.agent_index == index)
|
||
}
|
||
|
||
pub fn agents_mut(&mut self) -> &mut Vec<Option<OwnedAgent>> {
|
||
&mut self.agents
|
||
}
|
||
|
||
/// Remove the session for `channel_id` from all idle agents.
|
||
///
|
||
/// Called when the agent is removed from a channel — stale sessions
|
||
/// should not be reused. Checked-out agents (in-flight) are not
|
||
/// modified; their sessions will fail naturally on the next prompt
|
||
/// if the relay rejects the request.
|
||
///
|
||
/// Returns the number of sessions invalidated.
|
||
pub fn invalidate_channel_sessions(&mut self, channel_id: Uuid) -> usize {
|
||
let mut count = 0;
|
||
for slot in &mut self.agents {
|
||
if let Some(agent) = slot.as_mut() {
|
||
if agent.state.invalidate_channel(&channel_id) {
|
||
count += 1;
|
||
}
|
||
}
|
||
}
|
||
count
|
||
}
|
||
|
||
/// Idle-path model switch: set `desired_model` on the idle agent for
|
||
/// `channel_id` and invalidate its session so the next turn re-creates the
|
||
/// session under the new model.
|
||
///
|
||
/// Pre-cancel guard: the desired model is validated against the agent's
|
||
/// cached catalog *before* the session is invalidated, so an unsupported
|
||
/// pick is rejected without disturbing the existing session.
|
||
///
|
||
/// Returns [`IdleSwitchResult`] describing what happened. The model does not
|
||
/// take effect — and the panel does not reflect it — until the agent next
|
||
/// runs a turn (no live session exists to re-emit `session_config_captured`
|
||
/// from an idle agent). This lag is intentional: faking the emit would
|
||
/// surface an override the session has not actually applied.
|
||
pub fn switch_idle_agent_model(
|
||
&mut self,
|
||
channel_id: Uuid,
|
||
model_id: &str,
|
||
) -> IdleSwitchResult {
|
||
let Some(agent) = self
|
||
.agents
|
||
.iter_mut()
|
||
.flatten()
|
||
.find(|a| a.state.sessions.contains_key(&channel_id))
|
||
else {
|
||
return IdleSwitchResult::NoIdleAgent;
|
||
};
|
||
|
||
// Pre-cancel guard against the cached catalog. None = catalog not yet
|
||
// populated (no session ever created); defer validation to apply time.
|
||
if let Some(caps) = agent.model_capabilities.as_ref() {
|
||
if !model_in_catalog(
|
||
&caps.config_options_raw,
|
||
caps.available_models_raw.as_ref(),
|
||
model_id,
|
||
) {
|
||
return IdleSwitchResult::UnsupportedModel;
|
||
}
|
||
}
|
||
|
||
agent.desired_model = Some(model_id.to_string());
|
||
agent.model_overridden = true;
|
||
agent.state.invalidate_channel(&channel_id);
|
||
IdleSwitchResult::Switched
|
||
}
|
||
}
|
||
|
||
/// Outcome of [`AgentPool::switch_idle_agent_model`].
|
||
#[derive(Debug, PartialEq, Eq)]
|
||
pub enum IdleSwitchResult {
|
||
/// `desired_model` set and the channel session invalidated.
|
||
Switched,
|
||
/// Desired model is not in the agent's cached catalog — pick rejected,
|
||
/// session untouched.
|
||
UnsupportedModel,
|
||
/// No idle agent available (all checked out / none spawned).
|
||
NoIdleAgent,
|
||
}
|
||
|
||
/// Timeout for a single pre-prompt context fetch attempt (thread/DM history).
|
||
/// Each call gets this budget; with one retry the total worst-case is
|
||
/// 2 × CONTEXT_FETCH_TIMEOUT + CONTEXT_FETCH_RETRY_DELAY ≈ 6.5 s.
|
||
const CONTEXT_FETCH_TIMEOUT: Duration = Duration::from_millis(3_000);
|
||
|
||
/// Delay between the first failed context fetch and the single retry.
|
||
const CONTEXT_FETCH_RETRY_DELAY: Duration = Duration::from_millis(500);
|
||
|
||
/// Timeout for model-switch requests (`session/set_config_option`, `session/set_model`).
|
||
const MODEL_SWITCH_TIMEOUT: Duration = Duration::from_secs(5);
|
||
|
||
/// Bounded grace window for the post-cancel drain after a control-signal
|
||
/// cancellation (steer fallback, interrupt, or explicit stop). This is a
|
||
/// cleanup deadline, not the turn's configured max-turn wall clock — see
|
||
/// [`AcpClient::cancel_with_cleanup_grace`] and
|
||
/// [`classify_control_cancel_failure`].
|
||
const CONTROL_CANCEL_GRACE: Duration = Duration::from_secs(5);
|
||
|
||
/// Timeout for permission-mode requests (`session/set_config_option` with `configId: "mode"`).
|
||
const PERMISSION_MODE_TIMEOUT: Duration = Duration::from_secs(5);
|
||
|
||
/// Placeholder [`fetch_channel_info`] substitutes when a channel's metadata
|
||
/// event carries no `name` tag. Not a real channel name — consumers that need
|
||
/// an identifying name must treat it as absent.
|
||
const UNKNOWN_CHANNEL_NAME: &str = "unknown";
|
||
|
||
/// Channel-derived inputs for a new session — `(is_dm, title_channel)` — from
|
||
/// **one** metadata resolve.
|
||
///
|
||
/// Both new-session consumers need the same lookup: the canvas block skips DMs
|
||
/// (and fails closed when the channel type can't be determined), and the
|
||
/// session title is qualified with the channel name. Resolving once is
|
||
/// load-bearing rather than tidy: [`ChannelInfoResolver`] caches only `Some`,
|
||
/// so two calls against an unresolvable channel pay the whole
|
||
/// [`fetch_channel_info`] retry sequence twice — two `CONTEXT_FETCH_TIMEOUT`
|
||
/// attempts plus `CONTEXT_FETCH_RETRY_DELAY` each, in front of `session/new`,
|
||
/// precisely when the relay is already degraded.
|
||
///
|
||
/// `title_channel` is `None` whenever the channel can't usefully identify the
|
||
/// session: an unresolved channel, a DM (no meaningful name), or the literal
|
||
/// `"unknown"` that [`fetch_channel_info`] substitutes for a metadata event
|
||
/// with no `name` tag. Composing that sentinel would title every unnamed
|
||
/// channel identically (`Agent · #unknown`) — reintroducing the collision the
|
||
/// suffix exists to remove, while naming a channel something it isn't. The
|
||
/// startup cache already refuses `channel_type == "unknown"` for the same
|
||
/// reason.
|
||
///
|
||
/// Renames do not retitle live sessions, and a **channel** rename is stickier
|
||
/// than an agent rename: `invalidate_channel` drops the session but not the
|
||
/// resolver's cached entry, so a renamed channel keeps its old suffix until the
|
||
/// process restarts. An agent rename lands on the next spawn (the desktop
|
||
/// restart badge covers it — see `spawn_config_hash`).
|
||
async fn resolve_new_session_channel_context(
|
||
channel_info: &ChannelInfoResolver,
|
||
channel_id: Uuid,
|
||
) -> (bool, Option<String>) {
|
||
let Some(info) = channel_info.resolve(channel_id).await else {
|
||
return (true, None);
|
||
};
|
||
let is_dm = info.channel_type == "dm";
|
||
let title_channel = (!is_dm && info.name != UNKNOWN_CHANNEL_NAME).then_some(info.name);
|
||
(is_dm, title_channel)
|
||
}
|
||
|
||
/// Create a new ACP session via `session_new_full()`, populate model capabilities
|
||
/// on the agent (first session only), and apply `desired_model` if set.
|
||
///
|
||
/// On error from `session_new_full()`, returns the `AcpError` — caller handles
|
||
/// error reporting. Model-switch failures are logged and gracefully ignored
|
||
/// (the agent proceeds with its default model).
|
||
async fn create_session_and_apply_model(
|
||
agent: &mut OwnedAgent,
|
||
ctx: &PromptContext,
|
||
agent_core: Option<&str>,
|
||
agent_canvas: Option<&str>,
|
||
channel_name: Option<&str>,
|
||
) -> Result<String, AcpError> {
|
||
// Build base_prompt + system_prompt + agent core + canvas metadata into a
|
||
// single prompt. Standard protocol-v2 agents receive it in `session/new`;
|
||
// Goose receives it through the custom request below. Legacy agents receive
|
||
// the same content as user-message sections via `format_prompt`. Core carries
|
||
// its own `[Agent Memory — core]` header, and canvas carries its own
|
||
// `[Channel Canvas]` header; both are appended with a blank-line separator.
|
||
let is_goose = agent.agent_name == "goose";
|
||
let combined_system_prompt = with_canvas(
|
||
with_core(
|
||
with_team(
|
||
framed_system_prompt(&ctx.cwd, ctx.base_prompt, ctx.system_prompt.as_deref()),
|
||
ctx.team_instructions.as_deref(),
|
||
),
|
||
agent_core,
|
||
),
|
||
agent_canvas,
|
||
);
|
||
|
||
let session_title = ctx
|
||
.session_title
|
||
.as_deref()
|
||
.map(|agent_name| compose_session_title(agent_name, channel_name));
|
||
|
||
let resp = agent
|
||
.acp
|
||
.session_new_full(
|
||
&ctx.cwd,
|
||
ctx.mcp_servers.clone(),
|
||
session_new_system_prompt(
|
||
is_goose,
|
||
agent.protocol_version,
|
||
combined_system_prompt.as_deref(),
|
||
),
|
||
session_title.as_deref(),
|
||
)
|
||
.await?;
|
||
|
||
if is_goose && agent.goose_system_prompt_supported != Some(false) {
|
||
if let Some(prompt) = combined_system_prompt.as_deref() {
|
||
match agent
|
||
.acp
|
||
.session_set_goose_system_prompt(&resp.session_id, prompt)
|
||
.await
|
||
{
|
||
Ok(_) => agent.goose_system_prompt_supported = Some(true),
|
||
Err(AcpError::AgentError { code: -32601, .. }) => {
|
||
agent.goose_system_prompt_supported = Some(false);
|
||
tracing::warn!(
|
||
target: "pool::session",
|
||
"Goose does not support its system-prompt extension; using user-message framing"
|
||
);
|
||
}
|
||
Err(error) => return Err(error),
|
||
}
|
||
}
|
||
}
|
||
|
||
// Populate model capabilities on first session creation.
|
||
if agent.model_capabilities.is_none() {
|
||
agent.model_capabilities = Some(AgentModelCapabilities {
|
||
config_options_raw: extract_model_config_options(&resp.raw),
|
||
available_models_raw: extract_model_state(&resp.raw),
|
||
});
|
||
}
|
||
|
||
// Apply desired_model if set, matching against the fresh session/new response.
|
||
// Track whether the switch succeeded so session_config_captured reflects
|
||
// the post-switch state (not the pre-switch desired state).
|
||
let switch_succeeded = if let Some(ref desired) = agent.desired_model {
|
||
match resolve_model_switch_method(&resp.raw, desired) {
|
||
Some(method) => {
|
||
apply_model_switch(&mut agent.acp, &resp.session_id, desired, &method).await?;
|
||
true
|
||
}
|
||
None => {
|
||
tracing::warn!(
|
||
target: "pool::model",
|
||
"desired model {desired} not found in agent's available models — proceeding with agent default"
|
||
);
|
||
// Surface the miss so the desktop ModelPicker can reject a live
|
||
// pick rather than silently no-op. On the busy path the turn has
|
||
// already been cancelled+requeued by the time we get here, so the
|
||
// turn restarts on the unchanged model and the user is told no.
|
||
agent.acp.observe(
|
||
"control_result",
|
||
serde_json::json!({
|
||
"type": "switch_model",
|
||
"status": "unsupported_model",
|
||
"modelId": desired,
|
||
}),
|
||
);
|
||
false
|
||
}
|
||
}
|
||
} else {
|
||
false
|
||
};
|
||
|
||
// Emit session config for desktop consumption (config bridge tier 1b).
|
||
// Emitted AFTER desired_model resolution so the desktop caches the
|
||
// post-switch state. modelOverridden reflects whether the switch actually
|
||
// applied — false on the unsupported arm so the panel doesn't show a
|
||
// stale override badge.
|
||
agent.acp.observe(
|
||
"session_config_captured",
|
||
serde_json::json!({
|
||
"configOptions": resp.raw.get("configOptions").cloned().unwrap_or(serde_json::Value::Null),
|
||
"modes": resp.raw.get("modes").cloned().unwrap_or(serde_json::Value::Null),
|
||
"models": resp.raw.get("models").cloned().unwrap_or(serde_json::Value::Null),
|
||
"modelOverridden": agent.model_overridden && switch_succeeded,
|
||
// Pair identity for the desktop session-config cache, which is
|
||
// keyed by (agent, relay) like the lifecycle frames.
|
||
"relayUrl": ctx.relay_url,
|
||
}),
|
||
);
|
||
|
||
// Apply permission mode if not the agent's built-in default AND the agent
|
||
// advertises the requested mode in session/new. Agents that don't support
|
||
// the mode (e.g., goose crashes on unrecognized set_config_option values)
|
||
// are safely skipped — the harness auto-approves via handle_permission_request.
|
||
if !ctx.permission_mode.is_default()
|
||
&& agent_supports_mode(&resp.raw, ctx.permission_mode.as_wire_str())
|
||
{
|
||
apply_permission_mode(&mut agent.acp, &resp.session_id, &ctx.permission_mode).await?;
|
||
}
|
||
|
||
Ok(resp.session_id)
|
||
}
|
||
|
||
/// Send the appropriate ACP model-switch request with a timeout.
|
||
///
|
||
/// On timeout or error, logs a warning and returns — the caller proceeds
|
||
/// with the agent's default model. This is intentionally non-fatal: a stale
|
||
/// response from a timed-out request is safely ignored by `read_until_response`
|
||
/// (non-matching JSON-RPC IDs are skipped).
|
||
async fn apply_model_switch(
|
||
acp: &mut AcpClient,
|
||
session_id: &str,
|
||
desired: &str,
|
||
method: &ModelSwitchMethod,
|
||
) -> Result<(), AcpError> {
|
||
let method_label = match method {
|
||
ModelSwitchMethod::ConfigOption { config_id, .. } => {
|
||
format!("configOption (configId={config_id})")
|
||
}
|
||
ModelSwitchMethod::SetModel { .. } => "set_model".to_string(),
|
||
};
|
||
|
||
let result = tokio::time::timeout(MODEL_SWITCH_TIMEOUT, async {
|
||
match method {
|
||
ModelSwitchMethod::ConfigOption {
|
||
config_id,
|
||
option_value,
|
||
} => {
|
||
acp.session_set_config_option(session_id, config_id, option_value)
|
||
.await
|
||
}
|
||
ModelSwitchMethod::SetModel { model_id } => {
|
||
acp.session_set_model(session_id, model_id).await
|
||
}
|
||
}
|
||
})
|
||
.await;
|
||
|
||
match result {
|
||
Ok(Ok(_)) => {
|
||
tracing::info!(
|
||
target: "pool::model",
|
||
"applied model {desired} via {method_label} on session {session_id}"
|
||
);
|
||
}
|
||
// Transport-class errors may have corrupted the stdio stream — propagate
|
||
// so the caller can respawn the agent instead of reusing a poisoned one.
|
||
Ok(Err(e @ AcpError::Io(_)))
|
||
| Ok(Err(e @ AcpError::WriteTimeout(_)))
|
||
| Ok(Err(e @ AcpError::Timeout(_)))
|
||
| Ok(Err(e @ AcpError::Protocol(_)))
|
||
| Ok(Err(e @ AcpError::AgentExited)) => {
|
||
tracing::error!(
|
||
target: "pool::model",
|
||
"fatal error setting model {desired} via {method_label}: {e}"
|
||
);
|
||
return Err(e);
|
||
}
|
||
// Application-level errors (Json, etc.) — agent is fine, just uses default model.
|
||
Ok(Err(e)) => {
|
||
tracing::warn!(
|
||
target: "pool::model",
|
||
"failed to set model {desired} via {method_label}: {e} — proceeding with agent default"
|
||
);
|
||
}
|
||
Err(_) => {
|
||
// Outer timeout fired — the inner send_request may have left the
|
||
// stream in an unknown state. Treat as transport error.
|
||
tracing::error!(
|
||
target: "pool::model",
|
||
"model set via {method_label} timed out ({MODEL_SWITCH_TIMEOUT:?}) — treating as fatal"
|
||
);
|
||
return Err(AcpError::Timeout(MODEL_SWITCH_TIMEOUT));
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Set the session permission mode via `session/set_config_option`.
|
||
///
|
||
/// Non-fatal for most errors: logs and proceeds. The agent falls back
|
||
/// to its default permission mode (`"default"`), which still works via
|
||
/// Check if the agent's `session/new` response advertises a given mode ID
|
||
/// in `result.modes.availableModes[].id`. Returns `false` if the modes
|
||
/// field is absent or the mode isn't listed.
|
||
fn agent_supports_mode(session_new_result: &serde_json::Value, mode_wire: &str) -> bool {
|
||
session_new_result
|
||
.get("modes")
|
||
.and_then(|m| m.get("availableModes"))
|
||
.and_then(|a| a.as_array())
|
||
.map(|modes| {
|
||
modes
|
||
.iter()
|
||
.any(|m| m.get("id").and_then(|v| v.as_str()) == Some(mode_wire))
|
||
})
|
||
.unwrap_or(false)
|
||
}
|
||
|
||
/// per-tool auto-approval in `handle_permission_request`.
|
||
///
|
||
/// **Fatal exception:** if the agent process exits (e.g., goose crashes on
|
||
/// unrecognized methods), returns `Err(AgentExited)` so the caller can respawn.
|
||
async fn apply_permission_mode(
|
||
acp: &mut AcpClient,
|
||
session_id: &str,
|
||
mode: &PermissionMode,
|
||
) -> Result<(), AcpError> {
|
||
let wire = mode.as_wire_str();
|
||
let result = tokio::time::timeout(PERMISSION_MODE_TIMEOUT, async {
|
||
acp.session_set_config_option(session_id, "mode", wire)
|
||
.await
|
||
})
|
||
.await;
|
||
|
||
match result {
|
||
Ok(Ok(_)) => {
|
||
tracing::info!(
|
||
target: "pool::permission",
|
||
"applied permission mode {wire:?} on session {session_id}"
|
||
);
|
||
}
|
||
// Transport-class errors may have corrupted the stdio stream — propagate
|
||
// so the caller can respawn the agent.
|
||
Ok(Err(e @ AcpError::Io(_)))
|
||
| Ok(Err(e @ AcpError::WriteTimeout(_)))
|
||
| Ok(Err(e @ AcpError::Timeout(_)))
|
||
| Ok(Err(e @ AcpError::Protocol(_)))
|
||
| Ok(Err(e @ AcpError::AgentExited)) => {
|
||
tracing::error!(
|
||
target: "pool::permission",
|
||
"fatal error setting permission mode {wire:?}: {e}"
|
||
);
|
||
return Err(e);
|
||
}
|
||
// Application-level errors — agent is fine, just uses default permission mode.
|
||
Ok(Err(e)) => {
|
||
tracing::warn!(
|
||
target: "pool::permission",
|
||
"failed to set permission mode {wire:?}: {e} — falling back to per-tool auto-approval"
|
||
);
|
||
}
|
||
Err(_) => {
|
||
// Outer timeout fired — stream may be in unknown state.
|
||
tracing::error!(
|
||
target: "pool::permission",
|
||
"permission mode set timed out ({PERMISSION_MODE_TIMEOUT:?}) — treating as fatal"
|
||
);
|
||
return Err(AcpError::Timeout(PERMISSION_MODE_TIMEOUT));
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Prepend the `[Base]` section to a user-message body for legacy agents.
|
||
///
|
||
/// Legacy agents (`protocol_version < 2`) don't receive `base_prompt` via the
|
||
/// system role in `session/new`, so it must ride along in the user message.
|
||
/// Agents with `protocol_version >= 2`, or any agent without a `base_prompt`,
|
||
/// get `body` unchanged. The gate lives here so the heartbeat and
|
||
/// initial-message dispatch paths can't drift apart again.
|
||
pub(crate) fn prepend_base_for_legacy(
|
||
protocol_version: u32,
|
||
base_prompt: Option<&str>,
|
||
body: &str,
|
||
) -> String {
|
||
match base_prompt {
|
||
Some(bp) if protocol_version < 2 => {
|
||
format!("{}\n\n{body}", crate::queue::base_section(bp))
|
||
}
|
||
_ => body.to_string(),
|
||
}
|
||
}
|
||
|
||
/// Prepend the `[Channel Canvas]` section to the legacy initial-message body.
|
||
///
|
||
/// Protocol-v2 agents already receive the canvas in `systemPrompt`; only
|
||
/// legacy (protocol_version < 2) agents need it injected here so it arrives
|
||
/// before the first prompt — the same "every turn" semantics as per-turn core.
|
||
/// Heartbeats never have an initial_message, so the caller is responsible for
|
||
/// not passing a canvas when `source` is `Heartbeat`.
|
||
pub(crate) fn prepend_canvas_for_legacy(
|
||
protocol_version: u32,
|
||
agent_canvas: Option<&str>,
|
||
body: &str,
|
||
) -> String {
|
||
match agent_canvas {
|
||
Some(canvas) if protocol_version < 2 => format!("{canvas}\n\n{body}"),
|
||
_ => body.to_string(),
|
||
}
|
||
}
|
||
|
||
/// Frame the `session/new` `systemPrompt` so each present prompt carries its own
|
||
/// header, keeping the base/persona boundary recoverable downstream.
|
||
///
|
||
/// The header framing matches the legacy per-turn path (`queue::base_section`
|
||
/// for `[Base]`, `[System]\n{...}` for the persona) so the desktop observer can
|
||
/// split the combined value into labeled sub-sections. Each prompt is wrapped
|
||
/// only when present, so a persona-only agent yields `[System]\n{persona}`
|
||
/// rather than an unlabeled blob that would be mislabeled as `[Base]`.
|
||
///
|
||
/// Prepends a `[Workspace]` section naming the agent's absolute working
|
||
/// directory. The base prompt describes the workspace layout but never its
|
||
/// absolute root, so without this anchor a model fills the gap by searching
|
||
/// `$HOME` (triggering macOS TCC prompts) or by inventing its own workspace
|
||
/// directory. The line is emitted only when a real base prompt is present and
|
||
/// `cwd` is an absolute path other than the `/` fallback — naming `/` as the
|
||
/// workspace would itself invite a `$HOME`-wide scan.
|
||
fn framed_system_prompt(
|
||
cwd: &str,
|
||
base_prompt: Option<&str>,
|
||
system_prompt: Option<&str>,
|
||
) -> Option<String> {
|
||
let body = match (base_prompt, system_prompt) {
|
||
(Some(bp), Some(sp)) => Some(format!(
|
||
"{}\n\n[System]\n{sp}",
|
||
crate::queue::base_section(bp)
|
||
)),
|
||
(Some(bp), None) => Some(crate::queue::base_section(bp)),
|
||
(None, Some(sp)) => Some(format!("[System]\n{sp}")),
|
||
(None, None) => None,
|
||
}?;
|
||
// Anchor the workspace only when a base prompt is present — the workspace
|
||
// section grounds the base prompt's layout description, so it is meaningless
|
||
// for a persona-only (`[System]`-only) agent that never received that layout.
|
||
match (base_prompt, workspace_section(cwd)) {
|
||
(Some(_), Some(workspace)) => Some(format!("{workspace}\n\n{body}")),
|
||
_ => Some(body),
|
||
}
|
||
}
|
||
|
||
/// Render the `[Workspace]` grounding section, or `None` when `cwd` is unusable.
|
||
///
|
||
/// Skips relative paths and the `/` fallback (`std::env::current_dir()` resolves
|
||
/// to `/` on failure): a `/`-rooted workspace line would actively encourage the
|
||
/// `$HOME`-wide scan this section exists to prevent.
|
||
fn workspace_section(cwd: &str) -> Option<String> {
|
||
if cwd != "/" && cwd.starts_with('/') {
|
||
Some(format!(
|
||
"[Workspace]\nYour absolute working directory is `{cwd}`. All workspace \
|
||
files — `AGENTS.md`, `RESEARCH/`, `PLANS/`, `GUIDES/`, `WORK_LOGS/`, \
|
||
`OUTBOX/` — and any repositories you clone (under `{cwd}/REPOS/`) live \
|
||
here. This is where you already are; do not search `$HOME` or other \
|
||
directories for them."
|
||
))
|
||
} else {
|
||
None
|
||
}
|
||
}
|
||
|
||
/// Append the team-owned instruction section after `[System]` and before core memory.
|
||
fn with_team(prompt: Option<String>, instructions: Option<&str>) -> Option<String> {
|
||
let instructions = instructions
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty());
|
||
match (prompt, instructions) {
|
||
(Some(prompt), Some(instructions)) => {
|
||
Some(format!("{prompt}\n\n[Team Instructions]\n{instructions}"))
|
||
}
|
||
(None, Some(instructions)) => Some(format!("[Team Instructions]\n{instructions}")),
|
||
(Some(prompt), None) => Some(prompt),
|
||
(None, None) => None,
|
||
}
|
||
}
|
||
|
||
/// Append the agent's core memory section onto the framed system prompt.
|
||
///
|
||
/// Core already carries its own `[Agent Memory — core]` header from
|
||
/// `engram_fetch::build_core_section`, so it is joined with a blank-line
|
||
/// separator and never re-labeled. Either side may be absent.
|
||
fn with_core(framed: Option<String>, core: Option<&str>) -> Option<String> {
|
||
match (framed, core) {
|
||
(Some(framed), Some(core)) => Some(format!("{framed}\n\n{core}")),
|
||
(Some(framed), None) => Some(framed),
|
||
(None, Some(core)) => Some(core.to_string()),
|
||
(None, None) => None,
|
||
}
|
||
}
|
||
|
||
/// Append the `[Channel Canvas]` metadata section onto the accumulated system prompt.
|
||
///
|
||
/// The canvas section already carries its `[Channel Canvas]` header (from
|
||
/// `render_canvas_section`), so it is joined with a blank-line separator.
|
||
/// Either side may be absent.
|
||
fn with_canvas(prompt: Option<String>, canvas: Option<&str>) -> Option<String> {
|
||
match (prompt, canvas) {
|
||
(Some(prompt), Some(canvas)) => Some(format!("{prompt}\n\n{canvas}")),
|
||
(Some(prompt), None) => Some(prompt),
|
||
(None, Some(canvas)) => Some(canvas.to_string()),
|
||
(None, None) => None,
|
||
}
|
||
}
|
||
|
||
/// Return `agent` to the pool via `result_tx`, clearing any steer receiver first.
|
||
///
|
||
/// Every path that returns an `OwnedAgent` to the pool via `PromptResult` goes
|
||
/// through this function. Panic/abort paths do not — and don't need to, since a
|
||
/// panicked task's agent is never sent back via `PromptResult`.
|
||
///
|
||
/// Clearing `steer_rx` here — rather than per-arm — makes the `install_steer_rx`
|
||
/// invariant (`steer_rx.is_none()` at dispatch) structurally unviolatable: a receiver
|
||
/// installed for a turn that ends before the read loop's `take()` (e.g. session-create
|
||
/// error) is always dropped before the agent re-enters the pool, so the next dispatch
|
||
/// can never trigger the assert.
|
||
///
|
||
/// On the happy path the read loop has already called `take()`, so this is a no-op.
|
||
fn send_prompt_result(
|
||
result_tx: &mpsc::UnboundedSender<PromptResult>,
|
||
turn_id: &str,
|
||
mut agent: OwnedAgent,
|
||
source: PromptSource,
|
||
outcome: PromptOutcome,
|
||
batch: Option<FlushBatch>,
|
||
) {
|
||
agent.acp.clear_steer_rx();
|
||
let _ = result_tx.send(PromptResult {
|
||
agent,
|
||
source,
|
||
turn_id: turn_id.to_owned(),
|
||
outcome,
|
||
batch,
|
||
});
|
||
}
|
||
|
||
/// Core async function spawned for each prompt.
|
||
///
|
||
/// Lifecycle:
|
||
/// 1. Resolve or create a session (channel or heartbeat).
|
||
/// 2. Send `initial_message` on new channel sessions (if configured).
|
||
/// 3. Fetch conversation context if needed (thread reply or DM).
|
||
/// 4. Build the prompt text from batch + context.
|
||
/// 5. Send the actual prompt with turn timeout.
|
||
/// 6. Handle all error paths, always returning the agent via `result_tx`.
|
||
///
|
||
/// The agent is ALWAYS returned — even on panic the `JoinSet` detects the
|
||
/// abort and the caller uses `task_map` to recover the agent index.
|
||
pub async fn run_prompt_task(
|
||
mut agent: OwnedAgent,
|
||
batch: Option<FlushBatch>,
|
||
prompt_text: Option<String>,
|
||
ctx: Arc<PromptContext>,
|
||
result_tx: mpsc::UnboundedSender<PromptResult>,
|
||
control_rx: Option<tokio::sync::oneshot::Receiver<ControlSignal>>,
|
||
turn_id: String,
|
||
) {
|
||
// Is this a channel prompt or a heartbeat?
|
||
let source = match &batch {
|
||
Some(b) => PromptSource::Channel(b.channel_id),
|
||
None => PromptSource::Heartbeat,
|
||
};
|
||
let observer_channel_id = match &source {
|
||
PromptSource::Channel(channel_id) => Some(*channel_id),
|
||
PromptSource::Heartbeat => None,
|
||
};
|
||
let turn_started_at = chrono::Utc::now().to_rfc3339();
|
||
agent.acp.set_observer_context(observer::context_for_turn(
|
||
observer_channel_id,
|
||
None,
|
||
turn_id.clone(),
|
||
turn_started_at.clone(),
|
||
));
|
||
let triggering_event_ids: Vec<String> = batch
|
||
.as_ref()
|
||
.map(|b| b.events.iter().map(|be| be.event.id.to_hex()).collect())
|
||
.unwrap_or_default();
|
||
agent.acp.observe(
|
||
"turn_started",
|
||
serde_json::json!({
|
||
"source": match &source {
|
||
PromptSource::Channel(_) => "channel",
|
||
PromptSource::Heartbeat => "heartbeat",
|
||
},
|
||
"triggeringEventIds": triggering_event_ids,
|
||
}),
|
||
);
|
||
|
||
// Emits `turn_completed` on any exit path. Captures observer handle and
|
||
// metadata now, before the agent is moved into PromptResult. It must be
|
||
// declared before `liveness_guard`: Rust drops locals in reverse order, so
|
||
// liveness is aborted before completion makes the turn terminal.
|
||
let _turn_guard = TurnCompletionGuard::new(
|
||
agent.acp.observer_handle(),
|
||
agent.acp.observer_agent_index(),
|
||
observer_channel_id,
|
||
turn_id.clone(),
|
||
);
|
||
|
||
// Start liveness with `turn_started`, not the final session/prompt call:
|
||
// session creation, context fetches, and an initial message can themselves
|
||
// take longer than the desktop's bounded prune pause. This future is pinned
|
||
// for the whole task and dropped with the turn on every exit path.
|
||
//
|
||
// `liveness_state` is shared with `LivenessGuard`: see its docs for why a
|
||
// bare `abort()` alone cannot prevent a `turn_liveness` frame emitted after
|
||
// `turn_completed`. Once the session resolves below, `set_session_id`
|
||
// updates the same shared state so later ticks stop carrying `None`.
|
||
let liveness_state = Arc::new(Mutex::new(LivenessState {
|
||
closed: false,
|
||
session_id: None,
|
||
}));
|
||
let liveness = run_turn_liveness(
|
||
agent.acp.observer_handle(),
|
||
agent.acp.observer_agent_index(),
|
||
observer::context_for_turn(
|
||
observer_channel_id,
|
||
None,
|
||
turn_id.clone(),
|
||
turn_started_at.clone(),
|
||
),
|
||
ctx.turn_liveness_interval,
|
||
Arc::clone(&liveness_state),
|
||
);
|
||
let liveness_handle = tokio::spawn(liveness);
|
||
let liveness_guard = LivenessGuard::new(liveness_handle, liveness_state);
|
||
|
||
// Collects event IDs up front. On drop (any exit path — normal, early
|
||
// return, or panic), spawns best-effort cleanup of both 👀 and 💬.
|
||
// See `ReactionGuard` docs for ordering guarantees and known edge cases.
|
||
let reaction_ids: Vec<String> = batch
|
||
.as_ref()
|
||
.map(|b| b.events.iter().map(|be| be.event.id.to_hex()).collect())
|
||
.unwrap_or_default();
|
||
let _reaction_guard = ReactionGuard::new(ctx.rest_client.clone(), reaction_ids.clone());
|
||
|
||
//
|
||
// Core memory is delivered inside the system prompt the harness already
|
||
// builds (system role for protocol >= 2, the `[System]` user-message
|
||
// section for legacy agents). To put it on the wire at `session/new` for
|
||
// modern agents, the fetch must run *before* the session is created — so
|
||
// we do it here and cache the rendered section in `state.core_sections`.
|
||
//
|
||
// Core is keyed by (agent_keys, owner) — both fixed for the process — so
|
||
// it is identical across channels; the per-channel cache just avoids a
|
||
// re-fetch on each new session and is cleared on session invalidation.
|
||
//
|
||
// Failure modes (all fail open — no crash, no block):
|
||
// * no owner configured → skip (no NIP-AE namespace exists)
|
||
// * confirmed absence → cache the onboarding nudge so the agent
|
||
// learns how to bootstrap itself.
|
||
// * transport / decrypt / parse error → inject nothing. We never
|
||
// mistake "relay slow or broken" for "no core" — that would invite
|
||
// the agent to overwrite real, just-unreachable memory.
|
||
// * fetch exceeds CORE_FETCH_TIMEOUT → inject nothing, same reason.
|
||
//
|
||
// Per Tyler's locked spec: NO mid-session refreshes. Re-fetch only
|
||
// happens when a session is invalidated and recreated (see
|
||
// `SessionState::invalidate_channel`).
|
||
//
|
||
// Operator opt-out: `--no-memory` / `BUZZ_ACP_NO_MEMORY` skips the fetch.
|
||
if ctx.memory_enabled {
|
||
if let (PromptSource::Channel(cid), Some(owner_pk)) =
|
||
(&source, ctx.agent_owner_pubkey.as_ref())
|
||
{
|
||
let is_new_channel_session = !agent.state.sessions.contains_key(cid);
|
||
if is_new_channel_session && !agent.state.core_sections.contains_key(cid) {
|
||
// Bounded — we'd rather start the session with no core hint
|
||
// than block session creation on a stalled relay.
|
||
const CORE_FETCH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3);
|
||
let fetch = crate::engram_fetch::build_core_section(
|
||
&ctx.rest_client,
|
||
&ctx.agent_keys,
|
||
owner_pk,
|
||
);
|
||
let section = match tokio::time::timeout(CORE_FETCH_TIMEOUT, fetch).await {
|
||
Ok(s) => s,
|
||
Err(_) => {
|
||
tracing::warn!(
|
||
target: "engram::core",
|
||
channel = %cid,
|
||
timeout_ms = CORE_FETCH_TIMEOUT.as_millis() as u64,
|
||
"core fetch timed out — emitting no section"
|
||
);
|
||
None
|
||
}
|
||
};
|
||
if let Some(rendered) = section {
|
||
tracing::info!(
|
||
target: "engram::core",
|
||
channel = %cid,
|
||
section_len = rendered.len(),
|
||
"injected NIP-AE core section into system prompt"
|
||
);
|
||
agent.state.core_sections.insert(*cid, rendered);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Canvas metadata fetch — same lifecycle as core: once per new channel session,
|
||
// never for heartbeats, cached until session invalidation.
|
||
//
|
||
// DM check: use startup channel_info first; lazy-fetch only when missing.
|
||
// A confirmed DM never receives a canvas section. If the channel type cannot
|
||
// be determined (metadata absent and lazy fetch fails/unknown), skip the canvas
|
||
// rather than assuming non-DM — failing closed on DM ambiguity is safer.
|
||
//
|
||
// I3 lifecycle: hold the fetched section in a local `pending_canvas` and
|
||
// commit it to `canvas_sections` only after session creation succeeds. This
|
||
// prevents a stale revision A surviving a failed create and being re-used by
|
||
// the next attempt after the canvas was cleared.
|
||
let mut pending_canvas: Option<(Uuid, String)> = None;
|
||
// Channel name for the session title, from the same single resolve the
|
||
// canvas DM check uses — see `resolve_new_session_channel_context`.
|
||
let mut title_channel: Option<String> = None;
|
||
if let PromptSource::Channel(cid) = &source {
|
||
let is_new_channel_session = !agent.state.sessions.contains_key(cid);
|
||
let needs_canvas = is_new_channel_session && !agent.state.canvas_sections.contains_key(cid);
|
||
let needs_title = is_new_channel_session && ctx.session_title.is_some();
|
||
if needs_canvas || needs_title {
|
||
let (is_dm, resolved_channel) =
|
||
resolve_new_session_channel_context(&ctx.channel_info, *cid).await;
|
||
title_channel = resolved_channel;
|
||
// A confirmed DM never receives a canvas section; an undeterminable
|
||
// channel type fails closed as a DM for the same reason.
|
||
if needs_canvas && !is_dm {
|
||
if let Some(section) = fetch_canvas_section(*cid, &ctx.rest_client).await {
|
||
pending_canvas = Some((*cid, section));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// The core section to fold into the system prompt for this turn's session.
|
||
// Channel-scoped; heartbeats carry no owner core.
|
||
let agent_core: Option<String> = match &source {
|
||
PromptSource::Channel(cid) => agent.state.core_sections.get(cid).cloned(),
|
||
PromptSource::Heartbeat => None,
|
||
};
|
||
|
||
// The canvas metadata section — channel-scoped, absent for heartbeats/DMs.
|
||
// Prefer the committed cache; fall back to pending (for new sessions being created now).
|
||
let agent_canvas: Option<String> = match &source {
|
||
PromptSource::Channel(cid) => agent
|
||
.state
|
||
.canvas_sections
|
||
.get(cid)
|
||
.cloned()
|
||
.or_else(|| pending_canvas.as_ref().map(|(_, s)| s.clone())),
|
||
PromptSource::Heartbeat => None,
|
||
};
|
||
|
||
let (session_id, is_new_session) = match &source {
|
||
PromptSource::Channel(cid) => {
|
||
if let Some(sid) = agent.state.sessions.get(cid) {
|
||
(sid.clone(), false)
|
||
} else {
|
||
// The title is channel-qualified (`Agent · #channel`) so one
|
||
// agent in several channels doesn't produce identical session
|
||
// rows; `title_channel` comes from the single resolve above and
|
||
// is `None` for DM, unresolved, and unnamed channels.
|
||
match create_session_and_apply_model(
|
||
&mut agent,
|
||
&ctx,
|
||
agent_core.as_deref(),
|
||
agent_canvas.as_deref(),
|
||
title_channel.as_deref(),
|
||
)
|
||
.await
|
||
{
|
||
Ok(sid) => {
|
||
tracing::info!(
|
||
target: "pool::session",
|
||
"created session {sid} for channel {cid}"
|
||
);
|
||
agent.state.sessions.insert(*cid, sid.clone());
|
||
// Commit canvas only after session creation succeeds (I3).
|
||
if let Some((pending_cid, section)) = pending_canvas.take() {
|
||
agent.state.canvas_sections.insert(pending_cid, section);
|
||
}
|
||
(sid, true)
|
||
}
|
||
Err(AcpError::AgentExited) => {
|
||
agent.state.invalidate_all();
|
||
send_prompt_result(
|
||
&result_tx,
|
||
&turn_id,
|
||
agent,
|
||
source,
|
||
PromptOutcome::AgentExited,
|
||
requeue_batch_if_queue(&ctx, batch),
|
||
);
|
||
return;
|
||
}
|
||
Err(e) => {
|
||
// Session creation failed; pending canvas was never committed,
|
||
// so the next retry will re-fetch a fresh revision.
|
||
send_prompt_result(
|
||
&result_tx,
|
||
&turn_id,
|
||
agent,
|
||
source,
|
||
PromptOutcome::Error(e),
|
||
requeue_batch_if_queue(&ctx, batch),
|
||
);
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
PromptSource::Heartbeat => {
|
||
if let Some(sid) = &agent.state.heartbeat_session {
|
||
(sid.clone(), false)
|
||
} else {
|
||
match create_session_and_apply_model(&mut agent, &ctx, None, None, None).await {
|
||
Ok(sid) => {
|
||
tracing::info!(
|
||
target: "pool::session",
|
||
"created heartbeat session {sid} for agent {}",
|
||
agent.index
|
||
);
|
||
agent.state.heartbeat_session = Some(sid.clone());
|
||
(sid, true)
|
||
}
|
||
Err(AcpError::AgentExited) => {
|
||
agent.state.invalidate_all();
|
||
send_prompt_result(
|
||
&result_tx,
|
||
&turn_id,
|
||
agent,
|
||
source,
|
||
PromptOutcome::AgentExited,
|
||
None,
|
||
);
|
||
return;
|
||
}
|
||
Err(e) => {
|
||
send_prompt_result(
|
||
&result_tx,
|
||
&turn_id,
|
||
agent,
|
||
source,
|
||
PromptOutcome::Error(e),
|
||
None,
|
||
);
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
};
|
||
agent.acp.set_observer_context(observer::context_for_turn(
|
||
observer_channel_id,
|
||
Some(session_id.clone()),
|
||
turn_id.clone(),
|
||
turn_started_at,
|
||
));
|
||
// Backfill liveness's shared session ID so ticks after this point carry
|
||
// it too, matching every other observer frame for this turn.
|
||
liveness_guard.set_session_id(session_id.clone());
|
||
agent.acp.observe(
|
||
"session_resolved",
|
||
serde_json::json!({
|
||
"sessionId": session_id,
|
||
"isNewSession": is_new_session,
|
||
}),
|
||
);
|
||
|
||
if is_new_session {
|
||
if let (PromptSource::Channel(cid), Some(ref initial_msg)) = (&source, &ctx.initial_message)
|
||
{
|
||
tracing::info!(
|
||
target: "pool::session",
|
||
"sending initial_message to session {session_id} for channel {cid}"
|
||
);
|
||
// For agents with systemPrompt support (protocol_version >= 2),
|
||
// base_prompt is delivered via the system role in session/new.
|
||
// Legacy agents receive it via [Base] in the user message instead.
|
||
// Canvas is also injected here for legacy agents: protocol-v2 agents
|
||
// already have it in systemPrompt; legacy agents need it before the
|
||
// first prompt, matching the "every turn" per-turn delivery semantics.
|
||
let init_msg = prepend_base_for_legacy(
|
||
if agent.has_system_prompt_support() {
|
||
2
|
||
} else {
|
||
1
|
||
},
|
||
ctx.base_prompt,
|
||
initial_msg,
|
||
);
|
||
let init_msg = prepend_canvas_for_legacy(
|
||
if agent.has_system_prompt_support() {
|
||
2
|
||
} else {
|
||
1
|
||
},
|
||
agent_canvas.as_deref(),
|
||
&init_msg,
|
||
);
|
||
let init_result = agent
|
||
.acp
|
||
.session_prompt_with_idle_timeout(
|
||
&session_id,
|
||
&init_msg,
|
||
ctx.idle_timeout,
|
||
ctx.max_turn_duration,
|
||
)
|
||
.await;
|
||
|
||
match init_result {
|
||
Ok(stop_reason) => {
|
||
tracing::info!(
|
||
target: "pool::session",
|
||
"initial_message complete for channel {cid}: {stop_reason:?}"
|
||
);
|
||
}
|
||
Err(AcpError::AgentExited) => {
|
||
agent.state.invalidate_all();
|
||
send_prompt_result(
|
||
&result_tx,
|
||
&turn_id,
|
||
agent,
|
||
source,
|
||
PromptOutcome::AgentExited,
|
||
requeue_batch_if_queue(&ctx, batch),
|
||
);
|
||
return;
|
||
}
|
||
Err(AcpError::IdleTimeout(_)) => {
|
||
tracing::warn!(
|
||
target: "pool::session",
|
||
"initial_message idle timeout ({}s) for channel {cid} — cancelling",
|
||
ctx.idle_timeout.as_secs()
|
||
);
|
||
match agent
|
||
.acp
|
||
.cancel_with_cleanup(&session_id, ctx.idle_timeout)
|
||
.await
|
||
{
|
||
Ok(_) => {
|
||
agent.state.invalidate(&source);
|
||
}
|
||
Err(AcpError::AgentExited) => {
|
||
agent.state.invalidate_all();
|
||
send_prompt_result(
|
||
&result_tx,
|
||
&turn_id,
|
||
agent,
|
||
source,
|
||
PromptOutcome::AgentExited,
|
||
requeue_batch_if_queue(&ctx, batch),
|
||
);
|
||
return;
|
||
}
|
||
Err(e) => {
|
||
tracing::error!(
|
||
target: "pool::session",
|
||
"cancel_with_cleanup failed during initial_message timeout: {e}"
|
||
);
|
||
agent.state.invalidate(&source);
|
||
}
|
||
}
|
||
send_prompt_result(
|
||
&result_tx,
|
||
&turn_id,
|
||
agent,
|
||
source,
|
||
PromptOutcome::Timeout(TimeoutKind::Idle),
|
||
requeue_batch_if_queue(&ctx, batch),
|
||
);
|
||
return;
|
||
}
|
||
Err(AcpError::HardTimeout { silence }) => {
|
||
let recently_active = silence < RECENT_ACTIVITY_WINDOW;
|
||
tracing::error!(
|
||
target: "pool::session",
|
||
"hard timeout ({}s cap, silence {silence:?}, recently_active={recently_active}) during initial_message for channel {cid} — agent process is unrecoverable",
|
||
ctx.max_turn_duration.as_secs()
|
||
);
|
||
agent.state.invalidate_all();
|
||
send_prompt_result(
|
||
&result_tx,
|
||
&turn_id,
|
||
agent,
|
||
source,
|
||
PromptOutcome::Timeout(TimeoutKind::Hard { recently_active }),
|
||
requeue_batch_if_queue(&ctx, batch),
|
||
);
|
||
return;
|
||
}
|
||
Err(e) => {
|
||
tracing::error!(
|
||
target: "pool::session",
|
||
"initial_message failed for channel {cid}: {e} — invalidating session"
|
||
);
|
||
agent.state.invalidate(&source);
|
||
send_prompt_result(
|
||
&result_tx,
|
||
&turn_id,
|
||
agent,
|
||
source,
|
||
PromptOutcome::Error(e),
|
||
requeue_batch_if_queue(&ctx, batch),
|
||
);
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// When the batch is a single slash-command message (e.g. "@Eva /goal …"),
|
||
// `slash_command` holds the bare command. It is sent as the FIRST prompt
|
||
// content block so ACP connectors' slash-command detection
|
||
// (`prompt[0].text.startsWith("/")`) fires; the wrapped Buzz context
|
||
// follows as a second block.
|
||
let mut slash_command: Option<String> = None;
|
||
let prompt_sections: Vec<String> = if let Some(text) = prompt_text {
|
||
// Heartbeats create their session before this point, so a Goose method-not-found
|
||
// probe has already selected the correct framing for this process.
|
||
let text = prepend_base_for_legacy(
|
||
if agent.has_system_prompt_support() {
|
||
2
|
||
} else {
|
||
1
|
||
},
|
||
ctx.base_prompt,
|
||
&text,
|
||
);
|
||
vec![text]
|
||
} else if let Some(ref b) = batch {
|
||
// Build prompt from batch with context enrichment.
|
||
// Try startup cache first; lazy-fetch via REST for dynamic channels.
|
||
let channel_info = ctx.channel_info.resolve(b.channel_id).await;
|
||
|
||
let conversation_context = if ctx.context_message_limit > 0 {
|
||
fetch_conversation_context(b, &channel_info, &ctx).await
|
||
} else {
|
||
None
|
||
};
|
||
|
||
let profile_lookup =
|
||
fetch_prompt_profile_lookup(b, conversation_context.as_ref(), &ctx.rest_client).await;
|
||
|
||
let known_names: Vec<&str> = profile_lookup
|
||
.iter()
|
||
.flat_map(|lookup| lookup.values())
|
||
.flat_map(|p| [p.display_name.as_deref(), p.nip05_handle.as_deref()])
|
||
.flatten()
|
||
.collect();
|
||
slash_command = crate::queue::slash_command_for_batch(b, &known_names);
|
||
if let Some(ref cmd) = slash_command {
|
||
tracing::info!(
|
||
target: "pool::prompt",
|
||
channel = %b.channel_id,
|
||
command = %cmd,
|
||
"slash-command pass-through"
|
||
);
|
||
}
|
||
|
||
crate::queue::format_prompt(
|
||
b,
|
||
&crate::queue::FormatPromptArgs {
|
||
agent_core: agent_core.as_deref(),
|
||
channel_info: channel_info.as_ref(),
|
||
conversation_context: conversation_context.as_ref(),
|
||
profile_lookup: profile_lookup.as_ref(),
|
||
has_system_prompt_support: agent.has_system_prompt_support(),
|
||
base_prompt: ctx.base_prompt,
|
||
system_prompt: ctx.system_prompt.as_deref(),
|
||
team_instructions: ctx.team_instructions.as_deref(),
|
||
agent_canvas: agent_canvas.as_deref(),
|
||
},
|
||
)
|
||
} else {
|
||
// Should not happen — batch is None only for heartbeats which have prompt_text.
|
||
// Return the agent to the pool to prevent a permanent slot leak.
|
||
tracing::error!("run_prompt_task: no batch and no prompt_text — returning agent");
|
||
send_prompt_result(
|
||
&result_tx,
|
||
&turn_id,
|
||
agent,
|
||
source,
|
||
PromptOutcome::Error(AcpError::Protocol("no batch and no prompt_text".into())),
|
||
None,
|
||
);
|
||
return;
|
||
};
|
||
|
||
// 💬 — fire-and-forget so the prompt fires immediately.
|
||
// The guard's cleanup (spawned on drop) removes 💬 after the turn completes.
|
||
// A brief race where 💬 appears slightly after the agent starts is acceptable.
|
||
if !reaction_ids.is_empty() {
|
||
let rest = ctx.rest_client.clone();
|
||
let ids = reaction_ids.clone();
|
||
tokio::spawn(async move {
|
||
react_working(&rest, &ids).await;
|
||
});
|
||
}
|
||
|
||
// Slash-command pass-through sends the bare command as the first text
|
||
// block (so connector detection fires), then each prompt section as its
|
||
// own block. Per-section blocks let the observer size trimmer elide a
|
||
// section body in place while every `[Header]` line survives at the head
|
||
// of its own leaf — so the "Prompt context" panel counts every section.
|
||
let prompt_blocks: Vec<&str> = match slash_command {
|
||
Some(ref cmd) => std::iter::once(cmd.as_str())
|
||
.chain(prompt_sections.iter().map(String::as_str))
|
||
.collect(),
|
||
None => prompt_sections.iter().map(String::as_str).collect(),
|
||
};
|
||
|
||
// When control_rx is Some (channel tasks), wrap the prompt in select! so
|
||
// the main loop can cancel, interrupt, or rotate it. Heartbeats
|
||
// (control_rx=None) take the simple await path — they are not controllable.
|
||
//
|
||
let prompt_result = match control_rx {
|
||
None => {
|
||
// Heartbeat / non-cancellable path.
|
||
tokio::select! {
|
||
biased;
|
||
result = agent.acp.session_prompt_blocks_with_idle_timeout(
|
||
&session_id,
|
||
&prompt_blocks,
|
||
ctx.idle_timeout,
|
||
ctx.max_turn_duration,
|
||
) => result,
|
||
}
|
||
}
|
||
Some(rx) => {
|
||
tokio::select! {
|
||
biased;
|
||
result = agent.acp.session_prompt_blocks_with_idle_timeout(
|
||
&session_id,
|
||
&prompt_blocks,
|
||
ctx.idle_timeout,
|
||
ctx.max_turn_duration,
|
||
) => result,
|
||
mode = rx => {
|
||
let control_signal = mode.unwrap_or(ControlSignal::Cancel);
|
||
// Land the model switch before any cancel/requeue work: setting
|
||
// `desired_model` here means the fresh session created by the
|
||
// requeued turn (busy) or the next turn (already-completed)
|
||
// applies the new model. Runtime-only — never persisted.
|
||
if let ControlSignal::SwitchModel(ref model_id) = control_signal {
|
||
agent.desired_model = Some(model_id.clone());
|
||
agent.model_overridden = true;
|
||
}
|
||
// Control signal received. Guard against Race 1: the turn may
|
||
// have completed naturally just as cancel fired.
|
||
if agent.acp.has_in_flight_prompt() {
|
||
// Prompt is genuinely in-flight — cancel it.
|
||
match agent
|
||
.acp
|
||
.cancel_with_cleanup_grace(&session_id, CONTROL_CANCEL_GRACE)
|
||
.await
|
||
{
|
||
Ok(stop_reason) => {
|
||
log_stop_reason(&source, &stop_reason);
|
||
agent.state.invalidate(&source);
|
||
let retry_batch =
|
||
requeue_cancelled_batch(&ctx, control_signal, batch);
|
||
|
||
let usage = agent.acp.take_turn_usage();
|
||
publish_agent_turn_metric(
|
||
&ctx,
|
||
usage,
|
||
observer_channel_id,
|
||
&session_id,
|
||
&turn_id,
|
||
Some(buzz_core::agent_turn_metric::StopReason::Cancelled),
|
||
)
|
||
.await;
|
||
send_prompt_result(
|
||
&result_tx,
|
||
&turn_id,
|
||
agent,
|
||
source,
|
||
PromptOutcome::Cancelled,
|
||
retry_batch,
|
||
);
|
||
return;
|
||
}
|
||
Err(error) => {
|
||
// Single production arm: classify the error→outcome
|
||
// and outcome→batch-fate boundary once via the seam
|
||
// shared with tests, then invalidate/publish/send once.
|
||
let failure = classify_control_cancel_failure(
|
||
&ctx,
|
||
error,
|
||
control_signal,
|
||
batch,
|
||
);
|
||
if failure.invalidate_all {
|
||
agent.state.invalidate_all();
|
||
} else {
|
||
agent.state.invalidate(&source);
|
||
}
|
||
|
||
let usage = agent.acp.take_turn_usage();
|
||
publish_agent_turn_metric(
|
||
&ctx,
|
||
usage,
|
||
observer_channel_id,
|
||
&session_id,
|
||
&turn_id,
|
||
Some(buzz_core::agent_turn_metric::StopReason::Error),
|
||
)
|
||
.await;
|
||
send_prompt_result(
|
||
&result_tx,
|
||
&turn_id,
|
||
agent,
|
||
source,
|
||
failure.outcome,
|
||
failure.retry_batch,
|
||
);
|
||
return;
|
||
}
|
||
}
|
||
} else {
|
||
// Race 1 resolution: turn completed naturally before cancel
|
||
// could fire. last_prompt_id is None — cleared by
|
||
// session_prompt_with_idle_timeout() on success. The prompt
|
||
// future was dropped by select! — its Ok result is gone.
|
||
//
|
||
// Note: this `else` branch (last_prompt_id is None) cannot
|
||
// fire during the pre-prompt phase because `biased` select!
|
||
// polls the prompt arm first. That arm sets last_prompt_id
|
||
// synchronously before its first yield point, so by the time
|
||
// the cancel arm can win, last_prompt_id is already Some.
|
||
// This branch only fires when the turn genuinely completed
|
||
// and last_prompt_id was cleared by the success path.
|
||
//
|
||
// MUST send a PromptResult or the main loop deadlocks.
|
||
if matches!(
|
||
control_signal,
|
||
ControlSignal::Rotate | ControlSignal::SwitchModel(_)
|
||
) {
|
||
tracing::debug!(
|
||
target: "pool::prompt",
|
||
"rotate/switch signal arrived but turn already completed — invalidating session"
|
||
);
|
||
} else {
|
||
tracing::debug!(
|
||
target: "pool::prompt",
|
||
"control signal arrived but turn already completed — treating as success"
|
||
);
|
||
}
|
||
apply_completed_before_control_signal(
|
||
&mut agent.state,
|
||
&source,
|
||
&control_signal,
|
||
);
|
||
let usage = agent.acp.take_turn_usage();
|
||
publish_agent_turn_metric(
|
||
&ctx,
|
||
usage,
|
||
observer_channel_id,
|
||
&session_id,
|
||
&turn_id,
|
||
Some(buzz_core::agent_turn_metric::StopReason::EndTurn),
|
||
)
|
||
.await;
|
||
send_prompt_result(
|
||
&result_tx,
|
||
&turn_id,
|
||
agent,
|
||
source,
|
||
PromptOutcome::Ok(StopReason::EndTurn),
|
||
None, // turn succeeded — batch was processed, no requeue
|
||
);
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
};
|
||
|
||
match prompt_result {
|
||
Ok(stop_reason) => {
|
||
log_stop_reason(&source, &stop_reason);
|
||
|
||
let should_rotate = matches!(
|
||
stop_reason,
|
||
StopReason::MaxTokens | StopReason::MaxTurnRequests
|
||
);
|
||
|
||
let should_rotate = should_rotate || {
|
||
let limit = ctx.max_turns_per_session;
|
||
if limit > 0 {
|
||
match &source {
|
||
PromptSource::Channel(cid) => {
|
||
let count = agent.state.turn_counts.entry(*cid).or_insert(0);
|
||
*count += 1;
|
||
*count >= limit
|
||
}
|
||
PromptSource::Heartbeat => {
|
||
agent.state.heartbeat_turn_count += 1;
|
||
agent.state.heartbeat_turn_count >= limit
|
||
}
|
||
}
|
||
} else {
|
||
false
|
||
}
|
||
};
|
||
|
||
if should_rotate {
|
||
tracing::info!(
|
||
target: "pool::session",
|
||
"rotating session for {source:?} after {stop_reason:?}",
|
||
);
|
||
agent.state.invalidate(&source);
|
||
}
|
||
|
||
let core_stop = acp_stop_to_core(&stop_reason);
|
||
let usage = agent.acp.take_turn_usage();
|
||
publish_agent_turn_metric(
|
||
&ctx,
|
||
usage,
|
||
observer_channel_id,
|
||
&session_id,
|
||
&turn_id,
|
||
Some(core_stop),
|
||
)
|
||
.await;
|
||
|
||
send_prompt_result(
|
||
&result_tx,
|
||
&turn_id,
|
||
agent,
|
||
source,
|
||
PromptOutcome::Ok(stop_reason),
|
||
None,
|
||
);
|
||
}
|
||
Err(AcpError::AgentExited) => {
|
||
tracing::error!(target: "pool::prompt", "agent {} exited during prompt", agent.index);
|
||
agent.state.invalidate_all();
|
||
let usage = agent.acp.take_turn_usage();
|
||
publish_agent_turn_metric(
|
||
&ctx,
|
||
usage,
|
||
observer_channel_id,
|
||
&session_id,
|
||
&turn_id,
|
||
Some(buzz_core::agent_turn_metric::StopReason::Error),
|
||
)
|
||
.await;
|
||
send_prompt_result(
|
||
&result_tx,
|
||
&turn_id,
|
||
agent,
|
||
source,
|
||
PromptOutcome::AgentExited,
|
||
requeue_batch_if_queue(&ctx, batch),
|
||
);
|
||
}
|
||
Err(AcpError::IdleTimeout(_)) => {
|
||
tracing::warn!(
|
||
target: "pool::prompt",
|
||
"idle timeout ({}s) — cancelling session {session_id}",
|
||
ctx.idle_timeout.as_secs()
|
||
);
|
||
match agent
|
||
.acp
|
||
.cancel_with_cleanup(&session_id, ctx.idle_timeout)
|
||
.await
|
||
{
|
||
Ok(stop_reason) => {
|
||
log_stop_reason(&source, &stop_reason);
|
||
let usage = agent.acp.take_turn_usage();
|
||
publish_agent_turn_metric(
|
||
&ctx,
|
||
usage,
|
||
observer_channel_id,
|
||
&session_id,
|
||
&turn_id,
|
||
Some(buzz_core::agent_turn_metric::StopReason::Cancelled),
|
||
)
|
||
.await;
|
||
// Timeout triggers respawn in handle_prompt_result —
|
||
// session state will be discarded with the old agent.
|
||
send_prompt_result(
|
||
&result_tx,
|
||
&turn_id,
|
||
agent,
|
||
source,
|
||
PromptOutcome::Timeout(TimeoutKind::Idle),
|
||
requeue_batch_if_queue(&ctx, batch),
|
||
);
|
||
}
|
||
Err(AcpError::AgentExited) => {
|
||
tracing::error!(
|
||
target: "pool::prompt",
|
||
"agent {} exited during cancel_with_cleanup",
|
||
agent.index
|
||
);
|
||
agent.state.invalidate_all();
|
||
let usage = agent.acp.take_turn_usage();
|
||
publish_agent_turn_metric(
|
||
&ctx,
|
||
usage,
|
||
observer_channel_id,
|
||
&session_id,
|
||
&turn_id,
|
||
Some(buzz_core::agent_turn_metric::StopReason::Error),
|
||
)
|
||
.await;
|
||
send_prompt_result(
|
||
&result_tx,
|
||
&turn_id,
|
||
agent,
|
||
source,
|
||
PromptOutcome::AgentExited,
|
||
requeue_batch_if_queue(&ctx, batch),
|
||
);
|
||
}
|
||
Err(e) => {
|
||
tracing::error!(
|
||
target: "pool::prompt",
|
||
"cancel_with_cleanup error: {e} — invalidating session"
|
||
);
|
||
agent.state.invalidate(&source);
|
||
let usage = agent.acp.take_turn_usage();
|
||
publish_agent_turn_metric(
|
||
&ctx,
|
||
usage,
|
||
observer_channel_id,
|
||
&session_id,
|
||
&turn_id,
|
||
Some(buzz_core::agent_turn_metric::StopReason::Error),
|
||
)
|
||
.await;
|
||
send_prompt_result(
|
||
&result_tx,
|
||
&turn_id,
|
||
agent,
|
||
source,
|
||
PromptOutcome::Timeout(TimeoutKind::Idle),
|
||
requeue_batch_if_queue(&ctx, batch),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
Err(AcpError::HardTimeout { silence }) => {
|
||
let recently_active = silence < RECENT_ACTIVITY_WINDOW;
|
||
tracing::error!(
|
||
target: "pool::prompt",
|
||
"hard timeout ({}s cap, silence {silence:?}, recently_active={recently_active}) — agent process is unrecoverable, invalidating all sessions",
|
||
ctx.max_turn_duration.as_secs()
|
||
);
|
||
agent.state.invalidate_all();
|
||
let usage = agent.acp.take_turn_usage();
|
||
publish_agent_turn_metric(
|
||
&ctx,
|
||
usage,
|
||
observer_channel_id,
|
||
&session_id,
|
||
&turn_id,
|
||
Some(buzz_core::agent_turn_metric::StopReason::Error),
|
||
)
|
||
.await;
|
||
send_prompt_result(
|
||
&result_tx,
|
||
&turn_id,
|
||
agent,
|
||
source,
|
||
PromptOutcome::Timeout(TimeoutKind::Hard { recently_active }),
|
||
requeue_batch_if_queue(&ctx, batch),
|
||
);
|
||
}
|
||
Err(e) => {
|
||
tracing::error!(target: "pool::prompt", "session_prompt error: {e}");
|
||
// AgentError means the agent caught a problem before mutating
|
||
// session state (e.g. bad LLM response). The session is healthy —
|
||
// don't invalidate it. Other errors may have corrupted state.
|
||
if !matches!(e, AcpError::AgentError { .. }) {
|
||
agent.state.invalidate(&source);
|
||
}
|
||
let usage = agent.acp.take_turn_usage();
|
||
publish_agent_turn_metric(
|
||
&ctx,
|
||
usage,
|
||
observer_channel_id,
|
||
&session_id,
|
||
&turn_id,
|
||
Some(buzz_core::agent_turn_metric::StopReason::Error),
|
||
)
|
||
.await;
|
||
send_prompt_result(
|
||
&result_tx,
|
||
&turn_id,
|
||
agent,
|
||
source,
|
||
PromptOutcome::Error(e),
|
||
requeue_batch_if_queue(&ctx, batch),
|
||
);
|
||
}
|
||
}
|
||
// _reaction_guard drops here → spawns clear_reactions for all exit paths.
|
||
}
|
||
|
||
/// Retry wrapper for context fetches: one retry with `CONTEXT_FETCH_RETRY_DELAY`
|
||
/// on any `None` result. The closure is called twice at most.
|
||
///
|
||
/// Using a closure (not a `Future`) so the retry can construct a fresh `Future`
|
||
/// each attempt without requiring `Clone` or re-boxing.
|
||
async fn fetch_with_retry<F, Fut, T>(f: F) -> Option<T>
|
||
where
|
||
F: Fn() -> Fut,
|
||
Fut: std::future::Future<Output = Option<T>>,
|
||
{
|
||
if let Some(result) = f().await {
|
||
return Some(result);
|
||
}
|
||
tokio::time::sleep(CONTEXT_FETCH_RETRY_DELAY).await;
|
||
f().await
|
||
}
|
||
|
||
/// Lazy-fetch channel metadata for a channel not in the startup discovery cache.
|
||
///
|
||
/// Handles channels added dynamically via membership notifications after startup.
|
||
/// Uses `CONTEXT_FETCH_TIMEOUT` with one retry on failure. Returns `None` on
|
||
/// persistent failure (graceful degradation — prompt will lack channel name and
|
||
/// DM detection).
|
||
pub(crate) async fn fetch_channel_info(
|
||
channel_id: Uuid,
|
||
rest: &RestClient,
|
||
) -> Option<PromptChannelInfo> {
|
||
use nostr::{Alphabet, SingleLetterTag};
|
||
|
||
let d_tag = SingleLetterTag::lowercase(Alphabet::D);
|
||
let filter = nostr::Filter::new()
|
||
.kind(nostr::Kind::Custom(
|
||
buzz_core::kind::KIND_NIP29_GROUP_METADATA as u16,
|
||
))
|
||
.custom_tags(d_tag, [channel_id.to_string()]);
|
||
|
||
fetch_with_retry(|| async {
|
||
match timeout(
|
||
CONTEXT_FETCH_TIMEOUT,
|
||
rest.query(std::slice::from_ref(&filter)),
|
||
)
|
||
.await
|
||
{
|
||
Ok(Ok(json)) => {
|
||
let events = json.as_array()?;
|
||
let ev = events.first()?;
|
||
let tags = ev.get("tags")?.as_array()?;
|
||
let mut name = None;
|
||
for tag in tags {
|
||
if let Some(arr) = tag.as_array() {
|
||
if arr.first().and_then(|v| v.as_str()) == Some("name") {
|
||
name = arr.get(1).and_then(|v| v.as_str());
|
||
}
|
||
}
|
||
}
|
||
let channel_type = crate::relay::channel_type_from_tags(tags);
|
||
Some(PromptChannelInfo {
|
||
name: name.unwrap_or(UNKNOWN_CHANNEL_NAME).to_string(),
|
||
channel_type,
|
||
})
|
||
}
|
||
Ok(Err(e)) => {
|
||
tracing::debug!(
|
||
channel_id = %channel_id,
|
||
"channel info fetch failed: {e} — will retry"
|
||
);
|
||
None
|
||
}
|
||
Err(_) => {
|
||
tracing::debug!(
|
||
channel_id = %channel_id,
|
||
"channel info fetch timed out — will retry"
|
||
);
|
||
None
|
||
}
|
||
}
|
||
})
|
||
.await
|
||
}
|
||
|
||
/// Fetch the latest canvas event for `channel_id` and return a rendered
|
||
/// `[Channel Canvas]` metadata section, or `None` if absent/blank/error.
|
||
///
|
||
/// Failure modes (all fail open — no crash, no block):
|
||
/// * relay returns no event → `None`
|
||
/// * latest event's content is blank → `None` (cleared canvas; older revisions
|
||
/// are NOT resurrected)
|
||
/// * malformed JSON array, missing fields, bad event ID, bad timestamp →
|
||
/// logged at `warn`; returns `None`
|
||
/// * REST error or timeout → returns `None`
|
||
///
|
||
/// Called at most once per new channel session; the result is cached in
|
||
/// `SessionState::canvas_sections` and cleared on session invalidation.
|
||
async fn fetch_canvas_section(channel_id: Uuid, rest: &RestClient) -> Option<String> {
|
||
use nostr::{Alphabet, SingleLetterTag};
|
||
|
||
let h_tag = SingleLetterTag::lowercase(Alphabet::H);
|
||
let filter = nostr::Filter::new()
|
||
.kind(nostr::Kind::Custom(buzz_core::kind::KIND_CANVAS as u16))
|
||
.custom_tags(h_tag, [channel_id.to_string()])
|
||
.limit(1);
|
||
|
||
const CANVAS_FETCH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3);
|
||
let json = match tokio::time::timeout(
|
||
CANVAS_FETCH_TIMEOUT,
|
||
rest.query(std::slice::from_ref(&filter)),
|
||
)
|
||
.await
|
||
{
|
||
Ok(Ok(v)) => v,
|
||
Ok(Err(e)) => {
|
||
tracing::warn!(
|
||
target: "canvas::fetch",
|
||
channel = %channel_id,
|
||
"canvas query failed: {e} — emitting no section"
|
||
);
|
||
return None;
|
||
}
|
||
Err(_) => {
|
||
tracing::warn!(
|
||
target: "canvas::fetch",
|
||
channel = %channel_id,
|
||
timeout_ms = CANVAS_FETCH_TIMEOUT.as_millis() as u64,
|
||
"canvas fetch timed out — emitting no section"
|
||
);
|
||
return None;
|
||
}
|
||
};
|
||
|
||
let events = match json.as_array() {
|
||
Some(arr) => arr,
|
||
None => {
|
||
tracing::warn!(
|
||
target: "canvas::fetch",
|
||
channel = %channel_id,
|
||
"canvas query response is not a JSON array — emitting no section"
|
||
);
|
||
return None;
|
||
}
|
||
};
|
||
|
||
canvas_section_from_query_response(events, &channel_id.to_string())
|
||
}
|
||
|
||
/// Parse a canvas query response array and render a `[Channel Canvas]` section.
|
||
///
|
||
/// Extracted as a pure function so tests can exercise the parsing/validation
|
||
/// logic without async machinery or relay connectivity.
|
||
///
|
||
/// Returns `None` on: empty array, blank content, malformed/partial event JSON
|
||
/// (requires a complete, structurally valid Nostr event), or an out-of-range
|
||
/// `created_at` timestamp. Never falls back to epoch or raw integers.
|
||
pub(crate) fn canvas_section_from_query_response(
|
||
events: &[serde_json::Value],
|
||
channel_uuid: &str,
|
||
) -> Option<String> {
|
||
let raw = events.first()?;
|
||
|
||
// Deserialise as a complete Nostr Event. Partial objects (missing pubkey,
|
||
// sig, kind, or tags) are rejected here rather than trusted implicitly.
|
||
let event = match serde_json::from_value::<nostr::Event>(raw.clone()) {
|
||
Ok(ev) => ev,
|
||
Err(err) => {
|
||
tracing::warn!(
|
||
target: "canvas::fetch",
|
||
channel = %channel_uuid,
|
||
%err,
|
||
"canvas query returned a malformed event — emitting no section",
|
||
);
|
||
return None;
|
||
}
|
||
};
|
||
|
||
// Verify the event's id and signature agree with its content.
|
||
// A structurally complete but tampered event must not supply trusted metadata.
|
||
if let Err(err) = event.verify() {
|
||
tracing::warn!(
|
||
target: "canvas::fetch",
|
||
channel = %channel_uuid,
|
||
%err,
|
||
"canvas event failed signature verification — emitting no section",
|
||
);
|
||
return None;
|
||
}
|
||
|
||
// Validate kind: must be KIND_CANVAS (40100).
|
||
if event.kind != nostr::Kind::Custom(buzz_core::kind::KIND_CANVAS as u16) {
|
||
tracing::warn!(
|
||
target: "canvas::fetch",
|
||
channel = %channel_uuid,
|
||
kind = %event.kind.as_u16(),
|
||
"canvas event has unexpected kind — emitting no section",
|
||
);
|
||
return None;
|
||
}
|
||
|
||
// Validate h-tag: must carry the channel UUID we queried.
|
||
// The REST boundary filters by #h, but we verify here to prevent a
|
||
// misbehaving relay from injecting a different channel's canvas.
|
||
let h_tag_matches = event.tags.iter().any(|tag| {
|
||
let v = tag.as_slice();
|
||
v.len() >= 2 && v[0] == "h" && v[1] == channel_uuid
|
||
});
|
||
if !h_tag_matches {
|
||
tracing::warn!(
|
||
target: "canvas::fetch",
|
||
channel = %channel_uuid,
|
||
"canvas event is missing expected h-tag — emitting no section",
|
||
);
|
||
return None;
|
||
}
|
||
|
||
// Blank content means the canvas was cleared; do not fall back to older events.
|
||
if event.content.trim().is_empty() {
|
||
tracing::debug!(
|
||
target: "canvas::fetch",
|
||
channel = %channel_uuid,
|
||
"latest canvas event has blank content — emitting no section"
|
||
);
|
||
return None;
|
||
}
|
||
|
||
let id = event.id.to_hex();
|
||
|
||
// Convert the Nostr timestamp to a UTC RFC3339 string with Z suffix.
|
||
// Use checked conversion: a u64 that exceeds i64::MAX (e.g. Timestamp::max())
|
||
// wraps silently with `as i64`, producing a negative value that chrono would
|
||
// accept as a date in 1969. Reject out-of-range values explicitly instead.
|
||
let ts_secs = match i64::try_from(event.created_at.as_secs()) {
|
||
Ok(s) => s,
|
||
Err(_) => {
|
||
tracing::warn!(
|
||
target: "canvas::fetch",
|
||
channel = %channel_uuid,
|
||
"canvas event created_at overflows i64 — emitting no section",
|
||
);
|
||
return None;
|
||
}
|
||
};
|
||
let timestamp = match chrono::DateTime::from_timestamp(ts_secs, 0) {
|
||
Some(dt) => dt.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
|
||
None => {
|
||
tracing::warn!(
|
||
target: "canvas::fetch",
|
||
channel = %channel_uuid,
|
||
ts_secs,
|
||
"canvas event has out-of-range created_at — emitting no section",
|
||
);
|
||
return None;
|
||
}
|
||
};
|
||
|
||
tracing::info!(
|
||
target: "canvas::fetch",
|
||
channel = %channel_uuid,
|
||
event_id = %id,
|
||
"injected channel canvas metadata section into system prompt"
|
||
);
|
||
Some(render_canvas_section(&id, ×tamp, channel_uuid))
|
||
}
|
||
|
||
/// Render the `[Channel Canvas]` metadata section string.
|
||
///
|
||
/// Pure function — kept separate so unit tests can exercise rendering
|
||
/// without async machinery or relay connectivity.
|
||
pub(crate) fn render_canvas_section(event_id: &str, timestamp: &str, channel_uuid: &str) -> String {
|
||
format!(
|
||
"[Channel Canvas]\n\
|
||
Canvas revision (event ID): {event_id}\n\
|
||
Last modified: {timestamp}\n\
|
||
Fetch current content with: buzz canvas get --channel {channel_uuid}"
|
||
)
|
||
}
|
||
|
||
/// Fetch conversation context (thread or DM) for a batch before prompting.
|
||
///
|
||
/// Returns `None` if:
|
||
/// - The event is a plain channel message (not a thread reply, not a DM)
|
||
/// - The REST fetch fails or times out (graceful degradation)
|
||
/// - `context_message_limit` is 0
|
||
///
|
||
/// For batches with multiple events, thread context is fetched for the **last**
|
||
/// reply event only (most recent = most likely to need a response).
|
||
async fn fetch_conversation_context(
|
||
batch: &FlushBatch,
|
||
channel_info: &Option<PromptChannelInfo>,
|
||
ctx: &PromptContext,
|
||
) -> Option<ConversationContext> {
|
||
let limit = ctx.context_message_limit;
|
||
let is_dm = channel_info
|
||
.as_ref()
|
||
.map(|ci| ci.channel_type == "dm")
|
||
.unwrap_or(false);
|
||
|
||
// Check thread tags on the last event first — this applies to both
|
||
// channels and DMs. A DM reply needs thread context (not channel history)
|
||
// because /api/channels/{id}/messages excludes thread replies.
|
||
let last_event = batch.events.last()?;
|
||
let tags = crate::queue::parse_thread_tags(&last_event.event);
|
||
if let Some(root_id) = tags.root_event_id {
|
||
return fetch_thread_context(batch.channel_id, &root_id, limit, &ctx.rest_client).await;
|
||
}
|
||
|
||
// DM non-reply: fetch recent conversation history.
|
||
if is_dm {
|
||
return fetch_dm_context(batch.channel_id, limit, &ctx.rest_client).await;
|
||
}
|
||
|
||
None
|
||
}
|
||
|
||
/// Normalize AND validate a pubkey for the batch profile API request.
|
||
/// Returns `None` for malformed input — only valid 64-char hex passes.
|
||
/// See also: `normalize_lookup_key` in queue.rs (normalize-only, no validation).
|
||
fn normalize_prompt_pubkey(pubkey: &str) -> Option<String> {
|
||
let normalized = pubkey.trim().to_ascii_lowercase();
|
||
if normalized.len() == 64 && normalized.chars().all(|c| c.is_ascii_hexdigit()) {
|
||
Some(normalized)
|
||
} else {
|
||
None
|
||
}
|
||
}
|
||
|
||
fn collect_prompt_pubkeys(
|
||
batch: &FlushBatch,
|
||
conversation_context: Option<&ConversationContext>,
|
||
) -> Vec<String> {
|
||
let mut pubkeys = HashSet::new();
|
||
|
||
for event in &batch.events {
|
||
pubkeys.insert(event.event.pubkey.to_hex().to_ascii_lowercase());
|
||
|
||
for mentioned in crate::queue::parse_thread_tags(&event.event).mentioned_pubkeys {
|
||
if let Some(normalized) = normalize_prompt_pubkey(&mentioned) {
|
||
pubkeys.insert(normalized);
|
||
}
|
||
}
|
||
}
|
||
|
||
let context_messages = match conversation_context {
|
||
Some(ConversationContext::Thread { messages, .. })
|
||
| Some(ConversationContext::Dm { messages, .. }) => Some(messages),
|
||
None => None,
|
||
};
|
||
|
||
if let Some(messages) = context_messages {
|
||
for message in messages {
|
||
if let Some(normalized) = normalize_prompt_pubkey(&message.pubkey) {
|
||
pubkeys.insert(normalized);
|
||
}
|
||
}
|
||
}
|
||
|
||
let mut pubkeys: Vec<String> = pubkeys.into_iter().collect();
|
||
pubkeys.sort();
|
||
pubkeys
|
||
}
|
||
|
||
/// Detect whether a kind:0 profile event belongs to an owned agent.
|
||
///
|
||
/// Agents carry a NIP-OA `["auth", owner_pk, conditions, sig]` tag in their
|
||
/// profile; humans do not. This checks for the tag's presence/shape only — a
|
||
/// cheap routing heuristic for reply anchoring, not a verified security gate
|
||
/// (the signing path in `lib.rs::check_sibling_via_profile` does full
|
||
/// verification where it matters).
|
||
fn profile_event_is_agent(ev: &serde_json::Value) -> bool {
|
||
ev.get("tags")
|
||
.and_then(|t| t.as_array())
|
||
.is_some_and(|tags| {
|
||
tags.iter().any(|tag| {
|
||
tag.as_array()
|
||
.is_some_and(|parts| parts.len() == 4 && parts[0].as_str() == Some("auth"))
|
||
})
|
||
})
|
||
}
|
||
|
||
/// Parse kind:0 profile events into a `PromptProfileLookup`.
|
||
///
|
||
/// Each kind:0 event has `pubkey` and JSON `content` with optional fields:
|
||
/// `display_name` (or `name`), `nip05`.
|
||
fn parse_kind0_profile_lookup(json: serde_json::Value) -> Option<PromptProfileLookup> {
|
||
let events = json.as_array()?;
|
||
let mut lookup = PromptProfileLookup::new();
|
||
|
||
for ev in events {
|
||
let pubkey = ev.get("pubkey").and_then(|v| v.as_str());
|
||
let content_str = ev.get("content").and_then(|v| v.as_str());
|
||
if let (Some(pk), Some(content)) = (pubkey, content_str) {
|
||
if let Ok(profile) = serde_json::from_str::<serde_json::Value>(content) {
|
||
let display_name = profile
|
||
.get("display_name")
|
||
.or_else(|| profile.get("name"))
|
||
.and_then(|v| v.as_str())
|
||
.map(str::to_string);
|
||
let nip05_handle = profile
|
||
.get("nip05")
|
||
.and_then(|v| v.as_str())
|
||
.map(str::to_string);
|
||
let is_agent = profile_event_is_agent(ev);
|
||
lookup.insert(
|
||
pk.to_ascii_lowercase(),
|
||
PromptProfile {
|
||
display_name,
|
||
nip05_handle,
|
||
is_agent,
|
||
},
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
if lookup.is_empty() {
|
||
None
|
||
} else {
|
||
Some(lookup)
|
||
}
|
||
}
|
||
|
||
async fn fetch_prompt_profile_lookup(
|
||
batch: &FlushBatch,
|
||
conversation_context: Option<&ConversationContext>,
|
||
rest: &RestClient,
|
||
) -> Option<PromptProfileLookup> {
|
||
let pubkeys = collect_prompt_pubkeys(batch, conversation_context);
|
||
if pubkeys.is_empty() {
|
||
return None;
|
||
}
|
||
|
||
// Query kind:0 (NIP-01 profile metadata) for all pubkeys.
|
||
let authors: Vec<nostr::PublicKey> = pubkeys
|
||
.iter()
|
||
.filter_map(|s| nostr::PublicKey::from_hex(s).ok())
|
||
.collect();
|
||
if authors.is_empty() {
|
||
return None;
|
||
}
|
||
let filter = nostr::Filter::new()
|
||
.kind(nostr::Kind::Metadata)
|
||
.authors(authors);
|
||
|
||
fetch_with_retry(|| async {
|
||
match timeout(
|
||
CONTEXT_FETCH_TIMEOUT,
|
||
rest.query(std::slice::from_ref(&filter)),
|
||
)
|
||
.await
|
||
{
|
||
Ok(Ok(json)) => parse_kind0_profile_lookup(json),
|
||
Ok(Err(e)) => {
|
||
tracing::debug!("prompt profile lookup failed: {e} — will retry");
|
||
None
|
||
}
|
||
Err(_) => {
|
||
tracing::debug!("prompt profile lookup timed out — will retry");
|
||
None
|
||
}
|
||
}
|
||
})
|
||
.await
|
||
}
|
||
|
||
/// Fetch thread context via Nostr query: root event by ID + replies by `#e` tag.
|
||
async fn fetch_thread_context(
|
||
channel_id: Uuid,
|
||
root_event_id: &str,
|
||
limit: u32,
|
||
rest: &RestClient,
|
||
) -> Option<ConversationContext> {
|
||
use nostr::{Alphabet, SingleLetterTag};
|
||
|
||
// Defense-in-depth: validate hex event ID.
|
||
if root_event_id.is_empty()
|
||
|| root_event_id.len() != 64
|
||
|| !root_event_id.chars().all(|c| c.is_ascii_hexdigit())
|
||
{
|
||
tracing::warn!(
|
||
channel_id = %channel_id,
|
||
"invalid root_event_id (expected 64 hex chars) — skipping thread context fetch"
|
||
);
|
||
return None;
|
||
}
|
||
|
||
let e_tag = SingleLetterTag::lowercase(Alphabet::E);
|
||
let h_tag = SingleLetterTag::lowercase(Alphabet::H);
|
||
let ch_str = channel_id.to_string();
|
||
|
||
// Two filters: (1) root event by ID, (2) replies with #e=root + #h=channel.
|
||
let root_filter = nostr::Filter::new().id(nostr::EventId::from_hex(root_event_id).ok()?);
|
||
let replies_filter = nostr::Filter::new()
|
||
.kinds([
|
||
nostr::Kind::Custom(buzz_core::kind::KIND_STREAM_MESSAGE as u16),
|
||
nostr::Kind::Custom(buzz_core::kind::KIND_STREAM_MESSAGE_V2 as u16),
|
||
])
|
||
.custom_tags(e_tag, [root_event_id])
|
||
.custom_tags(h_tag, [ch_str.as_str()])
|
||
.limit(limit as usize);
|
||
|
||
fetch_with_retry(|| async {
|
||
match timeout(
|
||
CONTEXT_FETCH_TIMEOUT,
|
||
rest.query(&[root_filter.clone(), replies_filter.clone()]),
|
||
)
|
||
.await
|
||
{
|
||
Ok(Ok(json)) => parse_nostr_thread_response(json, root_event_id),
|
||
Ok(Err(e)) => {
|
||
tracing::warn!(
|
||
channel_id = %channel_id,
|
||
root = root_event_id,
|
||
"thread context fetch failed: {e} — will retry"
|
||
);
|
||
None
|
||
}
|
||
Err(_) => {
|
||
tracing::warn!(
|
||
channel_id = %channel_id,
|
||
root = root_event_id,
|
||
"thread context fetch timed out — will retry"
|
||
);
|
||
None
|
||
}
|
||
}
|
||
})
|
||
.await
|
||
}
|
||
|
||
/// Fetch DM context via Nostr query: recent messages in channel by `#h` tag.
|
||
async fn fetch_dm_context(
|
||
channel_id: Uuid,
|
||
limit: u32,
|
||
rest: &RestClient,
|
||
) -> Option<ConversationContext> {
|
||
use nostr::{Alphabet, SingleLetterTag};
|
||
|
||
let h_tag = SingleLetterTag::lowercase(Alphabet::H);
|
||
let ch_str = channel_id.to_string();
|
||
let filter = nostr::Filter::new()
|
||
.kinds([
|
||
nostr::Kind::Custom(buzz_core::kind::KIND_STREAM_MESSAGE as u16),
|
||
nostr::Kind::Custom(buzz_core::kind::KIND_STREAM_MESSAGE_V2 as u16),
|
||
])
|
||
.custom_tags(h_tag, [ch_str.as_str()])
|
||
.limit(limit as usize);
|
||
|
||
fetch_with_retry(|| async {
|
||
match timeout(
|
||
CONTEXT_FETCH_TIMEOUT,
|
||
rest.query(std::slice::from_ref(&filter)),
|
||
)
|
||
.await
|
||
{
|
||
Ok(Ok(json)) => parse_nostr_dm_response(json, limit),
|
||
Ok(Err(e)) => {
|
||
tracing::warn!(
|
||
channel_id = %channel_id,
|
||
"DM context fetch failed: {e} — will retry"
|
||
);
|
||
None
|
||
}
|
||
Err(_) => {
|
||
tracing::warn!(
|
||
channel_id = %channel_id,
|
||
"DM context fetch timed out — will retry"
|
||
);
|
||
None
|
||
}
|
||
}
|
||
})
|
||
.await
|
||
}
|
||
|
||
/// Parse the legacy REST thread response (used in tests only).
|
||
#[cfg(test)]
|
||
fn parse_thread_response(json: serde_json::Value) -> Option<ConversationContext> {
|
||
let mut messages = Vec::new();
|
||
|
||
// Root message.
|
||
if let Some(root) = json.get("root") {
|
||
if let Some(msg) = json_to_context_message(root) {
|
||
messages.push(msg);
|
||
}
|
||
}
|
||
|
||
// Replies.
|
||
if let Some(replies) = json.get("replies").and_then(|v| v.as_array()) {
|
||
for reply in replies {
|
||
if let Some(msg) = json_to_context_message(reply) {
|
||
messages.push(msg);
|
||
}
|
||
}
|
||
}
|
||
|
||
let total_replies = json
|
||
.get("total_replies")
|
||
.and_then(|v| v.as_u64())
|
||
.unwrap_or(0) as usize;
|
||
let total = total_replies + 1; // +1 for root
|
||
let truncated = total > messages.len();
|
||
|
||
if messages.is_empty() {
|
||
return None;
|
||
}
|
||
|
||
Some(ConversationContext::Thread {
|
||
messages,
|
||
total,
|
||
truncated,
|
||
})
|
||
}
|
||
|
||
/// Parse the DM messages REST response into a `ConversationContext::Dm`.
|
||
///
|
||
/// Parse the legacy REST DM response (used in tests only).
|
||
#[cfg(test)]
|
||
fn parse_dm_response(json: serde_json::Value, limit: u32) -> Option<ConversationContext> {
|
||
let arr = json.get("messages").and_then(|v| v.as_array())?;
|
||
|
||
let mut messages: Vec<ContextMessage> =
|
||
arr.iter().filter_map(json_to_context_message).collect();
|
||
|
||
// API returns newest-first; reverse to chronological for the prompt.
|
||
messages.reverse();
|
||
|
||
// The relay's next_cursor is always set when the page is non-empty (not
|
||
// just when more pages exist), so we can't use it for truncation detection.
|
||
// Instead, compare returned count against the requested limit.
|
||
let truncated = messages.len() >= limit as usize;
|
||
let total = if truncated {
|
||
messages.len() + 1 // indicate there are more
|
||
} else {
|
||
messages.len()
|
||
};
|
||
|
||
if messages.is_empty() {
|
||
return None;
|
||
}
|
||
|
||
Some(ConversationContext::Dm {
|
||
messages,
|
||
total,
|
||
truncated,
|
||
})
|
||
}
|
||
|
||
/// Extract a `ContextMessage` from a JSON message object.
|
||
///
|
||
/// Works with both thread reply objects and channel message objects.
|
||
fn json_to_context_message(obj: &serde_json::Value) -> Option<ContextMessage> {
|
||
let content = obj.get("content").and_then(|v| v.as_str())?;
|
||
let pubkey = obj
|
||
.get("pubkey")
|
||
.and_then(|v| v.as_str())
|
||
.unwrap_or("unknown");
|
||
let timestamp = obj
|
||
.get("created_at")
|
||
.and_then(|v| {
|
||
// Handle both string timestamps and integer timestamps.
|
||
v.as_str().map(|s| s.to_string()).or_else(|| {
|
||
v.as_i64().map(|ts| {
|
||
chrono::DateTime::from_timestamp(ts, 0)
|
||
.map(|dt| dt.to_rfc3339())
|
||
.unwrap_or_else(|| ts.to_string())
|
||
})
|
||
})
|
||
})
|
||
.unwrap_or_else(|| "unknown".to_string());
|
||
|
||
Some(ContextMessage {
|
||
pubkey: pubkey.to_string(),
|
||
timestamp,
|
||
content: content.to_string(),
|
||
})
|
||
}
|
||
|
||
/// Parse a Nostr query response (array of events) into thread context.
|
||
///
|
||
/// Separates the root event (matching `root_event_id`) from replies, sorts
|
||
/// chronologically by `created_at`.
|
||
fn parse_nostr_thread_response(
|
||
json: serde_json::Value,
|
||
root_event_id: &str,
|
||
) -> Option<ConversationContext> {
|
||
let events = json.as_array()?;
|
||
let mut root_msg = None;
|
||
let mut reply_msgs = Vec::new();
|
||
|
||
for ev in events {
|
||
let ev_id = ev.get("id").and_then(|v| v.as_str()).unwrap_or("");
|
||
if let Some(msg) = json_to_context_message(ev) {
|
||
if ev_id == root_event_id {
|
||
root_msg = Some(msg);
|
||
} else {
|
||
reply_msgs.push((
|
||
ev.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0),
|
||
msg,
|
||
));
|
||
}
|
||
}
|
||
}
|
||
|
||
// Sort replies chronologically.
|
||
reply_msgs.sort_by_key(|(ts, _)| *ts);
|
||
|
||
let mut messages = Vec::new();
|
||
if let Some(root) = root_msg {
|
||
messages.push(root);
|
||
}
|
||
messages.extend(reply_msgs.into_iter().map(|(_, msg)| msg));
|
||
|
||
let total = messages.len();
|
||
if messages.is_empty() {
|
||
return None;
|
||
}
|
||
|
||
Some(ConversationContext::Thread {
|
||
messages,
|
||
total,
|
||
truncated: false, // query returns all within limit
|
||
})
|
||
}
|
||
|
||
/// Parse a Nostr query response (array of events) into DM context.
|
||
///
|
||
/// Events arrive in relay order (newest first); reversed to chronological.
|
||
fn parse_nostr_dm_response(json: serde_json::Value, limit: u32) -> Option<ConversationContext> {
|
||
let events = json.as_array()?;
|
||
|
||
let mut messages: Vec<(u64, ContextMessage)> = events
|
||
.iter()
|
||
.filter_map(|ev| {
|
||
let ts = ev.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0);
|
||
json_to_context_message(ev).map(|msg| (ts, msg))
|
||
})
|
||
.collect();
|
||
|
||
// Sort chronologically (oldest first).
|
||
messages.sort_by_key(|(ts, _)| *ts);
|
||
|
||
let messages: Vec<ContextMessage> = messages.into_iter().map(|(_, msg)| msg).collect();
|
||
let truncated = messages.len() >= limit as usize;
|
||
let total = if truncated {
|
||
messages.len() + 1
|
||
} else {
|
||
messages.len()
|
||
};
|
||
|
||
if messages.is_empty() {
|
||
return None;
|
||
}
|
||
|
||
Some(ConversationContext::Dm {
|
||
messages,
|
||
total,
|
||
truncated,
|
||
})
|
||
}
|
||
|
||
/// Return the batch for requeue only in Queue mode; drop it in Drop mode.
|
||
#[inline]
|
||
fn requeue_batch_if_queue(ctx: &PromptContext, batch: Option<FlushBatch>) -> Option<FlushBatch> {
|
||
match ctx.dedup_mode {
|
||
DedupMode::Queue => batch,
|
||
DedupMode::Drop => None,
|
||
}
|
||
}
|
||
|
||
/// Map a cancelling [`ControlSignal`] to the [`CancelReason`] that should frame
|
||
/// the merged re-prompt, then requeue the batch (in `Queue` dedup mode) with
|
||
/// that reason stamped onto [`FlushBatch::cancel_reason`]. `Cancel`/`Rotate`
|
||
/// drop the batch entirely. The reason is consumed by the main loop at requeue
|
||
/// time (`requeue_as_cancelled`) and ultimately by `format_prompt`.
|
||
#[inline]
|
||
fn requeue_cancelled_batch(
|
||
ctx: &PromptContext,
|
||
signal: ControlSignal,
|
||
batch: Option<FlushBatch>,
|
||
) -> Option<FlushBatch> {
|
||
let reason = match signal {
|
||
ControlSignal::Steer => CancelReason::Steer,
|
||
ControlSignal::Interrupt | ControlSignal::SwitchModel(_) => CancelReason::Interrupt,
|
||
// Cancel/Rotate discard the batch — no merged re-prompt.
|
||
ControlSignal::Cancel | ControlSignal::Rotate => return None,
|
||
};
|
||
requeue_batch_if_queue(ctx, batch).map(|mut b| {
|
||
b.cancel_reason = Some(reason);
|
||
b
|
||
})
|
||
}
|
||
|
||
/// Result of classifying a failed [`AcpClient::cancel_with_cleanup_grace`]
|
||
/// call: the [`PromptOutcome`] to report and the triggering batch's fate,
|
||
/// decided together so tests cross the exact error→outcome→batch-fate
|
||
/// boundary the production `Err(error)` arm uses.
|
||
struct ControlCancelFailure {
|
||
outcome: PromptOutcome,
|
||
retry_batch: Option<FlushBatch>,
|
||
/// `AgentExited` invalidates every session on the agent; every other
|
||
/// failure invalidates only the source that triggered this turn.
|
||
invalidate_all: bool,
|
||
}
|
||
|
||
/// Classify a failed control-signal cancellation (steer fallback, interrupt,
|
||
/// or explicit stop) into the [`PromptOutcome`] to report and the triggering
|
||
/// batch's fate. This is the single production seam used by the `Err(error)`
|
||
/// arm of the control-cancel branch in [`run_prompt_task`] — the boundary
|
||
/// this exists to keep singular, so regressions there are regression-tested.
|
||
///
|
||
/// [`AcpError::CancelDrainTimeout`] is the expected, common case: the agent
|
||
/// didn't stop within its bounded grace window. [`AcpError::HardTimeout`] is
|
||
/// not expected here — [`AcpClient::cancel_with_cleanup_grace`] translates its
|
||
/// own drain-deadline `HardTimeout` into `CancelDrainTimeout` before
|
||
/// returning — but for defense in depth an unexpected `HardTimeout` at this
|
||
/// bounded cancellation boundary must never regain real hard-cap/dead-letter
|
||
/// classification, so it maps to `CancelDrainTimeout(CONTROL_CANCEL_GRACE)`
|
||
/// rather than `Timeout(Hard)`.
|
||
fn classify_control_cancel_failure(
|
||
ctx: &PromptContext,
|
||
error: AcpError,
|
||
signal: ControlSignal,
|
||
batch: Option<FlushBatch>,
|
||
) -> ControlCancelFailure {
|
||
let (outcome, invalidate_all) = match error {
|
||
AcpError::AgentExited => (PromptOutcome::AgentExited, true),
|
||
AcpError::IdleTimeout(_) => (PromptOutcome::Timeout(TimeoutKind::Idle), false),
|
||
AcpError::CancelDrainTimeout(grace) => (PromptOutcome::CancelDrainTimeout(grace), false),
|
||
// Defense in depth: this bounded cancellation API is documented to
|
||
// translate its own HardTimeout into CancelDrainTimeout, so this arm
|
||
// should be unreachable in practice. If it ever fires anyway, still
|
||
// report the truthful non-hard outcome rather than the real hard-cap
|
||
// (which would dead-letter the batch and claim the configured cap).
|
||
AcpError::HardTimeout { .. } => (
|
||
PromptOutcome::CancelDrainTimeout(CONTROL_CANCEL_GRACE),
|
||
false,
|
||
),
|
||
other => (PromptOutcome::Error(other), false),
|
||
};
|
||
ControlCancelFailure {
|
||
outcome,
|
||
retry_batch: requeue_cancelled_batch(ctx, signal, batch),
|
||
invalidate_all,
|
||
}
|
||
}
|
||
|
||
/// Log a stop reason at the appropriate tracing level.
|
||
fn log_stop_reason(source: &PromptSource, stop_reason: &StopReason) {
|
||
let label = match source {
|
||
PromptSource::Channel(cid) => format!("channel {cid}"),
|
||
PromptSource::Heartbeat => "heartbeat".to_string(),
|
||
};
|
||
match stop_reason {
|
||
StopReason::EndTurn => {
|
||
tracing::info!(target: "pool::prompt", "turn complete for {label}: end_turn");
|
||
}
|
||
StopReason::Cancelled => {
|
||
tracing::warn!(target: "pool::prompt", "turn cancelled for {label}");
|
||
}
|
||
StopReason::MaxTokens => {
|
||
tracing::warn!(target: "pool::prompt", "turn hit max_tokens for {label} — session will be rotated");
|
||
}
|
||
StopReason::MaxTurnRequests => {
|
||
tracing::warn!(target: "pool::prompt", "turn hit max_turn_requests for {label} — session will be rotated");
|
||
}
|
||
StopReason::Refusal => {
|
||
tracing::warn!(target: "pool::prompt", "turn refused for {label}");
|
||
}
|
||
}
|
||
}
|
||
|
||
//
|
||
// Two-phase lifecycle visible to users:
|
||
// 👀 "seen" — event was queued and an agent will handle it
|
||
// 💬 "working" — agent is actively prompting
|
||
//
|
||
// 💬 is awaited inline in `run_prompt_task` before the prompt fires, so
|
||
// add-before-remove ordering is structural. 👀 is fire-and-forget from
|
||
// `main.rs` at queue-push time for immediate responsiveness; on rare
|
||
// fast-failure paths the guard's cleanup may race with the 👀 add,
|
||
// leaving a cosmetic stale 👀 (see `ReactionGuard` docs).
|
||
//
|
||
// Cleanup is fire-and-forget via `ReactionGuard` (spawned on drop).
|
||
// Failures are debug-logged and ignored — reactions are cosmetic.
|
||
|
||
/// Drop guard that spawns reaction cleanup on any exit path.
|
||
///
|
||
/// Created at the top of `run_prompt_task`. On drop — normal return, early
|
||
/// return, or panic — spawns fire-and-forget removal of both 👀 and 💬.
|
||
///
|
||
/// ## Ordering
|
||
///
|
||
/// 💬 (`react_working`) is fire-and-forget (spawned before the prompt fires).
|
||
/// A brief race where 💬 appears slightly after the agent starts is acceptable.
|
||
///
|
||
/// 👀 (`react_seen`) is fire-and-forget from `main.rs` at queue-push time.
|
||
/// On rare fast-failure paths (e.g., `session_new` error on an idle agent),
|
||
/// the cleanup spawn may race with the 👀 add, leaving a stale 👀. This is
|
||
/// accepted as a cosmetic edge case — the message will be retried and the
|
||
/// stale 👀 is harmless.
|
||
struct ReactionGuard {
|
||
rest: Option<crate::relay::RestClient>,
|
||
ids: Vec<String>,
|
||
}
|
||
|
||
impl ReactionGuard {
|
||
fn new(rest: crate::relay::RestClient, ids: Vec<String>) -> Self {
|
||
Self {
|
||
rest: if ids.is_empty() { None } else { Some(rest) },
|
||
ids,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl Drop for ReactionGuard {
|
||
fn drop(&mut self) {
|
||
// Guard against drop outside a tokio runtime (e.g., in unit tests or
|
||
// during process teardown before the runtime is fully initialized).
|
||
// `run_prompt_task` is always spawned via `JoinSet::spawn`, so a
|
||
// runtime handle is normally available; `try_current` is the safe
|
||
// fallback for the rare cases it isn't.
|
||
if let Some(rest) = self.rest.take() {
|
||
let ids = std::mem::take(&mut self.ids);
|
||
if let Ok(handle) = tokio::runtime::Handle::try_current() {
|
||
handle.spawn(clear_reactions(rest, ids));
|
||
}
|
||
// If no runtime is available, reactions are left as-is — they are
|
||
// cosmetic indicators and the stale state is harmless.
|
||
}
|
||
}
|
||
}
|
||
|
||
// Periodically emits a `turn_liveness` observer event while a turn is in-flight,
|
||
// so the desktop can prune turns whose host died without unwinding (kill -9 /
|
||
// crash) far sooner than the no-activity backstop. `run_prompt_task` runs it in
|
||
// a background task from `turn_started` until `LivenessGuard` drops, covering
|
||
// session setup as well as the final prompt call. When `interval` is zero,
|
||
// liveness is disabled and the future parks forever without emitting.
|
||
//
|
||
// `state` is the other half of `LivenessGuard`'s shutdown mutex (see its
|
||
// docs): held here across the check-then-emit, so a `LivenessGuard::drop`
|
||
// racing an in-flight tick either observes `state.closed == true` and skips
|
||
// the emit, or is blocked on the same lock until this tick's emit has
|
||
// already landed. Either way `turn_completed` cannot pass a live
|
||
// `turn_liveness` frame on the wire — the race is closed, not narrowed.
|
||
//
|
||
// `context`'s `session_id` starts `None` (liveness begins before session
|
||
// creation) and is filled in from `state.session_id` on each tick — set once
|
||
// by `run_prompt_task` after session resolution — so pings emitted for the
|
||
// remainder of the turn carry the real session, matching every other
|
||
// observer frame for this turn instead of a permanent `None`.
|
||
async fn run_turn_liveness(
|
||
observer: Option<observer::ObserverHandle>,
|
||
agent_index: Option<usize>,
|
||
mut context: observer::ObserverContext,
|
||
interval: Duration,
|
||
state: Arc<Mutex<LivenessState>>,
|
||
) {
|
||
let Some(observer) = observer else {
|
||
return std::future::pending::<()>().await;
|
||
};
|
||
if interval.is_zero() {
|
||
return std::future::pending::<()>().await;
|
||
}
|
||
let mut ticker = tokio::time::interval(interval);
|
||
// The first tick completes immediately; skip it so the first liveness ping
|
||
// fires one interval after the turn starts, not at t=0 (turn_started already
|
||
// marks t=0).
|
||
ticker.tick().await;
|
||
loop {
|
||
ticker.tick().await;
|
||
// Nothing awaitable between the lock and the emit: `LivenessGuard::drop`
|
||
// takes this same lock before its `abort()`, so the guard can only ever
|
||
// observe this tick fully emitted or not yet started — never mid-emit.
|
||
let guard = match state.lock() {
|
||
Ok(guard) => guard,
|
||
Err(poisoned) => poisoned.into_inner(),
|
||
};
|
||
if guard.closed {
|
||
return;
|
||
}
|
||
context.session_id = guard.session_id.clone();
|
||
observer.emit(
|
||
"turn_liveness",
|
||
agent_index,
|
||
&context,
|
||
serde_json::json!({}),
|
||
);
|
||
drop(guard);
|
||
}
|
||
}
|
||
|
||
/// Shared shutdown/session state between `run_turn_liveness` and its
|
||
/// `LivenessGuard`. A single lock covers both fields so a tick's
|
||
/// check-session/emit and a guard's set-closed/abort can never interleave.
|
||
struct LivenessState {
|
||
closed: bool,
|
||
session_id: Option<String>,
|
||
}
|
||
|
||
/// Owns the background liveness task for one `run_prompt_task` invocation.
|
||
///
|
||
/// Dropping the guard aborts the non-resolving task, so liveness covers all
|
||
/// pre-prompt setup yet cannot survive a completed, cancelled, or panicked turn.
|
||
///
|
||
/// `abort()` alone leaves a race: tokio's cooperative cancellation only takes
|
||
/// effect at the next `.await` point inside the aborted task, so a tick that
|
||
/// has already passed its await and is mid-`observer.emit` when `drop` runs
|
||
/// can still complete that emit — a `turn_liveness` frame lands on the wire
|
||
/// after `turn_completed`, reviving a finished turn's badge for up to the
|
||
/// desktop's bounded prune-pause window. `state` shares a lock with
|
||
/// `run_turn_liveness`'s check-then-emit (see its docs): setting `closed`
|
||
/// and aborting under the same lock the emitter holds during its tick means
|
||
/// `drop` either sees the flag land before that tick's lock is taken (emit
|
||
/// skipped) or blocks until the in-flight emit under the lock has finished
|
||
/// (then aborts, so there is no next tick) — no interleaving emits a frame
|
||
/// after this guard has dropped.
|
||
struct LivenessGuard {
|
||
handle: JoinHandle<()>,
|
||
state: Arc<Mutex<LivenessState>>,
|
||
}
|
||
|
||
impl LivenessGuard {
|
||
fn new(handle: JoinHandle<()>, state: Arc<Mutex<LivenessState>>) -> Self {
|
||
Self { handle, state }
|
||
}
|
||
|
||
/// Record the turn's session ID once known, so subsequent liveness ticks
|
||
/// stamp it on the emitted `turn_liveness` frame instead of `None`.
|
||
fn set_session_id(&self, session_id: String) {
|
||
let mut guard = match self.state.lock() {
|
||
Ok(guard) => guard,
|
||
Err(poisoned) => poisoned.into_inner(),
|
||
};
|
||
guard.session_id = Some(session_id);
|
||
}
|
||
}
|
||
|
||
impl Drop for LivenessGuard {
|
||
fn drop(&mut self) {
|
||
{
|
||
let mut guard = match self.state.lock() {
|
||
Ok(guard) => guard,
|
||
Err(poisoned) => poisoned.into_inner(),
|
||
};
|
||
guard.closed = true;
|
||
}
|
||
self.handle.abort();
|
||
}
|
||
}
|
||
|
||
// Emits a `turn_completed` observer event on drop, covering ALL exit paths
|
||
// (success, error, timeout, cancel, panic) from `run_prompt_task`. Captures
|
||
// observer handle and metadata at creation time so it remains valid even after
|
||
// the agent is moved into `PromptResult`.
|
||
|
||
struct TurnCompletionGuard {
|
||
observer: Option<observer::ObserverHandle>,
|
||
agent_index: Option<usize>,
|
||
channel_id: Option<uuid::Uuid>,
|
||
turn_id: String,
|
||
}
|
||
|
||
impl TurnCompletionGuard {
|
||
fn new(
|
||
observer: Option<observer::ObserverHandle>,
|
||
agent_index: Option<usize>,
|
||
channel_id: Option<uuid::Uuid>,
|
||
turn_id: String,
|
||
) -> Self {
|
||
Self {
|
||
observer,
|
||
agent_index,
|
||
channel_id,
|
||
turn_id,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl Drop for TurnCompletionGuard {
|
||
fn drop(&mut self) {
|
||
if let Some(observer) = self.observer.take() {
|
||
let context = observer::context_for(self.channel_id, None, Some(self.turn_id.clone()));
|
||
observer.emit(
|
||
"turn_completed",
|
||
self.agent_index,
|
||
&context,
|
||
serde_json::json!({}),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Map an ACP `StopReason` to the NIP-AM `StopReason` used in kind 44200 payloads.
|
||
fn acp_stop_to_core(r: &StopReason) -> buzz_core::agent_turn_metric::StopReason {
|
||
use buzz_core::agent_turn_metric::StopReason as CoreStop;
|
||
match r {
|
||
StopReason::EndTurn => CoreStop::EndTurn,
|
||
StopReason::Cancelled => CoreStop::Cancelled,
|
||
StopReason::MaxTokens => CoreStop::MaxTokens,
|
||
StopReason::MaxTurnRequests => CoreStop::Unknown,
|
||
StopReason::Refusal => CoreStop::Unknown,
|
||
}
|
||
}
|
||
|
||
/// Best-effort: build and publish a `kind:44200` NIP-AM agent turn metric event.
|
||
///
|
||
/// Does nothing when `usage` is `None` (goose emitted no usage notification
|
||
/// for this turn) or when `owner_pubkey` is unconfigured (no NIP-AO identity).
|
||
/// Errors are logged at WARN and never surface to the caller — metric
|
||
/// publishing must never fail a turn.
|
||
async fn publish_agent_turn_metric(
|
||
ctx: &PromptContext,
|
||
usage: Option<crate::usage::TurnUsage>,
|
||
channel_id: Option<uuid::Uuid>,
|
||
session_id: &str,
|
||
turn_id: &str,
|
||
stop_reason: Option<buzz_core::agent_turn_metric::StopReason>,
|
||
) {
|
||
use buzz_core::agent_turn_metric::{AgentTurnMetricPayload, TokenCounts};
|
||
use nostr::{EventBuilder, Kind, Tag};
|
||
|
||
let (usage, owner_pk) = match (usage, ctx.agent_owner_pubkey.as_ref()) {
|
||
(Some(u), Some(pk)) => (u, pk),
|
||
_ => return,
|
||
};
|
||
|
||
let turn_counts = if usage.delta_reliable {
|
||
Some(TokenCounts {
|
||
input_tokens: usage.turn_input_tokens,
|
||
output_tokens: usage.turn_output_tokens,
|
||
total_tokens: None,
|
||
cost_usd: usage.turn_cost_usd,
|
||
cache_read_tokens: None,
|
||
cache_write_tokens: None,
|
||
})
|
||
} else {
|
||
// Defense-in-depth: UsageTracker already sets all turn_* fields to None
|
||
// when delta_reliable is false, so the None arm here is technically
|
||
// redundant. The explicit guard prevents a future refactor from
|
||
// accidentally publishing unreliable per-turn counts.
|
||
None
|
||
};
|
||
let cumulative_counts = Some(TokenCounts {
|
||
input_tokens: Some(usage.cumulative_input_tokens),
|
||
output_tokens: Some(usage.cumulative_output_tokens),
|
||
total_tokens: None,
|
||
cost_usd: usage.cumulative_cost_usd,
|
||
cache_read_tokens: None,
|
||
cache_write_tokens: None,
|
||
});
|
||
let timestamp = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
|
||
let payload = AgentTurnMetricPayload {
|
||
harness: ctx.harness_name.clone(),
|
||
model: usage.model.clone(),
|
||
channel_id: channel_id.map(|id| id.to_string()),
|
||
session_id: Some(usage.session_id.clone()),
|
||
turn_id: Some(turn_id.to_string()),
|
||
turn_seq: Some(usage.turn_seq),
|
||
timestamp,
|
||
turn: turn_counts,
|
||
cumulative: cumulative_counts,
|
||
delta_reliable: usage.delta_reliable,
|
||
stop_reason,
|
||
};
|
||
let ciphertext = match buzz_core::agent_turn_metric::encrypt_agent_turn_metric(
|
||
&ctx.agent_keys,
|
||
owner_pk,
|
||
&payload,
|
||
) {
|
||
Ok(c) => c,
|
||
Err(e) => {
|
||
tracing::warn!(
|
||
target: "pool::metrics",
|
||
session_id,
|
||
turn_id,
|
||
"NIP-AM: encrypt failed: {e}"
|
||
);
|
||
return;
|
||
}
|
||
};
|
||
let agent_hex = ctx.agent_keys.public_key().to_hex();
|
||
let owner_hex = owner_pk.to_hex();
|
||
let event = match EventBuilder::new(
|
||
Kind::Custom(buzz_core::kind::KIND_AGENT_TURN_METRIC as u16),
|
||
ciphertext,
|
||
)
|
||
.tags([
|
||
Tag::parse(["p", &owner_hex]).expect("p tag"),
|
||
Tag::parse(["agent", &agent_hex]).expect("agent tag"),
|
||
])
|
||
.sign_with_keys(&ctx.agent_keys)
|
||
{
|
||
Ok(e) => e,
|
||
Err(e) => {
|
||
tracing::warn!(
|
||
target: "pool::metrics",
|
||
session_id,
|
||
turn_id,
|
||
"NIP-AM: sign failed: {e}"
|
||
);
|
||
return;
|
||
}
|
||
};
|
||
const METRIC_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3);
|
||
match tokio::time::timeout(METRIC_TIMEOUT, ctx.rest_client.submit_event(&event)).await {
|
||
Ok(Ok(_)) => {}
|
||
Ok(Err(e)) => tracing::warn!(
|
||
target: "pool::metrics",
|
||
session_id,
|
||
turn_id,
|
||
"NIP-AM: publish failed: {e}"
|
||
),
|
||
Err(_) => tracing::warn!(
|
||
target: "pool::metrics",
|
||
session_id,
|
||
turn_id,
|
||
"NIP-AM: publish timed out"
|
||
),
|
||
}
|
||
}
|
||
|
||
const REACTION_SEEN: &str = "👀";
|
||
const REACTION_WORKING: &str = "💬";
|
||
|
||
/// Best-effort timeout for a single reaction REST call.
|
||
const REACTION_TIMEOUT: Duration = Duration::from_millis(500);
|
||
|
||
/// Percent-encode a string for use in a URL path segment (used in tests only).
|
||
#[cfg(test)]
|
||
fn pct_encode(s: &str) -> String {
|
||
let mut out = String::with_capacity(s.len() * 3);
|
||
for byte in s.bytes() {
|
||
match byte {
|
||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
||
out.push(byte as char);
|
||
}
|
||
_ => {
|
||
use std::fmt::Write;
|
||
let _ = write!(out, "%{byte:02X}");
|
||
}
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Best-effort: add a reaction via a signed Nostr kind-7 event (NIP-25).
|
||
///
|
||
/// Builds a reaction event with `buzz_sdk::build_reaction`, signs it with
|
||
/// the keys already stored in `RestClient`, and submits via `POST /events`.
|
||
/// Returns immediately on timeout or any error — reactions are cosmetic.
|
||
pub(crate) async fn reaction_add(rest: &crate::relay::RestClient, event_id: &str, emoji: &str) {
|
||
let target_id = match nostr::EventId::from_hex(event_id) {
|
||
Ok(id) => id,
|
||
Err(e) => {
|
||
tracing::debug!(event_id, emoji, "reaction add: invalid event ID: {e}");
|
||
return;
|
||
}
|
||
};
|
||
let builder = match buzz_sdk::build_reaction(target_id, emoji) {
|
||
Ok(b) => b,
|
||
Err(e) => {
|
||
tracing::warn!(event_id, emoji, "reaction add: build failed: {e}");
|
||
return;
|
||
}
|
||
};
|
||
let event = match builder.sign_with_keys(&rest.keys) {
|
||
Ok(e) => e,
|
||
Err(e) => {
|
||
tracing::warn!(event_id, emoji, "reaction add: sign failed: {e}");
|
||
return;
|
||
}
|
||
};
|
||
match tokio::time::timeout(REACTION_TIMEOUT, rest.submit_event(&event)).await {
|
||
Ok(Ok(_)) => {}
|
||
Ok(Err(e)) => tracing::debug!(event_id, emoji, "reaction add failed: {e}"),
|
||
Err(_) => tracing::debug!(event_id, emoji, "reaction add timed out"),
|
||
}
|
||
}
|
||
|
||
/// Best-effort: post a visible failure notice (kind:9) to a channel after a
|
||
/// batch is dead-lettered. Replies into the thread of `thread_tags` when the
|
||
/// triggering event was threaded. Errors are logged and swallowed — the
|
||
/// notice must never take down the main loop.
|
||
pub(crate) async fn post_failure_notice(
|
||
rest: &crate::relay::RestClient,
|
||
channel_id: Uuid,
|
||
thread_tags: &ThreadTags,
|
||
content: &str,
|
||
) {
|
||
let thread_ref = thread_tags.root_event_id.as_deref().and_then(|root| {
|
||
let root_id = nostr::EventId::from_hex(root).ok()?;
|
||
let parent_id = thread_tags
|
||
.parent_event_id
|
||
.as_deref()
|
||
.and_then(|p| nostr::EventId::from_hex(p).ok())
|
||
.unwrap_or(root_id);
|
||
Some(buzz_sdk::ThreadRef {
|
||
root_event_id: root_id,
|
||
parent_event_id: parent_id,
|
||
})
|
||
});
|
||
let builder =
|
||
match buzz_sdk::build_message(channel_id, content, thread_ref.as_ref(), &[], false, &[]) {
|
||
Ok(b) => b,
|
||
Err(e) => {
|
||
tracing::warn!(channel = %channel_id, "failure notice: build failed: {e}");
|
||
return;
|
||
}
|
||
};
|
||
let event = match builder.sign_with_keys(&rest.keys) {
|
||
Ok(e) => e,
|
||
Err(e) => {
|
||
tracing::warn!(channel = %channel_id, "failure notice: sign failed: {e}");
|
||
return;
|
||
}
|
||
};
|
||
match tokio::time::timeout(Duration::from_secs(5), rest.submit_event(&event)).await {
|
||
Ok(Ok(_)) => {}
|
||
Ok(Err(e)) => tracing::warn!(channel = %channel_id, "failure notice failed: {e}"),
|
||
Err(_) => tracing::warn!(channel = %channel_id, "failure notice timed out"),
|
||
}
|
||
}
|
||
|
||
/// Best-effort: remove a reaction via a signed kind:5 (NIP-09) deletion event.
|
||
///
|
||
/// Queries kind:7 reactions by our pubkey targeting the event, finds the matching
|
||
/// emoji, then submits a signed kind:5 deletion via `POST /events`.
|
||
/// Returns immediately on timeout or any error — reactions are cosmetic.
|
||
pub(crate) async fn reaction_remove(rest: &crate::relay::RestClient, event_id: &str, emoji: &str) {
|
||
use nostr::{Alphabet, SingleLetterTag};
|
||
|
||
// Step 1: query our kind:7 reactions targeting this event.
|
||
let my_pubkey = rest.keys.public_key();
|
||
let e_tag = SingleLetterTag::lowercase(Alphabet::E);
|
||
let filter = nostr::Filter::new()
|
||
.kind(nostr::Kind::Reaction)
|
||
.author(my_pubkey)
|
||
.custom_tags(e_tag, [event_id]);
|
||
|
||
let resp = match tokio::time::timeout(Duration::from_millis(1_000), rest.query(&[filter])).await
|
||
{
|
||
Ok(Ok(v)) => v,
|
||
Ok(Err(e)) => {
|
||
tracing::debug!(event_id, emoji, "reaction remove: query failed: {e}");
|
||
return;
|
||
}
|
||
Err(_) => {
|
||
tracing::debug!(event_id, emoji, "reaction remove: query timed out");
|
||
return;
|
||
}
|
||
};
|
||
|
||
// Find our reaction event with matching emoji content.
|
||
let reid = resp.as_array().and_then(|events| {
|
||
events.iter().find_map(|ev| {
|
||
let content = ev.get("content")?.as_str()?;
|
||
if content != emoji {
|
||
return None;
|
||
}
|
||
ev.get("id")?.as_str().map(|s| s.to_string())
|
||
})
|
||
});
|
||
|
||
let reid = match reid {
|
||
Some(id) => id,
|
||
None => {
|
||
tracing::debug!(event_id, emoji, "reaction remove: no reaction event found");
|
||
return;
|
||
}
|
||
};
|
||
|
||
// Step 2: build and submit a signed kind:5 deletion for the reaction event.
|
||
let target_id = match nostr::EventId::from_hex(&reid) {
|
||
Ok(id) => id,
|
||
Err(e) => {
|
||
tracing::debug!(
|
||
event_id,
|
||
emoji,
|
||
"reaction remove: invalid reaction event ID: {e}"
|
||
);
|
||
return;
|
||
}
|
||
};
|
||
let builder = match buzz_sdk::build_remove_reaction(target_id) {
|
||
Ok(b) => b,
|
||
Err(e) => {
|
||
tracing::warn!(event_id, emoji, "reaction remove: build failed: {e}");
|
||
return;
|
||
}
|
||
};
|
||
let event = match builder.sign_with_keys(&rest.keys) {
|
||
Ok(e) => e,
|
||
Err(e) => {
|
||
tracing::warn!(event_id, emoji, "reaction remove: sign failed: {e}");
|
||
return;
|
||
}
|
||
};
|
||
match tokio::time::timeout(Duration::from_millis(1_000), rest.submit_event(&event)).await {
|
||
Ok(Ok(_)) => {}
|
||
Ok(Err(e)) => tracing::debug!(event_id, emoji, "reaction remove failed: {e}"),
|
||
Err(_) => tracing::debug!(event_id, emoji, "reaction remove timed out"),
|
||
}
|
||
}
|
||
|
||
/// Maximum concurrent reaction HTTP requests per fan-out call.
|
||
/// Prevents unbounded parallelism when a large batch of events arrives.
|
||
const REACTION_CONCURRENCY: usize = 10;
|
||
|
||
/// Add 💬 to all events, capped at `REACTION_CONCURRENCY` concurrent requests.
|
||
/// Awaited inline before the prompt fires.
|
||
async fn react_working(rest: &crate::relay::RestClient, event_ids: &[String]) {
|
||
for chunk in event_ids.chunks(REACTION_CONCURRENCY) {
|
||
futures_util::future::join_all(
|
||
chunk
|
||
.iter()
|
||
.map(|eid| reaction_add(rest, eid, REACTION_WORKING)),
|
||
)
|
||
.await;
|
||
}
|
||
}
|
||
|
||
/// Fire-and-forget: remove both 👀 and 💬 from all events. Spawned on turn complete.
|
||
/// Capped at `REACTION_CONCURRENCY` concurrent requests per chunk to avoid
|
||
/// unbounded HTTP fan-out on large batches.
|
||
async fn clear_reactions(rest: crate::relay::RestClient, event_ids: Vec<String>) {
|
||
// Each event needs two removals (👀 and 💬); pair them and chunk by
|
||
// REACTION_CONCURRENCY pairs so the total concurrent requests stay bounded.
|
||
for chunk in event_ids.chunks(REACTION_CONCURRENCY) {
|
||
futures_util::future::join_all(chunk.iter().flat_map(|eid| {
|
||
[
|
||
reaction_remove(&rest, eid, REACTION_SEEN),
|
||
reaction_remove(&rest, eid, REACTION_WORKING),
|
||
]
|
||
}))
|
||
.await;
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp};
|
||
use serde_json::json;
|
||
|
||
// These pin the initial_message dispatch path (run_prompt_task, ~line 855):
|
||
// a legacy agent WITH a base_prompt must get [Base] prepended to the user
|
||
// message. This is the exact regression that shipped in the round-2 bug.
|
||
|
||
#[test]
|
||
fn test_initial_message_legacy_agent_gets_base_prepended() {
|
||
// protocol_version 1 + Some(base_prompt): [Base] rides along in the
|
||
// user message, composed as `[Base]\n{bp}\n\n{initial_msg}`.
|
||
let composed = prepend_base_for_legacy(1, Some("you are a helpful agent"), "hello channel");
|
||
assert_eq!(composed, "[Base]\nyou are a helpful agent\n\nhello channel");
|
||
assert!(composed.starts_with("[Base]\nyou are a helpful agent\n\n"));
|
||
}
|
||
|
||
#[test]
|
||
fn test_initial_message_modern_agent_omits_base() {
|
||
// protocol_version 2 receives base_prompt via session/new, so the user
|
||
// message is left untouched even when a base_prompt is present.
|
||
let composed = prepend_base_for_legacy(2, Some("you are a helpful agent"), "hello channel");
|
||
assert_eq!(composed, "hello channel");
|
||
}
|
||
|
||
#[test]
|
||
fn goose_uses_system_prompt_only_after_custom_method_succeeds() {
|
||
assert!(!has_system_prompt_support(2, "goose", None));
|
||
assert!(!has_system_prompt_support(2, "goose", Some(false)));
|
||
assert!(has_system_prompt_support(2, "goose", Some(true)));
|
||
assert!(has_system_prompt_support(1, "goose", Some(true)));
|
||
assert!(has_system_prompt_support(2, "buzz-agent", None));
|
||
assert_eq!(
|
||
session_new_system_prompt(true, 2, Some("instructions")),
|
||
None
|
||
);
|
||
assert_eq!(
|
||
session_new_system_prompt(false, 2, Some("instructions")),
|
||
Some("instructions")
|
||
);
|
||
assert_eq!(
|
||
session_new_system_prompt(false, 1, Some("instructions")),
|
||
None
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_initial_message_legacy_agent_without_base_is_unchanged() {
|
||
// No base_prompt configured: nothing to prepend regardless of version.
|
||
let composed = prepend_base_for_legacy(1, None, "hello channel");
|
||
assert_eq!(composed, "hello channel");
|
||
}
|
||
|
||
// ── prepend_canvas_for_legacy ─────────────────────────────────────────────
|
||
|
||
#[test]
|
||
fn test_initial_message_legacy_agent_gets_canvas_prepended() {
|
||
// Legacy agents (protocol_version < 2) receive the canvas section before
|
||
// the initial-message body so it arrives before the first prompt.
|
||
let canvas = "[Channel Canvas]\nCanvas revision (event ID): abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234\nLast modified: 2024-01-15T10:30:00Z\nFetch current content with: buzz canvas get --channel 00f1ccaf-1506-4dd7-9a0e-fa67e9e486ae";
|
||
let composed = prepend_canvas_for_legacy(1, Some(canvas), "do the thing");
|
||
assert!(
|
||
composed.starts_with("[Channel Canvas]"),
|
||
"canvas must precede the body"
|
||
);
|
||
assert!(
|
||
composed.ends_with("do the thing"),
|
||
"body must follow the canvas"
|
||
);
|
||
assert!(
|
||
composed.contains("\n\ndo the thing"),
|
||
"canvas and body separated by blank line"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_initial_message_modern_agent_omits_canvas_from_body() {
|
||
// Protocol-v2 agents receive canvas in systemPrompt; it must NOT be
|
||
// duplicated in the initial-message user turn.
|
||
let canvas = "[Channel Canvas]\nsome section";
|
||
let composed = prepend_canvas_for_legacy(2, Some(canvas), "do the thing");
|
||
assert_eq!(
|
||
composed, "do the thing",
|
||
"modern agent initial message must not contain canvas"
|
||
);
|
||
assert!(
|
||
!composed.contains("[Channel Canvas]"),
|
||
"canvas must be absent from modern agent initial message"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_initial_message_legacy_agent_no_canvas_is_unchanged() {
|
||
// No canvas present: body passes through unmodified.
|
||
let composed = prepend_canvas_for_legacy(1, None, "do the thing");
|
||
assert_eq!(composed, "do the thing");
|
||
}
|
||
|
||
#[test]
|
||
fn test_initial_message_legacy_canvas_and_base_compose_correctly() {
|
||
// Verify the full composition order when both base and canvas are present:
|
||
// [Base] → canvas section → initial-message body.
|
||
let canvas = "[Channel Canvas]\ncanvas content";
|
||
let base_composed = prepend_base_for_legacy(1, Some("be helpful"), "do the thing");
|
||
let full = prepend_canvas_for_legacy(1, Some(canvas), &base_composed);
|
||
assert!(
|
||
full.starts_with("[Channel Canvas]"),
|
||
"canvas must be first in composed message"
|
||
);
|
||
assert!(
|
||
full.contains("[Base]"),
|
||
"base must be present in composed message"
|
||
);
|
||
assert!(
|
||
full.ends_with("do the thing"),
|
||
"body must be last in composed message"
|
||
);
|
||
// Order: canvas → base → body
|
||
let canvas_pos = full.find("[Channel Canvas]").unwrap();
|
||
let base_pos = full.find("[Base]").unwrap();
|
||
let body_pos = full.find("do the thing").unwrap();
|
||
assert!(
|
||
canvas_pos < base_pos && base_pos < body_pos,
|
||
"order must be: canvas → base → body"
|
||
);
|
||
}
|
||
|
||
// Pin the session/new systemPrompt framing: each present prompt carries its
|
||
// own header so the desktop observer can split into labeled sub-sections.
|
||
|
||
#[test]
|
||
fn test_framed_system_prompt_both_present_carries_both_headers() {
|
||
// Also the regression guard against #2372: the session title travels
|
||
// out of band in `_meta.sessionTitle`, so this exact-bytes assertion is
|
||
// what pins the framing against a `[Session]` section reappearing here.
|
||
let framed = framed_system_prompt("/", Some("base text"), Some("persona text"))
|
||
.expect("both present yields Some");
|
||
assert_eq!(framed, "[Base]\nbase text\n\n[System]\npersona text");
|
||
}
|
||
|
||
#[test]
|
||
fn test_framed_system_prompt_base_only_labels_base() {
|
||
let framed = framed_system_prompt("/", Some("base text"), None).expect("base yields Some");
|
||
assert_eq!(framed, "[Base]\nbase text");
|
||
}
|
||
|
||
#[test]
|
||
fn test_framed_system_prompt_persona_only_labels_system() {
|
||
// A bare persona would be mislabeled "Base" downstream — it must carry
|
||
// its own [System] header even when no base prompt exists.
|
||
let framed =
|
||
framed_system_prompt("/", None, Some("persona text")).expect("persona yields Some");
|
||
assert_eq!(framed, "[System]\npersona text");
|
||
}
|
||
|
||
#[test]
|
||
fn test_framed_system_prompt_neither_is_none() {
|
||
assert!(framed_system_prompt("/", None, None).is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn test_framed_system_prompt_absolute_cwd_prepends_workspace_before_base() {
|
||
let framed = framed_system_prompt("/Users/me/.buzz", Some("base text"), None)
|
||
.expect("base yields Some");
|
||
assert!(
|
||
framed.starts_with("[Workspace]\n"),
|
||
"workspace section must lead: {framed}"
|
||
);
|
||
assert!(framed.contains("`/Users/me/.buzz`"));
|
||
assert!(
|
||
framed.contains("\n\n[Base]\nbase text"),
|
||
"base must follow the workspace section: {framed}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_framed_system_prompt_persona_only_omits_workspace() {
|
||
// The workspace section grounds the base prompt's layout; a persona-only
|
||
// agent never received that layout, so no [Workspace] anchor is emitted.
|
||
let framed = framed_system_prompt("/Users/me/.buzz", None, Some("persona text"))
|
||
.expect("persona yields Some");
|
||
assert_eq!(framed, "[System]\npersona text");
|
||
}
|
||
|
||
#[test]
|
||
fn test_framed_system_prompt_root_cwd_omits_workspace() {
|
||
// The "/" fallback must never be named — it would invite a $HOME scan.
|
||
let framed = framed_system_prompt("/", Some("base text"), None).expect("base yields Some");
|
||
assert_eq!(framed, "[Base]\nbase text");
|
||
}
|
||
|
||
#[test]
|
||
fn test_workspace_section_relative_cwd_is_none() {
|
||
assert!(workspace_section("relative/path").is_none());
|
||
assert!(workspace_section("").is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn test_with_core_appends_below_framed() {
|
||
let framed = with_core(
|
||
Some("[System]\npersona".to_string()),
|
||
Some("[Agent Memory — core]\nbe helpful"),
|
||
)
|
||
.expect("both present yields Some");
|
||
assert_eq!(
|
||
framed,
|
||
"[System]\npersona\n\n[Agent Memory — core]\nbe helpful"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_with_core_framed_only_passes_through() {
|
||
let framed = with_core(Some("[System]\npersona".to_string()), None)
|
||
.expect("framed-only yields Some");
|
||
assert_eq!(framed, "[System]\npersona");
|
||
}
|
||
|
||
#[test]
|
||
fn test_with_core_core_only_is_just_core() {
|
||
let framed = with_core(None, Some("[Agent Memory — core]\nbe helpful"))
|
||
.expect("core-only yields Some");
|
||
assert_eq!(framed, "[Agent Memory — core]\nbe helpful");
|
||
}
|
||
|
||
#[test]
|
||
fn test_with_core_neither_is_none() {
|
||
assert!(with_core(None, None).is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn test_parse_thread_response_basic() {
|
||
let json = json!({
|
||
"root": {
|
||
"event_id": "abc123",
|
||
"pubkey": "pub1",
|
||
"content": "root message",
|
||
"created_at": 1710518400
|
||
},
|
||
"replies": [
|
||
{
|
||
"event_id": "def456",
|
||
"pubkey": "pub2",
|
||
"content": "first reply",
|
||
"created_at": 1710518460
|
||
}
|
||
],
|
||
"total_replies": 1
|
||
});
|
||
|
||
let ctx = parse_thread_response(json).expect("should parse");
|
||
match ctx {
|
||
ConversationContext::Thread {
|
||
messages,
|
||
total,
|
||
truncated,
|
||
} => {
|
||
assert_eq!(messages.len(), 2); // root + 1 reply
|
||
assert_eq!(total, 2); // 1 reply + 1 root
|
||
assert!(!truncated);
|
||
assert_eq!(messages[0].content, "root message");
|
||
assert_eq!(messages[1].content, "first reply");
|
||
}
|
||
_ => panic!("expected Thread context"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_parse_thread_response_truncated() {
|
||
let json = json!({
|
||
"root": {
|
||
"event_id": "abc",
|
||
"pubkey": "pub1",
|
||
"content": "root",
|
||
"created_at": 1710518400
|
||
},
|
||
"replies": [
|
||
{
|
||
"event_id": "def",
|
||
"pubkey": "pub2",
|
||
"content": "reply1",
|
||
"created_at": 1710518460
|
||
}
|
||
],
|
||
"total_replies": 10
|
||
});
|
||
|
||
let ctx = parse_thread_response(json).expect("should parse");
|
||
match ctx {
|
||
ConversationContext::Thread {
|
||
messages,
|
||
total,
|
||
truncated,
|
||
} => {
|
||
assert_eq!(messages.len(), 2);
|
||
assert_eq!(total, 11); // 10 replies + 1 root
|
||
assert!(truncated);
|
||
}
|
||
_ => panic!("expected Thread context"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_parse_thread_response_empty() {
|
||
let json = json!({
|
||
"root": null,
|
||
"replies": [],
|
||
"total_replies": 0
|
||
});
|
||
assert!(parse_thread_response(json).is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn test_parse_thread_response_missing_fields() {
|
||
// Malformed JSON — no root, no replies key.
|
||
let json = json!({ "something": "else" });
|
||
assert!(parse_thread_response(json).is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn test_parse_dm_response_basic() {
|
||
let json = json!({
|
||
"messages": [
|
||
{
|
||
"event_id": "msg2",
|
||
"pubkey": "pub2",
|
||
"content": "newer message",
|
||
"created_at": 1710518500
|
||
},
|
||
{
|
||
"event_id": "msg1",
|
||
"pubkey": "pub1",
|
||
"content": "older message",
|
||
"created_at": 1710518400
|
||
}
|
||
],
|
||
"next_cursor": null
|
||
});
|
||
|
||
// limit=12 > 2 messages → not truncated.
|
||
let ctx = parse_dm_response(json, 12).expect("should parse");
|
||
match ctx {
|
||
ConversationContext::Dm {
|
||
messages,
|
||
total,
|
||
truncated,
|
||
} => {
|
||
// Should be reversed to chronological order.
|
||
assert_eq!(messages.len(), 2);
|
||
assert_eq!(messages[0].content, "older message");
|
||
assert_eq!(messages[1].content, "newer message");
|
||
assert!(!truncated);
|
||
assert_eq!(total, 2);
|
||
}
|
||
_ => panic!("expected Dm context"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_parse_dm_response_truncated() {
|
||
let json = json!({
|
||
"messages": [
|
||
{
|
||
"event_id": "msg1",
|
||
"pubkey": "pub1",
|
||
"content": "message",
|
||
"created_at": 1710518400
|
||
}
|
||
],
|
||
"next_cursor": "00000000660f5a80"
|
||
});
|
||
|
||
// limit=1 == 1 message → truncated.
|
||
let ctx = parse_dm_response(json, 1).expect("should parse");
|
||
match ctx {
|
||
ConversationContext::Dm {
|
||
truncated, total, ..
|
||
} => {
|
||
assert!(truncated);
|
||
assert_eq!(total, 2); // 1 message + indicator
|
||
}
|
||
_ => panic!("expected Dm context"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_parse_dm_response_not_truncated_despite_cursor() {
|
||
// Relay always sets next_cursor when page is non-empty, but if
|
||
// returned count < limit, the page is complete.
|
||
let json = json!({
|
||
"messages": [
|
||
{
|
||
"event_id": "msg1",
|
||
"pubkey": "pub1",
|
||
"content": "only message",
|
||
"created_at": 1710518400
|
||
}
|
||
],
|
||
"next_cursor": "00000000660f5a80"
|
||
});
|
||
|
||
// limit=12 > 1 message → NOT truncated despite next_cursor being set.
|
||
let ctx = parse_dm_response(json, 12).expect("should parse");
|
||
match ctx {
|
||
ConversationContext::Dm {
|
||
truncated, total, ..
|
||
} => {
|
||
assert!(!truncated, "should not be truncated when count < limit");
|
||
assert_eq!(total, 1);
|
||
}
|
||
_ => panic!("expected Dm context"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_parse_dm_response_empty() {
|
||
let json = json!({
|
||
"messages": [],
|
||
"next_cursor": null
|
||
});
|
||
assert!(parse_dm_response(json, 12).is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn test_parse_dm_response_missing_messages_key() {
|
||
let json = json!({ "data": [] });
|
||
assert!(parse_dm_response(json, 12).is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn test_json_to_context_message_integer_timestamp() {
|
||
let obj = json!({
|
||
"pubkey": "abc",
|
||
"content": "hello",
|
||
"created_at": 1710518400
|
||
});
|
||
let msg = json_to_context_message(&obj).expect("should parse");
|
||
assert_eq!(msg.pubkey, "abc");
|
||
assert_eq!(msg.content, "hello");
|
||
assert!(msg.timestamp.contains("2024")); // 1710518400 = 2024-03-15
|
||
}
|
||
|
||
#[test]
|
||
fn test_json_to_context_message_string_timestamp() {
|
||
let obj = json!({
|
||
"pubkey": "abc",
|
||
"content": "hello",
|
||
"created_at": "2026-03-15T16:30:00+00:00"
|
||
});
|
||
let msg = json_to_context_message(&obj).expect("should parse");
|
||
assert_eq!(msg.timestamp, "2026-03-15T16:30:00+00:00");
|
||
}
|
||
|
||
#[test]
|
||
fn test_json_to_context_message_missing_content() {
|
||
let obj = json!({ "pubkey": "abc" });
|
||
assert!(json_to_context_message(&obj).is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn test_collect_prompt_pubkeys_includes_authors_mentions_and_context() {
|
||
let keys = Keys::generate();
|
||
let p_tag = Tag::parse([
|
||
"p",
|
||
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||
])
|
||
.unwrap();
|
||
let event = EventBuilder::new(Kind::Custom(9), "hello")
|
||
.tags([p_tag])
|
||
.sign_with_keys(&keys)
|
||
.unwrap();
|
||
let author_hex = event.pubkey.to_hex();
|
||
let batch = FlushBatch {
|
||
channel_id: Uuid::new_v4(),
|
||
events: vec![crate::queue::BatchEvent {
|
||
event,
|
||
prompt_tag: "@mention".into(),
|
||
received_at: std::time::Instant::now(),
|
||
}],
|
||
cancelled_events: vec![],
|
||
cancel_reason: None,
|
||
};
|
||
let context = ConversationContext::Thread {
|
||
messages: vec![ContextMessage {
|
||
pubkey: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".into(),
|
||
timestamp: "2026-03-25T05:51:25Z".into(),
|
||
content: "follow up".into(),
|
||
}],
|
||
total: 1,
|
||
truncated: false,
|
||
};
|
||
|
||
let pubkeys = collect_prompt_pubkeys(&batch, Some(&context));
|
||
|
||
let mut expected = vec![
|
||
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(),
|
||
author_hex,
|
||
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string(),
|
||
];
|
||
expected.sort();
|
||
|
||
assert_eq!(pubkeys, expected);
|
||
}
|
||
|
||
#[test]
|
||
fn test_parse_kind0_profile_lookup_extracts_display_name_and_nip05() {
|
||
let lookup = parse_kind0_profile_lookup(json!([
|
||
{
|
||
"id": "0000000000000000000000000000000000000000000000000000000000000001",
|
||
"pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||
"kind": 0,
|
||
"content": "{\"display_name\":\"Wes\",\"nip05\":\"wes@example.com\"}",
|
||
"created_at": 1000,
|
||
"tags": [],
|
||
"sig": "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
|
||
}
|
||
]))
|
||
.expect("lookup should parse");
|
||
|
||
assert_eq!(
|
||
lookup.get("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"),
|
||
Some(&PromptProfile {
|
||
display_name: Some("Wes".into()),
|
||
nip05_handle: Some("wes@example.com".into()),
|
||
is_agent: false,
|
||
})
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_profile_event_is_agent_detects_nip_oa_auth_tag() {
|
||
// Agent profile carries a 4-element NIP-OA ["auth", owner, cond, sig] tag.
|
||
let agent_ev = json!({
|
||
"pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||
"tags": [["auth", "owner_pk", "conditions", "sig"]],
|
||
});
|
||
assert!(profile_event_is_agent(&agent_ev));
|
||
|
||
// Human profile: no auth tag.
|
||
let human_ev = json!({ "pubkey": "bbbb", "tags": [["t", "topic"]] });
|
||
assert!(!profile_event_is_agent(&human_ev));
|
||
|
||
// Empty / missing tags → not an agent.
|
||
assert!(!profile_event_is_agent(&json!({ "tags": [] })));
|
||
assert!(!profile_event_is_agent(&json!({})));
|
||
|
||
// Malformed auth tag (wrong arity) → not treated as an agent.
|
||
let malformed = json!({ "tags": [["auth", "owner_pk"]] });
|
||
assert!(!profile_event_is_agent(&malformed));
|
||
}
|
||
|
||
#[test]
|
||
fn test_parse_kind0_profile_lookup_returns_none_for_empty() {
|
||
assert!(parse_kind0_profile_lookup(json!([])).is_none());
|
||
assert!(parse_kind0_profile_lookup(json!({})).is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn test_json_to_context_message_missing_pubkey_uses_default() {
|
||
let obj = json!({ "content": "hello" });
|
||
let msg = json_to_context_message(&obj).expect("should parse");
|
||
assert_eq!(msg.pubkey, "unknown");
|
||
}
|
||
|
||
#[test]
|
||
fn test_pct_encode_hex_passthrough() {
|
||
let hex = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2";
|
||
assert_eq!(pct_encode(hex), hex);
|
||
}
|
||
|
||
#[test]
|
||
fn test_pct_encode_emoji() {
|
||
// 👀 = U+1F440 = F0 9F 91 80 in UTF-8
|
||
assert_eq!(pct_encode("👀"), "%F0%9F%91%80");
|
||
}
|
||
|
||
#[test]
|
||
fn test_pct_encode_emoji_speech_balloon() {
|
||
// 💬 = U+1F4AC = F0 9F 92 AC in UTF-8
|
||
assert_eq!(pct_encode("💬"), "%F0%9F%92%AC");
|
||
}
|
||
|
||
#[test]
|
||
fn test_pct_encode_empty() {
|
||
assert_eq!(pct_encode(""), "");
|
||
}
|
||
|
||
#[test]
|
||
fn test_pct_encode_unreserved_passthrough() {
|
||
assert_eq!(pct_encode("AZaz09-_.~"), "AZaz09-_.~");
|
||
}
|
||
|
||
#[test]
|
||
fn test_pct_encode_reserved_chars() {
|
||
assert_eq!(pct_encode("/"), "%2F");
|
||
assert_eq!(pct_encode("+"), "%2B");
|
||
assert_eq!(pct_encode(" "), "%20");
|
||
}
|
||
|
||
fn make_state() -> (SessionState, Uuid, Uuid) {
|
||
let ch_a = Uuid::new_v4();
|
||
let ch_b = Uuid::new_v4();
|
||
let mut s = SessionState::default();
|
||
s.sessions.insert(ch_a, "sess-a".into());
|
||
s.sessions.insert(ch_b, "sess-b".into());
|
||
s.turn_counts.insert(ch_a, 5);
|
||
s.turn_counts.insert(ch_b, 3);
|
||
s.core_sections.insert(ch_a, "core-a".into());
|
||
s.core_sections.insert(ch_b, "core-b".into());
|
||
s.heartbeat_session = Some("sess-hb".into());
|
||
s.heartbeat_turn_count = 7;
|
||
(s, ch_a, ch_b)
|
||
}
|
||
|
||
#[test]
|
||
fn test_rotate_after_natural_completion_invalidates_channel_state() {
|
||
let (mut s, ch_a, ch_b) = make_state();
|
||
|
||
apply_completed_before_control_signal(
|
||
&mut s,
|
||
&PromptSource::Channel(ch_a),
|
||
&ControlSignal::Rotate,
|
||
);
|
||
|
||
assert!(!s.sessions.contains_key(&ch_a));
|
||
assert!(!s.turn_counts.contains_key(&ch_a));
|
||
assert!(!s.core_sections.contains_key(&ch_a));
|
||
assert!(!s.has_channel_state(&ch_a));
|
||
assert_eq!(s.sessions.get(&ch_b).unwrap(), "sess-b");
|
||
assert_eq!(*s.turn_counts.get(&ch_b).unwrap(), 3);
|
||
assert_eq!(s.core_sections.get(&ch_b).unwrap(), "core-b");
|
||
assert_eq!(s.heartbeat_session.as_deref(), Some("sess-hb"));
|
||
assert_eq!(s.heartbeat_turn_count, 7);
|
||
}
|
||
|
||
#[test]
|
||
fn test_cancel_after_natural_completion_preserves_channel_state() {
|
||
let (mut s, ch_a, ch_b) = make_state();
|
||
|
||
apply_completed_before_control_signal(
|
||
&mut s,
|
||
&PromptSource::Channel(ch_a),
|
||
&ControlSignal::Cancel,
|
||
);
|
||
|
||
assert_eq!(s.sessions.get(&ch_a).unwrap(), "sess-a");
|
||
assert_eq!(*s.turn_counts.get(&ch_a).unwrap(), 5);
|
||
assert_eq!(s.core_sections.get(&ch_a).unwrap(), "core-a");
|
||
assert_eq!(s.sessions.get(&ch_b).unwrap(), "sess-b");
|
||
}
|
||
|
||
#[test]
|
||
fn test_invalidate_channel_clears_session_and_turn_count() {
|
||
let (mut s, ch_a, ch_b) = make_state();
|
||
s.invalidate(&PromptSource::Channel(ch_a));
|
||
|
||
assert!(!s.sessions.contains_key(&ch_a));
|
||
assert!(!s.turn_counts.contains_key(&ch_a));
|
||
assert!(!s.core_sections.contains_key(&ch_a));
|
||
assert!(!s.has_channel_state(&ch_a));
|
||
// ch_b untouched
|
||
assert_eq!(s.sessions.get(&ch_b).unwrap(), "sess-b");
|
||
assert_eq!(*s.turn_counts.get(&ch_b).unwrap(), 3);
|
||
assert_eq!(s.core_sections.get(&ch_b).unwrap(), "core-b");
|
||
// heartbeat untouched
|
||
assert_eq!(s.heartbeat_session.as_deref(), Some("sess-hb"));
|
||
assert_eq!(s.heartbeat_turn_count, 7);
|
||
}
|
||
|
||
#[test]
|
||
fn test_invalidate_heartbeat_clears_session_and_turn_count() {
|
||
let (mut s, ch_a, ch_b) = make_state();
|
||
s.invalidate(&PromptSource::Heartbeat);
|
||
|
||
assert!(s.heartbeat_session.is_none());
|
||
assert_eq!(s.heartbeat_turn_count, 0);
|
||
// channels untouched
|
||
assert_eq!(s.sessions.len(), 2);
|
||
assert_eq!(*s.turn_counts.get(&ch_a).unwrap(), 5);
|
||
assert_eq!(*s.turn_counts.get(&ch_b).unwrap(), 3);
|
||
assert_eq!(s.core_sections.get(&ch_a).unwrap(), "core-a");
|
||
assert_eq!(s.core_sections.get(&ch_b).unwrap(), "core-b");
|
||
}
|
||
|
||
#[test]
|
||
fn test_invalidate_all_clears_everything() {
|
||
let (mut s, _ch_a, _ch_b) = make_state();
|
||
s.invalidate_all();
|
||
|
||
assert!(s.sessions.is_empty());
|
||
assert!(s.turn_counts.is_empty());
|
||
assert!(s.core_sections.is_empty());
|
||
assert!(s.heartbeat_session.is_none());
|
||
assert_eq!(s.heartbeat_turn_count, 0);
|
||
}
|
||
|
||
#[test]
|
||
fn test_invalidate_nonexistent_channel_is_noop() {
|
||
let (mut s, ch_a, ch_b) = make_state();
|
||
let ghost = Uuid::new_v4();
|
||
s.invalidate(&PromptSource::Channel(ghost));
|
||
|
||
// Everything still intact.
|
||
assert_eq!(s.sessions.len(), 2);
|
||
assert_eq!(s.turn_counts.len(), 2);
|
||
assert_eq!(*s.turn_counts.get(&ch_a).unwrap(), 5);
|
||
assert_eq!(*s.turn_counts.get(&ch_b).unwrap(), 3);
|
||
assert_eq!(s.core_sections.get(&ch_a).unwrap(), "core-a");
|
||
assert_eq!(s.core_sections.get(&ch_b).unwrap(), "core-b");
|
||
}
|
||
|
||
#[test]
|
||
fn test_invalidate_all_on_empty_state_is_noop() {
|
||
let mut s = SessionState::default();
|
||
s.invalidate_all(); // should not panic
|
||
assert!(s.sessions.is_empty());
|
||
assert!(s.turn_counts.is_empty());
|
||
assert!(s.core_sections.is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn test_invalidate_channel_returns_true_when_session_existed() {
|
||
let (mut s, ch_a, ch_b) = make_state();
|
||
assert!(s.invalidate_channel(&ch_a));
|
||
assert!(!s.sessions.contains_key(&ch_a));
|
||
assert!(!s.turn_counts.contains_key(&ch_a));
|
||
assert!(!s.core_sections.contains_key(&ch_a));
|
||
assert!(!s.has_channel_state(&ch_a));
|
||
// ch_b untouched
|
||
assert_eq!(s.sessions.get(&ch_b).unwrap(), "sess-b");
|
||
assert_eq!(*s.turn_counts.get(&ch_b).unwrap(), 3);
|
||
assert_eq!(s.core_sections.get(&ch_b).unwrap(), "core-b");
|
||
// heartbeat untouched
|
||
assert_eq!(s.heartbeat_session.as_deref(), Some("sess-hb"));
|
||
assert_eq!(s.heartbeat_turn_count, 7);
|
||
}
|
||
|
||
#[test]
|
||
fn test_invalidate_channel_returns_false_when_no_session() {
|
||
let (mut s, _ch_a, _ch_b) = make_state();
|
||
let ghost = Uuid::new_v4();
|
||
assert!(!s.invalidate_channel(&ghost));
|
||
// Nothing changed.
|
||
assert_eq!(s.sessions.len(), 2);
|
||
assert_eq!(s.turn_counts.len(), 2);
|
||
}
|
||
|
||
#[test]
|
||
fn test_removed_channels_cleaned_via_invalidate_channel() {
|
||
// Simulates handle_prompt_result: channels removed while agent
|
||
// was checked out should have both sessions and turn_counts stripped.
|
||
let (mut s, ch_a, ch_b) = make_state();
|
||
let removed = vec![ch_a];
|
||
for ch in &removed {
|
||
s.invalidate_channel(ch);
|
||
}
|
||
assert!(!s.sessions.contains_key(&ch_a));
|
||
assert!(!s.turn_counts.contains_key(&ch_a));
|
||
assert!(!s.core_sections.contains_key(&ch_a));
|
||
assert!(!s.has_channel_state(&ch_a));
|
||
assert_eq!(s.sessions.get(&ch_b).unwrap(), "sess-b");
|
||
assert_eq!(*s.turn_counts.get(&ch_b).unwrap(), 3);
|
||
assert_eq!(s.core_sections.get(&ch_b).unwrap(), "core-b");
|
||
}
|
||
|
||
// ── ControlSignal::SwitchModel (Phase 3a, Option ii) ─────────────────────
|
||
|
||
#[test]
|
||
fn test_switch_model_after_natural_completion_invalidates_channel_state() {
|
||
let (mut s, ch_a, ch_b) = make_state();
|
||
|
||
// SwitchModel must invalidate just like Rotate so the requeued turn
|
||
// re-creates a fresh session that re-applies the new desired_model.
|
||
apply_completed_before_control_signal(
|
||
&mut s,
|
||
&PromptSource::Channel(ch_a),
|
||
&ControlSignal::SwitchModel("gpt-5".into()),
|
||
);
|
||
|
||
assert!(!s.has_channel_state(&ch_a));
|
||
// ch_b untouched — the switch is channel-scoped.
|
||
assert_eq!(s.sessions.get(&ch_b).unwrap(), "sess-b");
|
||
assert_eq!(*s.turn_counts.get(&ch_b).unwrap(), 3);
|
||
}
|
||
|
||
// ── requeue_cancelled_batch ────────────────────────────────────────────
|
||
// Table-driven pin of the `ControlSignal` → `CancelReason` ownership that
|
||
// decides whether a cancel-drain-expiry batch is merged into the next
|
||
// flush or dropped outright. `Cancel`/`Rotate` must return `None` — a
|
||
// regression here would silently fall through to
|
||
// `unwrap_or(CancelReason::Steer)` at the requeue site and preserve a
|
||
// batch that should have been discarded.
|
||
|
||
fn one_event_batch(channel_id: Uuid) -> FlushBatch {
|
||
let keys = Keys::generate();
|
||
let event = EventBuilder::new(Kind::Custom(9), "test")
|
||
.sign_with_keys(&keys)
|
||
.unwrap();
|
||
FlushBatch {
|
||
channel_id,
|
||
events: vec![crate::queue::BatchEvent {
|
||
event,
|
||
prompt_tag: "test".into(),
|
||
received_at: std::time::Instant::now(),
|
||
}],
|
||
cancelled_events: vec![],
|
||
cancel_reason: None,
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_requeue_cancelled_batch_maps_control_signal_to_cancel_reason() {
|
||
let cases = [
|
||
(ControlSignal::Steer, Some(CancelReason::Steer)),
|
||
(ControlSignal::Interrupt, Some(CancelReason::Interrupt)),
|
||
(
|
||
ControlSignal::SwitchModel("gpt-5".into()),
|
||
Some(CancelReason::Interrupt),
|
||
),
|
||
(ControlSignal::Cancel, None),
|
||
(ControlSignal::Rotate, None),
|
||
];
|
||
let mut ctx = make_prompt_context_no_owner();
|
||
ctx.dedup_mode = DedupMode::Queue;
|
||
|
||
for (signal, expected_reason) in cases {
|
||
let channel_id = Uuid::new_v4();
|
||
let batch = one_event_batch(channel_id);
|
||
let result = requeue_cancelled_batch(&ctx, signal.clone(), Some(batch));
|
||
match expected_reason {
|
||
Some(reason) => {
|
||
let batch = result
|
||
.unwrap_or_else(|| panic!("{signal:?} must preserve the batch, got None"));
|
||
assert_eq!(
|
||
batch.cancel_reason,
|
||
Some(reason),
|
||
"{signal:?} must stamp {reason:?}"
|
||
);
|
||
}
|
||
None => assert!(
|
||
result.is_none(),
|
||
"{signal:?} must drop the batch, got {result:?}"
|
||
),
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── classify_control_cancel_failure ─────────────────────────────────────
|
||
// Table-driven pin of the single production seam used by the
|
||
// `Err(error)` arm in `run_prompt_task`'s control-cancel branch. Crosses
|
||
// the exact error→outcome AND outcome→batch-fate boundary in one call,
|
||
// so a regression to the old per-arm duplication (or to routing an
|
||
// unexpected HardTimeout back through the real hard-cap path) fails
|
||
// here rather than only in independently-manufactured unit tests.
|
||
|
||
/// Assert `outcome` is the expected `PromptOutcome` variant. `PromptOutcome`
|
||
/// has no `PartialEq` (it wraps `AcpError`, which isn't `PartialEq`), so
|
||
/// this matches by shape instead of deriving equality onto the whole enum.
|
||
fn assert_outcome_matches(outcome: &PromptOutcome, expected: &str) {
|
||
let label = match outcome {
|
||
PromptOutcome::AgentExited => "AgentExited",
|
||
PromptOutcome::Timeout(TimeoutKind::Idle) => "Timeout(Idle)",
|
||
PromptOutcome::Timeout(TimeoutKind::Hard { .. }) => "Timeout(Hard)",
|
||
PromptOutcome::CancelDrainTimeout(_) => "CancelDrainTimeout",
|
||
PromptOutcome::Error(_) => "Error",
|
||
PromptOutcome::Cancelled => "Cancelled",
|
||
PromptOutcome::Ok(_) => "Ok",
|
||
};
|
||
assert_eq!(
|
||
label, expected,
|
||
"got outcome shape {label}, want {expected}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_classify_control_cancel_failure_crosses_error_outcome_and_batch_fate() {
|
||
let ctx = {
|
||
let mut ctx = make_prompt_context_no_owner();
|
||
ctx.dedup_mode = DedupMode::Queue;
|
||
ctx
|
||
};
|
||
|
||
struct Case {
|
||
name: &'static str,
|
||
error: fn() -> AcpError,
|
||
signal: ControlSignal,
|
||
expected_outcome: &'static str,
|
||
batch_preserved: bool,
|
||
expected_reason: Option<CancelReason>,
|
||
invalidate_all: bool,
|
||
}
|
||
|
||
let cases = [
|
||
Case {
|
||
name: "CancelDrainTimeout + Steer preserves batch with Steer reason",
|
||
error: || AcpError::CancelDrainTimeout(CONTROL_CANCEL_GRACE),
|
||
signal: ControlSignal::Steer,
|
||
expected_outcome: "CancelDrainTimeout",
|
||
batch_preserved: true,
|
||
expected_reason: Some(CancelReason::Steer),
|
||
invalidate_all: false,
|
||
},
|
||
Case {
|
||
name: "CancelDrainTimeout + Cancel drops the batch",
|
||
error: || AcpError::CancelDrainTimeout(CONTROL_CANCEL_GRACE),
|
||
signal: ControlSignal::Cancel,
|
||
expected_outcome: "CancelDrainTimeout",
|
||
batch_preserved: false,
|
||
expected_reason: None,
|
||
invalidate_all: false,
|
||
},
|
||
Case {
|
||
name: "CancelDrainTimeout + Interrupt preserves batch with Interrupt reason",
|
||
error: || AcpError::CancelDrainTimeout(CONTROL_CANCEL_GRACE),
|
||
signal: ControlSignal::Interrupt,
|
||
expected_outcome: "CancelDrainTimeout",
|
||
batch_preserved: true,
|
||
expected_reason: Some(CancelReason::Interrupt),
|
||
invalidate_all: false,
|
||
},
|
||
Case {
|
||
name: "CancelDrainTimeout + Rotate drops the batch",
|
||
error: || AcpError::CancelDrainTimeout(CONTROL_CANCEL_GRACE),
|
||
signal: ControlSignal::Rotate,
|
||
expected_outcome: "CancelDrainTimeout",
|
||
batch_preserved: false,
|
||
expected_reason: None,
|
||
invalidate_all: false,
|
||
},
|
||
Case {
|
||
name: "CancelDrainTimeout + SwitchModel preserves batch with Interrupt reason",
|
||
error: || AcpError::CancelDrainTimeout(CONTROL_CANCEL_GRACE),
|
||
signal: ControlSignal::SwitchModel("gpt-5".to_string()),
|
||
expected_outcome: "CancelDrainTimeout",
|
||
batch_preserved: true,
|
||
expected_reason: Some(CancelReason::Interrupt),
|
||
invalidate_all: false,
|
||
},
|
||
Case {
|
||
name: "unexpected HardTimeout cannot become Timeout(Hard)",
|
||
error: || AcpError::HardTimeout {
|
||
silence: Duration::from_secs(300),
|
||
},
|
||
signal: ControlSignal::Steer,
|
||
expected_outcome: "CancelDrainTimeout",
|
||
batch_preserved: true,
|
||
expected_reason: Some(CancelReason::Steer),
|
||
invalidate_all: false,
|
||
},
|
||
Case {
|
||
name: "AgentExited requests all-session invalidation and preserves via Steer",
|
||
error: || AcpError::AgentExited,
|
||
signal: ControlSignal::Steer,
|
||
expected_outcome: "AgentExited",
|
||
batch_preserved: true,
|
||
expected_reason: Some(CancelReason::Steer),
|
||
invalidate_all: true,
|
||
},
|
||
Case {
|
||
name: "AgentExited + Cancel still drops the batch",
|
||
error: || AcpError::AgentExited,
|
||
signal: ControlSignal::Cancel,
|
||
expected_outcome: "AgentExited",
|
||
batch_preserved: false,
|
||
expected_reason: None,
|
||
invalidate_all: true,
|
||
},
|
||
Case {
|
||
name: "IdleTimeout maps to Timeout(Idle)",
|
||
error: || AcpError::IdleTimeout(Duration::from_secs(30)),
|
||
signal: ControlSignal::Steer,
|
||
expected_outcome: "Timeout(Idle)",
|
||
batch_preserved: true,
|
||
expected_reason: Some(CancelReason::Steer),
|
||
invalidate_all: false,
|
||
},
|
||
];
|
||
|
||
for case in cases {
|
||
let channel_id = Uuid::new_v4();
|
||
let batch = one_event_batch(channel_id);
|
||
let failure = classify_control_cancel_failure(
|
||
&ctx,
|
||
(case.error)(),
|
||
case.signal.clone(),
|
||
Some(batch),
|
||
);
|
||
assert_outcome_matches(&failure.outcome, case.expected_outcome);
|
||
assert_eq!(
|
||
failure.invalidate_all, case.invalidate_all,
|
||
"{}: invalidate_all mismatch",
|
||
case.name
|
||
);
|
||
match case.expected_reason {
|
||
Some(reason) => {
|
||
let batch = failure
|
||
.retry_batch
|
||
.unwrap_or_else(|| panic!("{}: batch must be preserved", case.name));
|
||
assert_eq!(
|
||
batch.cancel_reason,
|
||
Some(reason),
|
||
"{}: cancel_reason mismatch",
|
||
case.name
|
||
);
|
||
}
|
||
None => assert!(
|
||
failure.retry_batch.is_none(),
|
||
"{}: batch must be dropped, got {:?}",
|
||
case.name,
|
||
failure.retry_batch
|
||
),
|
||
}
|
||
assert_eq!(
|
||
case.batch_preserved,
|
||
case.expected_reason.is_some(),
|
||
"{}: test table internally inconsistent",
|
||
case.name
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── turn liveness emission ───────────────────────────────────────────────
|
||
|
||
fn liveness_count(handle: &observer::ObserverHandle) -> usize {
|
||
handle
|
||
.snapshot()
|
||
.iter()
|
||
.filter(|e| e.kind == "turn_liveness")
|
||
.count()
|
||
}
|
||
|
||
fn open_liveness_state() -> Arc<Mutex<LivenessState>> {
|
||
Arc::new(Mutex::new(LivenessState {
|
||
closed: false,
|
||
session_id: None,
|
||
}))
|
||
}
|
||
|
||
#[tokio::test(start_paused = true)]
|
||
async fn test_liveness_stops_before_completion_frame() {
|
||
let observer = observer::ObserverHandle::in_process();
|
||
let context =
|
||
observer::context_for_turn(None, None, "t-1".into(), "2026-07-14T21:00:00Z".into());
|
||
let completion_context = observer::context_for(None, None, Some("t-1".into()));
|
||
let completion_observer = observer.clone();
|
||
let completion_handle = tokio::spawn(async move {
|
||
let state = open_liveness_state();
|
||
let _liveness_guard = LivenessGuard::new(
|
||
tokio::spawn(run_turn_liveness(
|
||
Some(observer.clone()),
|
||
Some(0),
|
||
context,
|
||
Duration::from_secs(10),
|
||
Arc::clone(&state),
|
||
)),
|
||
state,
|
||
);
|
||
tokio::time::sleep(Duration::from_secs(25)).await;
|
||
observer.emit(
|
||
"turn_completed",
|
||
Some(0),
|
||
&completion_context,
|
||
serde_json::json!({}),
|
||
);
|
||
});
|
||
tokio::task::yield_now().await;
|
||
|
||
tokio::time::advance(Duration::from_secs(25)).await;
|
||
completion_handle.await.unwrap();
|
||
tokio::task::yield_now().await;
|
||
|
||
let events = completion_observer.snapshot();
|
||
let completion_index = events
|
||
.iter()
|
||
.position(|event| event.kind == "turn_completed")
|
||
.expect("turn must complete");
|
||
assert!(
|
||
events[..completion_index]
|
||
.iter()
|
||
.all(|event| event.kind != "turn_liveness"
|
||
|| event.turn_id.as_deref() == Some("t-1")),
|
||
"pre-completion liveness must belong to the active turn"
|
||
);
|
||
assert!(
|
||
events[completion_index + 1..]
|
||
.iter()
|
||
.all(|event| event.kind != "turn_liveness"),
|
||
"liveness must be aborted before a completion frame is emitted"
|
||
);
|
||
}
|
||
|
||
#[tokio::test(start_paused = true)]
|
||
async fn test_liveness_fires_until_guard_drops() {
|
||
let observer = observer::ObserverHandle::in_process();
|
||
let started_at = "2026-07-14T21:00:00Z".to_string();
|
||
let context = observer::context_for_turn(None, None, "t-1".into(), started_at.clone());
|
||
let state = open_liveness_state();
|
||
let guard = LivenessGuard::new(
|
||
tokio::spawn(run_turn_liveness(
|
||
Some(observer.clone()),
|
||
Some(0),
|
||
context,
|
||
Duration::from_secs(10),
|
||
Arc::clone(&state),
|
||
)),
|
||
state,
|
||
);
|
||
tokio::task::yield_now().await;
|
||
|
||
// First liveness tick at 10s and the second at 20s.
|
||
tokio::time::advance(Duration::from_secs(25)).await;
|
||
tokio::task::yield_now().await;
|
||
assert_eq!(liveness_count(&observer), 2);
|
||
|
||
let pings: Vec<_> = observer
|
||
.snapshot()
|
||
.into_iter()
|
||
.filter(|e| e.kind == "turn_liveness")
|
||
.collect();
|
||
assert!(pings
|
||
.iter()
|
||
.all(|event| event.turn_id.as_deref() == Some("t-1")));
|
||
assert!(pings
|
||
.iter()
|
||
.all(|event| event.started_at.as_deref() == Some(&started_at)));
|
||
assert!(pings
|
||
.iter()
|
||
.all(|event| event.payload == serde_json::json!({})));
|
||
assert_eq!(
|
||
serde_json::to_value(&pings[0]).unwrap()["startedAt"],
|
||
started_at,
|
||
"turn start must serialize in the observer envelope"
|
||
);
|
||
|
||
// The guard is owned by `run_prompt_task`; dropping it aborts liveness
|
||
// so completed, cancelled, and errored turns cannot emit late pings.
|
||
drop(guard);
|
||
tokio::task::yield_now().await;
|
||
tokio::time::advance(Duration::from_secs(60)).await;
|
||
tokio::task::yield_now().await;
|
||
assert_eq!(liveness_count(&observer), 2);
|
||
}
|
||
|
||
#[tokio::test(start_paused = true)]
|
||
async fn test_liveness_backfills_session_id_after_resolution() {
|
||
let observer = observer::ObserverHandle::in_process();
|
||
let context =
|
||
observer::context_for_turn(None, None, "t-1".into(), "2026-07-14T21:00:00Z".into());
|
||
let state = open_liveness_state();
|
||
let guard = LivenessGuard::new(
|
||
tokio::spawn(run_turn_liveness(
|
||
Some(observer.clone()),
|
||
Some(0),
|
||
context,
|
||
Duration::from_secs(10),
|
||
Arc::clone(&state),
|
||
)),
|
||
state,
|
||
);
|
||
tokio::task::yield_now().await;
|
||
|
||
// First tick at 10s fires before the session resolves — must carry
|
||
// no session ID, matching every other pre-resolution observer frame.
|
||
tokio::time::advance(Duration::from_secs(10)).await;
|
||
tokio::task::yield_now().await;
|
||
guard.set_session_id("sess-1".to_string());
|
||
|
||
// Second tick at 20s fires after resolution — must carry it.
|
||
tokio::time::advance(Duration::from_secs(10)).await;
|
||
tokio::task::yield_now().await;
|
||
|
||
let pings: Vec<_> = observer
|
||
.snapshot()
|
||
.into_iter()
|
||
.filter(|e| e.kind == "turn_liveness")
|
||
.collect();
|
||
assert_eq!(pings.len(), 2);
|
||
assert_eq!(
|
||
pings[0].session_id, None,
|
||
"pre-resolution ping must not carry a session ID"
|
||
);
|
||
assert_eq!(
|
||
pings[1].session_id.as_deref(),
|
||
Some("sess-1"),
|
||
"post-resolution ping must carry the resolved session ID"
|
||
);
|
||
}
|
||
|
||
#[tokio::test(start_paused = true)]
|
||
async fn test_liveness_disabled_when_interval_zero_emits_nothing() {
|
||
let observer = observer::ObserverHandle::in_process();
|
||
let context = observer::context_for(None, None, Some("t-1".into()));
|
||
let liveness = run_turn_liveness(
|
||
Some(observer.clone()),
|
||
Some(0),
|
||
context,
|
||
Duration::ZERO,
|
||
open_liveness_state(),
|
||
);
|
||
tokio::pin!(liveness);
|
||
|
||
tokio::select! {
|
||
biased;
|
||
() = tokio::time::sleep(Duration::from_secs(120)) => {}
|
||
_ = &mut liveness => unreachable!("disabled liveness future never resolves"),
|
||
}
|
||
|
||
assert_eq!(liveness_count(&observer), 0);
|
||
}
|
||
|
||
#[tokio::test(start_paused = true)]
|
||
async fn test_liveness_without_observer_emits_nothing() {
|
||
// A turn that never started has no observer handle — the future must
|
||
// park without emitting or panicking.
|
||
let context = observer::context_for(None, None, Some("t-1".into()));
|
||
let liveness = run_turn_liveness(
|
||
None,
|
||
None,
|
||
context,
|
||
Duration::from_secs(10),
|
||
open_liveness_state(),
|
||
);
|
||
tokio::pin!(liveness);
|
||
|
||
tokio::select! {
|
||
biased;
|
||
() = tokio::time::sleep(Duration::from_secs(120)) => {}
|
||
_ = &mut liveness => unreachable!("handle-less liveness future never resolves"),
|
||
}
|
||
// No observer to assert against — reaching here without panic is the test.
|
||
}
|
||
|
||
// These two tests pin the shutdown mechanism itself (F1), not timing.
|
||
// The existing paused-clock tests above only prove liveness stops
|
||
// *eventually* after a guard drop — under `tokio::time::pause`, the
|
||
// scheduler never actually interleaves a drop with an in-flight emit, so
|
||
// they cannot catch a real cross-thread race between `LivenessGuard::drop`
|
||
// and `run_turn_liveness`'s tick. These assert the two halves of the
|
||
// contract directly: the check gates the emit with the flag pre-set (no
|
||
// `LivenessGuard` involved), and `drop` cannot return while the shared
|
||
// lock is held by an in-flight tick (real OS threads, no cooperative
|
||
// scheduling to serialize the race away).
|
||
|
||
#[tokio::test(start_paused = true)]
|
||
async fn test_liveness_emits_nothing_once_closed_flag_is_set() {
|
||
let observer = observer::ObserverHandle::in_process();
|
||
let context =
|
||
observer::context_for_turn(None, None, "t-1".into(), "2026-07-14T21:00:00Z".into());
|
||
// Set directly, bypassing `LivenessGuard` — isolates the read side of
|
||
// the contract: the check under the lock must gate the emit on its own.
|
||
let state = Arc::new(Mutex::new(LivenessState {
|
||
closed: true,
|
||
session_id: None,
|
||
}));
|
||
let liveness = run_turn_liveness(
|
||
Some(observer.clone()),
|
||
Some(0),
|
||
context,
|
||
Duration::from_secs(10),
|
||
state,
|
||
);
|
||
tokio::time::timeout(Duration::from_secs(60), liveness)
|
||
.await
|
||
.expect("run_turn_liveness must return once closed, not park forever");
|
||
|
||
assert_eq!(
|
||
liveness_count(&observer),
|
||
0,
|
||
"the pre-set closed flag must suppress every tick's emit"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_liveness_guard_drop_blocks_while_emit_lock_is_held() {
|
||
// Standing in for a tick that has already entered its critical
|
||
// section: hold the shared lock before the guard drops.
|
||
let state = Arc::new(Mutex::new(LivenessState {
|
||
closed: false,
|
||
session_id: None,
|
||
}));
|
||
let held = state.lock().unwrap();
|
||
|
||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||
let handle = rt.spawn(std::future::pending::<()>());
|
||
let guard = LivenessGuard::new(handle, Arc::clone(&state));
|
||
|
||
let (tx, rx) = std::sync::mpsc::channel();
|
||
let drop_thread = std::thread::spawn(move || {
|
||
drop(guard);
|
||
tx.send(()).unwrap();
|
||
});
|
||
|
||
// While the emit lock is held, `drop` cannot have completed: it takes
|
||
// the same lock before it sets the flag and aborts. A bounded timeout
|
||
// proves non-completion by construction of the lock, not the clock —
|
||
// `recv_timeout` returning `Timeout` here only holds because the
|
||
// mutex is genuinely contended; it cannot pass by scheduling luck.
|
||
assert_eq!(
|
||
rx.recv_timeout(Duration::from_millis(200)),
|
||
Err(std::sync::mpsc::RecvTimeoutError::Timeout),
|
||
"drop must block while the tick's emit lock is held"
|
||
);
|
||
|
||
// Release the lock — drop can now acquire it, set the flag, and abort.
|
||
drop(held);
|
||
rx.recv_timeout(Duration::from_secs(5))
|
||
.expect("drop must complete once the emit lock is released");
|
||
drop_thread.join().unwrap();
|
||
assert!(
|
||
state.lock().unwrap().closed,
|
||
"closed flag must be set by the time drop has returned"
|
||
);
|
||
}
|
||
|
||
// ── steer_rx invariant tests ──────────────────────────────────────────
|
||
//
|
||
// These pin the `send_prompt_result` invariant: `steer_rx` is always
|
||
// `None` on any agent returned to the pool, regardless of which exit
|
||
// path fired.
|
||
//
|
||
// Test 1 (session-create-error path): installs a receiver, then calls
|
||
// `send_prompt_result` without the read loop running `take()` — simulating
|
||
// any early-return arm (e.g. session-create failure). The receiver must be
|
||
// cleared and the next `install_steer_rx` must not panic.
|
||
//
|
||
// Test 2 (post-read-loop path): receiver is already `None` (the read loop
|
||
// already consumed it via `take()`). `send_prompt_result` is idempotent —
|
||
// `steer_rx` stays `None` and the next `install_steer_rx` still does not
|
||
// panic.
|
||
|
||
/// After an early-return path (receiver installed but read loop never ran),
|
||
/// the returned agent's `steer_rx` is `None` and a subsequent
|
||
/// `install_steer_rx` does not panic.
|
||
#[tokio::test]
|
||
async fn test_send_prompt_result_clears_steer_rx_on_early_return() {
|
||
let acp = AcpClient::spawn(
|
||
"bash",
|
||
&["-c".to_string(), "sleep 10".to_string()],
|
||
&[],
|
||
false,
|
||
)
|
||
.await
|
||
.expect("failed to spawn test agent");
|
||
let mut agent = OwnedAgent {
|
||
index: 0,
|
||
acp,
|
||
state: SessionState::default(),
|
||
model_capabilities: None,
|
||
desired_model: None,
|
||
model_overridden: false,
|
||
agent_name: "unknown".into(),
|
||
goose_system_prompt_supported: None,
|
||
protocol_version: 2,
|
||
};
|
||
|
||
// Simulate dispatch: install a steer receiver (normally done by
|
||
// `dispatch_pending` before `run_prompt_task` is spawned).
|
||
let (_steer_tx, steer_rx) = tokio::sync::mpsc::channel::<SteerRequest>(1);
|
||
agent.acp.install_steer_rx(steer_rx);
|
||
|
||
// Simulate session-create error: early-return path calls
|
||
// `send_prompt_result` without the read loop ever running `take()`.
|
||
let (result_tx, mut result_rx) = tokio::sync::mpsc::unbounded_channel::<PromptResult>();
|
||
let source = PromptSource::Heartbeat;
|
||
send_prompt_result(
|
||
&result_tx,
|
||
"test-turn-id",
|
||
agent,
|
||
source,
|
||
PromptOutcome::Error(AcpError::Protocol("simulated session-create error".into())),
|
||
None,
|
||
);
|
||
|
||
// Receive the PromptResult back from the channel.
|
||
let mut result = result_rx.recv().await.expect("PromptResult must be sent");
|
||
|
||
// steer_rx must be cleared even though the read loop never ran take().
|
||
assert!(
|
||
result.agent.acp.steer_rx_is_none(),
|
||
"steer_rx must be None after send_prompt_result on error path"
|
||
);
|
||
|
||
// The next dispatch can now install a fresh receiver without panicking.
|
||
let (_steer_tx2, steer_rx2) = tokio::sync::mpsc::channel::<SteerRequest>(1);
|
||
result.agent.acp.install_steer_rx(steer_rx2);
|
||
// Reaching here without a panic is the test.
|
||
}
|
||
|
||
/// After a successful prompt (read loop already consumed `steer_rx` via
|
||
/// `take()`), `send_prompt_result` is a no-op — `steer_rx` stays `None`
|
||
/// and the next `install_steer_rx` does not panic.
|
||
#[tokio::test]
|
||
async fn test_send_prompt_result_is_noop_when_steer_rx_already_consumed() {
|
||
let acp = AcpClient::spawn(
|
||
"bash",
|
||
&["-c".to_string(), "sleep 10".to_string()],
|
||
&[],
|
||
false,
|
||
)
|
||
.await
|
||
.expect("failed to spawn test agent");
|
||
let agent = OwnedAgent {
|
||
index: 0,
|
||
acp,
|
||
state: SessionState::default(),
|
||
model_capabilities: None,
|
||
desired_model: None,
|
||
model_overridden: false,
|
||
agent_name: "unknown".into(),
|
||
goose_system_prompt_supported: None,
|
||
protocol_version: 2,
|
||
};
|
||
|
||
// Simulate a completed turn: `steer_rx` was consumed by the read loop
|
||
// (`take()` was called), so it is already `None` when the turn ends.
|
||
assert!(
|
||
agent.acp.steer_rx_is_none(),
|
||
"precondition: steer_rx starts as None"
|
||
);
|
||
|
||
let (result_tx, mut result_rx) = tokio::sync::mpsc::unbounded_channel::<PromptResult>();
|
||
let source = PromptSource::Heartbeat;
|
||
send_prompt_result(
|
||
&result_tx,
|
||
"test-turn-id",
|
||
agent,
|
||
source,
|
||
PromptOutcome::Ok(StopReason::EndTurn),
|
||
None,
|
||
);
|
||
|
||
let mut result = result_rx.recv().await.expect("PromptResult must be sent");
|
||
|
||
// Still None — clear_steer_rx on an already-None field is idempotent.
|
||
assert!(
|
||
result.agent.acp.steer_rx_is_none(),
|
||
"steer_rx must remain None after send_prompt_result on happy path"
|
||
);
|
||
|
||
// The next dispatch can install a fresh receiver without panicking.
|
||
let (_steer_tx, steer_rx) = tokio::sync::mpsc::channel::<SteerRequest>(1);
|
||
result.agent.acp.install_steer_rx(steer_rx);
|
||
// Reaching here without a panic is the test.
|
||
}
|
||
|
||
// ── NIP-AM emit-hook unit tests ────────────────────────────────────────
|
||
|
||
/// `acp_stop_to_core` maps all ACP stop reasons to the correct NIP-AM
|
||
/// variants without panicking on any input.
|
||
#[test]
|
||
fn test_acp_stop_to_core_maps_all_variants() {
|
||
use buzz_core::agent_turn_metric::StopReason as CoreStop;
|
||
assert_eq!(acp_stop_to_core(&StopReason::EndTurn), CoreStop::EndTurn);
|
||
assert_eq!(
|
||
acp_stop_to_core(&StopReason::Cancelled),
|
||
CoreStop::Cancelled
|
||
);
|
||
assert_eq!(
|
||
acp_stop_to_core(&StopReason::MaxTokens),
|
||
CoreStop::MaxTokens
|
||
);
|
||
assert_eq!(
|
||
acp_stop_to_core(&StopReason::MaxTurnRequests),
|
||
CoreStop::Unknown
|
||
);
|
||
assert_eq!(acp_stop_to_core(&StopReason::Refusal), CoreStop::Unknown);
|
||
}
|
||
|
||
/// `publish_agent_turn_metric` is a no-op when `usage` is `None`.
|
||
#[tokio::test]
|
||
async fn test_publish_agent_turn_metric_noop_on_no_usage() {
|
||
let ctx = make_prompt_context_no_owner();
|
||
// usage = None → early return, no panic.
|
||
publish_agent_turn_metric(
|
||
&ctx,
|
||
None,
|
||
None,
|
||
"sess-1",
|
||
"turn-1",
|
||
Some(buzz_core::agent_turn_metric::StopReason::EndTurn),
|
||
)
|
||
.await;
|
||
}
|
||
|
||
/// `publish_agent_turn_metric` is a no-op when `owner_pubkey` is absent.
|
||
#[tokio::test]
|
||
async fn test_publish_agent_turn_metric_noop_on_no_owner() {
|
||
let ctx = make_prompt_context_no_owner();
|
||
let usage = crate::usage::TurnUsage {
|
||
session_id: "sess-1".to_string(),
|
||
turn_seq: 1,
|
||
delta_reliable: true,
|
||
turn_input_tokens: Some(100),
|
||
turn_output_tokens: Some(50),
|
||
turn_cost_usd: None,
|
||
cumulative_input_tokens: 100,
|
||
cumulative_output_tokens: 50,
|
||
cumulative_cost_usd: None,
|
||
model: None,
|
||
};
|
||
// owner_pubkey = None → early return, no panic.
|
||
publish_agent_turn_metric(
|
||
&ctx,
|
||
Some(usage),
|
||
None,
|
||
"sess-1",
|
||
"turn-1",
|
||
Some(buzz_core::agent_turn_metric::StopReason::EndTurn),
|
||
)
|
||
.await;
|
||
}
|
||
|
||
/// `publish_agent_turn_metric` encrypts the payload when owner is present
|
||
/// (the HTTP submit will fail in tests, but we verify no panic and the
|
||
/// encrypt/sign path executes).
|
||
#[tokio::test]
|
||
async fn test_publish_agent_turn_metric_encrypts_with_owner() {
|
||
let agent_keys = nostr::Keys::generate();
|
||
let owner_keys = nostr::Keys::generate();
|
||
let ctx = make_prompt_context_with_owner(&agent_keys, owner_keys.public_key());
|
||
let usage = crate::usage::TurnUsage {
|
||
session_id: "sess-1".to_string(),
|
||
turn_seq: 1,
|
||
delta_reliable: true,
|
||
turn_input_tokens: Some(200),
|
||
turn_output_tokens: Some(80),
|
||
turn_cost_usd: Some(0.001),
|
||
cumulative_input_tokens: 200,
|
||
cumulative_output_tokens: 80,
|
||
cumulative_cost_usd: Some(0.001),
|
||
model: None,
|
||
};
|
||
// Will try to publish and fail (no real relay) but must not panic.
|
||
publish_agent_turn_metric(
|
||
&ctx,
|
||
Some(usage),
|
||
Some(uuid::Uuid::new_v4()),
|
||
"sess-1",
|
||
"turn-1",
|
||
Some(buzz_core::agent_turn_metric::StopReason::EndTurn),
|
||
)
|
||
.await;
|
||
}
|
||
|
||
/// Regression for the control-cancel drain: `publish_agent_turn_metric`
|
||
/// with a `Cancelled` stop reason and pending usage executes without panic
|
||
/// (encrypt+sign path). This mirrors the control-signal arm that previously
|
||
/// returned early without draining usage.
|
||
#[tokio::test]
|
||
async fn test_publish_agent_turn_metric_cancelled_stop_reason() {
|
||
let agent_keys = nostr::Keys::generate();
|
||
let owner_keys = nostr::Keys::generate();
|
||
let ctx = make_prompt_context_with_owner(&agent_keys, owner_keys.public_key());
|
||
let usage = crate::usage::TurnUsage {
|
||
session_id: "sess-cancel".to_string(),
|
||
turn_seq: 2,
|
||
delta_reliable: true,
|
||
turn_input_tokens: Some(50),
|
||
turn_output_tokens: Some(20),
|
||
turn_cost_usd: None,
|
||
cumulative_input_tokens: 150,
|
||
cumulative_output_tokens: 70,
|
||
cumulative_cost_usd: None,
|
||
model: None,
|
||
};
|
||
// Must not panic; HTTP submit will fail (no real relay) — that's fine.
|
||
publish_agent_turn_metric(
|
||
&ctx,
|
||
Some(usage),
|
||
Some(uuid::Uuid::new_v4()),
|
||
"sess-cancel",
|
||
"turn-cancel",
|
||
Some(buzz_core::agent_turn_metric::StopReason::Cancelled),
|
||
)
|
||
.await;
|
||
}
|
||
|
||
/// `publish_agent_turn_metric` uses `ctx.harness_name` in the payload.
|
||
/// A buzz-agent-commanded context must not panic — verifies the harness
|
||
/// field flows through encrypt/sign without error.
|
||
#[tokio::test]
|
||
async fn test_publish_agent_turn_metric_buzz_agent_harness_name() {
|
||
let agent_keys = nostr::Keys::generate();
|
||
let owner_keys = nostr::Keys::generate();
|
||
let mut ctx = make_prompt_context_with_owner(&agent_keys, owner_keys.public_key());
|
||
ctx.harness_name = "buzz-agent".to_string();
|
||
let usage = crate::usage::TurnUsage {
|
||
session_id: "sess-ba".to_string(),
|
||
turn_seq: 1,
|
||
delta_reliable: false, // first turn from buzz-agent
|
||
turn_input_tokens: None,
|
||
turn_output_tokens: None,
|
||
turn_cost_usd: None,
|
||
cumulative_input_tokens: 400,
|
||
cumulative_output_tokens: 100,
|
||
cumulative_cost_usd: None,
|
||
model: None,
|
||
};
|
||
// Will try to publish (encrypt succeeds) and fail HTTP (no relay) — must not panic.
|
||
publish_agent_turn_metric(
|
||
&ctx,
|
||
Some(usage),
|
||
Some(uuid::Uuid::new_v4()),
|
||
"sess-ba",
|
||
"turn-ba",
|
||
Some(buzz_core::agent_turn_metric::StopReason::EndTurn),
|
||
)
|
||
.await;
|
||
}
|
||
|
||
fn make_prompt_context_no_owner() -> PromptContext {
|
||
let agent_keys = nostr::Keys::generate();
|
||
make_prompt_context_impl(&agent_keys, None)
|
||
}
|
||
|
||
fn make_prompt_context_with_owner(
|
||
agent_keys: &nostr::Keys,
|
||
owner_pubkey: nostr::PublicKey,
|
||
) -> PromptContext {
|
||
make_prompt_context_impl(agent_keys, Some(owner_pubkey))
|
||
}
|
||
|
||
fn make_prompt_context_impl(
|
||
agent_keys: &nostr::Keys,
|
||
owner_pubkey: Option<nostr::PublicKey>,
|
||
) -> PromptContext {
|
||
use crate::relay::RestClient;
|
||
PromptContext {
|
||
mcp_servers: vec![],
|
||
initial_message: None,
|
||
idle_timeout: Duration::from_secs(60),
|
||
max_turn_duration: Duration::from_secs(120),
|
||
turn_liveness_interval: Duration::ZERO,
|
||
dedup_mode: DedupMode::Drop,
|
||
system_prompt: None,
|
||
session_title: None,
|
||
team_instructions: None,
|
||
heartbeat_prompt: None,
|
||
base_prompt: None,
|
||
cwd: ".".to_string(),
|
||
rest_client: RestClient {
|
||
http: reqwest::Client::new(),
|
||
base_url: "http://127.0.0.1:0".to_string(),
|
||
keys: agent_keys.clone(),
|
||
auth_tag_json: None,
|
||
},
|
||
channel_info: ChannelInfoResolver::new(
|
||
std::collections::HashMap::new(),
|
||
RestClient {
|
||
http: reqwest::Client::new(),
|
||
base_url: "http://127.0.0.1:0".to_string(),
|
||
keys: agent_keys.clone(),
|
||
auth_tag_json: None,
|
||
},
|
||
),
|
||
context_message_limit: 0,
|
||
max_turns_per_session: 0,
|
||
permission_mode: PermissionMode::Default,
|
||
agent_keys: agent_keys.clone(),
|
||
agent_owner_pubkey: owner_pubkey,
|
||
memory_enabled: false,
|
||
harness_name: "goose".to_string(),
|
||
relay_url: "ws://127.0.0.1:3000".to_string(),
|
||
}
|
||
}
|
||
|
||
// ── render_canvas_section ────────────────────────────────────────────────
|
||
|
||
#[test]
|
||
fn test_render_canvas_section_produces_exact_shape() {
|
||
let id = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2";
|
||
let ts = "2024-01-15T10:30:00+00:00";
|
||
let uuid = "00f1ccaf-1506-4dd7-9a0e-fa67e9e486ae";
|
||
let section = render_canvas_section(id, ts, uuid);
|
||
assert_eq!(
|
||
section,
|
||
"[Channel Canvas]\n\
|
||
Canvas revision (event ID): a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2\n\
|
||
Last modified: 2024-01-15T10:30:00+00:00\n\
|
||
Fetch current content with: buzz canvas get --channel 00f1ccaf-1506-4dd7-9a0e-fa67e9e486ae"
|
||
);
|
||
}
|
||
|
||
// ── with_canvas ──────────────────────────────────────────────────────────
|
||
|
||
#[test]
|
||
fn test_with_canvas_appends_to_existing_prompt() {
|
||
let result = with_canvas(Some("base content".into()), Some("[Channel Canvas]\nstuff"));
|
||
assert_eq!(result.unwrap(), "base content\n\n[Channel Canvas]\nstuff");
|
||
}
|
||
|
||
#[test]
|
||
fn test_with_canvas_returns_canvas_alone_when_no_prompt() {
|
||
let result = with_canvas(None, Some("[Channel Canvas]\nstuff"));
|
||
assert_eq!(result.unwrap(), "[Channel Canvas]\nstuff");
|
||
}
|
||
|
||
#[test]
|
||
fn test_with_canvas_returns_prompt_alone_when_no_canvas() {
|
||
let result = with_canvas(Some("base content".into()), None);
|
||
assert_eq!(result.unwrap(), "base content");
|
||
}
|
||
|
||
#[test]
|
||
fn test_with_canvas_returns_none_when_both_absent() {
|
||
let result = with_canvas(None, None);
|
||
assert!(result.is_none());
|
||
}
|
||
|
||
// ── canvas_sections cache invalidation ───────────────────────────────────
|
||
|
||
#[test]
|
||
fn test_invalidate_channel_clears_canvas_section() {
|
||
let ch = Uuid::new_v4();
|
||
let mut s = SessionState::default();
|
||
s.sessions.insert(ch, "sess".into());
|
||
s.canvas_sections
|
||
.insert(ch, "[Channel Canvas]\nrev abc".into());
|
||
|
||
s.invalidate_channel(&ch);
|
||
|
||
assert!(!s.canvas_sections.contains_key(&ch));
|
||
assert!(!s.sessions.contains_key(&ch));
|
||
}
|
||
|
||
#[test]
|
||
fn test_invalidate_all_clears_canvas_sections() {
|
||
let ch_a = Uuid::new_v4();
|
||
let ch_b = Uuid::new_v4();
|
||
let mut s = SessionState::default();
|
||
s.canvas_sections.insert(ch_a, "canvas-a".into());
|
||
s.canvas_sections.insert(ch_b, "canvas-b".into());
|
||
s.sessions.insert(ch_a, "sess-a".into());
|
||
|
||
s.invalidate_all();
|
||
|
||
assert!(s.canvas_sections.is_empty());
|
||
assert!(s.sessions.is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn test_invalidate_channel_leaves_other_channels_canvas_intact() {
|
||
let ch_a = Uuid::new_v4();
|
||
let ch_b = Uuid::new_v4();
|
||
let mut s = SessionState::default();
|
||
s.sessions.insert(ch_a, "sess-a".into());
|
||
s.sessions.insert(ch_b, "sess-b".into());
|
||
s.canvas_sections.insert(ch_a, "canvas-a".into());
|
||
s.canvas_sections.insert(ch_b, "canvas-b".into());
|
||
|
||
s.invalidate_channel(&ch_a);
|
||
|
||
assert!(!s.canvas_sections.contains_key(&ch_a));
|
||
assert_eq!(s.canvas_sections.get(&ch_b).unwrap(), "canvas-b");
|
||
}
|
||
|
||
#[test]
|
||
fn test_has_channel_state_true_when_only_canvas_section_present() {
|
||
let ch = Uuid::new_v4();
|
||
let mut s = SessionState::default();
|
||
s.canvas_sections.insert(ch, "canvas".into());
|
||
assert!(s.has_channel_state(&ch));
|
||
}
|
||
|
||
// ── canvas_section_from_query_response ───────────────────────────────────
|
||
|
||
const CHANNEL_UUID: &str = "00f1ccaf-1506-4dd7-9a0e-fa67e9e486ae";
|
||
|
||
/// Build a real, cryptographically signed Nostr canvas event for tests.
|
||
///
|
||
/// Includes the correct kind (40100) and an `h` tag carrying `CHANNEL_UUID`
|
||
/// so all structural and content validations pass.
|
||
fn make_canvas_event_value(content: &str) -> serde_json::Value {
|
||
let keys = Keys::generate();
|
||
let h_tag = Tag::parse(["h", CHANNEL_UUID]).expect("h tag");
|
||
let event = EventBuilder::new(Kind::Custom(buzz_core::kind::KIND_CANVAS as u16), content)
|
||
.tags([h_tag])
|
||
.sign_with_keys(&keys)
|
||
.expect("sign");
|
||
serde_json::to_value(&event).expect("serialise")
|
||
}
|
||
|
||
#[test]
|
||
fn test_canvas_section_from_query_response_happy_path() {
|
||
let ev = make_canvas_event_value("# Team instructions\nBe helpful.");
|
||
let id = ev["id"].as_str().unwrap().to_string();
|
||
let result = canvas_section_from_query_response(&[ev], CHANNEL_UUID);
|
||
let section = result.expect("expected Some");
|
||
assert!(section.contains(&id), "section must contain the event id");
|
||
assert!(section.contains("buzz canvas get --channel"));
|
||
assert!(section.contains(CHANNEL_UUID));
|
||
assert!(section.starts_with("[Channel Canvas]"));
|
||
// Timestamp must use Z suffix, not +00:00
|
||
assert!(section.contains('Z'), "timestamp must use Z suffix");
|
||
}
|
||
|
||
#[test]
|
||
fn test_canvas_section_from_query_response_empty_array_returns_none() {
|
||
let result = canvas_section_from_query_response(&[], CHANNEL_UUID);
|
||
assert!(result.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn test_canvas_section_from_query_response_blank_content_returns_none() {
|
||
let ev = make_canvas_event_value(" ");
|
||
let result = canvas_section_from_query_response(&[ev], CHANNEL_UUID);
|
||
assert!(
|
||
result.is_none(),
|
||
"blank content must return None (cleared canvas)"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_canvas_section_from_query_response_empty_content_returns_none() {
|
||
let ev = make_canvas_event_value("");
|
||
let result = canvas_section_from_query_response(&[ev], CHANNEL_UUID);
|
||
assert!(result.is_none());
|
||
}
|
||
|
||
/// A bare JSON object with a plausible-looking id but missing pubkey/sig/kind/tags
|
||
/// must be rejected — not silently accepted with partial metadata.
|
||
#[test]
|
||
fn test_canvas_section_from_query_response_partial_object_returns_none() {
|
||
let partial = serde_json::json!({
|
||
"id": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2",
|
||
"created_at": 1705312200_i64,
|
||
"content": "some instructions"
|
||
});
|
||
let result = canvas_section_from_query_response(&[partial], CHANNEL_UUID);
|
||
assert!(
|
||
result.is_none(),
|
||
"partial event object (missing pubkey/sig/kind/tags) must return None"
|
||
);
|
||
}
|
||
|
||
/// A JSON object that looks like an event but has `created_at` as a string
|
||
/// must be rejected — the nostr::Event parser enforces integer type.
|
||
#[test]
|
||
fn test_canvas_section_from_query_response_string_timestamp_returns_none() {
|
||
let keys = Keys::generate();
|
||
let h_tag = Tag::parse(["h", CHANNEL_UUID]).expect("h tag");
|
||
let mut ev = serde_json::to_value(
|
||
EventBuilder::new(Kind::Custom(buzz_core::kind::KIND_CANVAS as u16), "content")
|
||
.tags([h_tag])
|
||
.sign_with_keys(&keys)
|
||
.expect("sign"),
|
||
)
|
||
.expect("serialise");
|
||
// Corrupt created_at to a string value.
|
||
ev["created_at"] = serde_json::Value::String("2026-03-15T16:30:00+00:00".into());
|
||
let result = canvas_section_from_query_response(&[ev], CHANNEL_UUID);
|
||
assert!(
|
||
result.is_none(),
|
||
"string created_at must be rejected by nostr::Event deserialiser"
|
||
);
|
||
}
|
||
|
||
/// A JSON object that looks like an event but is missing `created_at`
|
||
/// must be rejected — nostr::Event requires the field.
|
||
#[test]
|
||
fn test_canvas_section_from_query_response_missing_timestamp_returns_none() {
|
||
let keys = Keys::generate();
|
||
let h_tag = Tag::parse(["h", CHANNEL_UUID]).expect("h tag");
|
||
let mut ev = serde_json::to_value(
|
||
EventBuilder::new(Kind::Custom(buzz_core::kind::KIND_CANVAS as u16), "content")
|
||
.tags([h_tag])
|
||
.sign_with_keys(&keys)
|
||
.expect("sign"),
|
||
)
|
||
.expect("serialise");
|
||
ev.as_object_mut().unwrap().remove("created_at");
|
||
let result = canvas_section_from_query_response(&[ev], CHANNEL_UUID);
|
||
assert!(
|
||
result.is_none(),
|
||
"missing created_at must be rejected by nostr::Event deserialiser"
|
||
);
|
||
}
|
||
|
||
/// An event with a timestamp at Timestamp::max() (u64::MAX) must return None.
|
||
///
|
||
/// `u64::MAX as i64` wraps to -1, which chrono silently accepts as
|
||
/// 1969-12-31T23:59:59Z. The checked i64::try_from must reject it first.
|
||
#[test]
|
||
fn test_canvas_section_from_query_response_timestamp_max_returns_none() {
|
||
let keys = Keys::generate();
|
||
let h_tag = Tag::parse(["h", CHANNEL_UUID]).expect("h tag");
|
||
let ev = serde_json::to_value(
|
||
EventBuilder::new(Kind::Custom(buzz_core::kind::KIND_CANVAS as u16), "content")
|
||
.tags([h_tag])
|
||
.custom_created_at(Timestamp::max())
|
||
.sign_with_keys(&keys)
|
||
.expect("sign"),
|
||
)
|
||
.expect("serialise");
|
||
let result = canvas_section_from_query_response(&[ev], CHANNEL_UUID);
|
||
assert!(
|
||
result.is_none(),
|
||
"Timestamp::max() (u64::MAX) must return None — not wrap to 1969"
|
||
);
|
||
}
|
||
|
||
/// A structurally complete but tampered event (content altered after signing)
|
||
/// must be rejected by event.verify().
|
||
#[test]
|
||
fn test_canvas_section_from_query_response_tampered_event_returns_none() {
|
||
let keys = Keys::generate();
|
||
let h_tag = Tag::parse(["h", CHANNEL_UUID]).expect("h tag");
|
||
let mut ev = serde_json::to_value(
|
||
EventBuilder::new(
|
||
Kind::Custom(buzz_core::kind::KIND_CANVAS as u16),
|
||
"original",
|
||
)
|
||
.tags([h_tag])
|
||
.sign_with_keys(&keys)
|
||
.expect("sign"),
|
||
)
|
||
.expect("serialise");
|
||
// Tamper the content after signing — id and sig no longer agree.
|
||
ev["content"] = serde_json::Value::String("injected instructions".into());
|
||
let result = canvas_section_from_query_response(&[ev], CHANNEL_UUID);
|
||
assert!(
|
||
result.is_none(),
|
||
"tampered event must fail verify() and return None"
|
||
);
|
||
}
|
||
|
||
/// An event with the wrong kind (not 40100) must be rejected.
|
||
#[test]
|
||
fn test_canvas_section_from_query_response_wrong_kind_returns_none() {
|
||
let keys = Keys::generate();
|
||
let h_tag = Tag::parse(["h", CHANNEL_UUID]).expect("h tag");
|
||
let ev = serde_json::to_value(
|
||
EventBuilder::new(Kind::Custom(9), "content")
|
||
.tags([h_tag])
|
||
.sign_with_keys(&keys)
|
||
.expect("sign"),
|
||
)
|
||
.expect("serialise");
|
||
let result = canvas_section_from_query_response(&[ev], CHANNEL_UUID);
|
||
assert!(result.is_none(), "wrong kind must return None");
|
||
}
|
||
|
||
/// An event missing the expected h-tag (or carrying a different channel UUID)
|
||
/// must be rejected.
|
||
#[test]
|
||
fn test_canvas_section_from_query_response_wrong_h_tag_returns_none() {
|
||
let keys = Keys::generate();
|
||
let wrong_h = Tag::parse(["h", "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"]).expect("h tag");
|
||
let ev = serde_json::to_value(
|
||
EventBuilder::new(Kind::Custom(buzz_core::kind::KIND_CANVAS as u16), "content")
|
||
.tags([wrong_h])
|
||
.sign_with_keys(&keys)
|
||
.expect("sign"),
|
||
)
|
||
.expect("serialise");
|
||
let result = canvas_section_from_query_response(&[ev], CHANNEL_UUID);
|
||
assert!(result.is_none(), "mismatched h-tag must return None");
|
||
}
|
||
|
||
#[test]
|
||
fn test_canvas_section_from_query_response_timestamp_uses_z_suffix() {
|
||
let ev = make_canvas_event_value("instructions");
|
||
let result = canvas_section_from_query_response(&[ev], CHANNEL_UUID);
|
||
let section = result.expect("valid event must produce a section");
|
||
assert!(
|
||
section.contains('Z'),
|
||
"RFC3339 timestamp must use Z suffix, not +00:00"
|
||
);
|
||
assert!(
|
||
!section.contains("+00:00"),
|
||
"timestamp must not use +00:00 offset"
|
||
);
|
||
}
|
||
|
||
// ── new-session channel context (one resolve, two consumers) ─────────────
|
||
|
||
/// A [`ChannelInfoResolver`] whose lazy REST fallback is served by a local
|
||
/// HTTP server, plus a counter of the requests that actually reached it.
|
||
/// Counting real requests is the point: the composition tests are pure and
|
||
/// cannot see duplicated I/O.
|
||
async fn counting_resolver(
|
||
response: serde_json::Value,
|
||
) -> (
|
||
ChannelInfoResolver,
|
||
std::sync::Arc<std::sync::atomic::AtomicUsize>,
|
||
tokio::task::JoinHandle<()>,
|
||
) {
|
||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||
|
||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||
.await
|
||
.expect("bind test HTTP server");
|
||
let base_url = format!("http://{}", listener.local_addr().unwrap());
|
||
let requests = std::sync::Arc::new(AtomicUsize::new(0));
|
||
let server_requests = requests.clone();
|
||
let body = response.to_string();
|
||
let server = tokio::spawn(async move {
|
||
while let Ok((mut socket, _)) = listener.accept().await {
|
||
let mut buf = vec![0; 8192];
|
||
let _ = socket.read(&mut buf).await;
|
||
server_requests.fetch_add(1, Ordering::SeqCst);
|
||
let response = format!(
|
||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||
body.len(),
|
||
body
|
||
);
|
||
let _ = socket.write_all(response.as_bytes()).await;
|
||
}
|
||
});
|
||
let rest = crate::relay::RestClient {
|
||
http: reqwest::Client::new(),
|
||
base_url,
|
||
keys: nostr::Keys::generate(),
|
||
auth_tag_json: None,
|
||
};
|
||
(
|
||
ChannelInfoResolver::new(std::collections::HashMap::new(), rest),
|
||
requests,
|
||
server,
|
||
)
|
||
}
|
||
|
||
fn channel_metadata_response(id: Uuid, tags: &[[&str; 2]]) -> serde_json::Value {
|
||
let mut event_tags = vec![json!(["d", id.to_string()])];
|
||
event_tags.extend(tags.iter().map(|[k, v]| json!([k, v])));
|
||
json!([{ "tags": event_tags }])
|
||
}
|
||
|
||
/// A normal channel yields a non-DM (canvas allowed) and its name for the
|
||
/// title suffix — and the second consumer reads it from cache, not the wire.
|
||
#[tokio::test]
|
||
async fn test_new_session_channel_context_qualifies_a_normal_channel() {
|
||
use std::sync::atomic::Ordering;
|
||
|
||
let id = Uuid::new_v4();
|
||
let response = channel_metadata_response(id, &[["name", "buzz-dev"], ["t", "stream"]]);
|
||
let (resolver, requests, server) = counting_resolver(response).await;
|
||
|
||
let (is_dm, title_channel) = resolve_new_session_channel_context(&resolver, id).await;
|
||
assert!(!is_dm, "a stream channel is not a DM");
|
||
assert_eq!(title_channel.as_deref(), Some("buzz-dev"));
|
||
assert_eq!(requests.load(Ordering::SeqCst), 1);
|
||
|
||
let (_, again) = resolve_new_session_channel_context(&resolver, id).await;
|
||
assert_eq!(again.as_deref(), Some("buzz-dev"));
|
||
assert_eq!(
|
||
requests.load(Ordering::SeqCst),
|
||
1,
|
||
"a resolved channel is cached — no second lookup"
|
||
);
|
||
server.abort();
|
||
}
|
||
|
||
/// A DM carries no useful name, so it gets the bare agent title (and no
|
||
/// canvas section).
|
||
#[tokio::test]
|
||
async fn test_new_session_channel_context_leaves_a_dm_unqualified() {
|
||
let id = Uuid::new_v4();
|
||
let response = channel_metadata_response(id, &[["name", "DM"], ["t", "dm"]]);
|
||
let (resolver, _requests, server) = counting_resolver(response).await;
|
||
|
||
let (is_dm, title_channel) = resolve_new_session_channel_context(&resolver, id).await;
|
||
assert!(is_dm);
|
||
assert_eq!(
|
||
title_channel, None,
|
||
"a DM name must never reach the session title"
|
||
);
|
||
server.abort();
|
||
}
|
||
|
||
/// The `"unknown"` placeholder `fetch_channel_info` substitutes for a
|
||
/// metadata event with no `name` tag is not a channel name: qualifying with
|
||
/// it would title every unnamed channel `Agent · #unknown`.
|
||
#[tokio::test]
|
||
async fn test_new_session_channel_context_treats_the_unknown_name_as_absent() {
|
||
let id = Uuid::new_v4();
|
||
let response = channel_metadata_response(id, &[["t", "stream"]]);
|
||
let (resolver, _requests, server) = counting_resolver(response).await;
|
||
|
||
let (is_dm, title_channel) = resolve_new_session_channel_context(&resolver, id).await;
|
||
assert!(!is_dm, "a nameless stream channel is still not a DM");
|
||
assert_eq!(
|
||
title_channel, None,
|
||
"the `unknown` placeholder must yield a bare title"
|
||
);
|
||
server.abort();
|
||
}
|
||
|
||
/// An unresolvable channel yields the bare title, fails closed as a DM, and
|
||
/// costs exactly ONE `fetch_channel_info` sequence — two attempts, because
|
||
/// `fetch_with_retry` retries once. `resolve()` caches only `Some`, so a
|
||
/// second resolve for the title would double this in front of `session/new`,
|
||
/// exactly when the relay is already degraded.
|
||
#[tokio::test]
|
||
async fn test_new_session_channel_context_attempts_an_unresolved_channel_once() {
|
||
use std::sync::atomic::Ordering;
|
||
|
||
let (resolver, requests, server) = counting_resolver(json!([])).await;
|
||
|
||
let (is_dm, title_channel) =
|
||
resolve_new_session_channel_context(&resolver, Uuid::new_v4()).await;
|
||
assert!(is_dm, "an undeterminable channel type must fail closed");
|
||
assert_eq!(title_channel, None, "unresolved channels get a bare title");
|
||
assert_eq!(
|
||
requests.load(Ordering::SeqCst),
|
||
2,
|
||
"one fetch_channel_info sequence (initial attempt + single retry)"
|
||
);
|
||
server.abort();
|
||
}
|
||
}
|