From ad91e0420f8eae6d5820a5a00e84c4d3e65e0ef1 Mon Sep 17 00:00:00 2001 From: bbamnesia <68875027+bbamnesia@users.noreply.github.com> Date: Wed, 22 Jul 2026 01:16:59 -0400 Subject: [PATCH] feat(buzz-acp): isolate sessions by conversation thread Signed-off-by: bbamnesia <68875027+bbamnesia@users.noreply.github.com> --- crates/buzz-acp/README.md | 71 +- crates/buzz-acp/src/config.rs | 81 +- crates/buzz-acp/src/lib.rs | 2924 +++++++++++++++-- crates/buzz-acp/src/observer.rs | 96 + crates/buzz-acp/src/pool.rs | 2329 +++++++++++-- crates/buzz-acp/src/queue.rs | 1212 ++++--- crates/buzz-acp/src/relay.rs | 10 + crates/buzz-acp/src/scope.rs | 334 ++ crates/buzz-core/src/lib.rs | 2 + crates/buzz-core/src/thread.rs | 159 + crates/buzz-relay/src/handlers/ingest.rs | 57 +- .../agents/lib/liveSwitchOutcome.test.mjs | 62 + .../features/agents/lib/liveSwitchOutcome.ts | 37 +- .../src/features/agents/observerRelayStore.ts | 119 +- .../agents/ui/AgentSessionTranscriptList.tsx | 14 +- .../src/features/agents/ui/ModelPicker.tsx | 15 +- .../agents/ui/agentSessionPermission.ts | 89 + .../agents/ui/agentSessionTranscript.test.mjs | 180 + .../agents/ui/agentSessionTranscript.ts | 254 +- .../agentSessionTranscriptGrouping.test.mjs | 121 +- .../ui/agentSessionTranscriptGrouping.ts | 83 +- .../features/agents/ui/agentSessionTypes.ts | 3 + desktop/src/shared/api/agentControl.ts | 2 + desktop/src/shared/api/controlResultTypes.ts | 22 + desktop/src/shared/api/types.ts | 22 +- 25 files changed, 6955 insertions(+), 1343 deletions(-) create mode 100644 crates/buzz-acp/src/scope.rs create mode 100644 crates/buzz-core/src/thread.rs create mode 100644 desktop/src/features/agents/ui/agentSessionPermission.ts create mode 100644 desktop/src/shared/api/controlResultTypes.ts diff --git a/crates/buzz-acp/README.md b/crates/buzz-acp/README.md index e6164b02dd..fcf30d35be 100644 --- a/crates/buzz-acp/README.md +++ b/crates/buzz-acp/README.md @@ -152,10 +152,19 @@ The gate applies to **all** inbound events — @mentions, DMs, thread replies, a | Command | Effect | |---------|--------| | `!shutdown` | Gracefully exits the harness. | -| `!cancel` | Cancels the current in-flight turn for that channel, if any. | -| `!rotate` | Rotates the ACP session for that channel. If a turn is in-flight, it is cancelled and the channel session is invalidated when the task returns; otherwise the cached idle session is invalidated immediately. The next queued/received event starts a fresh session. | +| `!cancel` | Cancels the current in-flight turn for the conversation the command targets, if any. | +| `!rotate` | Rotates the ACP session for the conversation the command targets. If a turn is in-flight for that conversation, it is cancelled and the session is invalidated when the task returns; otherwise the cached idle session is invalidated immediately. The next queued/received event in that conversation starts a fresh session. | -Use `!cancel` to stop only the current turn; it is a no-op when the channel is idle. Use `!rotate` when you want the next turn in the channel to start from a fresh ACP session, even if the channel is currently idle. +`!cancel` and `!rotate` are **conversation-scoped** (see [Session Scope](#session-scope)): +send the command **as a reply inside a thread** to target that thread's session, +or as a **bare (unthreaded) channel message** to target the channel-level +conversation. A thread-scoped command never cancels or rotates sibling threads +or the channel conversation, and a channel-level command never destroys thread +sessions. + +Use `!cancel` to stop only the current turn; it is a no-op when the targeted +conversation is idle. Use `!rotate` when you want the next turn in that +conversation to start from a fresh ACP session, even if it is currently idle. Owner control commands must be kind:9 stream messages from the owner, must mention this agent with a `p` tag, and are consumed by the harness instead of being forwarded to the agent. @@ -203,7 +212,55 @@ buzz-acp --agents 2 --heartbeat-interval 300 \ ### Shared Identity -All N agents authenticate as the **same Nostr bot identity** — users see one bot regardless of how many agents are running. The same channel is never processed by two agents simultaneously (the queue enforces this). Cross-channel message ordering is not guaranteed when N>1. +All N agents authenticate as the **same Nostr bot identity** — users see one bot regardless of how many agents are running. The same conversation (see [Session Scope](#session-scope)) is never processed by two agents simultaneously (the queue enforces this); different conversations — including different threads in one channel — can run concurrently when N>1. Cross-conversation message ordering is not guaranteed when N>1. + +### Session Scope + +The harness keys ACP sessions (and queueing, steering, turn counters, and +`!cancel`/`!rotate` targeting) by **conversation**, not just by channel: + +- **Unthreaded channel messages** share one channel-level conversation — one + continuous ACP session per channel, as before. +- **Thread replies** in a regular channel are scoped by `(channel, thread + root)`: every thread gets its own isolated ACP session. Replies in the same + thread reuse that thread's session; separate threads in the same channel + never share or pollute each other's sessions, and can be in-flight + simultaneously. +- **Forum posts** (kind 45001) are thread roots: a post scopes to its own + event ID, and comments on it (kind 45003) resolve to the same + conversation, so a forum post and its comment thread share one session + while separate posts in the same channel stay isolated. +- **DMs** always use channel-level continuity — one session per DM + conversation, even for replies with thread tags. +- **Channels joined after startup** resolve and cache their metadata before + the harness opens event delivery. Failed lookups retry while the membership + remains active. The first delivered event therefore scopes normally — DMs + channel-level, regular/forum channels per-thread — without an + event-before-metadata fallback that could split one conversation. + +For stream messages the thread root is the canonical NIP-10 `root` marker +from the reply's `e` tags. Note a stream thread's root message is itself an +unthreaded channel message — it runs in the channel-level conversation; the +thread's own session begins with the first reply. + +**Stable agent ownership (N > 1):** a conversation's session history lives in +exactly one agent process, so each conversation is pinned to the agent that +serves it. If that agent is busy on other work when new events arrive, the +conversation waits for it (fairness position preserved) instead of being +forked onto another agent. Ownership is released when the session is rotated +or invalidated, or when the owning agent process dies — the next turn then +starts a fresh session on any agent. + +**Desktop model switching** is channel-level desired state: a `switch_model` +control frame updates every idle agent holding sessions under the channel, +signals every in-flight conversation under it, and is inherited by any agent +that later picks up a conversation in that channel. + +When the agent is removed from a channel, every conversation under that +channel — channel-level and all threads — is drained and its sessions are +invalidated. This is agent conversation routing only: channel identity, +subscriptions, and NIP-29 authorization remain keyed by the `h`-tag channel +UUID. ### Heartbeat Semantics @@ -252,12 +309,12 @@ Forum event kinds: 1. **Startup** — Spawns N agent subprocesses (default 1), sends ACP `initialize` to each, connects to the relay with NIP-42 auth. 2. **Channel discovery** — Queries the relay REST API for accessible channels, subscribes to each. -3. **Event loop** — Listens for @mention events (kind 9 with the agent's pubkey in a `#p` tag). Events queue per channel. -4. **Prompting** — When events are pending and no prompt is in flight for that channel, drains all queued events for the oldest channel into a single batched prompt via ACP `session/prompt`. +3. **Event loop** — Listens for @mention events (kind 9 with the agent's pubkey in a `#p` tag). Events queue per conversation (channel-level or thread — see [Session Scope](#session-scope)). +4. **Prompting** — When events are pending and no prompt is in flight for that conversation, drains all queued events for the oldest conversation into a single batched prompt via ACP `session/prompt`. Events from different conversations are never batched together. 5. **Agent response** — The agent processes the prompt and uses the Buzz CLI (`send_message`, `get_messages`, etc.) to interact with Buzz. 6. **Recovery** — If the agent crashes, the harness respawns it. If the relay disconnects, the harness reconnects with a `since` filter to avoid missing events. -Each channel has at most one prompt in flight. Multiple channels can be processed concurrently when agents > 1. +Each conversation has at most one prompt in flight. Multiple conversations — including multiple threads within one channel — can be processed concurrently when agents > 1. > **Note:** On startup, the harness replays all unprocessed @mentions since the last run. Expect a burst of activity if there are stale events in the channel. diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index 9a1b74c276..c4488892fb 100644 --- a/crates/buzz-acp/src/config.rs +++ b/crates/buzz-acp/src/config.rs @@ -600,29 +600,53 @@ fn sanitize_session_title(raw: &str) -> Option { /// U+00B7 MIDDLE DOT, spaces on both sides. const SESSION_TITLE_SEPARATOR: &str = " · "; -/// Compose a per-session title as `Agent · #channel`. +/// Compose a per-session title as `Agent · #channel`, optionally followed by +/// a short canonical thread identity. /// /// One agent in five channels gets five sessions; a bare agent name would show -/// five identical rows in the adapter's thread list. Only the channel part is -/// truncated to fit [`SESSION_TITLE_MAX_CHARS`], so the agent name always -/// survives. Returns the bare agent name when there is no channel, the channel -/// name is blank, or no room is left for it. -pub(crate) fn compose_session_title(agent: &str, channel_name: Option<&str>) -> String { +/// five identical rows in the adapter's thread list. Concurrent threads in one +/// channel also need distinct rows, so thread-scoped sessions append only the +/// first eight hexadecimal characters of the root — never message content. +/// Unthreaded and DM titles retain the historical exact format. +pub(crate) fn compose_session_title( + agent: &str, + channel_name: Option<&str>, + thread_root: Option<&str>, +) -> String { + let thread_suffix = thread_root.and_then(|root| { + let short: String = root.chars().take(8).collect(); + (short.len() == 8 && short.chars().all(|ch| ch.is_ascii_hexdigit())).then(|| { + format!( + "{SESSION_TITLE_SEPARATOR}thread {}", + short.to_ascii_lowercase() + ) + }) + }); + let prefix_cap = SESSION_TITLE_MAX_CHARS.saturating_sub( + thread_suffix + .as_deref() + .map(|suffix| suffix.chars().count()) + .unwrap_or_default(), + ); + let agent: String = agent.chars().take(prefix_cap).collect(); let Some(channel) = channel_name.and_then(sanitize_session_title) else { - return agent.to_string(); + return format!("{agent}{}", thread_suffix.as_deref().unwrap_or_default()); }; // Reserve the separator and the `#` sigil alongside the agent name. let reserved = agent.chars().count() + SESSION_TITLE_SEPARATOR.chars().count() + 1; let channel: String = channel .chars() - .take(SESSION_TITLE_MAX_CHARS.saturating_sub(reserved)) + .take(prefix_cap.saturating_sub(reserved)) .collect::() .trim_end() .to_string(); if channel.is_empty() { - return agent.to_string(); + return format!("{agent}{}", thread_suffix.as_deref().unwrap_or_default()); } - format!("{agent}{SESSION_TITLE_SEPARATOR}#{channel}") + format!( + "{agent}{SESSION_TITLE_SEPARATOR}#{channel}{}", + thread_suffix.as_deref().unwrap_or_default() + ) } /// Validate and deduplicate allowlist entries: each must be exactly 64 hex chars. @@ -2817,21 +2841,21 @@ channels = "ALL" #[test] fn compose_session_title_qualifies_the_agent_name_with_the_channel() { assert_eq!( - compose_session_title("Fizz", Some("buzz-dev")), + compose_session_title("Fizz", Some("buzz-dev"), None), "Fizz · #buzz-dev" ); } #[test] fn compose_session_title_falls_back_to_bare_agent_name_without_a_channel() { - assert_eq!(compose_session_title("Fizz", None), "Fizz"); - assert_eq!(compose_session_title("Fizz", Some(" ")), "Fizz"); + assert_eq!(compose_session_title("Fizz", None, None), "Fizz"); + assert_eq!(compose_session_title("Fizz", Some(" "), None), "Fizz"); } #[test] fn compose_session_title_truncates_the_channel_and_keeps_the_agent_name() { let channel = "c".repeat(200); - let title = compose_session_title("Fizz", Some(&channel)); + let title = compose_session_title("Fizz", Some(&channel), None); assert_eq!(title.chars().count(), SESSION_TITLE_MAX_CHARS); assert!(title.starts_with("Fizz · #c")); } @@ -2839,6 +2863,33 @@ channels = "ALL" #[test] fn compose_session_title_drops_the_channel_when_the_agent_name_fills_the_cap() { let agent = "a".repeat(SESSION_TITLE_MAX_CHARS); - assert_eq!(compose_session_title(&agent, Some("buzz-dev")), agent); + assert_eq!(compose_session_title(&agent, Some("buzz-dev"), None), agent); + } + + #[test] + fn compose_session_title_distinguishes_threads_with_bounded_safe_roots() { + let first = compose_session_title( + "Fizz", + Some("buzz-dev"), + Some("ABCDEF0123456789secret text"), + ); + let second = compose_session_title( + "Fizz", + Some("buzz-dev"), + Some("1234567890abcdefother content"), + ); + assert_eq!(first, "Fizz · #buzz-dev · thread abcdef01"); + assert_eq!(second, "Fizz · #buzz-dev · thread 12345678"); + assert_ne!(first, second); + assert!(!first.contains("secret")); + assert!(first.chars().count() <= SESSION_TITLE_MAX_CHARS); + } + + #[test] + fn compose_thread_title_preserves_identity_when_channel_is_unresolved() { + assert_eq!( + compose_session_title("Fizz", None, Some(&"a".repeat(64))), + "Fizz · thread aaaaaaaa" + ); } } diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index d63f720c65..eccba9ed41 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -9,6 +9,7 @@ mod pool; mod pool_lifecycle; mod queue; mod relay; +mod scope; mod setup_mode; mod usage; @@ -37,12 +38,14 @@ use filter::SubscriptionRule; use futures_util::FutureExt; use nostr::{PublicKey, ToBech32}; use pool::{ - AgentPool, ControlSignal, IdleSwitchResult, OwnedAgent, PromptContext, PromptOutcome, - PromptResult, PromptSource, SessionState, TimeoutKind, + AgentPool, ChannelModelControls, ControlSignal, IdleSwitchResult, ModelControlDecision, + ModelControlResult, OwnedAgent, PromptContext, PromptOutcome, PromptResult, PromptSource, + SessionState, TimeoutKind, }; use pool_lifecycle::PoolLifecycle; use queue::{CancelReason, EventQueue, FlushBatch, QueuedEvent, ThreadTags}; use relay::{HarnessRelay, RelayEventPublisher}; +use scope::ConversationScope; use tokio::sync::{mpsc, watch}; use tracing_subscriber::EnvFilter; use uuid::Uuid; @@ -531,6 +534,7 @@ struct ObserverChunkKey { update_type: String, message_id: Option, channel_id: Option, + thread_root: Option, session_id: Option, turn_id: Option, agent_index: Option, @@ -603,6 +607,7 @@ fn observer_chunk_key_and_text( update_type: update_type.to_string(), message_id, channel_id: event.channel_id.clone(), + thread_root: event.thread_root.clone(), session_id: event.session_id.clone(), turn_id: event.turn_id.clone(), agent_index: event.agent_index, @@ -906,14 +911,17 @@ fn handle_cancel_turn_control( return; }; - let fired = signal_in_flight_task(pool, channel_id, ControlSignal::Cancel); - let status = if fired { "sent" } else { "no_active_turn" }; + // Desktop control frames are addressed by channel only; cancel every + // in-flight conversation under the channel (channel-level + threads). + let fired = signal_in_flight_tasks_for_channel(pool, channel_id, ControlSignal::Cancel); + let status = if fired > 0 { "sent" } else { "no_active_turn" }; if let Some(observer) = observer { observer.emit( "control_result", None, &observer::ObserverContext { channel_id: Some(channel_id.to_string()), + thread_root: None, session_id: None, turn_id: None, started_at: None, @@ -928,15 +936,11 @@ fn handle_cancel_turn_control( /// Handle a `switch_model` control frame (Phase 3a, Option ii). /// -/// Busy path: deliver `SwitchModel` over the in-flight task's oneshot — the -/// task cancels the turn, sets `desired_model`, and requeues the batch so it -/// re-runs on a fresh session under the new model. A catalog miss surfaces -/// post-cancel via `create_session_and_apply_model` (the turn restarts on the -/// unchanged model + an `unsupported_model` result). -/// -/// Idle path: validate against the cached catalog *before* invalidating -/// (pre-cancel guard), then set `desired_model` + invalidate. The override -/// takes visible effect on the agent's next turn. +/// Catalog validation and desired state are process-lifetime concerns, not +/// properties of an idle pool slot. A known unsupported request is rejected +/// before any turn is disturbed. Before the first `session/new`, one pending +/// request per channel is retained and resolved exactly once when the catalog +/// arrives. fn handle_switch_model_control( payload: &serde_json::Value, pool: &mut AgentPool, @@ -954,51 +958,98 @@ fn handle_switch_model_control( tracing::warn!("observer switch_model control frame missing modelId"); return; }; + let request_id = payload + .get("requestId") + .and_then(|value| value.as_str()) + .filter(|value| { + !value.is_empty() + && value.len() <= 128 + && value + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_')) + }); + + // Desktop model selection is channel-level desired state. Record it and + // apply it to every idle session holder under the channel FIRST (also + // pre-validating against the cached catalog), then signal every active + // conversation under the channel. Agents that later claim a conversation + // in this channel inherit the desired model at claim time, so idle, + // active, and future holders all converge on the same model. + let (decision, idle_result) = pool.set_channel_desired_model(channel_id, model_id, request_id); + for result in decision.superseded() { + emit_model_control_result(observer, result); + } - // A turn is in flight for this channel iff a task_map entry exists. The - // agent is moved out of the pool during a turn, so the control oneshot is - // the only reachable lever; an idle channel has no such entry. - let turn_in_flight = pool - .task_map() - .values() - .any(|m| m.channel_id == Some(channel_id)); - - let status = if turn_in_flight { - // Busy path: deliver over the oneshot. `false` means the oneshot was - // already consumed this turn (a prior cancel/interrupt) — the turn is - // already ending, so the switch cannot land on it. - if signal_in_flight_task( - pool, + let result = match decision { + ModelControlDecision::Unsupported { generation, .. } => Some(ModelControlResult { channel_id, - ControlSignal::SwitchModel(model_id.to_string()), - ) { - "sent" - } else { - "turn_ending" - } - } else { - // Idle path: validate against the cached catalog before invalidating. - match pool.switch_idle_agent_model(channel_id, model_id) { - IdleSwitchResult::Switched => "switched", - IdleSwitchResult::UnsupportedModel => "unsupported_model", - IdleSwitchResult::NoIdleAgent => "no_active_turn", + model_id: model_id.to_string(), + generation, + request_id: request_id.map(ToOwned::to_owned), + status: "unsupported_model", + }), + ModelControlDecision::PendingCatalog { .. } => None, + ModelControlDecision::Accepted { generation, .. } => { + // Fan out to every in-flight conversation under the channel (desktop + // frames carry no thread identity). `0` fired with a turn in flight + // means every oneshot was already consumed (a prior cancel/interrupt) + // — those turns are ending; the recorded desired state still lands on + // their requeued/next turns via claim-time inheritance. + let turn_in_flight = pool + .task_map() + .values() + .any(|m| m.scope.is_some_and(|s| s.channel_id == channel_id)); + let fired = signal_in_flight_tasks_for_channel( + pool, + channel_id, + ControlSignal::SwitchModel { generation }, + ); + let status = if fired > 0 { + "sent" + } else if turn_in_flight { + "turn_ending" + } else if idle_result == IdleSwitchResult::Switched { + "switched" + } else { + "no_active_turn" + }; + Some(ModelControlResult { + channel_id, + model_id: model_id.to_string(), + generation, + request_id: request_id.map(ToOwned::to_owned), + status, + }) } }; + if let Some(result) = result { + emit_model_control_result(observer, &result); + } +} + +fn emit_model_control_result( + observer: Option<&observer::ObserverHandle>, + result: &ModelControlResult, +) { if let Some(observer) = observer { observer.emit( "control_result", None, &observer::ObserverContext { - channel_id: Some(channel_id.to_string()), + channel_id: Some(result.channel_id.to_string()), + thread_root: None, session_id: None, turn_id: None, started_at: None, }, serde_json::json!({ "type": "switch_model", - "status": status, - "modelId": model_id, + "status": result.status, + "channelId": result.channel_id, + "modelId": result.model_id, + "generation": result.generation, + "requestId": result.request_id, }), ); } @@ -1151,10 +1202,11 @@ struct RespawnResult { /// stream. /// /// Carries enough identity to operate on the right withheld event in -/// `EventQueue::withheld_native_steer`: `channel_id` is the routing key, -/// `event_id` is the hex id of the single event the steer carried. +/// `EventQueue::withheld_native_steer`: the conversation `scope` is the +/// routing key, `event_id` is the hex id of the single event the steer +/// carried. struct SteerAckEvent { - channel_id: Uuid, + scope: ConversationScope, event_id: String, /// `Ok` if the read loop sent any of the locked `SteerAck` variants. /// `Err` if the oneshot was dropped without a send — should not happen @@ -1164,6 +1216,39 @@ struct SteerAckEvent { ack: std::result::Result, } +/// A dynamically joined channel is not exposed to event delivery until its +/// metadata is cached. The membership event ID is a generation token: a late +/// resolver result is ignored if the channel was removed/re-added meanwhile. +#[derive(Debug, Clone, PartialEq, Eq)] +struct DynamicChannelSubscriptionReady { + channel_id: Uuid, + membership_event_id: String, + replay_since: u64, +} + +const DYNAMIC_CHANNEL_METADATA_RETRY_DELAY: Duration = Duration::from_secs(5); + +/// Resolve metadata before signalling that a dynamic subscription may open. +/// Failed lookups retry indefinitely while the membership remains pending; +/// the caller owns the task handle and aborts it on removal/shutdown. +async fn resolve_metadata_before_dynamic_subscribe( + mut resolve: F, + ready: DynamicChannelSubscriptionReady, + ready_tx: mpsc::UnboundedSender, + retry_delay: Duration, +) where + F: FnMut() -> Fut, + Fut: std::future::Future, +{ + loop { + if resolve().await { + let _ = ready_tx.send(ready); + return; + } + tokio::time::sleep(retry_delay).await; + } +} + /// RAII guard that ensures a `RespawnResult` is sent even if the task panics. /// Without this, a panicked respawn task would leave `respawn_in_flight = true` /// permanently, silently losing the slot forever. @@ -1312,10 +1397,18 @@ async fn tokio_main() -> Result<()> { ); } + let model_controls = ChannelModelControls::default(); let mut pool = if config.lazy_pool { - AgentPool::from_slots((0..config.agents).map(|_| None).collect()) + AgentPool::from_slots_with_model_controls( + (0..config.agents).map(|_| None).collect(), + model_controls.clone(), + ) } else { - initialize_agent_pool(&PoolStartup::from_config(&config, observer.clone()), None).await? + initialize_agent_pool( + &PoolStartup::from_config(&config, observer.clone(), model_controls.clone()), + None, + ) + .await? }; let mut pool_ready = !config.lazy_pool; let mut pool_lifecycle: PoolLifecycle = PoolLifecycle::listening(); @@ -1559,6 +1652,7 @@ async fn tokio_main() -> Result<()> { memory_enabled: config.memory_enabled, harness_name: crate::config::normalize_agent_command_identity(&config.agent_command), relay_url: config.relay_url.clone(), + model_controls: model_controls.clone(), }); if !config.memory_enabled { @@ -1598,7 +1692,7 @@ async fn tokio_main() -> Result<()> { } else { None }; - let mut typing_channels: HashMap = HashMap::new(); + let mut typing_channels: HashMap = HashMap::new(); let mut presence_task: Option> = None; // Runs at the TOP of every loop iteration via Instant check — cannot be @@ -1627,6 +1721,17 @@ async fn tokio_main() -> Result<()> { // `IN_FLIGHT_DEADLINE_SECS` expires. let (steer_ack_tx, mut steer_ack_rx) = mpsc::unbounded_channel::(); + // Metadata resolution gate for channels joined after startup. Resolver + // tasks never subscribe directly: they populate `ctx.channel_info` first, + // then notify the main loop, which opens event delivery only if the same + // membership generation is still pending. + let (dynamic_channel_ready_tx, mut dynamic_channel_ready_rx) = + mpsc::unbounded_channel::(); + let mut pending_dynamic_channel_subscriptions: HashMap< + Uuid, + (String, tokio::task::JoinHandle<()>), + > = HashMap::new(); + // ── Step 7: Shutdown signal ─────────────────────────────────────────────── let (shutdown_tx, mut shutdown_rx) = watch::channel(()); @@ -1700,7 +1805,7 @@ async fn tokio_main() -> Result<()> { Result(Box), Panic(tokio::task::JoinError), SteerAck(SteerAckEvent), - Wake(u32, Result), + Wake(u32, Box>), } loop { @@ -1723,7 +1828,8 @@ async fn tokio_main() -> Result<()> { "waking", None, ); - let startup = PoolStartup::from_config(&config, observer.clone()); + let startup = + PoolStartup::from_config(&config, observer.clone(), model_controls.clone()); let wake_tx = wake_tx.clone(); let wake_shutdown = shutdown_rx.clone(); wake_tasks.spawn(async move { @@ -1774,8 +1880,8 @@ async fn tokio_main() -> Result<()> { // called on relay events or pool results, neither of which // arrive when the channel is silent. if queue.has_flushable_work() { - for (channel_id, thread_tags) in dispatch_pending(&mut pool, &mut queue, &ctx) { - typing_channels.insert(channel_id, thread_tags); + for (scope, thread_tags) in dispatch_pending(&mut pool, &mut queue, &ctx) { + typing_channels.insert(scope, thread_tags); } } } @@ -1790,7 +1896,9 @@ async fn tokio_main() -> Result<()> { acp, state: SessionState::default(), model_capabilities: None, + baseline_model: config.model.clone(), desired_model: config.model.clone(), + desired_model_generation: None, model_overridden: false, agent_name, goose_system_prompt_supported: None, @@ -1810,8 +1918,8 @@ async fn tokio_main() -> Result<()> { // this, batches requeued during crash recovery sit idle until the // next relay event arrives — which can be minutes on quiet channels. if respawn_collected { - for (channel_id, thread_tags) in dispatch_pending(&mut pool, &mut queue, &ctx) { - typing_channels.insert(channel_id, thread_tags); + for (scope, thread_tags) in dispatch_pending(&mut pool, &mut queue, &ctx) { + typing_channels.insert(scope, thread_tags); } } @@ -1844,7 +1952,7 @@ async fn tokio_main() -> Result<()> { Some(PoolEvent::SteerAck(ack_event)) } Some((attempt, result)) = wake_rx.recv(), if config.lazy_pool && !pool_ready => { - Some(PoolEvent::Wake(attempt, result)) + Some(PoolEvent::Wake(attempt, Box::new(result))) } // Gated on pending work: with an empty queue there is nothing // for the retry to dispatch, and a past `retry_at` would @@ -1900,6 +2008,75 @@ async fn tokio_main() -> Result<()> { } None } + Some(ready) = dynamic_channel_ready_rx.recv() => { + let _ = result_rx; + let still_pending = pending_dynamic_channel_subscriptions + .get(&ready.channel_id) + .is_some_and(|(event_id, _)| { + event_id == &ready.membership_event_id + }); + if !still_pending { + tracing::debug!( + channel_id = %ready.channel_id, + "ignoring stale dynamic-channel metadata result" + ); + } else if removed_channels.contains(&ready.channel_id) { + if let Some((_, task)) = pending_dynamic_channel_subscriptions + .remove(&ready.channel_id) + { + task.abort(); + } + } else if subscribed_channel_ids.contains(&ready.channel_id) { + pending_dynamic_channel_subscriptions.remove(&ready.channel_id); + } else if let Some(filter) = config::resolve_dynamic_channel_filter( + &config, + ready.channel_id, + &rules, + ) { + tracing::info!( + channel_id = %ready.channel_id, + "metadata cached: subscribing to dynamically joined channel" + ); + match relay + .subscribe_channel_from( + ready.channel_id, + filter, + Some(ready.replay_since), + ) + .await + { + Ok(()) => { + subscribed_channel_ids.insert(ready.channel_id); + pending_dynamic_channel_subscriptions + .remove(&ready.channel_id); + } + Err(error) => { + tracing::warn!( + channel_id = %ready.channel_id, + "failed to subscribe to dynamically joined channel: {error}; retrying" + ); + let retry_ready = ready.clone(); + let retry_tx = dynamic_channel_ready_tx.clone(); + let retry_task = tokio::spawn(async move { + tokio::time::sleep( + DYNAMIC_CHANNEL_METADATA_RETRY_DELAY, + ) + .await; + let _ = retry_tx.send(retry_ready); + }); + pending_dynamic_channel_subscriptions.insert( + ready.channel_id, + (ready.membership_event_id, retry_task), + ); + } + } + } else if let Some((_, task)) = pending_dynamic_channel_subscriptions + .remove(&ready.channel_id) + { + task.abort(); + } + None + } // Remaining branches don't touch pool — evaluated when pool is idle. buzz_event = relay.next_event() => { let _ = result_rx; // end split borrow before relay handling @@ -1939,7 +2116,7 @@ async fn tokio_main() -> Result<()> { ); continue; } - seen_membership_current.insert(eid); + seen_membership_current.insert(eid.clone()); // Rotate at 1000: current → previous, no amnesia window. if seen_membership_current.len() >= 1000 { seen_membership_previous = @@ -1966,17 +2143,55 @@ async fn tokio_main() -> Result<()> { if subscribed_channel_ids.contains(&ch) { tracing::debug!(channel_id = %ch, "membership notification: channel already subscribed"); - } else if let Some(filter) = config::resolve_dynamic_channel_filter(&config, ch, &rules) { - tracing::info!(channel_id = %ch, "membership notification: subscribing to new channel"); - if let Err(e) = relay.subscribe_channel_from(ch, filter, Some(ts)).await { - tracing::warn!("failed to subscribe to new channel {ch}: {e}"); - } else { - subscribed_channel_ids.insert(ch); + } else if config::resolve_dynamic_channel_filter(&config, ch, &rules).is_some() { + tracing::info!( + channel_id = %ch, + "membership notification: resolving metadata before subscribing" + ); + if let Some((_, prior)) = + pending_dynamic_channel_subscriptions.remove(&ch) + { + prior.abort(); } + let ready = DynamicChannelSubscriptionReady { + channel_id: ch, + membership_event_id: eid, + replay_since: ts, + }; + let membership_event_id = + ready.membership_event_id.clone(); + let ctx_for_refresh = Arc::clone(&ctx); + let ready_tx = dynamic_channel_ready_tx.clone(); + let task = tokio::spawn(async move { + resolve_metadata_before_dynamic_subscribe( + || { + let ctx_for_refresh = + Arc::clone(&ctx_for_refresh); + async move { + pool::refresh_channel_info( + &ctx_for_refresh, + ch, + ) + .await + } + }, + ready, + ready_tx, + DYNAMIC_CHANNEL_METADATA_RETRY_DELAY, + ) + .await; + }); + pending_dynamic_channel_subscriptions + .insert(ch, (membership_event_id, task)); } else { tracing::debug!(channel_id = %ch, "membership notification: no matching rules — skipping"); } } else { + if let Some((_, pending)) = + pending_dynamic_channel_subscriptions.remove(&ch) + { + pending.abort(); + } subscribed_channel_ids.remove(&ch); tracing::info!(channel_id = %ch, "membership notification: unsubscribing from channel"); if let Err(e) = relay.unsubscribe_channel(ch).await { @@ -1995,7 +2210,7 @@ async fn tokio_main() -> Result<()> { // Track removed channels so checked-out agents get // their sessions stripped when they return to the pool. removed_channels.insert(ch); - typing_channels.remove(&ch); + typing_channels.retain(|s, _| s.channel_id != ch); // Best-effort: clean up 👀 on drained events. // Note: the relay revokes membership before // emitting the notification, so this DELETE may @@ -2028,6 +2243,23 @@ async fn tokio_main() -> Result<()> { continue; } + // Conversation scope for this event: thread replies + // in regular channels get their own scope; DM + // traffic keeps channel-level continuity. Metadata + // is resolved before the event is accepted, with + // unresolved channels failing closed to channel + // scope. Owner control commands + // below target this same scope, so a `!cancel` / + // `!rotate` sent as a thread reply acts on that + // thread only, and a bare channel-level command + // acts on the channel conversation only. + let scope = conversation_scope_for_event( + &ctx, + buzz_event.channel_id, + &buzz_event.event, + ) + .await; + // Check: kind:9, content "!shutdown", from owner, mentions THIS agent. let is_shutdown = is_owner_control_command( &buzz_event.event, @@ -2071,13 +2303,13 @@ async fn tokio_main() -> Result<()> { if buzz_event.event.pubkey.to_hex() == *owner { let fired = signal_in_flight_task( &mut pool, - buzz_event.channel_id, + scope, ControlSignal::Cancel, ); if !fired { tracing::warn!( - channel_id = %buzz_event.channel_id, - "!cancel received but no in-flight task — no-op" + scope = %scope, + "!cancel received but no in-flight task for this conversation — no-op" ); } continue; // consume event — do NOT push to queue @@ -2090,11 +2322,11 @@ async fn tokio_main() -> Result<()> { // "!rotate", from owner, mentions THIS agent. // // Rotation is explicit owner intent to start the - // next turn in this channel with a fresh ACP + // next turn in this conversation with a fresh ACP // session. It is consumed by the harness and never // forwarded to the agent. If a turn is in-flight, // cancel it, drop its triggering batch, and - // invalidate the channel session when the task + // invalidate the conversation session when the task // returns. If idle, invalidate the cached channel // session immediately. Queued future events remain // queued and will create a fresh session on dispatch. @@ -2109,22 +2341,35 @@ async fn tokio_main() -> Result<()> { if buzz_event.event.pubkey.to_hex() == *owner { let fired = signal_in_flight_task( &mut pool, - buzz_event.channel_id, + scope, ControlSignal::Rotate, ); if fired { tracing::info!( - channel_id = %buzz_event.channel_id, + scope = %scope, "!rotate received — cancelling in-flight turn and rotating session" ); } else { - let invalidated = pool.invalidate_channel_sessions(buzz_event.channel_id); + let invalidated = pool.invalidate_scope_sessions(scope); tracing::info!( - channel_id = %buzz_event.channel_id, + scope = %scope, invalidated, - "!rotate received — invalidated idle channel session(s)" + "!rotate received — invalidated idle session(s) for this conversation" ); } + // Rotation may release a scope whose sticky + // owner is busy on another conversation. Give + // its already-queued work an immediate chance + // to claim a fresh idle worker instead of + // waiting for the old owner (or another relay + // event) to wake dispatch. + if pool_ready { + for (scope, thread_tags) in + dispatch_pending(&mut pool, &mut queue, &ctx) + { + typing_channels.insert(scope, thread_tags); + } + } continue; // consume event — do NOT push to queue } } @@ -2195,7 +2440,7 @@ async fn tokio_main() -> Result<()> { let event_for_steer = buzz_event.event.clone(); let prompt_tag_for_steer = prompt_tag.clone(); let accepted = queue.push(QueuedEvent { - channel_id: buzz_event.channel_id, + scope, event: buzz_event.event, received_at: std::time::Instant::now(), prompt_tag, @@ -2213,9 +2458,12 @@ async fn tokio_main() -> Result<()> { }); } // Event is already queued. If mode requires it AND - // the channel has an in-flight task, fire cancel — - // OR take the non-cancelling (ACP steer) fork for Steer signals. - if accepted && queue.is_channel_in_flight(buzz_event.channel_id) { + // the conversation has an in-flight task, fire cancel — + // OR take the non-cancelling (ACP steer) fork for Steer + // signals. Scoped per conversation: a reply landing in + // thread A never steers or cancels a turn running for + // thread B or for the channel-level conversation. + if accepted && queue.is_scope_in_flight(scope) { // Author eligibility (owner ∪ allowlist ∪ siblings) // is already enforced by the inbound author gate // above, so the mid-turn signal fires for every @@ -2242,7 +2490,7 @@ async fn tokio_main() -> Result<()> { && try_native_steer( &mut pool, &mut queue, - buzz_event.channel_id, + scope, event_for_steer, prompt_tag_for_steer, &steer_ack_tx, @@ -2250,17 +2498,17 @@ async fn tokio_main() -> Result<()> { if !native_attempted { signal_in_flight_task( &mut pool, - buzz_event.channel_id, + scope, signal, ); } } } if pool_ready { - for (channel_id, thread_tags) in + for (scope, thread_tags) in dispatch_pending(&mut pool, &mut queue, &ctx) { - typing_channels.insert(channel_id, thread_tags); + typing_channels.insert(scope, thread_tags); } } } @@ -2328,14 +2576,14 @@ async fn tokio_main() -> Result<()> { // Use try_publish (non-blocking) for typing indicators — // they're ephemeral and must not block the main loop during // relay reconnection (#35). - for (&ch, thread_tags) in &typing_channels { + for (&scope, thread_tags) in &typing_channels { if let Ok(event) = relay.build_typing_event( - ch, + scope.channel_id, thread_tags.root_event_id.as_deref(), thread_tags.parent_event_id.as_deref(), ) { if let Err(e) = relay.try_publish_event(event) { - tracing::debug!("typing indicator dropped for {ch}: {e}"); + tracing::debug!("typing indicator dropped for {scope}: {e}"); } } } @@ -2350,9 +2598,9 @@ async fn tokio_main() -> Result<()> { match pool_event { Some(PoolEvent::Result(result)) => { - // Stop typing indicator for the completed channel. - if let PromptSource::Channel(ch) = &result.source { - typing_channels.remove(ch); + // Stop typing indicator for the completed conversation. + if let PromptSource::Conversation(scope) = &result.source { + typing_channels.remove(scope); } if handle_prompt_result( &mut pool, @@ -2385,8 +2633,8 @@ async fn tokio_main() -> Result<()> { { break; } - for (channel_id, thread_tags) in dispatch_pending(&mut pool, &mut queue, &ctx) { - typing_channels.insert(channel_id, thread_tags); + for (scope, thread_tags) in dispatch_pending(&mut pool, &mut queue, &ctx) { + typing_channels.insert(scope, thread_tags); } } Some(PoolEvent::Panic(join_error)) => { @@ -2408,12 +2656,12 @@ async fn tokio_main() -> Result<()> { tracing::error!("all agents dead — exiting"); break; } - for (channel_id, thread_tags) in dispatch_pending(&mut pool, &mut queue, &ctx) { - typing_channels.insert(channel_id, thread_tags); + for (scope, thread_tags) in dispatch_pending(&mut pool, &mut queue, &ctx) { + typing_channels.insert(scope, thread_tags); } } Some(PoolEvent::SteerAck(SteerAckEvent { - channel_id, + scope, event_id, ack, })) => { @@ -2518,7 +2766,7 @@ async fn tokio_main() -> Result<()> { Err(_recv_err) => (true, false, false), }; tracing::info!( - channel = %channel_id, + scope = %scope, event_id = %event_id, ?ack, release_withheld, @@ -2527,37 +2775,41 @@ async fn tokio_main() -> Result<()> { "non-cancelling steer ack received" ); if matches!(ack, Ok(pool::SteerAck::Success)) { - queue.extend_in_flight_deadline(channel_id, config.max_turn_duration_secs); + queue.extend_in_flight_deadline(scope, config.max_turn_duration_secs); } if drop_withheld { - queue.remove_event(channel_id, &event_id); + queue.remove_event(scope, &event_id); } if release_withheld { - queue.release_native_steer(channel_id, &event_id); + queue.release_native_steer(scope, &event_id); } if signal_fallback { // Universal cancel+merge fallback. Note: the // queued event has already been released to the - // front of `queues[channel_id]`, so the cancel + // front of `queues[scope]`, so the cancel // will pick it up as part of the merged batch and // re-prompt the agent. - signal_in_flight_task(&mut pool, channel_id, ControlSignal::Steer); + signal_in_flight_task(&mut pool, scope, ControlSignal::Steer); } // After releasing a withheld event, give dispatch a chance // to re-flush. If the prompt is still in flight, the - // channel stays `in_flight_channels` and `flush_next` + // conversation stays in `in_flight_scopes` and `flush_next` // skips it — but a Steer fallback signal sent above will // tear down the in-flight task; on its completion the // queue drains. We still try here in case the in-flight // task has already returned. - for (channel_id, thread_tags) in dispatch_pending(&mut pool, &mut queue, &ctx) { - typing_channels.insert(channel_id, thread_tags); + for (scope, thread_tags) in dispatch_pending(&mut pool, &mut queue, &ctx) { + typing_channels.insert(scope, thread_tags); } } Some(PoolEvent::Wake(attempt, result)) => { - let completion = result.as_ref().map(|_| ()).map_err(|error| error.clone()); + let completion = result + .as_ref() + .as_ref() + .map(|_| ()) + .map_err(|error| error.clone()); if let Err(error) = - pool_lifecycle.complete_wake(attempt, result, tokio::time::Instant::now()) + pool_lifecycle.complete_wake(attempt, *result, tokio::time::Instant::now()) { tracing::warn!(attempt, error, "discarding stale pool wake result"); continue; @@ -2599,6 +2851,10 @@ async fn tokio_main() -> Result<()> { } } + for (_, (_, task)) in pending_dynamic_channel_subscriptions.drain() { + task.abort(); + } + // Drain wake tasks gracefully rather than aborting: an in-flight // initialize_agent_pool observes the shutdown watch at its biased per-slot // select and reaps its partially-spawned agents itself. `shutdown()` here @@ -2622,7 +2878,6 @@ async fn tokio_main() -> Result<()> { shutdown_agent_pool(&mut awakened_pool).await; } } - tracing::info!("shutdown: waiting for in-flight prompts"); // 30 s is generous for in-flight prompts to be cancelled; using // max_turn_duration here would cause Ctrl+C to hang for up to an hour. @@ -2747,6 +3002,29 @@ fn is_owner_control_command( && event_mentions_agent(event, agent_pubkey_hex) } +/// Whether events in `channel_id` are eligible for thread-scoped sessions. +/// +/// True only for channels whose resolved metadata confirms they are not DMs. +/// DMs keep channel-level continuity (one session per DM conversation), and a +/// failed metadata lookup conservatively falls back to channel scope. +fn channel_supports_thread_scoping(channel_info: Option<&queue::PromptChannelInfo>) -> bool { + channel_info.is_some_and(|ci| ci.channel_type != "dm") +} + +async fn conversation_scope_for_event( + ctx: &PromptContext, + channel_id: Uuid, + event: &nostr::Event, +) -> ConversationScope { + // Resolve before accepting the event into any scope. Dynamic channel + // subscriptions populate this cache before opening their delivery gate, + // while this awaited fallback also protects startup-unknown channels. + // A failed lookup remains channel-scoped, which is the safe DM behavior. + let channel_info = ctx.channel_info.resolve(channel_id).await; + let thread_scoped = channel_supports_thread_scoping(channel_info.as_ref()); + ConversationScope::for_event(channel_id, event, thread_scoped) +} + // ── signal_in_flight_task ───────────────────────────────────────────────────── /// Decide which [`ControlSignal`] (if any) to send to an in-flight turn when a @@ -2775,32 +3053,79 @@ fn mode_gate_signal( } } -/// Send a control signal to the in-flight task for `channel_id`. +/// Send a control signal to the in-flight task for the conversation `scope`. /// Returns `true` if a signal was sent, `false` if no in-flight task was found. fn signal_in_flight_task( pool: &mut AgentPool, - channel_id: uuid::Uuid, + scope: ConversationScope, mode: ControlSignal, ) -> bool { let entry = pool .task_map_mut() .values_mut() - .find(|m| m.channel_id == Some(channel_id)); + .find(|m| m.scope == Some(scope)); if let Some(meta) = entry { if let Some(tx) = meta.control_tx.take() { - tracing::info!(channel = %channel_id, ?mode, "control signal sent to in-flight task"); - let _ = tx.send(mode); - return true; + return match tx.send(mode) { + Ok(()) => { + tracing::info!(scope = %scope, "control signal sent to in-flight task"); + true + } + Err(mode) => { + tracing::debug!( + scope = %scope, + ?mode, + "in-flight task already completed before control signal" + ); + false + } + }; } } false } +/// Send a control signal to every in-flight task under `channel_id` — the +/// channel-level conversation and any thread conversations. +/// +/// Used by desktop observer control frames, which are addressed by channel +/// only (they carry no thread identity). Returns the number of tasks +/// signalled. +fn signal_in_flight_tasks_for_channel( + pool: &mut AgentPool, + channel_id: uuid::Uuid, + mode: ControlSignal, +) -> usize { + let mut fired = 0; + for meta in pool.task_map_mut().values_mut() { + let Some(scope) = meta.scope else { continue }; + if scope.channel_id != channel_id { + continue; + } + if let Some(tx) = meta.control_tx.take() { + match tx.send(mode.clone()) { + Ok(()) => { + tracing::info!(scope = %scope, ?mode, "control signal sent to in-flight task"); + fired += 1; + } + Err(mode) => { + tracing::debug!( + scope = %scope, + ?mode, + "in-flight task already completed before channel-wide control signal" + ); + } + } + } + } + fired +} + /// Attempt the non-cancelling (ACP) steer for a freshly-queued event. /// /// Caller invariants: -/// - `event` has already been pushed into `EventQueue::queues[channel_id]` +/// - `event` has already been pushed into `EventQueue::queues[scope]` /// via [`EventQueue::push`] — its `event.id` must still be locatable /// there so [`EventQueue::mark_native_steer_pending`] can move it to the /// side table. @@ -2816,7 +3141,7 @@ fn signal_in_flight_task( /// Returns `false` if `pool.send_steer` failed (no in-flight task, /// `steer_tx` already full from a prior in-flight steer, or read loop /// torn down). The caller MUST fall through to -/// `signal_in_flight_task(channel_id, ControlSignal::Steer)` so the +/// `signal_in_flight_task(scope, ControlSignal::Steer)` so the /// event still reaches the agent via the universal path. /// /// The withheld event is NOT released here on `false` because no withhold @@ -2824,7 +3149,7 @@ fn signal_in_flight_task( fn try_native_steer( pool: &mut AgentPool, queue: &mut EventQueue, - channel_id: uuid::Uuid, + scope: ConversationScope, event: nostr::Event, prompt_tag: String, steer_ack_tx: &mpsc::UnboundedSender, @@ -2849,7 +3174,7 @@ fn try_native_steer( prompt_tag: prompt_tag.clone(), received_at: std::time::Instant::now(), }; - let event_block = queue::format_event_block(channel_id, None, &be, None); + let event_block = queue::format_event_block(scope.channel_id, None, &be, None); let body = format!("{header}\n\n[Buzz event: {prompt_tag}]\n{event_block}\n\n{closing}"); let (ack_tx, ack_rx) = tokio::sync::oneshot::channel::(); @@ -2858,14 +3183,14 @@ fn try_native_steer( ack_tx, }; - match pool.send_steer(channel_id, request) { + match pool.send_steer(scope, request) { Ok(()) => { // Withhold the queued event synchronously BEFORE spawning // the watcher: this closes the race where `mark_complete` - // clears `in_flight_channels` and a stray `flush_next` could + // clears `in_flight_scopes` and a stray `flush_next` could // re-deliver the event via normal dispatch. See // `EventQueue::mark_native_steer_pending` docs at queue.rs:606. - let withheld = queue.mark_native_steer_pending(channel_id, &event_id_hex); + let withheld = queue.mark_native_steer_pending(scope, &event_id_hex); if !withheld { // Race: the event was already drained out of the queue // before we got here (e.g. a concurrent flush picked it @@ -2875,7 +3200,7 @@ fn try_native_steer( // the same message twice). Log so this is visible if it // ever happens in production. tracing::warn!( - channel = %channel_id, + scope = %scope, event_id = %event_id_hex, "native steer accepted by read loop but event was not in queue to withhold \ — possible duplicate delivery if steer succeeds" @@ -2886,7 +3211,7 @@ fn try_native_steer( tokio::spawn(async move { let ack = ack_rx.await; let _ = ack_tx_clone.send(SteerAckEvent { - channel_id, + scope, event_id: event_id_for_watcher, ack, }); @@ -2895,7 +3220,7 @@ fn try_native_steer( } Err(e) => { tracing::info!( - channel = %channel_id, + scope = %scope, error = ?e, "non-cancelling steer not accepted — falling back to cancel+merge" ); @@ -2907,35 +3232,50 @@ fn try_native_steer( // ── dispatch_pending ────────────────────────────────────────────────────────── /// Flush queued work to available agents. +/// +/// Conversation ownership is stable: a scope whose owning agent is checked +/// out is **deferred** (left queued with its fairness position preserved), +/// not handed to another agent — that would fork the ACP session history. +/// Deferred scopes are parked in-flight for the duration of the loop so +/// `flush_next` moves on to other conversations, then requeued at the end; +/// they are retried on the next dispatch cycle (every pool result triggers +/// one, so the owner's return re-dispatches them promptly). fn dispatch_pending( pool: &mut AgentPool, queue: &mut EventQueue, ctx: &Arc, -) -> Vec<(Uuid, ThreadTags)> { - let mut dispatched_channels = Vec::new(); +) -> Vec<(ConversationScope, ThreadTags)> { + let mut dispatched_conversations = Vec::new(); + let mut deferred: Vec = Vec::new(); loop { let batch = match queue.flush_next() { Some(b) => b, None => break, }; - let channel_id = batch.channel_id; + let scope = batch.scope; let typing_scope = batch .events .last() .map(|event| queue::parse_thread_tags(&event.event)) .unwrap_or_default(); - let affinity_hit = pool.has_session_for(channel_id); - let mut agent = match pool.try_claim(Some(channel_id)) { - Some(a) => a, - None => { - let pending = queue.pending_channels(); - tracing::debug!(pending_channels = pending, "pool_exhausted"); - queue.requeue_preserve_timestamps(batch); - queue.mark_complete(channel_id); + let affinity_hit = pool.has_session_for(scope); + let mut agent = match pool.try_claim_for_scope(scope) { + pool::ClaimOutcome::Claimed(a) => *a, + pool::ClaimOutcome::OwnerBusy => { + tracing::debug!(scope = %scope, "owner_busy — deferring conversation"); + // Keep the scope marked in-flight for now so flush_next + // proceeds to other conversations; requeued after the loop. + deferred.push(batch); + continue; + } + pool::ClaimOutcome::NoIdleAgent => { + let pending = queue.pending_conversations(); + tracing::debug!(pending_conversations = pending, "pool_exhausted"); + requeue_undispatched_batch(queue, batch); break; } }; - tracing::debug!(agent = agent.index, channel = %channel_id, affinity_hit, "agent_claimed"); + tracing::debug!(agent = agent.index, scope = %scope, affinity_hit, "agent_claimed"); let recoverable_batch = match ctx.dedup_mode { DedupMode::Queue => Some(batch.clone()), @@ -2948,7 +3288,7 @@ fn dispatch_pending( // Mid-turn non-cancelling steer seam: install the per-turn steer // receiver on the read loop so the main loop's mode-gate fork - // (see the `if accepted && queue.is_channel_in_flight(...)` block + // (see the `if accepted && queue.is_scope_in_flight(...)` block // in the relay event branch of the main `select!` loop) can drive // it via the matching sender stored in `TaskMeta.steer_tx`. // Installed for every prompt task: the read loop picks the steer @@ -2982,21 +3322,58 @@ fn dispatch_pending( abort_handle.id(), pool::TaskMeta { agent_index, - channel_id: Some(channel_id), + scope: Some(scope), turn_id, recoverable_batch, control_tx: Some(control_tx), steer_tx, }, ); - dispatched_channels.push((channel_id, typing_scope)); + dispatched_conversations.push((scope, typing_scope)); + } + // Release deferred conversations back to their queue lanes with original + // timestamps (fairness preserved); the next dispatch cycle retries them. + let deferred_count = deferred.len(); + for batch in deferred { + requeue_undispatched_batch(queue, batch); } tracing::debug!( - dispatched = dispatched_channels.len(), - queue_depth = queue.pending_channels(), + dispatched = dispatched_conversations.len(), + deferred = deferred_count, + queue_depth = queue.pending_conversations(), "dispatch_pending" ); - dispatched_channels + dispatched_conversations +} + +/// Return a flushed-but-undispatched batch to the queue without losing state. +/// +/// Regular events go back to the queue front with their original timestamps +/// (fairness preserved, no retry throttle). Any merged cancelled-events +/// carryover is re-stored via `requeue_as_cancelled` so the annotated +/// merged-prompt framing survives the deferral instead of being flattened +/// into regular events. A pure cancelled re-dispatch batch (the `flush_next` +/// fallback: events promoted from the cancelled store, `cancel_reason` set, +/// no fresh events) goes back to the cancelled store wholesale for the same +/// reason. Finally the scope's in-flight slot is released. +fn requeue_undispatched_batch(queue: &mut EventQueue, mut batch: FlushBatch) { + let scope = batch.scope; + if !batch.cancelled_events.is_empty() { + let reason = batch.cancel_reason.unwrap_or(CancelReason::Steer); + let cancelled_carryover = FlushBatch { + scope, + events: std::mem::take(&mut batch.cancelled_events), + cancelled_events: vec![], + cancel_reason: None, + }; + queue.requeue_as_cancelled(cancelled_carryover, reason); + } else if let Some(reason) = batch.cancel_reason { + queue.requeue_as_cancelled(batch, reason); + queue.mark_complete(scope); + return; + } + queue.requeue_preserve_timestamps(batch); + queue.mark_complete(scope); } /// Returns `true` when `error` is a non-retryable authentication failure. @@ -3044,7 +3421,7 @@ fn spawn_failure_notice( .map(|be| queue::parse_thread_tags(&be.event)) .unwrap_or_default(); let rest = rest.clone(); - let channel_id = batch.channel_id; + let channel_id = batch.channel_id(); tokio::spawn(async move { pool::post_failure_notice(&rest, channel_id, &thread_tags, &content).await; }); @@ -3070,6 +3447,7 @@ fn handle_prompt_result( pool.task_map_mut() .retain(|_, meta| meta.agent_index != agent_index); debug_assert_eq!(before, pool.task_map().len() + 1); + let discard_result_batch = pool.take_discard_result_batch(agent_index); // The hard-timeout death_message (below) must describe the batch's // *actual* fate, not just the `recently_active` eligibility flag — a @@ -3086,9 +3464,11 @@ fn handle_prompt_result( // every retry starts at attempt 1 — defeating exponential backoff and // dead-letter protection. if let Some(batch) = result.batch.take() { - // Don't requeue batches for channels the agent was removed from — - // those events are stale and should be silently dropped. - if !removed_channels.contains(&batch.channel_id) { + // Don't requeue explicitly invalidated batches. The per-task marker + // covers both a removed membership generation (even across a rapid + // rejoin) and a late `!rotate` whose control one-shot was already + // consumed. + if !discard_result_batch && !removed_channels.contains(&batch.channel_id()) { if matches!( result.outcome, PromptOutcome::Cancelled | PromptOutcome::CancelDrainTimeout(_) @@ -3116,7 +3496,7 @@ fn handle_prompt_result( }) ) { tracing::error!( - channel_id = %batch.channel_id, + scope = %batch.scope, events = batch.events.len(), "dead-lettering batch after hard-cap timeout (no recent activity) — discarding {} events", batch.events.len(), @@ -3134,7 +3514,7 @@ fn handle_prompt_result( }) ) { tracing::warn!( - channel_id = %batch.channel_id, + scope = %batch.scope, events = batch.events.len(), "hard-cap timeout with recent activity — requeueing for retry" ); @@ -3154,7 +3534,7 @@ fn handle_prompt_result( // delays the visible failure. Dead-letter immediately and tell // the user to re-authenticate the CLI. tracing::warn!( - channel_id = %batch.channel_id, + channel_id = %batch.channel_id(), events = batch.events.len(), "dead-lettering batch immediately — non-retryable auth error" ); @@ -3180,16 +3560,17 @@ fn handle_prompt_result( } } else { tracing::debug!( - channel_id = %batch.channel_id, + scope = %batch.scope, events = batch.events.len(), - "dropping failed batch for removed channel" + explicitly_invalidated = discard_result_batch, + "dropping invalidated failed batch" ); - hard_timeout_fate_suffix = Some(" — batch dropped (channel removed)"); + hard_timeout_fate_suffix = Some(" — batch dropped (conversation invalidated)"); } } match &result.source { - PromptSource::Channel(ch) => queue.mark_complete(*ch), + PromptSource::Conversation(scope) => queue.mark_complete(*scope), PromptSource::Heartbeat => *heartbeat_in_flight = false, } @@ -3210,22 +3591,20 @@ fn handle_prompt_result( PromptOutcome::CancelDrainTimeout(_) => "cancel_drain_timeout", }; let agent_index = result.agent.index; - // Capture the spawn-time configured model and our PID before the agent is - // moved into match arms below. `desired_model` reflects the config/persona - // model at spawn time — it does NOT reflect `session/set_model` overrides, - // which live in buzz-agent's session state and are what `llm: (model) …` - // errors carry. The two can legitimately differ; `configured_model=` is - // still valuable for identifying a stale orphan running an old model. + // Capture the spawn-time configured baseline and our PID before the agent + // is moved into match arms below. Runtime channel overrides are deliberately + // excluded: `configured_model=` identifies the persona/config model used by + // this process, while session-level model errors may legitimately differ. let harness_configured_model = result .agent - .desired_model + .baseline_model .as_deref() .unwrap_or("") .to_string(); let harness_pid = std::process::id(); - let channel_id = match &result.source { - PromptSource::Channel(ch) => Some(*ch), + let observer_scope = match &result.source { + PromptSource::Conversation(scope) => Some(*scope), PromptSource::Heartbeat => None, }; let turn_id = result.turn_id.clone(); @@ -3241,7 +3620,7 @@ fn handle_prompt_result( observer.emit( "turn_error", Some(agent_index), - &observer::context_for(channel_id, None, Some(turn_id.clone())), + &observer::context_for_scope(observer_scope, None, Some(turn_id.clone())), payload, ); } @@ -3283,6 +3662,9 @@ fn handle_prompt_result( emit_turn_error(&death_message, None); let index = result.agent.index; + // The process is being replaced — its ACP sessions die with it, + // so conversations it owned must be free to re-own elsewhere. + pool.release_agent_ownerships(index); let slot_history = &mut crash_history[index]; if !spawn_respawn_task( result.agent, @@ -3323,6 +3705,8 @@ fn handle_prompt_result( emit_turn_error(&death_message, None); let index = result.agent.index; + // Same as the fatal-outcome path: the process is being replaced. + pool.release_agent_ownerships(index); let slot_history = &mut crash_history[index]; if !spawn_respawn_task( result.agent, @@ -3388,6 +3772,8 @@ fn handle_prompt_result( emit_turn_error(&e.to_string(), error_code); let index = result.agent.index; + // The process is being replaced — release its ownerships. + pool.release_agent_ownerships(index); let slot_history = &mut crash_history[index]; if !spawn_respawn_task( result.agent, @@ -3427,7 +3813,7 @@ fn recover_panicked_agent( join_error: tokio::task::JoinError, heartbeat_in_flight: &mut bool, removed_channels: &HashSet, - typing_channels: &mut HashMap, + typing_channels: &mut HashMap, crash_history: &mut [SlotCircuit], respawn_tx: &mpsc::Sender, respawn_tasks: &mut tokio::task::JoinSet<()>, @@ -3439,28 +3825,33 @@ fn recover_panicked_agent( return; }; let i = meta.agent_index; + // The panicked task dropped its AcpClient — the process and its sessions + // are gone, so conversations owned by this slot must re-own elsewhere. + pool.release_agent_ownerships(i); + let discard_result_batch = pool.take_discard_result_batch(i); // Requeue BEFORE mark_complete (same rationale as handle_prompt_result). if let Some(batch) = meta.recoverable_batch { - if let Some(ch) = meta.channel_id { - if !removed_channels.contains(&ch) { + if let Some(scope) = meta.scope { + if !discard_result_batch && !removed_channels.contains(&scope.channel_id) { // Dead-letter on exhaustion is logged inside requeue(); a // panic path has no outcome to report, so no notice here. let _ = queue.requeue(batch); tracing::warn!("requeued batch for panicked agent {i}"); } else { tracing::debug!( - channel_id = %ch, - "dropping panicked batch for removed channel" + scope = %scope, + explicitly_invalidated = discard_result_batch, + "dropping invalidated panicked batch" ); } } } - if let Some(ch) = meta.channel_id { - queue.mark_complete(ch); - typing_channels.remove(&ch); - tracing::warn!("cleared wedged in-flight channel {ch} from panicked agent {i}"); + if let Some(scope) = meta.scope { + queue.mark_complete(scope); + typing_channels.remove(&scope); + tracing::warn!("cleared wedged in-flight conversation {scope} from panicked agent {i}"); } else { *heartbeat_in_flight = false; tracing::warn!("cleared wedged heartbeat_in_flight from panicked agent {i}"); @@ -3470,7 +3861,7 @@ fn recover_panicked_agent( observer.emit( "agent_panic", Some(i), - &observer::context_for(meta.channel_id, None, Some(meta.turn_id)), + &observer::context_for_scope(meta.scope, None, Some(meta.turn_id)), serde_json::json!({ "outcome": "panic", "error": format!("Agent task panicked: {join_error}"), @@ -3525,7 +3916,7 @@ fn drain_ready_join_results( config: &Config, heartbeat_in_flight: &mut bool, removed_channels: &HashSet, - typing_channels: &mut HashMap, + typing_channels: &mut HashMap, crash_history: &mut [SlotCircuit], respawn_tx: &mpsc::Sender, respawn_tasks: &mut tokio::task::JoinSet<()>, @@ -3563,7 +3954,7 @@ fn dispatch_heartbeat( if *heartbeat_in_flight { return; } - let agent = match pool.try_claim(None) { + let agent = match pool.try_claim_for_heartbeat() { Some(a) => a, None => return, }; @@ -3595,7 +3986,7 @@ fn dispatch_heartbeat( abort_handle.id(), pool::TaskMeta { agent_index, - channel_id: None, + scope: None, turn_id, recoverable_batch: None, control_tx: None, @@ -3743,10 +4134,15 @@ struct PoolStartup { has_generated_codex_config: bool, model: Option, observer: Option, + model_controls: ChannelModelControls, } impl PoolStartup { - fn from_config(config: &Config, observer: Option) -> Self { + fn from_config( + config: &Config, + observer: Option, + model_controls: ChannelModelControls, + ) -> Self { Self { agents: config.agents, command: config.agent_command.clone(), @@ -3755,6 +4151,7 @@ impl PoolStartup { has_generated_codex_config: config.has_generated_codex_config, model: config.model.clone(), observer, + model_controls, } } } @@ -3819,7 +4216,9 @@ async fn initialize_agent_pool( acp, state: SessionState::default(), model_capabilities: None, + baseline_model: startup.model.clone(), desired_model: startup.model.clone(), + desired_model_generation: None, model_overridden: false, agent_name, goose_system_prompt_supported: None, @@ -3859,7 +4258,10 @@ async fn initialize_agent_pool( ); } tracing::info!("agent_pool_ready agents={}", live_count); - Ok(AgentPool::from_slots(agent_slots)) + Ok(AgentPool::from_slots_with_model_controls( + agent_slots, + startup.model_controls.clone(), + )) } // ── spawn_and_init ──────────────────────────────────────────────────────────── @@ -4350,7 +4752,7 @@ mod owner_control_command_tests { abort_handle.id(), pool::TaskMeta { agent_index: 0, - channel_id: Some(channel_id), + scope: Some(ConversationScope::channel(channel_id)), turn_id: "test-turn-id".to_string(), recoverable_batch: None, control_tx: Some(control_tx), @@ -4360,21 +4762,46 @@ mod owner_control_command_tests { assert!(!signal_in_flight_task( &mut pool, - other_channel_id, + ConversationScope::channel(other_channel_id), ControlSignal::Rotate )); assert!(signal_in_flight_task( &mut pool, - channel_id, + ConversationScope::channel(channel_id), ControlSignal::Rotate )); assert_eq!(control_rx.await.unwrap(), ControlSignal::Rotate); assert!(!signal_in_flight_task( &mut pool, - channel_id, + ConversationScope::channel(channel_id), ControlSignal::Rotate )); } + + #[tokio::test] + async fn signal_in_flight_task_reports_completed_receiver_as_unsent() { + let mut pool = AgentPool::from_slots(vec![]); + let scope = ConversationScope::channel(Uuid::new_v4()); + let (control_tx, control_rx) = tokio::sync::oneshot::channel(); + drop(control_rx); + let abort_handle = pool.join_set.spawn(async {}); + pool.task_map_mut().insert( + abort_handle.id(), + pool::TaskMeta { + agent_index: 0, + scope: Some(scope), + turn_id: "completed-turn".to_string(), + recoverable_batch: None, + control_tx: Some(control_tx), + steer_tx: None, + }, + ); + + assert!( + !signal_in_flight_task(&mut pool, scope, ControlSignal::Rotate), + "a dropped receiver must fall through to idle/deferred invalidation" + ); + } } #[cfg(test)] @@ -4887,6 +5314,7 @@ mod observer_chunk_coalescer_tests { kind: "acp_read".to_string(), agent_index: Some(0), channel_id: Some("channel-1".to_string()), + thread_root: None, session_id: Some("session-1".to_string()), turn_id: Some("turn-1".to_string()), started_at: None, @@ -4915,6 +5343,7 @@ mod observer_chunk_coalescer_tests { kind: "turn_started".to_string(), agent_index: Some(0), channel_id: Some("channel-1".to_string()), + thread_root: None, session_id: Some("session-1".to_string()), turn_id: Some("turn-1".to_string()), started_at: None, @@ -5270,7 +5699,9 @@ mod error_outcome_emission_tests { .expect("spawn cat as inert agent"), state: Default::default(), model_capabilities: None, + baseline_model: None, desired_model: None, + desired_model_generation: None, model_overridden: false, agent_name: "unknown".into(), goose_system_prompt_supported: None, @@ -5295,7 +5726,7 @@ mod error_outcome_emission_tests { task_id, crate::pool::TaskMeta { agent_index: 0, - channel_id: None, + scope: None, turn_id: "test-turn-id".to_string(), recoverable_batch: None, control_tx: None, @@ -5318,7 +5749,7 @@ mod error_outcome_emission_tests { let result = PromptResult { agent, - source: PromptSource::Channel(Uuid::new_v4()), + source: PromptSource::Conversation(ConversationScope::channel(Uuid::new_v4())), turn_id: "test-turn-id".to_string(), outcome, batch: None, @@ -5371,7 +5802,7 @@ mod error_outcome_emission_tests { task_id, crate::pool::TaskMeta { agent_index: 0, - channel_id: Some(channel_id), + scope: Some(ConversationScope::channel(channel_id)), turn_id: "panic-turn-id".to_string(), recoverable_batch: None, control_tx: None, @@ -5463,7 +5894,7 @@ mod error_outcome_emission_tests { task_id, crate::pool::TaskMeta { agent_index: 0, - channel_id: None, + scope: None, turn_id: "test-turn-id".to_string(), recoverable_batch: None, control_tx: None, @@ -5484,7 +5915,7 @@ mod error_outcome_emission_tests { let observer = ObserverHandle::in_process(); let result = PromptResult { agent, - source: PromptSource::Channel(Uuid::new_v4()), + source: PromptSource::Conversation(ConversationScope::channel(Uuid::new_v4())), turn_id: "test-turn-id".to_string(), outcome, batch: None, @@ -5533,7 +5964,7 @@ mod error_outcome_emission_tests { .sign_with_keys(&keys) .unwrap(); FlushBatch { - channel_id: Uuid::new_v4(), + scope: ConversationScope::channel(Uuid::new_v4()), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -5544,9 +5975,9 @@ mod error_outcome_emission_tests { } }; - // Returns (pending_channels, queued_event_count_for_channel). + // Returns (pending_conversations, queued_event_count_for_channel). let run = |outcome: PromptOutcome, batch: FlushBatch| async move { - let channel_id = batch.channel_id; + let channel_id = batch.channel_id(); let agent = dummy_agent(0).await; let mut pool = AgentPool::from_slots(vec![None]); let task_id = pool.join_set.spawn(async {}).id(); @@ -5554,7 +5985,7 @@ mod error_outcome_emission_tests { task_id, crate::pool::TaskMeta { agent_index: 0, - channel_id: None, + scope: None, turn_id: "test-turn-id".to_string(), recoverable_batch: None, control_tx: None, @@ -5574,7 +6005,7 @@ mod error_outcome_emission_tests { let mut respawn_tasks = tokio::task::JoinSet::new(); let result = PromptResult { agent, - source: PromptSource::Channel(channel_id), + source: PromptSource::Conversation(ConversationScope::channel(channel_id)), turn_id: "test-turn-id".to_string(), outcome, batch: Some(batch), @@ -5593,8 +6024,8 @@ mod error_outcome_emission_tests { None, ); ( - queue.pending_channels(), - queue.queued_event_count(&channel_id), + queue.pending_conversations(), + queue.queued_event_count(&ConversationScope::channel(channel_id)), ) }; @@ -5639,7 +6070,7 @@ mod error_outcome_emission_tests { .sign_with_keys(&keys) .unwrap(); FlushBatch { - channel_id, + scope: ConversationScope::channel(channel_id), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -5651,7 +6082,7 @@ mod error_outcome_emission_tests { }; let run = |outcome: PromptOutcome, batch: FlushBatch| async move { - let channel_id = batch.channel_id; + let channel_id = batch.channel_id(); let agent = dummy_agent(0).await; let mut pool = AgentPool::from_slots(vec![None]); let task_id = pool.join_set.spawn(async {}).id(); @@ -5659,7 +6090,7 @@ mod error_outcome_emission_tests { task_id, crate::pool::TaskMeta { agent_index: 0, - channel_id: None, + scope: None, turn_id: "test-turn-id".to_string(), recoverable_batch: None, control_tx: None, @@ -5679,7 +6110,7 @@ mod error_outcome_emission_tests { let mut respawn_tasks = tokio::task::JoinSet::new(); let result = PromptResult { agent, - source: PromptSource::Channel(channel_id), + source: PromptSource::Conversation(ConversationScope::channel(channel_id)), turn_id: "test-turn-id".to_string(), outcome, batch: Some(batch), @@ -5698,8 +6129,8 @@ mod error_outcome_emission_tests { None, ); ( - queue.pending_channels(), - queue.queued_event_count(&channel_id), + queue.pending_conversations(), + queue.queued_event_count(&ConversationScope::channel(channel_id)), ) }; @@ -5735,7 +6166,7 @@ mod error_outcome_emission_tests { task_id, crate::pool::TaskMeta { agent_index: 0, - channel_id: None, + scope: None, turn_id: "test-turn-id".to_string(), recoverable_batch: None, control_tx: None, @@ -5755,7 +6186,7 @@ mod error_outcome_emission_tests { let mut respawn_tasks = tokio::task::JoinSet::new(); let observer = ObserverHandle::in_process(); let batch = FlushBatch { - channel_id, + scope: ConversationScope::channel(channel_id), events: vec![BatchEvent { event: EventBuilder::new(Kind::Custom(9), "test") .sign_with_keys(&Keys::generate()) @@ -5768,7 +6199,7 @@ mod error_outcome_emission_tests { }; let result = PromptResult { agent, - source: PromptSource::Channel(channel_id), + source: PromptSource::Conversation(ConversationScope::channel(channel_id)), turn_id: "test-turn-id".to_string(), outcome: PromptOutcome::Timeout(TimeoutKind::Hard { recently_active: true, @@ -5802,7 +6233,7 @@ mod error_outcome_emission_tests { ), ); assert_eq!( - queue.pending_channels(), + queue.pending_conversations(), 1, "batch must be requeued, not dead-lettered, while within the retry budget" ); @@ -5820,7 +6251,10 @@ mod error_outcome_emission_tests { // Simulate MAX_RETRIES prior failed attempts on this channel so the // upcoming requeue() call in handle_prompt_result crosses the // dead-letter threshold. - queue.set_retry_count_for_test(channel_id, crate::queue::MAX_RETRIES); + queue.set_retry_count_for_test( + ConversationScope::channel(channel_id), + crate::queue::MAX_RETRIES, + ); let agent = dummy_agent(0).await; let mut pool = AgentPool::from_slots(vec![None]); @@ -5829,7 +6263,7 @@ mod error_outcome_emission_tests { task_id, crate::pool::TaskMeta { agent_index: 0, - channel_id: None, + scope: None, turn_id: "test-turn-id".to_string(), recoverable_batch: None, control_tx: None, @@ -5848,7 +6282,7 @@ mod error_outcome_emission_tests { let mut respawn_tasks = tokio::task::JoinSet::new(); let observer = ObserverHandle::in_process(); let batch = FlushBatch { - channel_id, + scope: ConversationScope::channel(channel_id), events: vec![BatchEvent { event: EventBuilder::new(Kind::Custom(9), "final-attempt") .sign_with_keys(&Keys::generate()) @@ -5861,7 +6295,7 @@ mod error_outcome_emission_tests { }; let result = PromptResult { agent, - source: PromptSource::Channel(channel_id), + source: PromptSource::Conversation(ConversationScope::channel(channel_id)), turn_id: "test-turn-id".to_string(), outcome: PromptOutcome::Timeout(TimeoutKind::Hard { recently_active: true, @@ -5895,7 +6329,7 @@ mod error_outcome_emission_tests { ), ); assert_eq!( - queue.queued_event_count(&channel_id), + queue.queued_event_count(&ConversationScope::channel(channel_id)), 0, "batch with an exhausted retry budget must be dead-lettered, not requeued" ); @@ -5928,7 +6362,7 @@ mod error_outcome_emission_tests { ); let channel_id = Uuid::new_v4(); let batch = FlushBatch { - channel_id, + scope: ConversationScope::channel(channel_id), events: vec![BatchEvent { event: original_event.clone(), prompt_tag: "test".into(), @@ -5945,7 +6379,7 @@ mod error_outcome_emission_tests { task_id, crate::pool::TaskMeta { agent_index: 0, - channel_id: None, + scope: None, turn_id: "test-turn-id".to_string(), recoverable_batch: None, control_tx: None, @@ -5958,7 +6392,7 @@ mod error_outcome_emission_tests { // out on drain — so it is already queued by the time // handle_prompt_result runs. queue.push(QueuedEvent { - channel_id, + scope: ConversationScope::channel(channel_id), event: new_event.clone(), received_at: std::time::Instant::now(), prompt_tag: "test".into(), @@ -5977,7 +6411,7 @@ mod error_outcome_emission_tests { let grace = std::time::Duration::from_secs(5); let result = PromptResult { agent, - source: PromptSource::Channel(channel_id), + source: PromptSource::Conversation(ConversationScope::channel(channel_id)), turn_id: "test-turn-id".to_string(), outcome: PromptOutcome::CancelDrainTimeout(grace), batch: Some(batch), @@ -6084,7 +6518,7 @@ mod error_outcome_emission_tests { task_id, crate::pool::TaskMeta { agent_index: 0, - channel_id: None, + scope: None, turn_id: "test-turn-id".to_string(), recoverable_batch: None, control_tx: None, @@ -6106,7 +6540,7 @@ mod error_outcome_emission_tests { let grace = std::time::Duration::from_secs(5); let result = PromptResult { agent, - source: PromptSource::Channel(Uuid::new_v4()), + source: PromptSource::Conversation(ConversationScope::channel(Uuid::new_v4())), turn_id: "test-turn-id".to_string(), outcome: PromptOutcome::CancelDrainTimeout(grace), // Explicit Stop already dropped the batch upstream in @@ -6131,7 +6565,7 @@ mod error_outcome_emission_tests { // No batch to merge — the queue has nothing pending for any channel. assert_eq!( - queue.pending_channels(), + queue.pending_conversations(), 0, "a dropped Stop batch must not leave anything queued" ); @@ -6248,8 +6682,9 @@ mod error_outcome_emission_tests { .sign_with_keys(&keys) .unwrap(); let channel_id = uuid::Uuid::new_v4(); + let scope = ConversationScope::channel(channel_id); let batch = FlushBatch { - channel_id, + scope, events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -6272,7 +6707,7 @@ mod error_outcome_emission_tests { task_id, crate::pool::TaskMeta { agent_index: 0, - channel_id: None, + scope: Some(scope), turn_id: "test-turn-id".to_string(), recoverable_batch: None, control_tx: None, @@ -6292,7 +6727,7 @@ mod error_outcome_emission_tests { let mut respawn_tasks = tokio::task::JoinSet::new(); let result = PromptResult { agent, - source: PromptSource::Channel(channel_id), + source: PromptSource::Conversation(scope), turn_id: "test-turn-id".to_string(), outcome: PromptOutcome::Error(auth_error), batch: Some(batch), @@ -6311,14 +6746,14 @@ mod error_outcome_emission_tests { None, ); - // The batch must not be requeued: pending_channels returns 0. + // The batch must not be requeued. assert_eq!( - queue.pending_channels(), + queue.pending_conversations(), 0, "auth error must dead-letter immediately — batch must not be requeued" ); assert_eq!( - queue.queued_event_count(&channel_id), + queue.queued_event_count(&scope), 0, "auth error must dead-letter immediately — no events should be pending" ); @@ -6333,8 +6768,9 @@ mod error_outcome_emission_tests { .sign_with_keys(&keys) .unwrap(); let channel_id = uuid::Uuid::new_v4(); + let scope = ConversationScope::channel(channel_id); let batch = FlushBatch { - channel_id, + scope, events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -6357,7 +6793,7 @@ mod error_outcome_emission_tests { task_id, crate::pool::TaskMeta { agent_index: 0, - channel_id: None, + scope: Some(scope), turn_id: "test-turn-id".to_string(), recoverable_batch: None, control_tx: None, @@ -6377,7 +6813,7 @@ mod error_outcome_emission_tests { let mut respawn_tasks = tokio::task::JoinSet::new(); let result = PromptResult { agent, - source: PromptSource::Channel(channel_id), + source: PromptSource::Conversation(scope), turn_id: "test-turn-id".to_string(), outcome: PromptOutcome::Error(usage_error), batch: Some(batch), @@ -6398,16 +6834,100 @@ mod error_outcome_emission_tests { // Non-auth application error: batch IS requeued (first attempt, retry budget > 0). assert_eq!( - queue.pending_channels(), + queue.pending_conversations(), 1, "non-auth application error must requeue the batch for retry" ); assert_eq!( - queue.queued_event_count(&channel_id), + queue.queued_event_count(&scope), 1, "non-auth application error must preserve the event for retry" ); } + + /// A channel may be rejoined before an old in-flight task reports failure. + /// The old batch belongs to the removed membership generation and must not + /// become retryable merely because the channel is currently joined again. + #[tokio::test] + async fn pre_removal_failed_batch_is_not_requeued_after_rapid_rejoin() { + let channel_id = Uuid::new_v4(); + let scope = ConversationScope::channel(channel_id); + let event = nostr::EventBuilder::new(nostr::Kind::Custom(9), "pre-removal") + .sign_with_keys(&nostr::Keys::generate()) + .expect("sign event"); + let batch = FlushBatch { + scope, + events: vec![BatchEvent { + event, + prompt_tag: "test".into(), + received_at: std::time::Instant::now(), + }], + cancelled_events: vec![], + cancel_reason: None, + }; + + let agent = dummy_agent(0).await; + let mut pool = AgentPool::from_slots(vec![None]); + let task_id = pool.join_set.spawn(async {}).id(); + pool.task_map_mut().insert( + task_id, + crate::pool::TaskMeta { + agent_index: 0, + scope: Some(scope), + turn_id: "pre-removal-turn".to_string(), + recoverable_batch: Some(batch.clone()), + control_tx: None, + steer_tx: None, + }, + ); + + // Removal marks this exact checked-out task. An empty current + // `removed_channels` set below models the subsequent rapid rejoin. + assert_eq!(pool.invalidate_channel_sessions(channel_id), 0); + + let mut queue = EventQueue::new(config::DedupMode::Queue); + let config = test_config(); + let mut heartbeat_in_flight = false; + let removed_channels = HashSet::new(); + let mut crash_history = vec![SlotCircuit { + crash_times: Vec::new(), + open_until: None, + respawn_in_flight: false, + }]; + let (respawn_tx, _respawn_rx) = mpsc::channel(8); + let mut respawn_tasks = tokio::task::JoinSet::new(); + let result = PromptResult { + agent, + source: PromptSource::Conversation(scope), + turn_id: "pre-removal-turn".to_string(), + outcome: PromptOutcome::Error(acp::AcpError::AgentError { + code: -32000, + message: "transient application error".to_string(), + }), + batch: Some(batch), + }; + + handle_prompt_result( + &mut pool, + &mut queue, + &config, + result, + &mut heartbeat_in_flight, + &removed_channels, + &mut crash_history, + &respawn_tx, + &mut respawn_tasks, + None, + None, + ); + + assert_eq!( + queue.queued_event_count(&scope), + 0, + "the pre-removal source must stay discarded after rejoin" + ); + assert_eq!(queue.pending_conversations(), 0); + } } #[cfg(test)] @@ -6421,6 +6941,7 @@ mod observer_payload_trim_tests { kind: kind.to_string(), agent_index: Some(0), channel_id: Some("11111111-1111-1111-1111-111111111111".to_string()), + thread_root: None, session_id: Some("sess-1".to_string()), turn_id: Some("turn-1".to_string()), started_at: None, @@ -6658,3 +7179,1960 @@ mod observer_payload_trim_tests { assert!(leaf.contains("[elided")); } } + +#[cfg(test)] +mod conversation_scope_routing_tests { + use super::*; + use crate::pool::{AgentPool, OwnedAgent}; + use nostr::EventId; + + fn thread_scope(channel_id: Uuid, root_byte: char) -> ConversationScope { + let root = EventId::from_hex(&root_byte.to_string().repeat(64)).expect("valid hex"); + ConversationScope::thread(channel_id, root) + } + + async fn dummy_agent(index: usize) -> OwnedAgent { + OwnedAgent { + index, + acp: AcpClient::spawn("cat", &[], &[], false) + .await + .expect("spawn cat as inert agent"), + state: Default::default(), + model_capabilities: Some(pool::AgentModelCapabilities { + config_options_raw: vec![], + available_models_raw: Some(serde_json::json!({ + "availableModels": [ + {"modelId": "model-base"}, + {"modelId": "model-x"}, + {"modelId": "model-a"}, + {"modelId": "model-b"} + ] + })), + }), + baseline_model: None, + desired_model: None, + desired_model_generation: None, + model_overridden: false, + agent_name: "unknown".into(), + goose_system_prompt_supported: None, + protocol_version: 1, + } + } + + async fn dummy_agent_with_baseline(index: usize, baseline: &str) -> OwnedAgent { + let mut agent = dummy_agent(index).await; + agent.baseline_model = Some(baseline.to_string()); + agent.desired_model = Some(baseline.to_string()); + agent + } + + fn insert_task( + pool: &mut AgentPool, + agent_index: usize, + scope: ConversationScope, + ) -> tokio::sync::oneshot::Receiver { + let (control_tx, control_rx) = tokio::sync::oneshot::channel(); + let abort_handle = pool.join_set.spawn(async {}); + pool.task_map_mut().insert( + abort_handle.id(), + pool::TaskMeta { + agent_index, + scope: Some(scope), + turn_id: format!("turn-{agent_index}"), + recoverable_batch: None, + control_tx: Some(control_tx), + steer_tx: None, + }, + ); + control_rx + } + + /// With two threads of the SAME channel in flight simultaneously, a + /// control signal for one thread must not reach the other — and a + /// channel-scope signal must not reach either thread. + #[tokio::test] + async fn control_signal_targets_only_the_matching_thread() { + let mut pool = AgentPool::from_slots(vec![]); + let ch = Uuid::new_v4(); + let thread_a = thread_scope(ch, 'a'); + let thread_b = thread_scope(ch, 'b'); + + let rx_a = insert_task(&mut pool, 0, thread_a); + let mut rx_b = insert_task(&mut pool, 1, thread_b); + + // Channel-scope signal: no channel-level turn is in flight → no-op. + assert!(!signal_in_flight_task( + &mut pool, + ConversationScope::channel(ch), + ControlSignal::Cancel + )); + + // Thread A signal reaches only thread A's task. + assert!(signal_in_flight_task( + &mut pool, + thread_a, + ControlSignal::Cancel + )); + assert_eq!(rx_a.await.unwrap(), ControlSignal::Cancel); + assert!( + rx_b.try_recv().is_err(), + "thread B's control channel must be untouched" + ); + + // Thread B can still be rotated independently afterwards. + assert!(signal_in_flight_task( + &mut pool, + thread_b, + ControlSignal::Rotate + )); + assert_eq!(rx_b.await.unwrap(), ControlSignal::Rotate); + } + + /// Desktop observer frames are channel-addressed: they fan out to every + /// in-flight conversation under the channel, but never cross channels. + #[tokio::test] + async fn channel_wide_signal_fans_out_within_the_channel_only() { + let mut pool = AgentPool::from_slots(vec![]); + let ch = Uuid::new_v4(); + let other = Uuid::new_v4(); + + let rx_a = insert_task(&mut pool, 0, thread_scope(ch, 'a')); + let rx_b = insert_task(&mut pool, 1, ConversationScope::channel(ch)); + let mut rx_other = insert_task(&mut pool, 2, ConversationScope::channel(other)); + + let fired = signal_in_flight_tasks_for_channel(&mut pool, ch, ControlSignal::Cancel); + assert_eq!(fired, 2, "both conversations under the channel signalled"); + assert_eq!(rx_a.await.unwrap(), ControlSignal::Cancel); + assert_eq!(rx_b.await.unwrap(), ControlSignal::Cancel); + assert!( + rx_other.try_recv().is_err(), + "other channel's task must be untouched" + ); + } + + /// Idle `!rotate` invalidates only the targeted conversation's session; + /// sibling threads and the channel session survive. Channel removal + /// sweeps them all. + #[tokio::test] + async fn idle_rotation_and_channel_removal_scope_correctly() { + let ch = Uuid::new_v4(); + let thread_a = thread_scope(ch, 'a'); + let thread_b = thread_scope(ch, 'b'); + let channel = ConversationScope::channel(ch); + + let mut agent = dummy_agent(0).await; + agent.state.sessions.insert(thread_a, "sess-a".into()); + agent.state.sessions.insert(thread_b, "sess-b".into()); + agent.state.sessions.insert(channel, "sess-ch".into()); + let mut pool = AgentPool::from_slots(vec![Some(agent)]); + + assert!(pool.has_session_for(thread_a)); + assert_eq!(pool.invalidate_scope_sessions(thread_a), 1); + assert!(!pool.has_session_for(thread_a)); + assert!(pool.has_session_for(thread_b), "sibling thread survives"); + assert!(pool.has_session_for(channel), "channel session survives"); + + // Membership removal: every remaining scope under the channel goes. + assert_eq!(pool.invalidate_channel_sessions(ch), 1); + assert!(!pool.has_session_for(thread_b)); + assert!(!pool.has_session_for(channel)); + } + + /// A checked-out holder from before membership removal must discard its + /// channel sessions on return even if the channel was rejoined in the + /// meantime. Sessions created after the rejoin on other slots, and + /// sessions for unrelated channels, must survive. + #[tokio::test] + async fn channel_removal_invalidates_checked_out_holder_across_rejoin() { + let channel_id = Uuid::new_v4(); + let other_channel = Uuid::new_v4(); + let old_scope = thread_scope(channel_id, 'a'); + let rejoined_scope = thread_scope(channel_id, 'b'); + let unrelated_scope = ConversationScope::channel(other_channel); + + let agent0 = dummy_agent(0).await; + let agent1 = dummy_agent(1).await; + let mut pool = AgentPool::from_slots(vec![Some(agent0), Some(agent1)]); + + let mut old_holder = match pool.try_claim_for_scope(old_scope) { + pool::ClaimOutcome::Claimed(agent) => *agent, + _ => panic!("old scope must be claimable"), + }; + old_holder + .state + .sessions + .insert(old_scope, "pre-removal".into()); + old_holder + .state + .sessions + .insert(unrelated_scope, "unrelated".into()); + pool.return_agent(old_holder); + + let old_holder = match pool.try_claim_for_scope(old_scope) { + pool::ClaimOutcome::Claimed(agent) => *agent, + _ => panic!("old owner must be claimable"), + }; + assert_eq!(old_holder.index, 0); + let _old_task = insert_task(&mut pool, old_holder.index, old_scope); + + assert_eq!( + pool.invalidate_channel_sessions(channel_id), + 0, + "the only pre-removal holder is checked out" + ); + assert_eq!(pool.scope_owner(&old_scope), None); + + // Model a rapid rejoin: new work may already create a fresh session + // on another slot before the pre-removal holder returns. + let mut rejoined_holder = match pool.try_claim_for_scope(rejoined_scope) { + pool::ClaimOutcome::Claimed(agent) => *agent, + _ => panic!("rejoined scope must use the idle slot"), + }; + assert_eq!(rejoined_holder.index, 1); + rejoined_holder + .state + .sessions + .insert(rejoined_scope, "post-rejoin".into()); + pool.return_agent(rejoined_holder); + + pool.task_map_mut() + .retain(|_, meta| meta.agent_index != old_holder.index); + pool.return_agent(old_holder); + + assert!( + !pool.has_session_for(old_scope), + "pre-removal session must be purged when its holder returns" + ); + assert!( + pool.has_session_for(rejoined_scope), + "post-rejoin session on another slot must survive" + ); + assert!( + pool.has_session_for(unrelated_scope), + "unrelated channel session on the old holder must survive" + ); + assert_eq!(pool.scope_owner(&rejoined_scope), Some(1)); + } + + /// Agent affinity is per-conversation: a claim for thread B prefers the + /// agent holding thread B's session even when another idle agent holds a + /// session for the same channel's other thread. + #[tokio::test] + async fn try_claim_prefers_the_agent_with_the_thread_session() { + let ch = Uuid::new_v4(); + let thread_a = thread_scope(ch, 'a'); + let thread_b = thread_scope(ch, 'b'); + + let mut agent0 = dummy_agent(0).await; + agent0.state.sessions.insert(thread_a, "sess-a".into()); + let mut agent1 = dummy_agent(1).await; + agent1.state.sessions.insert(thread_b, "sess-b".into()); + let mut pool = AgentPool::from_slots(vec![Some(agent0), Some(agent1)]); + + let claimed = match pool.try_claim_for_scope(thread_b) { + pool::ClaimOutcome::Claimed(a) => *a, + _ => panic!("an agent must be claimable for thread B"), + }; + assert_eq!(claimed.index, 1, "affinity must follow the thread session"); + pool.return_agent(claimed); + } + + /// DM channels and unknown channels stay channel-scoped; regular channels + /// opt in to thread scoping. + #[test] + fn thread_scoping_gate_respects_channel_type() { + let dm = queue::PromptChannelInfo { + name: "dm".into(), + channel_type: "dm".into(), + }; + let regular = queue::PromptChannelInfo { + name: "general".into(), + channel_type: "channel".into(), + }; + + assert!(!channel_supports_thread_scoping(Some(&dm))); + assert!(channel_supports_thread_scoping(Some(®ular))); + assert!( + !channel_supports_thread_scoping(None), + "unknown channel type must fall back to channel scope" + ); + } + + fn make_test_prompt_context() -> PromptContext { + let agent_keys = nostr::Keys::generate(); + let rest_client = relay::RestClient { + http: reqwest::Client::new(), + base_url: "http://127.0.0.1:0".to_string(), + keys: agent_keys.clone(), + auth_tag_json: None, + }; + 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::Queue, + system_prompt: None, + session_title: None, + team_instructions: None, + heartbeat_prompt: None, + base_prompt: None, + cwd: ".".to_string(), + rest_client: rest_client.clone(), + channel_info: pool::ChannelInfoResolver::new(HashMap::new(), rest_client), + context_message_limit: 0, + max_turns_per_session: 0, + permission_mode: config::PermissionMode::Default, + agent_keys, + agent_owner_pubkey: None, + memory_enabled: false, + harness_name: "goose".to_string(), + relay_url: "ws://127.0.0.1:3000".to_string(), + model_controls: ChannelModelControls::default(), + } + } + + fn make_stream_event(content: &str) -> nostr::Event { + let keys = nostr::Keys::generate(); + nostr::EventBuilder::new(nostr::Kind::Custom(9), content) + .tags([]) + .sign_with_keys(&keys) + .expect("sign test event") + } + + /// Finding 1 (pool level): while the agent owning scope A is checked out + /// on scope B, no fallback agent may claim A — it must wait for its + /// owner; once the owner returns, A dispatches to that same agent. + #[tokio::test] + async fn owner_busy_scope_waits_for_owner_instead_of_forking() { + let ch = Uuid::new_v4(); + let scope_a = thread_scope(ch, 'a'); + let scope_b = thread_scope(ch, 'b'); + let agent0 = dummy_agent(0).await; + let agent1 = dummy_agent(1).await; + let mut pool = AgentPool::from_slots(vec![Some(agent0), Some(agent1)]); + + // Agent 0 becomes A's owner and holds a live session for it. + let mut a0 = match pool.try_claim_for_scope(scope_a) { + pool::ClaimOutcome::Claimed(a) => *a, + _ => panic!("first claim must succeed"), + }; + assert_eq!(a0.index, 0); + a0.state.sessions.insert(scope_a, "sess-a".into()); + pool.return_agent(a0); + assert_eq!(pool.scope_owner(&scope_a), Some(0)); + + // Agent 0 checks out on unrelated scope B (task registered, as + // dispatch would do). + let a0 = match pool.try_claim_for_scope(scope_b) { + pool::ClaimOutcome::Claimed(a) => *a, + _ => panic!("claim for B must succeed"), + }; + assert_eq!(a0.index, 0, "unowned scope claims the first idle agent"); + let _rx_b = insert_task(&mut pool, 0, scope_b); + + // The exact reviewed scenario: A's owner is busy; agent 1 is idle + // and must NOT be claimed for A. + assert!(matches!( + pool.try_claim_for_scope(scope_a), + pool::ClaimOutcome::OwnerBusy + )); + assert!( + pool.any_idle(), + "the idle fallback agent was left untouched" + ); + assert_eq!( + pool.scope_owner(&scope_a), + Some(0), + "ownership must not migrate while the owner is alive" + ); + + // Owner returns → A goes back to the SAME agent (session intact). + pool.task_map_mut().retain(|_, m| m.agent_index != 0); + pool.return_agent(a0); + let a = match pool.try_claim_for_scope(scope_a) { + pool::ClaimOutcome::Claimed(a) => *a, + _ => panic!("owner idle again — claim must succeed"), + }; + assert_eq!(a.index, 0); + assert_eq!( + a.state.sessions.get(&scope_a).map(String::as_str), + Some("sess-a") + ); + pool.return_agent(a); + } + + /// `!rotate` can target a scope whose sticky owner is checked out on + /// different work, so there is no matching in-flight control receiver. + /// The old holder must still discard that scope on return while a fresh + /// owner is allowed to start immediately. + #[tokio::test] + async fn rotation_defers_invalidation_when_scope_owner_is_busy_elsewhere() { + let channel_id = Uuid::new_v4(); + let rotated_scope = thread_scope(channel_id, 'a'); + let busy_scope = thread_scope(channel_id, 'b'); + let agent0 = dummy_agent(0).await; + let agent1 = dummy_agent(1).await; + let mut pool = AgentPool::from_slots(vec![Some(agent0), Some(agent1)]); + + let mut old_owner = match pool.try_claim_for_scope(rotated_scope) { + pool::ClaimOutcome::Claimed(agent) => *agent, + _ => panic!("rotated scope must be claimable"), + }; + old_owner + .state + .sessions + .insert(rotated_scope, "old-history".into()); + pool.return_agent(old_owner); + + let old_owner = match pool.try_claim_for_scope(busy_scope) { + pool::ClaimOutcome::Claimed(agent) => *agent, + _ => panic!("owner must be claimable for sibling work"), + }; + assert_eq!(old_owner.index, 0); + let _busy_task = insert_task(&mut pool, old_owner.index, busy_scope); + assert!( + !signal_in_flight_task(&mut pool, rotated_scope, ControlSignal::Rotate), + "the owner is busy on a sibling scope, not the rotated scope" + ); + + assert_eq!( + pool.invalidate_scope_sessions(rotated_scope), + 0, + "the historical session is held by a checked-out worker" + ); + assert!( + !pool.take_discard_result_batch(old_owner.index), + "rotating A must not discard the owner's unrelated B task" + ); + assert_eq!(pool.scope_owner(&rotated_scope), None); + + let mut fresh_owner = match pool.try_claim_for_scope(rotated_scope) { + pool::ClaimOutcome::Claimed(agent) => *agent, + _ => panic!("rotation must permit a fresh owner"), + }; + assert_eq!(fresh_owner.index, 1); + fresh_owner + .state + .sessions + .insert(rotated_scope, "fresh-history".into()); + pool.return_agent(fresh_owner); + + pool.task_map_mut() + .retain(|_, meta| meta.agent_index != old_owner.index); + pool.return_agent(old_owner); + + let fresh_owner = match pool.try_claim_for_scope(rotated_scope) { + pool::ClaimOutcome::Claimed(agent) => *agent, + _ => panic!("fresh rotated session must remain claimable"), + }; + assert_eq!(fresh_owner.index, 1); + assert_eq!( + fresh_owner + .state + .sessions + .get(&rotated_scope) + .map(String::as_str), + Some("fresh-history"), + "the returning old holder must not displace fresh history" + ); + pool.return_agent(fresh_owner); + } + + /// A rotate command is also a dispatch wake-up. Once it releases sticky + /// ownership for a queued scope whose old owner is busy elsewhere, an idle + /// worker must be able to start that fresh conversation immediately. + #[tokio::test] + async fn rotation_releases_owner_busy_queue_to_an_idle_worker() { + let ctx = Arc::new(make_test_prompt_context()); + let channel_id = Uuid::new_v4(); + let rotated_scope = thread_scope(channel_id, 'a'); + let busy_scope = thread_scope(channel_id, 'b'); + let mut pool = + AgentPool::from_slots(vec![Some(dummy_agent(0).await), Some(dummy_agent(1).await)]); + + let mut old_owner = match pool.try_claim_for_scope(rotated_scope) { + pool::ClaimOutcome::Claimed(agent) => *agent, + _ => panic!("rotated scope must be claimable"), + }; + old_owner + .state + .sessions + .insert(rotated_scope, "old-history".into()); + pool.return_agent(old_owner); + + let old_owner = match pool.try_claim_for_scope(busy_scope) { + pool::ClaimOutcome::Claimed(agent) => *agent, + _ => panic!("owner must be claimable for sibling work"), + }; + assert_eq!(old_owner.index, 0); + let _busy_task = insert_task(&mut pool, old_owner.index, busy_scope); + + let mut queue = EventQueue::new(config::DedupMode::Queue); + assert!(queue.push(QueuedEvent { + scope: rotated_scope, + event: make_stream_event("queued before rotation"), + received_at: std::time::Instant::now(), + prompt_tag: "test".into(), + })); + assert!( + dispatch_pending(&mut pool, &mut queue, &ctx).is_empty(), + "sticky ownership must initially defer the queued conversation" + ); + + assert!( + !signal_in_flight_task(&mut pool, rotated_scope, ControlSignal::Rotate), + "the owner is busy on a sibling scope" + ); + pool.invalidate_scope_sessions(rotated_scope); + + let dispatched = dispatch_pending(&mut pool, &mut queue, &ctx); + assert_eq!(dispatched.len(), 1); + assert_eq!(dispatched[0].0, rotated_scope); + assert!( + pool.task_map() + .values() + .any(|meta| meta.agent_index == 1 && meta.scope == Some(rotated_scope)), + "released work must start on the idle worker without waiting for the old owner" + ); + } + + /// If a task has already consumed its one-shot control receiver, a later + /// `!rotate` cannot signal it again. Rotation must still mark that exact + /// task's source batch for discard and purge its session on return. + #[tokio::test] + async fn late_rotation_discards_batch_after_control_signal_was_consumed() { + let channel_id = Uuid::new_v4(); + let scope = thread_scope(channel_id, 'a'); + let mut pool = AgentPool::from_slots(vec![Some(dummy_agent(0).await)]); + + let mut agent = match pool.try_claim_for_scope(scope) { + pool::ClaimOutcome::Claimed(agent) => *agent, + _ => panic!("scope must be claimable"), + }; + agent.state.sessions.insert(scope, "old-history".into()); + pool.return_agent(agent); + + let agent = match pool.try_claim_for_scope(scope) { + pool::ClaimOutcome::Claimed(agent) => *agent, + _ => panic!("owner must be claimable"), + }; + let first_signal = insert_task(&mut pool, agent.index, scope); + assert!(signal_in_flight_task( + &mut pool, + scope, + ControlSignal::Steer + )); + assert_eq!( + first_signal.await.expect("first signal"), + ControlSignal::Steer + ); + assert!( + !signal_in_flight_task(&mut pool, scope, ControlSignal::Rotate), + "the task's one-shot receiver is already consumed" + ); + + assert_eq!(pool.invalidate_scope_sessions(scope), 0); + assert!( + pool.take_discard_result_batch(agent.index), + "late rotation must discard the active scope's source batch" + ); + pool.task_map_mut().clear(); + pool.return_agent(agent); + assert!(!pool.has_session_for(scope)); + assert_eq!(pool.scope_owner(&scope), None); + } + + /// Finding 1 (dispatch level): the full dispatch path defers an + /// owner-busy conversation — the event stays queued, no task is spawned + /// on the idle fallback agent — and dispatches it to the owner once the + /// owner returns. + #[tokio::test] + async fn dispatch_defers_owner_busy_conversation_without_fallback() { + let ctx = Arc::new(make_test_prompt_context()); + let ch = Uuid::new_v4(); + let scope_a = thread_scope(ch, 'a'); + let scope_b = thread_scope(ch, 'b'); + let agent0 = dummy_agent(0).await; + let agent1 = dummy_agent(1).await; + let mut pool = AgentPool::from_slots(vec![Some(agent0), Some(agent1)]); + + // Agent 0 owns A (live session) and is checked out on B. + let mut a0 = match pool.try_claim_for_scope(scope_a) { + pool::ClaimOutcome::Claimed(a) => *a, + _ => panic!("claim A"), + }; + a0.state.sessions.insert(scope_a, "sess-a".into()); + pool.return_agent(a0); + let a0 = match pool.try_claim_for_scope(scope_b) { + pool::ClaimOutcome::Claimed(a) => *a, + _ => panic!("claim B"), + }; + assert_eq!(a0.index, 0); + let _rx_b = insert_task(&mut pool, 0, scope_b); + let tasks_before = pool.task_map().len(); + + let mut queue = EventQueue::new(config::DedupMode::Queue); + queue.push(QueuedEvent { + scope: scope_a, + event: make_stream_event("reply in thread A"), + received_at: std::time::Instant::now(), + prompt_tag: "test".into(), + }); + + let dispatched = dispatch_pending(&mut pool, &mut queue, &ctx); + assert!(dispatched.is_empty(), "owner-busy scope must not dispatch"); + assert_eq!( + queue.queued_event_count(&scope_a), + 1, + "the event must remain queued for the owner" + ); + assert!(pool.any_idle(), "no fallback agent was claimed for A"); + assert_eq!( + pool.task_map().len(), + tasks_before, + "no new task was spawned for A" + ); + + // Owner returns → the deferred conversation dispatches to agent 0. + pool.task_map_mut().retain(|_, m| m.agent_index != 0); + pool.return_agent(a0); + let dispatched = dispatch_pending(&mut pool, &mut queue, &ctx); + assert_eq!(dispatched.len(), 1); + assert_eq!(dispatched[0].0, scope_a); + assert!( + pool.task_map() + .values() + .any(|m| m.agent_index == 0 && m.scope == Some(scope_a)), + "the deferred conversation must run on its owning agent" + ); + } + + #[derive(Default)] + struct AcceptanceRelayState { + context_events: Vec, + submitted_events: Vec, + } + + struct AcceptanceTempDir(std::path::PathBuf); + + impl AcceptanceTempDir { + fn new() -> Self { + let path = + std::env::temp_dir().join(format!("buzz-acp-thread-acceptance-{}", Uuid::new_v4())); + std::fs::create_dir_all(&path).expect("create acceptance temp dir"); + Self(path) + } + } + + impl Drop for AcceptanceTempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + async fn read_acceptance_http_request( + socket: &mut tokio::net::TcpStream, + ) -> Option<(String, Vec)> { + use tokio::io::AsyncReadExt; + + let mut bytes = Vec::new(); + let mut chunk = [0_u8; 4096]; + loop { + let read = socket.read(&mut chunk).await.ok()?; + if read == 0 { + return None; + } + bytes.extend_from_slice(&chunk[..read]); + let Some(header_end) = bytes.windows(4).position(|part| part == b"\r\n\r\n") else { + continue; + }; + let body_start = header_end + 4; + let headers = String::from_utf8_lossy(&bytes[..header_end]); + let content_length = headers + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().ok()) + .flatten() + }) + .unwrap_or_default(); + if bytes.len() < body_start + content_length { + continue; + } + let path = headers + .lines() + .next()? + .split_whitespace() + .nth(1)? + .to_string(); + return Some(( + path, + bytes[body_start..body_start + content_length].to_vec(), + )); + } + } + + fn event_has_e_tag(event: &nostr::Event, event_id: &str) -> bool { + event.tags.iter().any(|tag| { + let parts = tag.as_slice(); + parts.first().map(String::as_str) == Some("e") + && parts.get(1).map(String::as_str) == Some(event_id) + }) + } + + async fn spawn_acceptance_relay( + keys: nostr::Keys, + ) -> ( + relay::RestClient, + Arc>, + tokio::task::JoinHandle<()>, + ) { + use tokio::io::AsyncWriteExt; + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind acceptance relay"); + let base_url = format!( + "http://{}", + listener.local_addr().expect("acceptance relay address") + ); + let state = Arc::new(tokio::sync::Mutex::new(AcceptanceRelayState::default())); + let server_state = Arc::clone(&state); + let server = tokio::spawn(async move { + while let Ok((mut socket, _)) = listener.accept().await { + let Some((path, body)) = read_acceptance_http_request(&mut socket).await else { + continue; + }; + let response_body = if path == "/events" { + let event = + serde_json::from_slice::(&body).expect("signed event JSON"); + buzz_core::verify_event(&event).expect("submitted event verifies"); + server_state.lock().await.submitted_events.push(event); + serde_json::json!({"accepted": true}).to_string() + } else if path == "/query" { + let filters = + serde_json::from_slice::(&body).expect("filter JSON"); + let filter = filters + .as_array() + .and_then(|filters| filters.first()) + .cloned() + .unwrap_or_default(); + let reaction_query = filter + .get("kinds") + .and_then(serde_json::Value::as_array) + .is_some_and(|kinds| kinds.iter().any(|kind| kind.as_u64() == Some(7))); + let locked = server_state.lock().await; + let events: Vec = if reaction_query { + let target = filter + .get("#e") + .and_then(serde_json::Value::as_array) + .and_then(|ids| ids.first()) + .and_then(serde_json::Value::as_str); + locked + .submitted_events + .iter() + .filter(|event| { + event.kind == nostr::Kind::Reaction + && target.is_some_and(|id| event_has_e_tag(event, id)) + }) + .map(|event| { + serde_json::to_value(event).expect("serialize reaction event") + }) + .collect() + } else { + locked + .context_events + .iter() + .map(|event| { + serde_json::to_value(event).expect("serialize context event") + }) + .collect() + }; + serde_json::to_string(&events).expect("serialize query response") + } else { + "[]".to_string() + }; + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\ + Content-Length: {}\r\nConnection: close\r\n\r\n{}", + response_body.len(), + response_body + ); + let _ = socket.write_all(response.as_bytes()).await; + } + }); + ( + relay::RestClient { + http: reqwest::Client::new(), + base_url, + keys, + auth_tag_json: None, + }, + state, + server, + ) + } + + const ACCEPTANCE_AGENT_SCRIPT: &str = r#" +SESSION_COUNT=0 +PROMPT_COUNT=0 +while IFS= read -r REQ; do + ID="${REQ#*\"id\":}" + ID="${ID%%,*}" + case "$REQ" in + *'"method":"initialize"'*) + printf '{"jsonrpc":"2.0","id":%s,"result":{"protocolVersion":2,"agentCapabilities":{},"agentInfo":{"name":"acceptance-agent"}}}\n' "$ID" + ;; + *'"method":"session/new"'*) + SESSION_COUNT=$((SESSION_COUNT + 1)) + printf '{"jsonrpc":"2.0","id":%s,"result":{"sessionId":"session-%s-%s"}}\n' "$ID" "$ACCEPTANCE_SLOT" "$SESSION_COUNT" + ;; + *'"method":"session/prompt"'*) + PROMPT_COUNT=$((PROMPT_COUNT + 1)) + touch "$ACCEPTANCE_DIR/started-$ACCEPTANCE_SLOT-$PROMPT_COUNT" + while [ ! -f "$ACCEPTANCE_DIR/release-$ACCEPTANCE_SLOT-$PROMPT_COUNT" ]; do + sleep 0.01 + done + printf '{"jsonrpc":"2.0","id":%s,"result":{"stopReason":"end_turn"}}\n' "$ID" + ;; + *'"method":"session/cancel"'*) + printf '{"jsonrpc":"2.0","id":%s,"result":{}}\n' "$ID" + ;; + *) + printf '{"jsonrpc":"2.0","id":%s,"result":{}}\n' "$ID" + ;; + esac +done +"#; + + async fn acceptance_agent( + index: usize, + temp_dir: &std::path::Path, + observer: observer::ObserverHandle, + ) -> OwnedAgent { + let env = vec![ + ( + "ACCEPTANCE_DIR".to_string(), + temp_dir.to_string_lossy().to_string(), + ), + ("ACCEPTANCE_SLOT".to_string(), index.to_string()), + ]; + let mut acp = AcpClient::spawn( + "bash", + &["-c".to_string(), ACCEPTANCE_AGENT_SCRIPT.to_string()], + &env, + false, + ) + .await + .expect("spawn fake acceptance ACP"); + acp.set_observer(Some(observer), index); + let init = acp.initialize().await.expect("initialize fake ACP"); + OwnedAgent { + index, + acp, + state: SessionState::default(), + model_capabilities: None, + baseline_model: None, + desired_model: None, + desired_model_generation: None, + model_overridden: false, + agent_name: "acceptance-agent".to_string(), + goose_system_prompt_supported: None, + protocol_version: init["protocolVersion"].as_u64().unwrap_or(2) as u32, + } + } + + async fn wait_for_started_prompts(temp_dir: &std::path::Path, expected: usize) { + tokio::time::timeout(Duration::from_secs(5), async { + loop { + let count = std::fs::read_dir(temp_dir) + .expect("read acceptance temp dir") + .filter_map(Result::ok) + .filter(|entry| entry.file_name().to_string_lossy().starts_with("started-")) + .count(); + if count >= expected { + return; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("fake ACP prompts started"); + } + + async fn wait_for_submitted_kind( + state: &Arc>, + kind: nostr::Kind, + expected: usize, + ) { + tokio::time::timeout(Duration::from_secs(5), async { + loop { + let count = state + .lock() + .await + .submitted_events + .iter() + .filter(|event| event.kind == kind) + .count(); + if count >= expected { + return; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("relay side effects reached expected count"); + } + + fn release_acceptance_prompt(temp_dir: &std::path::Path, agent: usize, prompt: usize) { + std::fs::write( + temp_dir.join(format!("release-{agent}-{prompt}")), + b"release", + ) + .expect("release fake ACP prompt"); + } + + #[derive(Debug)] + struct AcceptanceCallback { + source_ids: Vec, + turn_id: String, + agent_index: usize, + scope: ConversationScope, + session_id: String, + } + + async fn settle_acceptance_results( + pool: &mut AgentPool, + queue: &mut EventQueue, + expected: usize, + ) -> Vec { + let mut results = Vec::new(); + tokio::time::timeout(Duration::from_secs(5), async { + while results.len() < expected { + match pool.result_rx_try_recv() { + Ok(result) => results.push(result), + Err(tokio::sync::mpsc::error::TryRecvError::Empty) => { + tokio::time::sleep(Duration::from_millis(10)).await; + } + Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => { + panic!("acceptance result channel disconnected"); + } + } + } + }) + .await + .expect("terminal ACP callbacks"); + + for _ in 0..expected { + tokio::time::timeout(Duration::from_secs(5), pool.join_set.join_next()) + .await + .expect("prompt task join timeout") + .expect("prompt task join result") + .expect("prompt task completed without panic"); + } + + let mut callbacks = Vec::new(); + for result in results { + assert!( + matches!(result.outcome, PromptOutcome::Ok(_)), + "fake ACP prompt must finish successfully" + ); + let agent_index = result.agent.index; + let meta = pool + .task_map() + .values() + .find(|meta| meta.agent_index == agent_index) + .expect("task metadata for callback"); + let batch = meta + .recoverable_batch + .as_ref() + .expect("queue mode callback retains its source"); + let source_ids = batch + .events + .iter() + .map(|event| event.event.id.to_hex()) + .collect::>(); + let scope = match result.source { + PromptSource::Conversation(scope) => scope, + PromptSource::Heartbeat => panic!("acceptance callback must be a conversation"), + }; + let session_id = result + .agent + .state + .sessions + .get(&scope) + .cloned() + .expect("conversation session survives successful turn"); + callbacks.push(AcceptanceCallback { + source_ids, + turn_id: result.turn_id, + agent_index, + scope, + session_id, + }); + pool.task_map_mut() + .retain(|_, meta| meta.agent_index != agent_index); + queue.mark_complete(scope); + pool.return_agent(result.agent); + } + callbacks + } + + fn observer_prompt_text(event: &observer::ObserverEvent) -> String { + event.payload["params"]["prompt"] + .as_array() + .into_iter() + .flatten() + .filter_map(|block| block.get("text").and_then(serde_json::Value::as_str)) + .collect::>() + .join("\n") + } + + /// End-to-end acceptance contract for thread-scoped sessions. This crosses + /// the actual queue/dispatch/task/session/context/reaction boundaries with + /// SDK-canonical forum events, real ACP JSON-RPC subprocesses, and a signed + /// HTTP relay bridge. The prompt gates prove three roots are simultaneously + /// live and that later replies serialize onto the sticky root owner. + #[tokio::test] + async fn canonical_three_root_acceptance_completes_once_without_cross_root_context() { + let temp_dir = AcceptanceTempDir::new(); + let observer = observer::ObserverHandle::in_process(); + let relay_keys = nostr::Keys::generate(); + let (rest_client, relay_state, relay_server) = + spawn_acceptance_relay(relay_keys.clone()).await; + let model_controls = ChannelModelControls::default(); + let agents = vec![ + Some(acceptance_agent(0, &temp_dir.0, observer.clone()).await), + Some(acceptance_agent(1, &temp_dir.0, observer.clone()).await), + Some(acceptance_agent(2, &temp_dir.0, observer.clone()).await), + ]; + let mut pool = AgentPool::from_slots_with_model_controls(agents, model_controls.clone()); + let channel_id = Uuid::new_v4(); + let mut context = make_test_prompt_context(); + context.context_message_limit = 10; + context.session_title = Some("Acceptance Agent".to_string()); + context.rest_client = rest_client.clone(); + context.channel_info = pool::ChannelInfoResolver::new( + HashMap::from([( + channel_id, + relay::ChannelInfo { + name: "acceptance-forum".to_string(), + channel_type: "forum".to_string(), + }, + )]), + rest_client.clone(), + ); + context.agent_keys = relay_keys; + context.model_controls = model_controls; + let context = Arc::new(context); + let mut queue = EventQueue::new(config::DedupMode::Queue); + let event_keys = nostr::Keys::generate(); + + let root_a = buzz_sdk::build_forum_post(channel_id, "forum-root-A", &[], &[]) + .expect("build root A") + .sign_with_keys(&event_keys) + .expect("sign root A"); + let root_b = buzz_sdk::build_forum_post(channel_id, "forum-root-B", &[], &[]) + .expect("build root B") + .sign_with_keys(&event_keys) + .expect("sign root B"); + let root_c = buzz_sdk::build_forum_post(channel_id, "forum-root-C", &[], &[]) + .expect("build root C") + .sign_with_keys(&event_keys) + .expect("sign root C"); + let initial_roots = [root_a.clone(), root_b.clone(), root_c.clone()]; + relay_state + .lock() + .await + .context_events + .extend(initial_roots.iter().cloned()); + + let mut all_sources = Vec::new(); + for event in initial_roots { + let scope = ConversationScope::for_event(channel_id, &event, true); + assert_eq!(scope.thread_root, Some(event.id)); + pool::reaction_add(&rest_client, &event.id.to_hex(), "👀").await; + all_sources.push(event.clone()); + assert!(queue.push(QueuedEvent { + scope, + event, + received_at: std::time::Instant::now(), + prompt_tag: "acceptance".to_string(), + })); + } + + let dispatched = dispatch_pending(&mut pool, &mut queue, &context); + assert_eq!(dispatched.len(), 3, "all three roots dispatch together"); + assert_eq!(pool.task_map().len(), 3); + assert_eq!( + pool.task_map() + .values() + .map(|meta| meta.agent_index) + .collect::>() + .len(), + 3, + "each independently in-flight root owns a worker" + ); + wait_for_started_prompts(&temp_dir.0, 3).await; + wait_for_submitted_kind(&relay_state, nostr::Kind::Reaction, 6).await; + for index in 0..3 { + release_acceptance_prompt(&temp_dir.0, index, 1); + } + let mut callbacks = settle_acceptance_results(&mut pool, &mut queue, 3).await; + let root_a_callback = callbacks + .iter() + .find(|callback| callback.source_ids == [root_a.id.to_hex()]) + .expect("root A callback"); + let sticky_agent = root_a_callback.agent_index; + let sticky_session = root_a_callback.session_id.clone(); + let root_a_scope = root_a_callback.scope; + + let comment_a1 = buzz_sdk::build_forum_comment( + channel_id, + "forum-comment-A-1", + &buzz_sdk::ThreadRef { + root_event_id: root_a.id, + parent_event_id: root_a.id, + }, + &[], + &[], + ) + .expect("build comment A1") + .sign_with_keys(&event_keys) + .expect("sign comment A1"); + assert_eq!( + ConversationScope::for_event(channel_id, &comment_a1, true), + root_a_scope + ); + relay_state + .lock() + .await + .context_events + .push(comment_a1.clone()); + pool::reaction_add(&rest_client, &comment_a1.id.to_hex(), "👀").await; + all_sources.push(comment_a1.clone()); + assert!(queue.push(QueuedEvent { + scope: root_a_scope, + event: comment_a1.clone(), + received_at: std::time::Instant::now(), + prompt_tag: "acceptance".to_string(), + })); + assert_eq!(dispatch_pending(&mut pool, &mut queue, &context).len(), 1); + assert_eq!( + pool.task_map().values().next().map(|meta| meta.agent_index), + Some(sticky_agent) + ); + wait_for_started_prompts(&temp_dir.0, 4).await; + + // A nested SDK-canonical reply arrives while A1 is still gated. It + // stays queued under the same scope and cannot start a second task. + let comment_a2 = buzz_sdk::build_forum_comment( + channel_id, + "forum-comment-A-2", + &buzz_sdk::ThreadRef { + root_event_id: root_a.id, + parent_event_id: comment_a1.id, + }, + &[], + &[], + ) + .expect("build comment A2") + .sign_with_keys(&event_keys) + .expect("sign comment A2"); + assert_eq!( + ConversationScope::for_event(channel_id, &comment_a2, true), + root_a_scope + ); + relay_state + .lock() + .await + .context_events + .push(comment_a2.clone()); + pool::reaction_add(&rest_client, &comment_a2.id.to_hex(), "👀").await; + all_sources.push(comment_a2.clone()); + assert!(queue.push(QueuedEvent { + scope: root_a_scope, + event: comment_a2.clone(), + received_at: std::time::Instant::now(), + prompt_tag: "acceptance".to_string(), + })); + assert!( + dispatch_pending(&mut pool, &mut queue, &context).is_empty(), + "same-root reply remains serialized while its owner is active" + ); + assert_eq!(queue.queued_event_count(&root_a_scope), 1); + + wait_for_submitted_kind(&relay_state, nostr::Kind::Reaction, 9).await; + release_acceptance_prompt(&temp_dir.0, sticky_agent, 2); + callbacks.extend(settle_acceptance_results(&mut pool, &mut queue, 1).await); + assert_eq!(dispatch_pending(&mut pool, &mut queue, &context).len(), 1); + assert_eq!( + pool.task_map().values().next().map(|meta| meta.agent_index), + Some(sticky_agent), + "next same-root reply returns to the sticky owner" + ); + wait_for_started_prompts(&temp_dir.0, 5).await; + wait_for_submitted_kind(&relay_state, nostr::Kind::Reaction, 10).await; + release_acceptance_prompt(&temp_dir.0, sticky_agent, 3); + callbacks.extend(settle_acceptance_results(&mut pool, &mut queue, 1).await); + + assert_eq!(callbacks.len(), all_sources.len()); + let source_ids: HashSet = + all_sources.iter().map(|event| event.id.to_hex()).collect(); + let callback_ids = callbacks + .iter() + .flat_map(|callback| callback.source_ids.iter().cloned()) + .collect::>(); + assert_eq!(callback_ids.len(), source_ids.len()); + assert_eq!( + callback_ids.iter().cloned().collect::>(), + source_ids, + "every source reaches one and only one terminal callback" + ); + for callback in callbacks + .iter() + .filter(|callback| callback.scope == root_a_scope) + { + assert_eq!(callback.agent_index, sticky_agent); + assert_eq!(callback.session_id, sticky_session); + } + + let observer_events = observer.snapshot(); + let turn_started: Vec<_> = observer_events + .iter() + .filter(|event| event.kind == "turn_started") + .collect(); + let terminal: Vec<_> = observer_events + .iter() + .filter(|event| event.kind == "turn_completed") + .collect(); + let prompt_writes: Vec<_> = observer_events + .iter() + .filter(|event| { + event.kind == "acp_write" + && event.payload["method"].as_str() == Some("session/prompt") + }) + .collect(); + assert_eq!(turn_started.len(), all_sources.len()); + assert_eq!(prompt_writes.len(), all_sources.len()); + assert_eq!(terminal.len(), all_sources.len()); + let triggering_ids = turn_started + .iter() + .flat_map(|event| { + event.payload["triggeringEventIds"] + .as_array() + .into_iter() + .flatten() + .filter_map(serde_json::Value::as_str) + .map(ToOwned::to_owned) + }) + .collect::>(); + assert_eq!(triggering_ids.len(), source_ids.len()); + assert_eq!( + triggering_ids.iter().cloned().collect::>(), + source_ids + ); + let callback_turns: HashSet<_> = callbacks + .iter() + .map(|callback| callback.turn_id.clone()) + .collect(); + assert_eq!( + terminal + .iter() + .filter_map(|event| event.turn_id.clone()) + .collect::>(), + callback_turns + ); + + let trigger_by_turn: HashMap<_, _> = turn_started + .iter() + .map(|event| { + ( + event.turn_id.clone().expect("turn id"), + event.payload["triggeringEventIds"][0] + .as_str() + .expect("one triggering id") + .to_string(), + ) + }) + .collect(); + let content_by_id: HashMap<_, _> = all_sources + .iter() + .map(|event| (event.id.to_hex(), event.content.clone())) + .collect(); + for prompt in prompt_writes { + let trigger_id = trigger_by_turn + .get(prompt.turn_id.as_deref().expect("prompt turn id")) + .expect("prompt matches turn start"); + let text = observer_prompt_text(prompt); + let source_content = &content_by_id[trigger_id]; + assert_eq!( + text.matches(source_content).count(), + 1, + "current source content is excluded from fetched context" + ); + if trigger_id == &root_a.id.to_hex() { + assert!(!text.contains("forum-root-B") && !text.contains("forum-root-C")); + } else if trigger_id == &root_b.id.to_hex() { + assert!(!text.contains("forum-root-A") && !text.contains("forum-root-C")); + } else if trigger_id == &root_c.id.to_hex() { + assert!(!text.contains("forum-root-A") && !text.contains("forum-root-B")); + } else { + assert!(text.contains("forum-root-A")); + assert!(!text.contains("forum-root-B") && !text.contains("forum-root-C")); + } + if trigger_id == &comment_a2.id.to_hex() { + assert!( + text.contains("forum-comment-A-1"), + "nested reply receives only its own root's prior context" + ); + } + } + + let session_new_writes: Vec<_> = observer_events + .iter() + .filter(|event| { + event.kind == "acp_write" && event.payload["method"].as_str() == Some("session/new") + }) + .collect(); + assert_eq!(session_new_writes.len(), 3); + let titles = session_new_writes + .iter() + .filter_map(|event| { + event.payload["params"]["_meta"]["sessionTitle"] + .as_str() + .map(ToOwned::to_owned) + }) + .collect::>(); + assert_eq!(titles.len(), 3, "BYOH thread session titles are distinct"); + + wait_for_submitted_kind(&relay_state, nostr::Kind::EventDeletion, 10).await; + let locked = relay_state.lock().await; + let reactions: Vec<_> = locked + .submitted_events + .iter() + .filter(|event| event.kind == nostr::Kind::Reaction) + .collect(); + let deletions: Vec<_> = locked + .submitted_events + .iter() + .filter(|event| event.kind == nostr::Kind::EventDeletion) + .collect(); + assert_eq!(reactions.len(), all_sources.len() * 2); + assert_eq!(deletions.len(), reactions.len()); + for source in &all_sources { + let source_id = source.id.to_hex(); + for emoji in ["👀", "💬"] { + assert_eq!( + reactions + .iter() + .filter(|event| { + event.content == emoji && event_has_e_tag(event, &source_id) + }) + .count(), + 1, + "each source gets one {emoji} reaction" + ); + } + } + for reaction in reactions { + assert_eq!( + deletions + .iter() + .filter(|deletion| event_has_e_tag(deletion, &reaction.id.to_hex())) + .count(), + 1, + "each side effect is cleaned up exactly once" + ); + } + drop(locked); + + shutdown_agent_pool(&mut pool).await; + relay_server.abort(); + } + + /// Finding 1: a dead owner (slot neither idle nor checked out) releases + /// the scope, which re-owns on another idle agent with a fresh history. + #[tokio::test] + async fn dead_owner_releases_scope_to_another_agent() { + let ch = Uuid::new_v4(); + let scope_a = thread_scope(ch, 'a'); + let agent0 = dummy_agent(0).await; + let agent1 = dummy_agent(1).await; + let mut pool = AgentPool::from_slots(vec![Some(agent0), Some(agent1)]); + + let mut a0 = match pool.try_claim_for_scope(scope_a) { + pool::ClaimOutcome::Claimed(a) => *a, + _ => panic!("claim A"), + }; + a0.state.sessions.insert(scope_a, "sess-a".into()); + // Agent 0 dies while checked out: no return, no task_map entry — + // exactly the state after handle_prompt_result strips the task and + // hands the agent to the respawn path. + pool.release_agent_ownerships(0); + drop(a0); + + let a = match pool.try_claim_for_scope(scope_a) { + pool::ClaimOutcome::Claimed(a) => *a, + _ => panic!("scope must re-own on the surviving agent"), + }; + assert_eq!(a.index, 1); + assert_eq!(pool.scope_owner(&scope_a), Some(1)); + pool.return_agent(a); + } + + /// Finding 1: ownership is released when the owner returns without a + /// live session for the scope (rotation / invalidation during the turn), + /// so the next event may be served by any agent. + #[tokio::test] + async fn ownership_released_when_session_dropped_during_turn() { + let ch = Uuid::new_v4(); + let scope_a = thread_scope(ch, 'a'); + let agent0 = dummy_agent(0).await; + let mut pool = AgentPool::from_slots(vec![Some(agent0)]); + + let a0 = match pool.try_claim_for_scope(scope_a) { + pool::ClaimOutcome::Claimed(a) => *a, + _ => panic!("claim A"), + }; + assert_eq!(pool.scope_owner(&scope_a), Some(0)); + // Turn ends with the session rotated away (no session for A). + pool.return_agent(a0); + assert_eq!( + pool.scope_owner(&scope_a), + None, + "no history left to protect — ownership must be released" + ); + } + + /// A cached catalog remains authoritative while every agent is checked + /// out. Unsupported controls neither cancel active work nor leave a stale + /// desired model, and exactly one channel result is emitted. + #[tokio::test] + async fn all_active_unsupported_model_is_rejected_once_without_state_change() { + let channel_id = Uuid::new_v4(); + let scope_a = thread_scope(channel_id, 'a'); + let scope_b = thread_scope(channel_id, 'b'); + let future_scope = thread_scope(channel_id, 'c'); + let agent0 = dummy_agent_with_baseline(0, "model-base").await; + let agent1 = dummy_agent_with_baseline(1, "model-base").await; + let mut pool = AgentPool::from_slots(vec![Some(agent0), Some(agent1)]); + + let active_a = match pool.try_claim_for_scope(scope_a) { + pool::ClaimOutcome::Claimed(agent) => *agent, + _ => panic!("first active scope must be claimable"), + }; + let active_b = match pool.try_claim_for_scope(scope_b) { + pool::ClaimOutcome::Claimed(agent) => *agent, + _ => panic!("second active scope must be claimable"), + }; + let mut rx_a = insert_task(&mut pool, active_a.index, scope_a); + let mut rx_b = insert_task(&mut pool, active_b.index, scope_b); + let observer = observer::ObserverHandle::in_process(); + + handle_switch_model_control( + &serde_json::json!({ + "type": "switch_model", + "channelId": channel_id, + "modelId": "not-in-the-catalog", + "requestId": "picker-1", + }), + &mut pool, + Some(&observer), + ); + + assert!( + rx_a.try_recv().is_err() && rx_b.try_recv().is_err(), + "unsupported controls must not disturb either active turn" + ); + let results: Vec<_> = observer + .snapshot() + .into_iter() + .filter(|event| event.kind == "control_result") + .collect(); + assert_eq!(results.len(), 1, "one request has one result"); + assert_eq!(results[0].payload["status"], "unsupported_model"); + assert_eq!(results[0].payload["generation"], 1); + assert_eq!(results[0].payload["requestId"], "picker-1"); + + pool.task_map_mut().clear(); + pool.return_agent(active_a); + pool.return_agent(active_b); + let claimed = match pool.try_claim_for_scope(future_scope) { + pool::ClaimOutcome::Claimed(agent) => *agent, + _ => panic!("future scope must remain claimable"), + }; + assert_eq!(claimed.desired_model.as_deref(), Some("model-base")); + assert_eq!(claimed.desired_model_generation, None); + assert!(!claimed.model_overridden); + pool.return_agent(claimed); + assert_eq!( + observer + .snapshot() + .iter() + .filter(|event| event.kind == "control_result") + .count(), + 1, + "later holder lifecycle must not emit duplicate unsupported results" + ); + } + + /// Finding 2 (all idle): a desktop channel-level model switch must reach + /// EVERY idle agent holding sessions under the channel, invalidating all + /// of them — and leave other channels' sessions alone. + #[tokio::test] + async fn channel_model_switch_updates_every_idle_session_holder() { + let ch = Uuid::new_v4(); + let other_ch = Uuid::new_v4(); + let thread_a = thread_scope(ch, 'a'); + let channel = ConversationScope::channel(ch); + let other_scope = ConversationScope::channel(other_ch); + + let mut agent0 = dummy_agent_with_baseline(0, "model-base").await; + agent0.state.sessions.insert(thread_a, "sess-a".into()); + let mut agent1 = dummy_agent_with_baseline(1, "model-base").await; + agent1.state.sessions.insert(channel, "sess-ch".into()); + agent1 + .state + .sessions + .insert(other_scope, "sess-other".into()); + let mut pool = AgentPool::from_slots(vec![Some(agent0), Some(agent1)]); + + let (_decision, result) = pool.set_channel_desired_model(ch, "model-x", None); + assert_eq!(result, IdleSwitchResult::Switched); + + for slot in pool.agents_mut().iter() { + let agent = slot.as_ref().expect("both agents idle"); + assert_eq!( + agent.desired_model.as_deref(), + Some("model-base"), + "idle agent {} must remain on its explicit baseline", + agent.index + ); + assert!(!agent.model_overridden); + assert!( + !agent.state.sessions.keys().any(|s| s.channel_id == ch), + "agent {}'s sessions under the channel must be invalidated", + agent.index + ); + } + // The unrelated channel's session survives. + let agent1 = pool.agents_mut()[1].as_ref().expect("idle"); + assert_eq!( + agent1.state.sessions.get(&other_scope).map(String::as_str), + Some("sess-other") + ); + + // A claim under the switched channel resolves the override; a claim + // under an unrelated channel resolves the baseline. + let switched = match pool.try_claim_for_scope(thread_a) { + pool::ClaimOutcome::Claimed(agent) => *agent, + _ => panic!("switched channel must be claimable"), + }; + assert_eq!(switched.desired_model.as_deref(), Some("model-x")); + assert!(switched.model_overridden); + pool.return_agent(switched); + + let unrelated = match pool.try_claim_for_scope(other_scope) { + pool::ClaimOutcome::Claimed(agent) => *agent, + _ => panic!("unrelated channel must be claimable"), + }; + assert_eq!(unrelated.desired_model.as_deref(), Some("model-base")); + assert!(!unrelated.model_overridden); + pool.return_agent(unrelated); + } + + /// A channel override is never sticky agent-global state: after serving + /// switched channel A, the same worker resolves channel B (which has no + /// override) back to its configured baseline before session creation. + #[tokio::test] + async fn switched_channel_then_unrelated_channel_restores_baseline() { + let channel_a = Uuid::new_v4(); + let channel_b = Uuid::new_v4(); + let scope_a = ConversationScope::channel(channel_a); + let scope_b = ConversationScope::channel(channel_b); + let agent = dummy_agent_with_baseline(0, "model-base").await; + let mut pool = AgentPool::from_slots(vec![Some(agent)]); + + assert_eq!( + pool.set_channel_desired_model(channel_a, "model-a", None).1, + IdleSwitchResult::NoIdleAgent + ); + let mut claimed_a = match pool.try_claim_for_scope(scope_a) { + pool::ClaimOutcome::Claimed(agent) => *agent, + _ => panic!("channel A must be claimable"), + }; + assert_eq!(claimed_a.desired_model.as_deref(), Some("model-a")); + assert!(claimed_a.model_overridden); + claimed_a.state.sessions.insert(scope_a, "sess-a".into()); + pool.return_agent(claimed_a); + + let claimed_b = match pool.try_claim_for_scope(scope_b) { + pool::ClaimOutcome::Claimed(agent) => *agent, + _ => panic!("channel B must reuse the only worker"), + }; + assert_eq!(claimed_b.index, 0); + assert_eq!( + claimed_b.desired_model.as_deref(), + Some("model-base"), + "a new B session must apply the configured baseline, not A's override" + ); + assert!(!claimed_b.model_overridden); + assert_eq!( + claimed_b.state.sessions.get(&scope_a).map(String::as_str), + Some("sess-a"), + "resolving B must not mutate A's valid session" + ); + pool.return_agent(claimed_b); + } + + /// Distinct channel overrides alternate safely on one worker. Each claim + /// resolves its own channel model and neither switch invalidates the other + /// channel's already-correct session. + #[tokio::test] + async fn distinct_channel_overrides_alternate_on_one_agent() { + let channel_a = Uuid::new_v4(); + let channel_b = Uuid::new_v4(); + let scope_a = ConversationScope::channel(channel_a); + let scope_b = ConversationScope::channel(channel_b); + let agent = dummy_agent_with_baseline(0, "model-base").await; + let mut pool = AgentPool::from_slots(vec![Some(agent)]); + + let _ = pool.set_channel_desired_model(channel_a, "model-a", None); + let mut claimed_a = match pool.try_claim_for_scope(scope_a) { + pool::ClaimOutcome::Claimed(agent) => *agent, + _ => panic!("channel A must be claimable"), + }; + assert_eq!(claimed_a.desired_model.as_deref(), Some("model-a")); + claimed_a.state.sessions.insert(scope_a, "sess-a".into()); + pool.return_agent(claimed_a); + + let _ = pool.set_channel_desired_model(channel_b, "model-b", None); + assert!(pool.has_session_for(scope_a), "B switch must preserve A"); + let mut claimed_b = match pool.try_claim_for_scope(scope_b) { + pool::ClaimOutcome::Claimed(agent) => *agent, + _ => panic!("channel B must reuse the only worker"), + }; + assert_eq!(claimed_b.desired_model.as_deref(), Some("model-b")); + assert!(claimed_b.model_overridden); + claimed_b.state.sessions.insert(scope_b, "sess-b".into()); + pool.return_agent(claimed_b); + + let claimed_a_again = match pool.try_claim_for_scope(scope_a) { + pool::ClaimOutcome::Claimed(agent) => *agent, + _ => panic!("channel A must be reclaimable"), + }; + assert_eq!(claimed_a_again.desired_model.as_deref(), Some("model-a")); + assert!(claimed_a_again.model_overridden); + assert_eq!( + claimed_a_again + .state + .sessions + .get(&scope_b) + .map(String::as_str), + Some("sess-b"), + "alternating back to A must preserve B's session" + ); + pool.return_agent(claimed_a_again); + } + + /// Finding 2 (mixed active/idle): the desktop control frame signals the + /// active conversation, updates the idle holder, and any agent claiming + /// a conversation under the channel afterwards inherits the model. + #[tokio::test] + async fn channel_model_switch_covers_active_idle_and_future_claims() { + let ch = Uuid::new_v4(); + let thread_a = thread_scope(ch, 'a'); + let thread_b = thread_scope(ch, 'b'); + let thread_c = thread_scope(ch, 'c'); + let channel = ConversationScope::channel(ch); + + // Agent 0 active on thread A; agent 1 idle holding the channel + // session; agent 2 idle with no sessions (future claimer). + let mut agent0 = dummy_agent_with_baseline(0, "model-base").await; + agent0.state.sessions.insert(thread_a, "sess-a".into()); + agent0.state.sessions.insert(thread_c, "sess-c".into()); + let mut agent1 = dummy_agent_with_baseline(1, "model-base").await; + agent1.state.sessions.insert(channel, "sess-ch".into()); + let agent2 = dummy_agent_with_baseline(2, "model-base").await; + let mut pool = AgentPool::from_slots(vec![Some(agent0), Some(agent1), Some(agent2)]); + let mut active_agent = match pool.try_claim_for_scope(thread_a) { + pool::ClaimOutcome::Claimed(agent) => *agent, + _ => panic!("thread A must be claimable"), + }; + let mut rx_a = insert_task(&mut pool, 0, thread_a); + + let payload = serde_json::json!({ + "type": "switch_model", + "channelId": ch.to_string(), + "modelId": "model-x", + }); + handle_switch_model_control(&payload, &mut pool, None); + + // Active conversation received the switch signal. + assert_eq!( + rx_a.try_recv().expect("active task must be signalled"), + ControlSignal::SwitchModel { generation: 1 } + ); + // Idle holder invalidated but remains on its baseline while idle. + let a1 = pool.agents_mut()[1].as_ref().expect("idle"); + assert_eq!(a1.desired_model.as_deref(), Some("model-base")); + assert!(!a1.model_overridden); + assert!(!a1.state.sessions.contains_key(&channel)); + + // Model the active task's invalidation arm, then return it. Generation + // reconciliation must also invalidate thread C, which was cached in + // the checked-out holder but was not itself in flight. + active_agent.state.invalidate_scope(&thread_a); + pool.task_map_mut().retain(|_, meta| meta.agent_index != 0); + pool.return_agent(active_agent); + let a0 = pool.agents_mut()[0].as_ref().expect("returned idle"); + assert!(!a0.state.sessions.contains_key(&thread_c)); + assert_eq!(a0.desired_model.as_deref(), Some("model-base")); + + // A future claim under the channel inherits the desired model. + let claimed = match pool.try_claim_for_scope(thread_b) { + pool::ClaimOutcome::Claimed(a) => *a, + _ => panic!("an idle agent must be claimable"), + }; + assert_eq!(claimed.desired_model.as_deref(), Some("model-x")); + assert!(claimed.model_overridden); + pool.return_agent(claimed); + } + + /// Scope derivation uses cached channel type correctly: DMs stay + /// channel-scoped and regular/forum channels thread-scope. Unknown types + /// fail closed to channel scope; the dynamic-subscription gate below + /// prevents joined-channel events from being delivered in that state. + #[test] + fn dynamic_channel_metadata_enables_scoping_without_restart() { + let ctx = make_test_prompt_context(); + let dm = Uuid::new_v4(); + let regular = Uuid::new_v4(); + let forum = Uuid::new_v4(); + + let reply_tags = |root: char| { + vec![vec![ + "e".to_string(), + root.to_string().repeat(64), + String::new(), + "reply".to_string(), + ]] + }; + let make_reply = |root: char| { + let keys = nostr::Keys::generate(); + let tags: Vec = reply_tags(root) + .into_iter() + .map(|t| nostr::Tag::parse(&t).expect("tag")) + .collect(); + nostr::EventBuilder::new(nostr::Kind::Custom(9), "reply") + .tags(tags) + .sign_with_keys(&keys) + .expect("sign") + }; + let scoping = |ctx: &PromptContext, ch: Uuid| { + let info = ctx.channel_info.cached(ch); + channel_supports_thread_scoping(info.as_ref()) + }; + + // The low-level resolver fails closed for unknown metadata. Production + // dynamic subscriptions remain closed here rather than delivering. + for ch in [dm, regular, forum] { + assert!(!scoping(&ctx, ch)); + let scope = ConversationScope::for_event(ch, &make_reply('a'), scoping(&ctx, ch)); + assert_eq!(scope, ConversationScope::channel(ch)); + } + + // Metadata resolves (what `refresh_channel_info` / the per-prompt + // lazy fetch cache on success). + for (ch, ty) in [(dm, "dm"), (regular, "channel"), (forum, "forum")] { + ctx.channel_info.insert_for_test( + ch, + queue::PromptChannelInfo { + name: "dynamic".into(), + channel_type: ty.into(), + }, + ); + } + + // DM stays channel-scoped; regular and forum channels thread-scope. + assert!(!scoping(&ctx, dm)); + let dm_scope = ConversationScope::for_event(dm, &make_reply('a'), scoping(&ctx, dm)); + assert_eq!(dm_scope, ConversationScope::channel(dm)); + for ch in [regular, forum] { + assert!(scoping(&ctx, ch)); + let scope = ConversationScope::for_event(ch, &make_reply('a'), scoping(&ctx, ch)); + assert!( + scope.is_thread(), + "resolved non-DM channel must thread-scope" + ); + } + } + + /// Scope derivation itself awaits metadata when startup discovery did not + /// know the channel. A rooted-looking first DM event must therefore remain + /// channel-scoped rather than briefly creating a thread session. + #[tokio::test] + async fn first_uncached_dm_event_resolves_metadata_before_scoping() { + use std::sync::atomic::{AtomicUsize, Ordering}; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let channel_id = Uuid::new_v4(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind metadata server"); + let base_url = format!("http://{}", listener.local_addr().expect("server address")); + let requests = Arc::new(AtomicUsize::new(0)); + let server_requests = Arc::clone(&requests); + let body = serde_json::json!([{ + "tags": [ + ["d", channel_id.to_string()], + ["name", "DM"], + ["t", "dm"] + ] + }]) + .to_string(); + let server = tokio::spawn(async move { + while let Ok((mut socket, _)) = listener.accept().await { + let mut request = vec![0; 8192]; + let _ = socket.read(&mut request).await; + server_requests.fetch_add(1, Ordering::SeqCst); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\ + Content-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + let _ = socket.write_all(response.as_bytes()).await; + } + }); + + let keys = nostr::Keys::generate(); + let rest_client = relay::RestClient { + http: reqwest::Client::new(), + base_url, + keys: keys.clone(), + auth_tag_json: None, + }; + let mut ctx = make_test_prompt_context(); + ctx.channel_info = pool::ChannelInfoResolver::new(HashMap::new(), rest_client.clone()); + ctx.rest_client = rest_client; + + let root_tag = nostr::Tag::parse(&[ + "e".to_string(), + "e".repeat(64), + String::new(), + "reply".to_string(), + ]) + .expect("root tag"); + let event = nostr::EventBuilder::new(nostr::Kind::Custom(9), "first DM message") + .tags([root_tag]) + .sign_with_keys(&keys) + .expect("sign DM event"); + + let scope = conversation_scope_for_event(&ctx, channel_id, &event).await; + assert_eq!(scope, ConversationScope::channel(channel_id)); + assert_eq!( + requests.load(Ordering::SeqCst), + 1, + "scope derivation must resolve the uncached channel exactly once" + ); + server.abort(); + } + + /// Regression for the membership-add event-before-metadata race. The + /// subscription-ready signal is the production delivery gate: it cannot + /// fire until metadata is cached, so an event arriving immediately when + /// the subscription opens and a later event resolve to the same thread + /// scope and therefore the same cached ACP session. + #[tokio::test] + async fn dynamic_subscription_gates_first_event_on_metadata() { + let ctx = Arc::new(make_test_prompt_context()); + let channel_id = Uuid::new_v4(); + let membership_event_id = "membership-generation".to_string(); + let (attempt_tx, mut attempt_rx) = mpsc::unbounded_channel(); + let (ready_tx, mut ready_rx) = mpsc::unbounded_channel(); + let release_metadata = Arc::new(tokio::sync::Semaphore::new(0)); + let resolver_ctx = Arc::clone(&ctx); + let resolver_release = Arc::clone(&release_metadata); + let ready = DynamicChannelSubscriptionReady { + channel_id, + membership_event_id: membership_event_id.clone(), + replay_since: 42, + }; + + let resolver = tokio::spawn(resolve_metadata_before_dynamic_subscribe( + move || { + let attempt_tx = attempt_tx.clone(); + let resolver_ctx = Arc::clone(&resolver_ctx); + let resolver_release = Arc::clone(&resolver_release); + async move { + let _ = attempt_tx.send(()); + let permit = resolver_release + .acquire() + .await + .expect("metadata gate remains open"); + permit.forget(); + resolver_ctx.channel_info.insert_for_test( + channel_id, + queue::PromptChannelInfo { + name: "dynamic-forum".into(), + channel_type: "forum".into(), + }, + ); + true + } + }, + ready, + ready_tx, + Duration::from_secs(60), + )); + + attempt_rx.recv().await.expect("metadata lookup started"); + assert!( + matches!(ready_rx.try_recv(), Err(mpsc::error::TryRecvError::Empty)), + "event delivery must remain closed while metadata is unresolved" + ); + + // Metadata resolves; only then may the main loop subscribe and expose + // the first replayed/live event. + release_metadata.add_permits(1); + let delivered_gate = ready_rx.recv().await.expect("subscription becomes ready"); + assert_eq!(delivered_gate.membership_event_id, membership_event_id); + + let make_reply = || { + let keys = nostr::Keys::generate(); + let root = "d".repeat(64); + let tag = + nostr::Tag::parse(&["e".to_string(), root, String::new(), "reply".to_string()]) + .expect("reply tag"); + nostr::EventBuilder::new(nostr::Kind::Custom(45003), "comment") + .tags([tag]) + .sign_with_keys(&keys) + .expect("sign comment") + }; + + let first_scope = conversation_scope_for_event(&ctx, channel_id, &make_reply()).await; + assert!( + first_scope.is_thread(), + "the first event must immediately use resolved forum scoping" + ); + let mut state = SessionState::default(); + state + .sessions + .insert(first_scope, "dynamic-session".to_string()); + + let later_scope = conversation_scope_for_event(&ctx, channel_id, &make_reply()).await; + assert_eq!(later_scope, first_scope); + assert_eq!( + state.sessions.get(&later_scope).map(String::as_str), + Some("dynamic-session"), + "first and later events must route to the same ACP session" + ); + + resolver.await.expect("metadata resolver exits cleanly"); + } +} diff --git a/crates/buzz-acp/src/observer.rs b/crates/buzz-acp/src/observer.rs index 7029e5af6d..4252a0ce11 100644 --- a/crates/buzz-acp/src/observer.rs +++ b/crates/buzz-acp/src/observer.rs @@ -22,6 +22,9 @@ const OBSERVER_BUFFER_CAP: usize = 1_000; pub struct ObserverContext { /// Buzz channel UUID for the current turn, when channel-scoped. pub channel_id: Option, + /// Canonical NIP-10 thread root for a thread-scoped conversation. + /// Absent for unthreaded channels, DMs, heartbeats, and legacy frames. + pub thread_root: Option, /// ACP session ID associated with the current turn, once known. pub session_id: Option, /// Local UUID for one prompt turn. @@ -67,6 +70,9 @@ pub struct ObserverEvent { pub agent_index: Option, /// Buzz channel UUID for channel-scoped events. pub channel_id: Option, + /// Canonical NIP-10 thread root for a thread-scoped conversation. + #[serde(skip_serializing_if = "Option::is_none")] + pub thread_root: Option, /// ACP session ID when known. pub session_id: Option, /// Local UUID for one prompt turn. @@ -114,6 +120,7 @@ impl ObserverHandle { kind: kind.into(), agent_index, channel_id: context.channel_id.clone(), + thread_root: context.thread_root.clone(), session_id: context.session_id.clone(), turn_id: context.turn_id.clone(), started_at: context.started_at.clone(), @@ -137,6 +144,7 @@ impl ObserverHandle { } /// Build observer context values from optional channel/session/turn IDs. +#[cfg(test)] pub fn context_for( channel_id: Option, session_id: Option, @@ -144,6 +152,7 @@ pub fn context_for( ) -> ObserverContext { ObserverContext { channel_id: channel_id.map(|id| id.to_string()), + thread_root: None, session_id, turn_id, started_at: None, @@ -151,6 +160,7 @@ pub fn context_for( } /// Attach the authoritative start timestamp to every observer frame for a turn. +#[cfg(test)] pub fn context_for_turn( channel_id: Option, session_id: Option, @@ -159,8 +169,94 @@ pub fn context_for_turn( ) -> ObserverContext { ObserverContext { channel_id: channel_id.map(|id| id.to_string()), + thread_root: None, session_id, turn_id: Some(turn_id), started_at: Some(started_at), } } + +/// Build observer context from a typed conversation scope. +pub fn context_for_scope( + scope: Option, + session_id: Option, + turn_id: Option, +) -> ObserverContext { + ObserverContext { + channel_id: scope.map(|scope| scope.channel_id.to_string()), + thread_root: scope.and_then(|scope| scope.thread_root.map(|root| root.to_hex())), + session_id, + turn_id, + started_at: None, + } +} + +/// Build turn context from a typed conversation scope, including identity on +/// pre-session frames. +pub fn context_for_scope_turn( + scope: Option, + session_id: Option, + turn_id: String, + started_at: String, +) -> ObserverContext { + ObserverContext { + channel_id: scope.map(|scope| scope.channel_id.to_string()), + thread_root: scope.and_then(|scope| scope.thread_root.map(|root| root.to_hex())), + session_id, + turn_id: Some(turn_id), + started_at: Some(started_at), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use nostr::EventId; + use uuid::Uuid; + + #[test] + fn thread_scope_is_present_before_session_resolution_and_on_emitted_frames() { + let channel_id = Uuid::new_v4(); + let root = EventId::from_hex(&"a".repeat(64)).expect("valid root"); + let scope = crate::scope::ConversationScope::thread(channel_id, root); + let context = context_for_scope_turn( + Some(scope), + None, + "turn-a".to_string(), + "2026-07-28T00:00:00Z".to_string(), + ); + let channel_text = channel_id.to_string(); + let root_text = "a".repeat(64); + assert_eq!(context.channel_id.as_deref(), Some(channel_text.as_str())); + assert_eq!(context.thread_root.as_deref(), Some(root_text.as_str())); + assert_eq!(context.session_id, None); + + let observer = ObserverHandle::in_process(); + observer.emit("turn_started", Some(0), &context, serde_json::json!({})); + let event = observer.snapshot().pop().expect("one observer event"); + assert_eq!(event.thread_root.as_deref(), Some(root_text.as_str())); + assert_eq!(event.session_id, None); + assert_eq!( + serde_json::to_value(event).expect("serialize")["threadRoot"], + "a".repeat(64) + ); + } + + #[test] + fn unthreaded_context_remains_backwards_compatible() { + let context = context_for_scope( + Some(crate::scope::ConversationScope::channel(Uuid::new_v4())), + Some("session-1".to_string()), + Some("turn-1".to_string()), + ); + let observer = ObserverHandle::in_process(); + observer.emit("session_resolved", Some(0), &context, serde_json::json!({})); + let event = observer.snapshot().pop().expect("one observer event"); + let serialized = serde_json::to_value(event).expect("serialize"); + assert!( + serialized.get("threadRoot").is_none(), + "legacy/unthreaded frames must omit the optional field" + ); + assert_eq!(serialized["sessionId"], "session-1"); + } +} diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index b1fd68d044..66c3896d75 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -39,6 +39,7 @@ use crate::queue::{ PromptProfile, PromptProfileLookup, ThreadTags, }; use crate::relay::{ChannelInfo, RestClient}; +use crate::scope::ConversationScope; /// Window within which agent activity before a hard-cap death qualifies /// the turn as "recently active" (eligible for requeue instead of dead-letter). @@ -50,7 +51,8 @@ const RECENT_ACTIVITY_WINDOW: Duration = Duration::from_secs(60); /// Metadata stored per in-flight task for panic recovery. pub struct TaskMeta { pub agent_index: usize, - pub channel_id: Option, + /// Conversation scope of the in-flight prompt. `None` for heartbeats. + pub scope: Option, /// Identifies terminal events when the task panics before returning a result. pub turn_id: String, /// Clone of batch for Queue mode panic recovery. @@ -70,6 +72,7 @@ pub struct TaskMeta { /// 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). +#[derive(Clone, Debug)] #[allow(dead_code)] // Scaffolding for desktop integration — fields read via serde. pub struct AgentModelCapabilities { /// Stable: configOptions with category "model" from session/new. @@ -78,38 +81,43 @@ pub struct AgentModelCapabilities { pub available_models_raw: Option, } -/// Per-channel session IDs and turn counters. +/// Per-conversation session IDs and turn counters. +/// +/// Keyed by [`ConversationScope`]: unthreaded channel traffic and DMs use the +/// channel-level scope; thread replies in regular channels get their own +/// scope (and therefore their own session, turn counter, and cached prompt +/// sections). /// /// 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, + /// conversation scope → session_id + pub sessions: HashMap, pub heartbeat_session: Option, - /// Per-channel turn counters for proactive session rotation. + /// Per-conversation turn counters for proactive session rotation. /// Incremented on each successful prompt; reset when the session is rotated. - pub turn_counts: HashMap, + pub turn_counts: HashMap, /// 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, - /// channel_id → rendered `[Channel Canvas]` metadata section. + /// conversation scope → rendered NIP-AE core prompt section, populated + /// once at session creation per Tyler's spec (no mid-session refresh). + pub core_sections: HashMap, + /// conversation scope → 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, + pub canvas_sections: HashMap, } 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::Conversation(scope) => { + self.invalidate_scope(scope); } PromptSource::Heartbeat => { self.heartbeat_session = None; @@ -118,13 +126,30 @@ impl SessionState { } } - /// Invalidate a single channel's session and turn counter. - /// Returns `true` if the channel had an active session. + /// Invalidate a single conversation's session and turn counter. + /// Returns `true` if the conversation had an active session. + pub fn invalidate_scope(&mut self, scope: &ConversationScope) -> bool { + self.turn_counts.remove(scope); + self.core_sections.remove(scope); + self.canvas_sections.remove(scope); + self.sessions.remove(scope).is_some() + } + + /// Invalidate every conversation under `channel_id` — the channel-level + /// conversation and all of its thread conversations. Returns `true` if at + /// least one active session was removed. + /// + /// Used for channel-wide teardown (membership removal); scope-targeted + /// rotation goes through [`invalidate_scope`](Self::invalidate_scope). 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() + self.turn_counts.retain(|s, _| s.channel_id != *channel_id); + self.core_sections + .retain(|s, _| s.channel_id != *channel_id); + self.canvas_sections + .retain(|s, _| s.channel_id != *channel_id); + let before = self.sessions.len(); + self.sessions.retain(|s, _| s.channel_id != *channel_id); + self.sessions.len() != before } /// Invalidate all sessions and turn counters (e.g. after agent exit). @@ -138,11 +163,11 @@ impl SessionState { } #[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) + fn has_scope_state(&self, scope: &ConversationScope) -> bool { + self.sessions.contains_key(scope) + || self.turn_counts.contains_key(scope) + || self.core_sections.contains_key(scope) + || self.canvas_sections.contains_key(scope) } } @@ -153,12 +178,20 @@ pub struct OwnedAgent { pub state: SessionState, /// Model catalog from first session/new. None until first session created. pub model_capabilities: Option, - /// Desired model ID (from `Config.model`). Applied after every `session_new_full()`. + /// Configured/default model for this process. This is immutable for the + /// lifetime of the agent and is the fallback whenever a claimed channel + /// has no runtime override. + pub baseline_model: Option, + /// Effective model for the currently claimed conversation (or heartbeat). + /// Re-resolved from the channel override or [`baseline_model`](Self::baseline_model) + /// on every claim before a session can be created. pub desired_model: Option, - /// 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. + /// Generation of the channel override in [`desired_model`](Self::desired_model). + /// `None` means the configured baseline is effective. + pub desired_model_generation: Option, + /// Whether the effective model came from a live channel override rather + /// than the configured baseline. Reset whenever the agent becomes idle or + /// is claimed for a channel without an override. pub model_overridden: bool, /// Normalized agent name from initialize (`agentInfo.name`/`serverInfo.name`). pub agent_name: String, @@ -202,6 +235,294 @@ impl OwnedAgent { self.goose_system_prompt_supported, ) } + + fn use_baseline_model(&mut self) { + self.desired_model.clone_from(&self.baseline_model); + self.desired_model_generation = None; + self.model_overridden = false; + } + + fn use_channel_model(&mut self, desired_model: Option<&ChannelDesiredModel>) { + match desired_model { + Some(desired) => { + self.desired_model = Some(desired.model_id.clone()); + self.desired_model_generation = Some(desired.generation); + self.model_overridden = true; + } + None => self.use_baseline_model(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ChannelDesiredModel { + model_id: String, + generation: u64, +} + +#[derive(Debug, Clone)] +struct PendingModelControl { + model_id: String, + generation: u64, + request_id: Option, +} + +#[derive(Default)] +struct ChannelModelControlState { + catalog: Option, + desired_models: HashMap, + /// Latest state transition for each channel, including a baseline + /// tombstone created when a once-supported override fails against a fresh + /// session catalog. Pool slots reconcile this generation even when no + /// desired model remains, so sessions opened under the failed override + /// cannot survive on another holder. + channel_generations: HashMap, + pending_controls: HashMap, + next_generation: u64, +} + +/// Process-lifetime authority for channel model controls. +/// +/// This state deliberately lives outside [`AgentPool`]. Agents move out of the +/// pool while turns run, and a lazy startup replaces the placeholder pool +/// wholesale. Keeping the catalog, desired models, and pending generations in +/// one shared coordinator makes both lifecycle transitions lossless. +#[derive(Clone, Default)] +pub struct ChannelModelControls { + inner: Arc>, +} + +/// One authoritative result for a model-control request. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ModelControlResult { + pub channel_id: Uuid, + pub model_id: String, + pub generation: u64, + pub request_id: Option, + pub status: &'static str, +} + +/// Resolution of a newly received model-control request. +#[derive(Debug, PartialEq, Eq)] +pub enum ModelControlDecision { + /// The cached catalog accepts the model. The desired state is live. + Accepted { + generation: u64, + superseded: Vec, + }, + /// The cached catalog rejects the model. Desired state is unchanged. + Unsupported { + generation: u64, + superseded: Vec, + }, + /// No session has exposed a catalog yet. The request is retained until + /// the first `session/new`; at most one unresolved request is kept per + /// channel, and any older request is resolved as `superseded`. + PendingCatalog { + generation: u64, + superseded: Vec, + }, +} + +impl ModelControlDecision { + pub fn generation(&self) -> u64 { + match self { + Self::Accepted { generation, .. } + | Self::Unsupported { generation, .. } + | Self::PendingCatalog { generation, .. } => *generation, + } + } + + pub fn superseded(&self) -> &[ModelControlResult] { + match self { + Self::Accepted { superseded, .. } + | Self::Unsupported { superseded, .. } + | Self::PendingCatalog { superseded, .. } => superseded, + } + } +} + +impl ChannelModelControls { + fn lock(&self) -> std::sync::MutexGuard<'_, ChannelModelControlState> { + match self.inner.lock() { + Ok(state) => state, + Err(poisoned) => { + tracing::warn!(target: "pool::model", "channel model control lock poisoned"); + poisoned.into_inner() + } + } + } + + fn next_generation(state: &mut ChannelModelControlState) -> u64 { + let mut generation = state.next_generation.wrapping_add(1); + if generation == 0 { + generation = 1; + } + state.next_generation = generation; + generation + } + + /// Record a channel model request against the process-wide catalog. + pub fn request( + &self, + channel_id: Uuid, + model_id: &str, + request_id: Option<&str>, + ) -> ModelControlDecision { + let mut state = self.lock(); + let generation = Self::next_generation(&mut state); + let superseded = state + .pending_controls + .remove(&channel_id) + .map(|pending| ModelControlResult { + channel_id, + model_id: pending.model_id, + generation: pending.generation, + request_id: pending.request_id, + status: "superseded", + }) + .into_iter() + .collect(); + + let Some(catalog) = state.catalog.as_ref() else { + state.pending_controls.insert( + channel_id, + PendingModelControl { + model_id: model_id.to_string(), + generation, + request_id: request_id.map(ToOwned::to_owned), + }, + ); + return ModelControlDecision::PendingCatalog { + generation, + superseded, + }; + }; + + if !model_in_catalog( + &catalog.config_options_raw, + catalog.available_models_raw.as_ref(), + model_id, + ) { + return ModelControlDecision::Unsupported { + generation, + superseded, + }; + } + + state.desired_models.insert( + channel_id, + ChannelDesiredModel { + model_id: model_id.to_string(), + generation, + }, + ); + state.channel_generations.insert(channel_id, generation); + ModelControlDecision::Accepted { + generation, + superseded, + } + } + + /// Cache the first authoritative session catalog and resolve every lazy + /// pre-wake request exactly once. + pub fn register_catalog( + &self, + capabilities: AgentModelCapabilities, + ) -> Vec { + let mut state = self.lock(); + if state.catalog.is_some() { + return Vec::new(); + } + state.catalog = Some(capabilities); + let pending = std::mem::take(&mut state.pending_controls); + let Some(catalog) = state.catalog.clone() else { + return Vec::new(); + }; + + let mut results: Vec = pending + .into_iter() + .map(|(channel_id, pending)| { + let supported = model_in_catalog( + &catalog.config_options_raw, + catalog.available_models_raw.as_ref(), + &pending.model_id, + ); + if supported { + state.desired_models.insert( + channel_id, + ChannelDesiredModel { + model_id: pending.model_id.clone(), + generation: pending.generation, + }, + ); + state + .channel_generations + .insert(channel_id, pending.generation); + } + ModelControlResult { + channel_id, + model_id: pending.model_id, + generation: pending.generation, + request_id: pending.request_id, + status: if supported { + "sent" + } else { + "unsupported_model" + }, + } + }) + .collect(); + results.sort_by_key(|result| result.generation); + results + } + + fn desired_model(&self, channel_id: Uuid) -> Option { + self.lock().desired_models.get(&channel_id).cloned() + } + + fn channel_generations(&self) -> Vec<(Uuid, u64)> { + self.lock() + .channel_generations + .iter() + .map(|(channel_id, generation)| (*channel_id, *generation)) + .collect() + } + + /// Clear a failed override only if the failed session was applying the + /// channel's current generation. A superseded session must never erase a + /// newer control. The returned tombstone generation makes every other + /// holder invalidate any session it may already have opened under the + /// failed override. + fn clear_if_current(&self, channel_id: Uuid, generation: u64) -> Option { + let mut state = self.lock(); + if state + .desired_models + .get(&channel_id) + .is_some_and(|desired| desired.generation == generation) + { + state.desired_models.remove(&channel_id); + let tombstone_generation = Self::next_generation(&mut state); + state + .channel_generations + .insert(channel_id, tombstone_generation); + Some(tombstone_generation) + } else { + None + } + } + + fn remove_channel(&self, channel_id: Uuid) { + let mut state = self.lock(); + state.desired_models.remove(&channel_id); + state.channel_generations.remove(&channel_id); + state.pending_controls.remove(&channel_id); + } + + #[cfg(test)] + fn pending_len(&self) -> usize { + self.lock().pending_controls.len() + } } /// Pool of agents with take-and-return ownership semantics. @@ -215,6 +536,59 @@ pub struct AgentPool { result_rx: mpsc::UnboundedReceiver, pub join_set: JoinSet<()>, task_map: HashMap, + /// Stable conversation → agent-slot ownership. A scope's ACP session + /// history lives inside exactly one agent process, so once a scope is + /// dispatched to a slot, later turns for that scope must go back to the + /// SAME slot — even if that means waiting while it is checked out on + /// other work. Without this, a busy owner would let `try_claim` hand the + /// scope to another idle agent, silently forking the conversation into a + /// second divergent session. + /// + /// Lifecycle: recorded on claim; released when the owning agent returns + /// without a live session for the scope (rotation, invalidation, failed + /// create), when the scope/channel is explicitly invalidated, or when the + /// owning slot dies (respawn/panic — the process and its sessions are + /// gone, so re-owning elsewhere creates a fresh history, not a fork). + scope_owners: HashMap, + /// Shared process-lifetime model authority. This survives checked-out + /// agents and lazy placeholder-pool replacement. + model_controls: ChannelModelControls, + /// Last channel-model generation reconciled into each agent slot. A + /// switched channel may have cached sessions inside a checked-out agent; + /// comparing generations when it returns guarantees those stale sessions + /// are invalidated before the slot is reused. + slot_channel_model_generations: HashMap<(usize, Uuid), u64>, + /// Channel invalidations deferred for slots that were checked out when + /// membership was removed. This is slot-local rather than a global + /// "removed channel" flag because the channel may be rejoined before an + /// old holder returns; that old process must still discard its pre-removal + /// sessions without invalidating sessions created after the rejoin on + /// other slots. + pending_channel_invalidations: HashMap>, + /// Conversation rotations deferred for a checked-out owning slot. A + /// `!rotate` may target a scope whose owner is busy on another scope, or + /// whose current task has already consumed its one-shot control signal. + /// Ownership can be released immediately so future work starts fresh, but + /// the old holder must still discard its historical session on return. + pending_scope_invalidations: HashMap>, + /// Checked-out task slots whose current source batch was explicitly + /// invalidated. Channel removal marks every active task under the channel; + /// a late `!rotate` marks only the task running that exact scope. Keeping + /// this per task slot lets a rapid channel rejoin accept fresh work without + /// reviving an old batch. + discard_result_batches: HashSet, +} + +/// Outcome of [`AgentPool::try_claim`] for a conversation-scoped claim. +pub enum ClaimOutcome { + /// An agent was claimed for the conversation (ownership recorded). + Claimed(Box), + /// The conversation's owning agent is checked out on other work. The + /// caller must leave the conversation queued and retry after the owner + /// returns — claiming a different agent would fork the session history. + OwnerBusy, + /// No idle agent is available at all. + NoIdleAgent, } /// Result returned by a completed prompt task. @@ -228,10 +602,11 @@ pub struct PromptResult { pub batch: Option, } -/// Whether the prompt came from a channel event or a heartbeat. +/// Whether the prompt came from a conversation (channel or thread) event or a +/// heartbeat. #[derive(Debug)] pub enum PromptSource { - Channel(Uuid), + Conversation(ConversationScope), Heartbeat, } @@ -245,11 +620,11 @@ fn apply_completed_before_control_signal( 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. + // session. A SwitchModel re-resolves the shared desired generation during + // that new session's creation. if matches!( control_signal, - ControlSignal::Rotate | ControlSignal::SwitchModel(_) + ControlSignal::Rotate | ControlSignal::SwitchModel { .. } ) { state.invalidate(source); } @@ -257,8 +632,6 @@ fn apply_completed_before_control_signal( /// 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. @@ -275,12 +648,11 @@ pub enum ControlSignal { /// 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), + /// Requeue the triggering batch so it re-runs on a fresh session under + /// the process-lifetime desired-model generation. The task never carries + /// or writes model state itself; it re-resolves the coordinator after + /// `session/new`, preventing a stale signal from overwriting a newer pick. + SwitchModel { generation: u64 }, } /// Goose-native non-cancelling steer request, sent from the main loop to an @@ -479,22 +851,40 @@ impl ChannelInfoResolver { } } - pub async fn resolve(&self, channel_id: Uuid) -> Option { - if let Some(info) = self + /// Return cached metadata without performing relay I/O. + /// + /// Event scope derivation uses this after startup discovery or the + /// dynamic-subscription metadata gate has populated the resolver. + pub(crate) fn cached(&self, channel_id: Uuid) -> Option { + let cache = self .cache .read() - .ok() - .and_then(|cache| cache.get(&channel_id).cloned()) - { + .unwrap_or_else(|poisoned| poisoned.into_inner()); + cache.get(&channel_id).cloned() + } + + pub async fn resolve(&self, channel_id: Uuid) -> Option { + if let Some(info) = self.cached(channel_id) { 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()); - } + let mut cache = self + .cache + .write() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + cache.entry(channel_id).or_insert_with(|| info.clone()); Some(info) } + + #[cfg(test)] + pub(crate) fn insert_for_test(&self, channel_id: Uuid, info: PromptChannelInfo) { + let mut cache = self + .cache + .write() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + cache.insert(channel_id, info); + } } pub struct PromptContext { @@ -550,6 +940,8 @@ pub struct PromptContext { /// the desktop keys per (agent, relay) pair, e.g. `session_config_captured`, /// mirroring the `managed_agent_runtime_lifecycle` frames. pub relay_url: String, + /// Process-lifetime channel model authority shared with the agent pool. + pub model_controls: ChannelModelControls, } impl AgentPool { @@ -559,7 +951,27 @@ impl AgentPool { /// 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. + #[cfg(test)] pub fn from_slots(slots: Vec>) -> Self { + Self::from_slots_with_model_controls(slots, ChannelModelControls::default()) + } + + /// Create a pool backed by an existing process-lifetime model authority. + /// + /// The main harness uses this for both the lazy placeholder and the + /// awakened pool. Tests that do not exercise lifecycle replacement can use + /// [`from_slots`](Self::from_slots). + pub fn from_slots_with_model_controls( + slots: Vec>, + model_controls: ChannelModelControls, + ) -> Self { + for capabilities in slots + .iter() + .flatten() + .filter_map(|agent| agent.model_capabilities.clone()) + { + let _ = model_controls.register_catalog(capabilities); + } let (result_tx, result_rx) = mpsc::unbounded_channel(); Self { agents: slots, @@ -567,35 +979,134 @@ impl AgentPool { result_rx, join_set: JoinSet::new(), task_map: HashMap::new(), + scope_owners: HashMap::new(), + model_controls, + slot_channel_model_generations: HashMap::new(), + pending_channel_invalidations: HashMap::new(), + pending_scope_invalidations: HashMap::new(), + discard_result_batches: HashSet::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) -> Option { - // 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(); + /// Apply channel removals that occurred while `agent` was checked out. + fn reconcile_pending_channel_invalidations(&mut self, agent: &mut OwnedAgent) { + let Some(channel_ids) = self.pending_channel_invalidations.remove(&agent.index) else { + return; + }; + for channel_id in channel_ids { + agent.state.invalidate_channel(&channel_id); + } + } + + /// Apply conversation rotations that occurred while `agent` was checked + /// out, without disturbing sibling scopes. + fn reconcile_pending_scope_invalidations(&mut self, agent: &mut OwnedAgent) { + let Some(scopes) = self.pending_scope_invalidations.remove(&agent.index) else { + return; + }; + for scope in scopes { + agent.state.invalidate_scope(&scope); + } + } + + /// Reconcile channel-model switches that occurred while `agent` was + /// checked out. Generations include baseline tombstones created when a + /// fresh session rejects the cached override, so sessions under a failed + /// model are invalidated across every holder. Unrelated channels remain + /// intact. + fn reconcile_channel_model_generations(&mut self, agent: &mut OwnedAgent) { + for (channel_id, generation) in self.model_controls.channel_generations() { + let key = (agent.index, channel_id); + if self.slot_channel_model_generations.get(&key).copied() != Some(generation) { + agent.state.invalidate_channel(&channel_id); + self.slot_channel_model_generations.insert(key, generation); } } + } - // Pass 2: first idle agent. + /// Drop ownership entries whose sessions were removed while reconciling + /// an idle/returning slot. + fn release_missing_ownerships(&mut self, agent: &OwnedAgent) { + let idx = agent.index; + self.scope_owners + .retain(|scope, owner| *owner != idx || agent.state.sessions.contains_key(scope)); + } + + /// Whether the given slot is currently checked out on an in-flight task. + fn slot_checked_out(&self, index: usize) -> bool { + self.task_map.values().any(|m| m.agent_index == index) + } + + /// Try to claim an agent for the given conversation. + /// + /// Ownership is stable: if `scope` was previously dispatched to a slot, + /// only that slot may serve it again while its session history is alive. + /// + /// - Owner idle → claim the owner. + /// - Owner checked out on other work → [`ClaimOutcome::OwnerBusy`]; the + /// caller must keep the scope queued until the owner returns. + /// - Owner slot dead (neither idle nor checked out — process died and its + /// sessions with it) → re-own on any idle agent. + /// - Unowned scope → any idle agent, prefering one that already holds a + /// session for the scope (covers ownership rebuilt from state after + /// explicit map cleanup). + /// + /// On every claim, the effective model is resolved afresh: the target + /// channel override when present, otherwise the agent's configured + /// baseline. Pending switch generations are reconciled first so sessions + /// cached inside an agent that was checked out during a switch cannot be + /// reused under the old model. + pub fn try_claim_for_scope(&mut self, scope: ConversationScope) -> ClaimOutcome { + let claim_idx = match self.scope_owners.get(&scope).copied() { + Some(owner) if self.agents.get(owner).is_some_and(|s| s.is_some()) => Some(owner), + Some(owner) if self.slot_checked_out(owner) => return ClaimOutcome::OwnerBusy, + _ => { + // Unowned, or owner slot is dead: prefer an idle agent that + // already holds a session for this scope, else any idle agent. + self.agents + .iter() + .position(|slot| { + slot.as_ref() + .is_some_and(|a| a.state.sessions.contains_key(&scope)) + }) + .or_else(|| self.agents.iter().position(|slot| slot.is_some())) + } + }; + let Some(idx) = claim_idx else { + return ClaimOutcome::NoIdleAgent; + }; + let Some(mut agent) = self.agents[idx].take() else { + return ClaimOutcome::NoIdleAgent; + }; + self.reconcile_pending_channel_invalidations(&mut agent); + self.reconcile_pending_scope_invalidations(&mut agent); + self.reconcile_channel_model_generations(&mut agent); + self.release_missing_ownerships(&agent); + self.scope_owners.insert(scope, idx); + let channel_model = self.model_controls.desired_model(scope.channel_id); + agent.use_channel_model(channel_model.as_ref()); + ClaimOutcome::Claimed(Box::new(agent)) + } + + /// Claim any idle agent for a heartbeat (no conversation ownership). + pub fn try_claim_for_heartbeat(&mut self) -> Option { let idx = self.agents.iter().position(|slot| slot.is_some()); - idx.map(|i| self.agents[i].take().unwrap()) + let mut agent = idx.and_then(|i| self.agents[i].take())?; + self.reconcile_pending_channel_invalidations(&mut agent); + self.reconcile_pending_scope_invalidations(&mut agent); + self.reconcile_channel_model_generations(&mut agent); + self.release_missing_ownerships(&agent); + agent.use_baseline_model(); + Some(agent) } /// Return an agent to its slot after a task completes. - pub fn return_agent(&mut self, agent: OwnedAgent) { + /// + /// Ownership cleanup happens here: any scope owned by this slot whose + /// session no longer exists on the agent (rotated, invalidated, or never + /// created) is released, so the next event for that scope may be served + /// by any agent — there is no history left to protect. + pub fn return_agent(&mut self, mut 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 @@ -607,20 +1118,42 @@ impl AgentPool { "BUG: return_agent called for slot {idx} which is already occupied — overwriting" ); } + self.reconcile_pending_channel_invalidations(&mut agent); + self.reconcile_pending_scope_invalidations(&mut agent); + self.reconcile_channel_model_generations(&mut agent); + self.release_missing_ownerships(&agent); + // An idle agent is not associated with any channel. Reset the + // ephemeral effective selection so no caller can mistake the last + // claimed channel's override for agent-global state. + agent.use_baseline_model(); self.agents[idx] = Some(agent); } + /// Release every conversation ownership held by `index`. + /// + /// Called when the slot's agent process is being replaced (respawn after + /// exit/timeout/transport error, or panic recovery) — its sessions died + /// with the process, so waiting scopes must be free to re-own elsewhere. + pub fn release_agent_ownerships(&mut self, index: usize) { + self.scope_owners.retain(|_, owner| *owner != index); + } + + /// Consume the invalidation marker for an agent's completed task. + pub(crate) fn take_discard_result_batch(&mut self, index: usize) -> bool { + self.discard_result_batches.remove(&index) + } + /// 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`. + /// Whether any idle agent already has a session for `scope`. /// Used to compute `affinity_hit` before calling `try_claim`. - pub fn has_session_for(&self, channel_id: Uuid) -> bool { + pub fn has_session_for(&self, scope: ConversationScope) -> bool { self.agents.iter().any(|slot| { slot.as_ref() - .map(|a| a.state.sessions.contains_key(&channel_id)) + .map(|a| a.state.sessions.contains_key(&scope)) .unwrap_or(false) }) } @@ -643,7 +1176,7 @@ impl AgentPool { } /// Try to send a goose-native steer request to the in-flight task for - /// `channel_id`. + /// `scope`. /// /// Returns `Ok(())` if the request was accepted by the read loop's /// receiver (capacity-1 mpsc; one slot is the single in-flight steer @@ -660,19 +1193,19 @@ impl AgentPool { /// 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 + /// for `scope` (the prompt completed between the mode-gate check + /// and this call, or the conversation 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, + scope: ConversationScope, request: SteerRequest, ) -> Result<(), SteerError> { let meta = self .task_map .values_mut() - .find(|m| m.channel_id == Some(channel_id)) + .find(|m| m.scope == Some(scope)) .ok_or(SteerError::PromptCompleted)?; let tx = meta .steer_tx @@ -717,73 +1250,185 @@ impl AgentPool { &mut self.agents } - /// Remove the session for `channel_id` from all idle agents. + /// Remove every session under `channel_id` (channel-level and thread + /// conversations alike) from all 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. + /// Called when the agent is removed from a channel — stale sessions must + /// not be reused. Idle agents are invalidated immediately. Checked-out + /// slots are recorded in `pending_channel_invalidations` and reconciled + /// when their process returns, even if the channel has already been + /// rejoined by then. /// - /// Returns the number of sessions invalidated. + /// Returns the number of agents that had at least one session invalidated. pub fn invalidate_channel_sessions(&mut self, channel_id: Uuid) -> usize { let mut count = 0; - for slot in &mut self.agents { + let checked_out: HashSet = self + .task_map + .values() + .map(|meta| meta.agent_index) + .collect(); + self.discard_result_batches.extend( + self.task_map + .values() + .filter(|meta| { + meta.scope + .is_some_and(|scope| scope.channel_id == channel_id) + }) + .map(|meta| meta.agent_index), + ); + for (index, slot) in self.agents.iter_mut().enumerate() { if let Some(agent) = slot.as_mut() { if agent.state.invalidate_channel(&channel_id) { count += 1; } + } else if checked_out.contains(&index) { + self.pending_channel_invalidations + .entry(index) + .or_default() + .insert(channel_id); + } + } + // Explicit channel-wide invalidation ends every conversation under + // the channel: release ownerships so future traffic (e.g. after a + // re-add) can be served by any agent, and drop the channel's + // desired-model override. + self.scope_owners + .retain(|scope, _| scope.channel_id != channel_id); + self.model_controls.remove_channel(channel_id); + self.slot_channel_model_generations + .retain(|(_, channel), _| *channel != channel_id); + count + } + + /// Remove the session for a single conversation `scope` from all agents, + /// leaving sibling conversations in the same channel untouched. + /// + /// Used by the idle `!rotate` path so rotating one thread (or the + /// channel-level conversation) never destroys unrelated thread sessions. + /// + /// Idle holders are invalidated immediately. If the sticky owner is + /// checked out, its invalidation is deferred until return; ownership is + /// still released now so the next queued turn starts a fresh session. + /// + /// Returns the number of idle sessions invalidated immediately. + pub fn invalidate_scope_sessions(&mut self, scope: ConversationScope) -> usize { + let mut count = 0; + let owner = self.scope_owners.get(&scope).copied(); + for slot in &mut self.agents { + if let Some(agent) = slot.as_mut() { + if agent.state.invalidate_scope(&scope) { + count += 1; + } + } + } + if let Some(owner) = owner { + if self.slot_checked_out(owner) { + self.pending_scope_invalidations + .entry(owner) + .or_default() + .insert(scope); + if self + .task_map + .values() + .any(|meta| meta.agent_index == owner && meta.scope == Some(scope)) + { + // The task's control one-shot may already be consumed. + // Rotation still owns the final word on batch fate: its + // triggering request must not be re-prompted. + self.discard_result_batches.insert(owner); + } } } + // The conversation's history is gone — release ownership so the next + // event may be served by any agent with a fresh session. + self.scope_owners.remove(&scope); 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. + /// Channel-level model switch (desktop `switch_model` control frame). /// - /// 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. + /// Desktop model selection is **channel-level desired state**: a + /// channel's conversations (channel-level + threads) may be spread across + /// several agents, idle and active, so a single-agent switch would leave + /// stale sessions behind. This method: /// - /// 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( + /// 1. Validates `model_id` against the first cached catalog (all slots + /// run the same agent binary, so one catalog answers for all). No + /// catalog cached anywhere → defer validation to apply time, as + /// before. + /// 2. Records the desired model for the channel, so any agent that later + /// claims a conversation under it inherits the model at claim time + /// (see [`try_claim_for_scope`](Self::try_claim_for_scope)). + /// 3. Invalidates sessions under the channel on **every idle holder** and + /// stamps its slot with this switch generation. Checked-out holders are + /// reconciled when they return, including sessions for sibling scopes + /// that were not themselves in flight. + /// + /// Active (checked-out) conversations are NOT handled here — the caller + /// signals each in-flight task under the channel with + /// `ControlSignal::SwitchModel`, which invalidates and requeues the turn. + /// + /// Returns [`IdleSwitchResult`] describing what happened. The model does + /// not take effect — and the panel does not reflect it — until an agent + /// next runs a turn in the channel (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 set_channel_desired_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; + request_id: Option<&str>, + ) -> (ModelControlDecision, IdleSwitchResult) { + let decision = self + .model_controls + .request(channel_id, model_id, request_id); + if matches!(decision, ModelControlDecision::Unsupported { .. }) { + return (decision, IdleSwitchResult::UnsupportedModel); + } + if matches!(decision, ModelControlDecision::PendingCatalog { .. }) { + return (decision, IdleSwitchResult::PendingCatalog); + } + let generation = decision.generation(); + + let mut applied = 0; + let mut reconciled_idle_slots = HashSet::new(); + for agent in self.agents.iter_mut().flatten() { + reconciled_idle_slots.insert(agent.index); + if agent.state.invalidate_channel(&channel_id) { + applied += 1; } + self.slot_channel_model_generations + .insert((agent.index, channel_id), generation); } + // Invalidation destroys the history that required sticky ownership. + // Release ownership only for idle slots reconciled above; checked-out + // holders retain ownership until their sessions are invalidated on + // return (or by their active SwitchModel signal). + self.scope_owners.retain(|scope, owner| { + scope.channel_id != channel_id || !reconciled_idle_slots.contains(owner) + }); + let idle_result = if applied > 0 { + IdleSwitchResult::Switched + } else { + IdleSwitchResult::NoIdleAgent + }; + (decision, idle_result) + } - agent.desired_model = Some(model_id.to_string()); - agent.model_overridden = true; - agent.state.invalidate_channel(&channel_id); - IdleSwitchResult::Switched + #[cfg(test)] + pub fn model_controls(&self) -> ChannelModelControls { + self.model_controls.clone() + } + + /// Test-only visibility into conversation ownership. + #[cfg(test)] + pub fn scope_owner(&self, scope: &ConversationScope) -> Option { + self.scope_owners.get(scope).copied() } } -/// Outcome of [`AgentPool::switch_idle_agent_model`]. +/// Outcome of [`AgentPool::set_channel_desired_model`]. #[derive(Debug, PartialEq, Eq)] pub enum IdleSwitchResult { /// `desired_model` set and the channel session invalidated. @@ -791,6 +1436,9 @@ pub enum IdleSwitchResult { /// Desired model is not in the agent's cached catalog — pick rejected, /// session untouched. UnsupportedModel, + /// No ACP session has returned a model catalog yet. No sessions or turns + /// were disturbed; resolution is deferred until the first catalog. + PendingCatalog, /// No idle agent available (all checked out / none spawned). NoIdleAgent, } @@ -871,6 +1519,7 @@ async fn create_session_and_apply_model( agent_core: Option<&str>, agent_canvas: Option<&str>, channel_name: Option<&str>, + conversation_scope: Option, ) -> Result { // Build base_prompt + system_prompt + agent core + canvas metadata into a // single prompt. Standard protocol-v2 agents receive it in `session/new`; @@ -890,10 +1539,12 @@ async fn create_session_and_apply_model( agent_canvas, ); - let session_title = ctx - .session_title - .as_deref() - .map(|agent_name| compose_session_title(agent_name, channel_name)); + let session_title = ctx.session_title.as_deref().map(|agent_name| { + let thread_root = conversation_scope + .and_then(|scope| scope.thread_root) + .map(|root| root.to_hex()); + compose_session_title(agent_name, channel_name, thread_root.as_deref()) + }); let resp = agent .acp @@ -929,18 +1580,55 @@ async fn create_session_and_apply_model( } } - // Populate model capabilities on first session creation. + // Publish the first session catalog to the process-lifetime coordinator. + // This resolves lazy pre-wake controls once, even though the placeholder + // pool has already been replaced and this agent is checked out. + let capabilities = AgentModelCapabilities { + config_options_raw: extract_model_config_options(&resp.raw), + available_models_raw: extract_model_state(&resp.raw), + }; 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), - }); + agent.model_capabilities = Some(capabilities.clone()); + } + for result in ctx.model_controls.register_catalog(capabilities) { + if let Some(observer) = agent.acp.observer_handle() { + observer.emit( + "control_result", + agent.acp.observer_agent_index(), + &observer::ObserverContext { + channel_id: Some(result.channel_id.to_string()), + thread_root: None, + session_id: None, + turn_id: None, + started_at: None, + }, + serde_json::json!({ + "type": "switch_model", + "status": result.status, + "channelId": result.channel_id, + "modelId": result.model_id, + "generation": result.generation, + "requestId": result.request_id, + }), + ); + } + } + + // A control can arrive while session/new is in flight. Resolve the latest + // channel generation only after the response, immediately before apply. + // Heartbeats deliberately remain on the configured baseline. + if let Some(scope) = conversation_scope { + let desired = ctx.model_controls.desired_model(scope.channel_id); + agent.use_channel_model(desired.as_ref()); + } else { + agent.use_baseline_model(); } // 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 { + let desired_to_apply = agent.desired_model.clone(); + let switch_succeeded = if let Some(desired) = desired_to_apply.as_deref() { match resolve_model_switch_method(&resp.raw, desired) { Some(method) => { apply_model_switch(&mut agent.acp, &resp.session_id, desired, &method).await?; @@ -951,18 +1639,20 @@ async fn create_session_and_apply_model( 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, - }), - ); + if let (Some(scope), Some(generation)) = + (conversation_scope, agent.desired_model_generation) + { + // Catalog validation should make this unreachable unless + // an agent changes its catalog between sessions. Never let + // that stale failure erase a newer concurrent generation. + if ctx + .model_controls + .clear_if_current(scope.channel_id, generation) + .is_some() + { + agent.use_baseline_model(); + } + } false } } @@ -1342,22 +2032,25 @@ pub async fn run_prompt_task( control_rx: Option>, turn_id: String, ) { - // Is this a channel prompt or a heartbeat? + // Is this a conversation prompt or a heartbeat? let source = match &batch { - Some(b) => PromptSource::Channel(b.channel_id), + Some(b) => PromptSource::Conversation(b.scope), None => PromptSource::Heartbeat, }; - let observer_channel_id = match &source { - PromptSource::Channel(channel_id) => Some(*channel_id), + let observer_scope = match &source { + PromptSource::Conversation(scope) => Some(*scope), PromptSource::Heartbeat => None, }; + let observer_channel_id = observer_scope.map(|scope| scope.channel_id); 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(), - )); + agent + .acp + .set_observer_context(observer::context_for_scope_turn( + observer_scope, + None, + turn_id.clone(), + turn_started_at.clone(), + )); let triggering_event_ids: Vec = batch .as_ref() .map(|b| b.events.iter().map(|be| be.event.id.to_hex()).collect()) @@ -1366,7 +2059,7 @@ pub async fn run_prompt_task( "turn_started", serde_json::json!({ "source": match &source { - PromptSource::Channel(_) => "channel", + PromptSource::Conversation(_) => "channel", PromptSource::Heartbeat => "heartbeat", }, "triggeringEventIds": triggering_event_ids, @@ -1380,7 +2073,7 @@ pub async fn run_prompt_task( let _turn_guard = TurnCompletionGuard::new( agent.acp.observer_handle(), agent.acp.observer_agent_index(), - observer_channel_id, + observer_scope, turn_id.clone(), ); @@ -1400,8 +2093,8 @@ pub async fn run_prompt_task( let liveness = run_turn_liveness( agent.acp.observer_handle(), agent.acp.observer_agent_index(), - observer::context_for_turn( - observer_channel_id, + observer::context_for_scope_turn( + observer_scope, None, turn_id.clone(), turn_started_at.clone(), @@ -1429,8 +2122,9 @@ pub async fn run_prompt_task( // 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. + // it is identical across conversations; the per-conversation 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) @@ -1447,11 +2141,11 @@ pub async fn run_prompt_task( // // Operator opt-out: `--no-memory` / `BUZZ_ACP_NO_MEMORY` skips the fetch. if ctx.memory_enabled { - if let (PromptSource::Channel(cid), Some(owner_pk)) = + if let (PromptSource::Conversation(scope), 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) { + let is_new_session = !agent.state.sessions.contains_key(scope); + if is_new_session && !agent.state.core_sections.contains_key(scope) { // 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); @@ -1465,7 +2159,7 @@ pub async fn run_prompt_task( Err(_) => { tracing::warn!( target: "engram::core", - channel = %cid, + scope = %scope, timeout_ms = CORE_FETCH_TIMEOUT.as_millis() as u64, "core fetch timed out — emitting no section" ); @@ -1475,11 +2169,11 @@ pub async fn run_prompt_task( if let Some(rendered) = section { tracing::info!( target: "engram::core", - channel = %cid, + scope = %scope, section_len = rendered.len(), "injected NIP-AE core section into system prompt" ); - agent.state.core_sections.insert(*cid, rendered); + agent.state.core_sections.insert(*scope, rendered); } } } @@ -1497,50 +2191,51 @@ pub async fn run_prompt_task( // 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; + let mut pending_canvas: Option<(ConversationScope, 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 = 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 let PromptSource::Conversation(scope) = &source { + let is_new_session = !agent.state.sessions.contains_key(scope); + let needs_canvas = is_new_session && !agent.state.canvas_sections.contains_key(scope); + let needs_title = is_new_session && ctx.session_title.is_some(); if needs_canvas || needs_title { + let cid = scope.channel_id; let (is_dm, resolved_channel) = - resolve_new_session_channel_context(&ctx.channel_info, *cid).await; + 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)); + if let Some(section) = fetch_canvas_section(cid, &ctx.rest_client).await { + pending_canvas = Some((*scope, section)); } } } } // The core section to fold into the system prompt for this turn's session. - // Channel-scoped; heartbeats carry no owner core. + // Conversation-scoped; heartbeats carry no owner core. let agent_core: Option = match &source { - PromptSource::Channel(cid) => agent.state.core_sections.get(cid).cloned(), + PromptSource::Conversation(scope) => agent.state.core_sections.get(scope).cloned(), PromptSource::Heartbeat => None, }; - // The canvas metadata section — channel-scoped, absent for heartbeats/DMs. + // The canvas metadata section — conversation-scoped, absent for heartbeats/DMs. // Prefer the committed cache; fall back to pending (for new sessions being created now). let agent_canvas: Option = match &source { - PromptSource::Channel(cid) => agent + PromptSource::Conversation(scope) => agent .state .canvas_sections - .get(cid) + .get(scope) .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) { + PromptSource::Conversation(scope) => { + if let Some(sid) = agent.state.sessions.get(scope) { (sid.clone(), false) } else { // The title is channel-qualified (`Agent · #channel`) so one @@ -1553,18 +2248,19 @@ pub async fn run_prompt_task( agent_core.as_deref(), agent_canvas.as_deref(), title_channel.as_deref(), + Some(*scope), ) .await { Ok(sid) => { tracing::info!( target: "pool::session", - "created session {sid} for channel {cid}" + "created session {sid} for conversation {scope}" ); - agent.state.sessions.insert(*cid, sid.clone()); + agent.state.sessions.insert(*scope, 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); + if let Some((pending_scope, section)) = pending_canvas.take() { + agent.state.canvas_sections.insert(pending_scope, section); } (sid, true) } @@ -1600,7 +2296,8 @@ pub async fn run_prompt_task( 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 { + match create_session_and_apply_model(&mut agent, &ctx, None, None, None, None).await + { Ok(sid) => { tracing::info!( target: "pool::session", @@ -1637,12 +2334,14 @@ pub async fn run_prompt_task( } } }; - agent.acp.set_observer_context(observer::context_for_turn( - observer_channel_id, - Some(session_id.clone()), - turn_id.clone(), - turn_started_at, - )); + agent + .acp + .set_observer_context(observer::context_for_scope_turn( + observer_scope, + 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()); @@ -1655,11 +2354,12 @@ pub async fn run_prompt_task( ); if is_new_session { - if let (PromptSource::Channel(cid), Some(ref initial_msg)) = (&source, &ctx.initial_message) + if let (PromptSource::Conversation(scope), Some(ref initial_msg)) = + (&source, &ctx.initial_message) { tracing::info!( target: "pool::session", - "sending initial_message to session {session_id} for channel {cid}" + "sending initial_message to session {session_id} for conversation {scope}" ); // For agents with systemPrompt support (protocol_version >= 2), // base_prompt is delivered via the system role in session/new. @@ -1699,7 +2399,7 @@ pub async fn run_prompt_task( Ok(stop_reason) => { tracing::info!( target: "pool::session", - "initial_message complete for channel {cid}: {stop_reason:?}" + "initial_message complete for conversation {scope}: {stop_reason:?}" ); } Err(AcpError::AgentExited) => { @@ -1717,7 +2417,7 @@ pub async fn run_prompt_task( Err(AcpError::IdleTimeout(_)) => { tracing::warn!( target: "pool::session", - "initial_message idle timeout ({}s) for channel {cid} — cancelling", + "initial_message idle timeout ({}s) for conversation {scope} — cancelling", ctx.idle_timeout.as_secs() ); match agent @@ -1762,7 +2462,7 @@ pub async fn run_prompt_task( 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", + "hard timeout ({}s cap, silence {silence:?}, recently_active={recently_active}) during initial_message for conversation {scope} — agent process is unrecoverable", ctx.max_turn_duration.as_secs() ); agent.state.invalidate_all(); @@ -1779,7 +2479,7 @@ pub async fn run_prompt_task( Err(e) => { tracing::error!( target: "pool::session", - "initial_message failed for channel {cid}: {e} — invalidating session" + "initial_message failed for conversation {scope}: {e} — invalidating session" ); agent.state.invalidate(&source); send_prompt_result( @@ -1818,7 +2518,7 @@ pub async fn run_prompt_task( } 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 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 @@ -1839,7 +2539,7 @@ pub async fn run_prompt_task( if let Some(ref cmd) = slash_command { tracing::info!( target: "pool::prompt", - channel = %b.channel_id, + channel = %b.scope, command = %cmd, "slash-command pass-through" ); @@ -1925,14 +2625,6 @@ pub async fn run_prompt_task( ) => 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() { @@ -2022,7 +2714,7 @@ pub async fn run_prompt_task( // MUST send a PromptResult or the main loop deadlocks. if matches!( control_signal, - ControlSignal::Rotate | ControlSignal::SwitchModel(_) + ControlSignal::Rotate | ControlSignal::SwitchModel { .. } ) { tracing::debug!( target: "pool::prompt", @@ -2077,8 +2769,8 @@ pub async fn run_prompt_task( 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); + PromptSource::Conversation(scope) => { + let count = agent.state.turn_counts.entry(*scope).or_insert(0); *count += 1; *count >= limit } @@ -2305,6 +2997,33 @@ where f().await } +/// Resolve and cache metadata for a dynamically joined channel. +/// +/// Called by the membership-add resolver before opening event delivery, so a +/// channel joined after startup gains correct DM/thread scoping on its first +/// event. Returns `true` once metadata is cached; callers retry failures while +/// the membership remains pending. +pub(crate) async fn refresh_channel_info(ctx: &PromptContext, channel_id: Uuid) -> bool { + match ctx.channel_info.resolve(channel_id).await { + Some(info) => { + tracing::info!( + channel_id = %channel_id, + channel_type = %info.channel_type, + "cached metadata for dynamically joined channel" + ); + true + } + None => { + tracing::warn!( + channel_id = %channel_id, + "could not resolve metadata for dynamically joined channel — \ + subscription remains closed until retry succeeds" + ); + false + } + } +} + /// Lazy-fetch channel metadata for a channel not in the startup discovery cache. /// /// Handles channels added dynamically via membership notifications after startup. @@ -2569,31 +3288,133 @@ pub(crate) fn render_canvas_section(event_id: &str, timestamp: &str, channel_uui /// - 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). +/// The typed batch scope is authoritative for non-DM threads. DM sessions stay +/// channel-scoped, so a DM reply falls back to the last event's NIP-10 root to +/// preserve the existing reply-hydration behavior without changing session +/// ownership. +fn context_thread_root(batch: &FlushBatch, is_dm: bool) -> Option { + batch.scope.thread_root.or_else(|| { + if is_dm { + batch + .events + .last() + .and_then(|event| buzz_core::thread::parse_nip10_thread(&event.event)) + .map(|thread| thread.root_event_id) + } else { + None + } + }) +} + +fn context_excluded_event_ids(batch: &FlushBatch) -> HashSet { + batch + .events + .iter() + .chain(batch.cancelled_events.iter()) + .map(|event| event.event.id) + .collect() +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ThreadContextType { + Stream, + Forum, + Dm, +} + +impl ThreadContextType { + fn from_channel_info(channel_info: Option<&PromptChannelInfo>) -> Self { + match channel_info.map(|info| info.channel_type.as_str()) { + Some("forum") => Self::Forum, + Some("dm") => Self::Dm, + _ => Self::Stream, + } + } + + fn root_kinds(self) -> Vec { + match self { + Self::Stream | Self::Dm => vec![ + nostr::Kind::Custom(buzz_core::kind::KIND_STREAM_MESSAGE as u16), + nostr::Kind::Custom(buzz_core::kind::KIND_STREAM_MESSAGE_V2 as u16), + ], + Self::Forum => { + vec![nostr::Kind::Custom(buzz_core::kind::KIND_FORUM_POST as u16)] + } + } + } + + fn reply_kinds(self) -> Vec { + match self { + Self::Stream | Self::Dm => vec![ + nostr::Kind::Custom(buzz_core::kind::KIND_STREAM_MESSAGE as u16), + nostr::Kind::Custom(buzz_core::kind::KIND_STREAM_MESSAGE_V2 as u16), + ], + Self::Forum => { + vec![nostr::Kind::Custom( + buzz_core::kind::KIND_FORUM_COMMENT as u16, + )] + } + } + } + + fn allows_root(self, kind: nostr::Kind) -> bool { + let kind = kind.as_u16() as u32; + match self { + Self::Stream | Self::Dm => matches!( + kind, + buzz_core::kind::KIND_STREAM_MESSAGE | buzz_core::kind::KIND_STREAM_MESSAGE_V2 + ), + Self::Forum => kind == buzz_core::kind::KIND_FORUM_POST, + } + } + + fn allows_reply(self, kind: nostr::Kind) -> bool { + let kind = kind.as_u16() as u32; + match self { + Self::Stream | Self::Dm => matches!( + kind, + buzz_core::kind::KIND_STREAM_MESSAGE | buzz_core::kind::KIND_STREAM_MESSAGE_V2 + ), + Self::Forum => kind == buzz_core::kind::KIND_FORUM_COMMENT, + } + } +} + async fn fetch_conversation_context( batch: &FlushBatch, channel_info: &Option, ctx: &PromptContext, ) -> Option { 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; + let context_type = ThreadContextType::from_channel_info(channel_info.as_ref()); + let is_dm = context_type == ThreadContextType::Dm; + + // A non-DM thread's typed scope is the single source of truth: queueing, + // session ownership, and hydrated context must all use the same canonical + // root. DMs are intentionally channel-scoped, so their reply context uses + // the last event's NIP-10 root without changing session ownership. + let excluded_event_ids = context_excluded_event_ids(batch); + if let Some(root_id) = context_thread_root(batch, is_dm) { + return fetch_thread_context( + batch.channel_id(), + root_id, + limit, + &excluded_event_ids, + context_type, + &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; + return fetch_dm_context( + batch.channel_id(), + limit, + &excluded_event_ids, + &ctx.rest_client, + ) + .await; } None @@ -2752,38 +3573,38 @@ async fn fetch_prompt_profile_lookup( /// 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, + root_event_id: nostr::EventId, limit: u32, + excluded_event_ids: &HashSet, + context_type: ThreadContextType, rest: &RestClient, ) -> Option { 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 root_hex = root_event_id.to_hex(); + // Fetch one validated sentinel beyond the display limit, plus enough room + // for every current/cancelled source ID that the relay may return. This + // preserves the requested historical window after exclusions and lets the + // parser retain its truncation signal. + let query_limit = (limit as usize) + .saturating_add(excluded_event_ids.len()) + .saturating_add(1); + + // Both filters are channel- and kind-scoped. The response is still treated + // as untrusted below: every signed event is re-checked against these + // constraints because a compromised/misbehaving relay can ignore filters. + let root_filter = nostr::Filter::new() + .id(root_event_id) + .kinds(context_type.root_kinds()) + .custom_tags(h_tag, [ch_str.as_str()]); 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]) + .kinds(context_type.reply_kinds()) + .custom_tags(e_tag, [root_hex.as_str()]) .custom_tags(h_tag, [ch_str.as_str()]) - .limit(limit as usize); + .limit(query_limit); fetch_with_retry(|| async { match timeout( @@ -2792,11 +3613,18 @@ async fn fetch_thread_context( ) .await { - Ok(Ok(json)) => parse_nostr_thread_response(json, root_event_id), + Ok(Ok(json)) => parse_nostr_thread_response( + json, + channel_id, + root_event_id, + limit, + excluded_event_ids, + context_type, + ), Ok(Err(e)) => { tracing::warn!( channel_id = %channel_id, - root = root_event_id, + root = %root_event_id, "thread context fetch failed: {e} — will retry" ); None @@ -2804,7 +3632,7 @@ async fn fetch_thread_context( Err(_) => { tracing::warn!( channel_id = %channel_id, - root = root_event_id, + root = %root_event_id, "thread context fetch timed out — will retry" ); None @@ -2818,19 +3646,23 @@ async fn fetch_thread_context( async fn fetch_dm_context( channel_id: Uuid, limit: u32, + excluded_event_ids: &HashSet, rest: &RestClient, ) -> Option { use nostr::{Alphabet, SingleLetterTag}; let h_tag = SingleLetterTag::lowercase(Alphabet::H); let ch_str = channel_id.to_string(); + let query_limit = (limit as usize) + .saturating_add(excluded_event_ids.len()) + .saturating_add(1); 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); + .limit(query_limit); fetch_with_retry(|| async { match timeout( @@ -2839,7 +3671,7 @@ async fn fetch_dm_context( ) .await { - Ok(Ok(json)) => parse_nostr_dm_response(json, limit), + Ok(Ok(json)) => parse_nostr_dm_response(json, channel_id, limit, excluded_event_ids), Ok(Err(e)) => { tracing::warn!( channel_id = %channel_id, @@ -2935,6 +3767,7 @@ fn parse_dm_response(json: serde_json::Value, limit: u32) -> Option Option { let content = obj.get("content").and_then(|v| v.as_str())?; let pubkey = obj @@ -2955,88 +3788,167 @@ fn json_to_context_message(obj: &serde_json::Value) -> Option { }) .unwrap_or_else(|| "unknown".to_string()); - Some(ContextMessage { - pubkey: pubkey.to_string(), + Some(ContextMessage { + pubkey: pubkey.to_string(), + timestamp, + content: content.to_string(), + }) +} + +fn context_event_channel(event: &nostr::Event) -> Option { + event.tags.iter().find_map(|tag| { + let parts = tag.as_slice(); + (parts.first().map(String::as_str) == Some("h")) + .then(|| parts.get(1)?.parse::().ok()) + .flatten() + }) +} + +fn verified_context_event(raw: &serde_json::Value, channel_id: Uuid) -> Option { + let event = serde_json::from_value::(raw.clone()).ok()?; + event.verify().ok()?; + (context_event_channel(&event) == Some(channel_id)).then_some(event) +} + +fn context_message_from_event(event: &nostr::Event) -> ContextMessage { + let timestamp = chrono::DateTime::from_timestamp(event.created_at.as_secs() as i64, 0) + .map(|value| value.to_rfc3339()) + .unwrap_or_else(|| event.created_at.as_secs().to_string()); + ContextMessage { + pubkey: event.pubkey.to_hex(), timestamp, - content: content.to_string(), - }) + content: event.content.clone(), + } +} + +fn is_dm_context_kind(kind: nostr::Kind) -> bool { + matches!( + kind.as_u16() as u32, + buzz_core::kind::KIND_STREAM_MESSAGE | buzz_core::kind::KIND_STREAM_MESSAGE_V2 + ) } -/// Parse a Nostr query response (array of events) into thread context. +/// Parse an untrusted Nostr query response into thread context. /// -/// Separates the root event (matching `root_event_id`) from replies, sorts -/// chronologically by `created_at`. +/// Every event must have a valid signature and ID, resolve to the requested +/// channel under the relay's first-valid-`h` rule, use an allowed message kind, +/// and either be the exact root or carry a canonical NIP-10 reference to that +/// root. Mention-only `e` tags cannot admit a cross-root event. fn parse_nostr_thread_response( json: serde_json::Value, - root_event_id: &str, + channel_id: Uuid, + root_event_id: nostr::EventId, + limit: u32, + excluded_event_ids: &HashSet, + context_type: ThreadContextType, ) -> Option { let events = json.as_array()?; let mut root_msg = None; - let mut reply_msgs = Vec::new(); + let mut reply_msgs: Vec<(u64, String, ContextMessage)> = Vec::new(); + let mut seen = HashSet::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, - )); + for raw in events { + let Some(event) = verified_context_event(raw, channel_id) else { + continue; + }; + if excluded_event_ids.contains(&event.id) || !seen.insert(event.id) { + continue; + } + if event.id == root_event_id { + // A canonical root cannot itself point into another thread. Relay + // ancestry validation enforces the same shape for accepted + // replies; repeat it here because query responses are untrusted. + if context_type.allows_root(event.kind) + && buzz_core::thread::parse_nip10_thread(&event).is_none() + { + root_msg = Some(context_message_from_event(&event)); } + continue; + } + if !context_type.allows_reply(event.kind) { + continue; + } + let Some(thread) = buzz_core::thread::parse_nip10_thread(&event) else { + continue; + }; + if thread.root_event_id != root_event_id { + continue; } + reply_msgs.push(( + event.created_at.as_secs(), + event.id.to_hex(), + context_message_from_event(&event), + )); } - // Sort replies chronologically. - reply_msgs.sort_by_key(|(ts, _)| *ts); + // Query results are newest-first. Sort deterministically, then keep the + // newest `limit` historical replies while rendering them oldest-first. + reply_msgs.sort_by(|left, right| (left.0, &left.1).cmp(&(right.0, &right.1))); + let truncated = reply_msgs.len() > limit as usize; + if truncated { + let keep_from = reply_msgs.len().saturating_sub(limit as usize); + reply_msgs.drain(..keep_from); + } let mut messages = Vec::new(); if let Some(root) = root_msg { messages.push(root); } - messages.extend(reply_msgs.into_iter().map(|(_, msg)| msg)); + messages.extend(reply_msgs.into_iter().map(|(_, _, msg)| msg)); - let total = messages.len(); if messages.is_empty() { return None; } + let total = messages.len() + usize::from(truncated); Some(ConversationContext::Thread { messages, total, - truncated: false, // query returns all within limit + truncated, }) } -/// 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 { +/// Parse an untrusted Nostr query response into channel-scoped DM context. +fn parse_nostr_dm_response( + json: serde_json::Value, + channel_id: Uuid, + limit: u32, + excluded_event_ids: &HashSet, +) -> Option { let events = json.as_array()?; - - let mut messages: Vec<(u64, ContextMessage)> = events + let mut seen = HashSet::new(); + let mut messages: Vec<(u64, String, 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)) + .filter_map(|raw| verified_context_event(raw, channel_id)) + .filter(|event| { + is_dm_context_kind(event.kind) + && !excluded_event_ids.contains(&event.id) + && seen.insert(event.id) + }) + .map(|event| { + ( + event.created_at.as_secs(), + event.id.to_hex(), + context_message_from_event(&event), + ) }) .collect(); - // Sort chronologically (oldest first). - messages.sort_by_key(|(ts, _)| *ts); - - let messages: Vec = messages.into_iter().map(|(_, msg)| msg).collect(); - let truncated = messages.len() >= limit as usize; - let total = if truncated { - messages.len() + 1 - } else { - messages.len() - }; + messages.sort_by(|left, right| (left.0, &left.1).cmp(&(right.0, &right.1))); + let truncated = messages.len() > limit as usize; + if truncated { + let keep_from = messages.len().saturating_sub(limit as usize); + messages.drain(..keep_from); + } + let messages: Vec = messages + .into_iter() + .map(|(_, _, message)| message) + .collect(); if messages.is_empty() { return None; } + let total = messages.len() + usize::from(truncated); Some(ConversationContext::Dm { messages, @@ -3067,7 +3979,7 @@ fn requeue_cancelled_batch( ) -> Option { let reason = match signal { ControlSignal::Steer => CancelReason::Steer, - ControlSignal::Interrupt | ControlSignal::SwitchModel(_) => CancelReason::Interrupt, + ControlSignal::Interrupt | ControlSignal::SwitchModel { .. } => CancelReason::Interrupt, // Cancel/Rotate discard the batch — no merged re-prompt. ControlSignal::Cancel | ControlSignal::Rotate => return None, }; @@ -3134,7 +4046,7 @@ fn classify_control_cancel_failure( /// 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::Conversation(scope) => format!("conversation {scope}"), PromptSource::Heartbeat => "heartbeat".to_string(), }; match stop_reason { @@ -3344,7 +4256,7 @@ impl Drop for LivenessGuard { struct TurnCompletionGuard { observer: Option, agent_index: Option, - channel_id: Option, + scope: Option, turn_id: String, } @@ -3352,13 +4264,13 @@ impl TurnCompletionGuard { fn new( observer: Option, agent_index: Option, - channel_id: Option, + scope: Option, turn_id: String, ) -> Self { Self { observer, agent_index, - channel_id, + scope, turn_id, } } @@ -3367,7 +4279,7 @@ impl TurnCompletionGuard { 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())); + let context = observer::context_for_scope(self.scope, None, Some(self.turn_id.clone())); observer.emit( "turn_completed", self.agent_index, @@ -3730,6 +4642,211 @@ mod tests { use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; use serde_json::json; + fn model_capabilities(models: &[&str]) -> AgentModelCapabilities { + AgentModelCapabilities { + config_options_raw: Vec::new(), + available_models_raw: Some(json!({ + "availableModels": models + .iter() + .map(|model_id| json!({"modelId": model_id})) + .collect::>() + })), + } + } + + #[test] + fn model_control_generations_resolve_once_and_never_erase_a_newer_override() { + let controls = ChannelModelControls::default(); + let channel_id = Uuid::new_v4(); + + let first = controls.request(channel_id, "model-a", Some("request-a")); + assert!(matches!( + first, + ModelControlDecision::PendingCatalog { + generation: 1, + ref superseded + } if superseded.is_empty() + )); + let second = controls.request(channel_id, "model-b", Some("request-b")); + assert!(matches!( + second, + ModelControlDecision::PendingCatalog { + generation: 2, + ref superseded + } if superseded == &[ModelControlResult { + channel_id, + model_id: "model-a".to_string(), + generation: 1, + request_id: Some("request-a".to_string()), + status: "superseded", + }] + )); + assert_eq!( + controls.pending_len(), + 1, + "pending state is channel-bounded" + ); + + let resolved = controls.register_catalog(model_capabilities(&["model-a", "model-b"])); + assert_eq!( + resolved, + vec![ModelControlResult { + channel_id, + model_id: "model-b".to_string(), + generation: 2, + request_id: Some("request-b".to_string()), + status: "sent", + }] + ); + assert!( + controls + .register_catalog(model_capabilities(&["model-a", "model-b"])) + .is_empty(), + "catalog replay must not duplicate a control result" + ); + assert_eq!( + controls.desired_model(channel_id), + Some(ChannelDesiredModel { + model_id: "model-b".to_string(), + generation: 2, + }) + ); + + assert!( + controls.clear_if_current(channel_id, 1).is_none(), + "a stale generation must not clear the current override" + ); + assert_eq!( + controls + .desired_model(channel_id) + .map(|desired| desired.model_id), + Some("model-b".to_string()) + ); + assert_eq!( + controls.clear_if_current(channel_id, 2), + Some(3), + "clearing the current override publishes a baseline tombstone" + ); + assert_eq!(controls.desired_model(channel_id), None); + assert_eq!( + controls.channel_generations(), + vec![(channel_id, 3)], + "other holders must reconcile the cleared override" + ); + } + + #[test] + fn lazy_placeholder_replacement_preserves_pre_wake_model_control() { + let controls = ChannelModelControls::default(); + let channel_id = Uuid::new_v4(); + let placeholder = AgentPool::from_slots_with_model_controls(vec![None], controls.clone()); + + assert!(matches!( + placeholder + .model_controls() + .request(channel_id, "model-b", Some("lazy-request"),), + ModelControlDecision::PendingCatalog { generation: 1, .. } + )); + drop(placeholder); + + let ready = AgentPool::from_slots_with_model_controls(vec![None], controls.clone()); + assert_eq!( + controls.register_catalog(model_capabilities(&["model-b"])), + vec![ModelControlResult { + channel_id, + model_id: "model-b".to_string(), + generation: 1, + request_id: Some("lazy-request".to_string()), + status: "sent", + }] + ); + assert_eq!( + ready.model_controls().desired_model(channel_id), + Some(ChannelDesiredModel { + model_id: "model-b".to_string(), + generation: 1, + }), + "the awakened pool must see state accepted by the placeholder" + ); + } + + #[tokio::test] + async fn clearing_current_override_invalidates_every_holder_via_tombstone() { + async fn inert_agent(index: usize) -> OwnedAgent { + OwnedAgent { + index, + acp: AcpClient::spawn( + "bash", + &["-c".to_string(), "sleep 10".to_string()], + &[], + false, + ) + .await + .expect("spawn inert ACP process"), + state: SessionState::default(), + model_capabilities: None, + baseline_model: Some("baseline".to_string()), + desired_model: Some("baseline".to_string()), + desired_model_generation: None, + model_overridden: false, + agent_name: "test".to_string(), + goose_system_prompt_supported: None, + protocol_version: 2, + } + } + + let controls = ChannelModelControls::default(); + let channel_id = Uuid::new_v4(); + let scope_a = ConversationScope::thread( + channel_id, + nostr::EventId::from_hex(&"a".repeat(64)).expect("root A"), + ); + let scope_b = ConversationScope::thread( + channel_id, + nostr::EventId::from_hex(&"b".repeat(64)).expect("root B"), + ); + assert!(controls + .register_catalog(model_capabilities(&["model-a"])) + .is_empty()); + assert!(matches!( + controls.request(channel_id, "model-a", None), + ModelControlDecision::Accepted { generation: 1, .. } + )); + + let mut agent_a = inert_agent(0).await; + agent_a.state.sessions.insert(scope_a, "session-a".into()); + let mut agent_b = inert_agent(1).await; + agent_b.state.sessions.insert(scope_b, "session-b".into()); + let mut pool = AgentPool::from_slots_with_model_controls( + vec![Some(agent_a), Some(agent_b)], + controls.clone(), + ); + + assert_eq!(controls.clear_if_current(channel_id, 1), Some(2)); + + let claimed_a = match pool.try_claim_for_scope(scope_a) { + ClaimOutcome::Claimed(agent) => *agent, + _ => panic!("holder A must be claimable"), + }; + assert!( + !claimed_a.state.sessions.contains_key(&scope_a), + "holder A must discard its session opened under the failed override" + ); + assert_eq!(claimed_a.desired_model.as_deref(), Some("baseline")); + pool.return_agent(claimed_a); + + let claimed_b = match pool.try_claim_for_scope(scope_b) { + ClaimOutcome::Claimed(agent) => *agent, + _ => panic!("holder B must be claimable"), + }; + assert!( + !claimed_b.state.sessions.contains_key(&scope_b), + "holder B must independently reconcile the baseline tombstone" + ); + assert_eq!(claimed_b.desired_model.as_deref(), Some("baseline")); + pool.return_agent(claimed_b); + } + // 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. @@ -4184,6 +5301,356 @@ mod tests { assert!(json_to_context_message(&obj).is_none()); } + fn signed_context_event( + kind: u16, + channel_id: Uuid, + content: &str, + created_at: u64, + extra_tags: Vec>, + ) -> nostr::Event { + let mut tags = + vec![Tag::parse(["h", channel_id.to_string().as_str()]).expect("channel tag")]; + tags.extend( + extra_tags + .into_iter() + .map(|parts| Tag::parse(parts).expect("test tag")), + ); + EventBuilder::new(Kind::Custom(kind), content) + .tags(tags) + .custom_created_at(Timestamp::from(created_at)) + .sign_with_keys(&Keys::generate()) + .expect("sign context event") + } + + fn direct_reply_tags(root: nostr::EventId) -> Vec> { + vec![vec![ + "e".to_string(), + root.to_hex(), + String::new(), + "reply".to_string(), + ]] + } + + fn nested_reply_tags(root: nostr::EventId, parent: nostr::EventId) -> Vec> { + vec![ + vec![ + "e".to_string(), + root.to_hex(), + String::new(), + "root".to_string(), + ], + vec![ + "e".to_string(), + parent.to_hex(), + String::new(), + "reply".to_string(), + ], + ] + } + + fn context_json(events: &[&nostr::Event]) -> serde_json::Value { + serde_json::Value::Array( + events + .iter() + .map(|event| serde_json::to_value(event).expect("serialize event")) + .collect(), + ) + } + + #[test] + fn nostr_thread_context_rejects_cross_root_channel_shape_and_source_duplicates() { + let channel_id = Uuid::new_v4(); + let wrong_channel = Uuid::new_v4(); + let root_a = signed_context_event(9, channel_id, "root-a", 10, vec![]); + let root_b = signed_context_event(9, channel_id, "root-b", 11, vec![]); + let history = + signed_context_event(9, channel_id, "history-a", 20, direct_reply_tags(root_a.id)); + let trigger = signed_context_event( + 9, + channel_id, + "current-trigger", + 30, + nested_reply_tags(root_a.id, history.id), + ); + let cancelled = signed_context_event( + 9, + channel_id, + "cancelled-trigger", + 25, + nested_reply_tags(root_a.id, history.id), + ); + + // This event matches a marker-agnostic #e=root-a query through its + // mention tag, but its canonical thread is root B. + let mut cross_root_tags = direct_reply_tags(root_b.id); + cross_root_tags.push(vec![ + "e".to_string(), + root_a.id.to_hex(), + String::new(), + "mention".to_string(), + ]); + let cross_root = signed_context_event(9, channel_id, "cross-root", 21, cross_root_tags); + let wrong_channel_reply = signed_context_event( + 9, + wrong_channel, + "wrong-channel", + 22, + direct_reply_tags(root_a.id), + ); + let malformed_shape = signed_context_event( + 9, + channel_id, + "root-only-is-not-a-reply", + 23, + vec![vec![ + "e".to_string(), + root_a.id.to_hex(), + String::new(), + "root".to_string(), + ]], + ); + let wrong_kind = signed_context_event( + buzz_core::kind::KIND_REACTION as u16, + channel_id, + "wrong-kind", + 24, + direct_reply_tags(root_a.id), + ); + let mut tampered = serde_json::to_value(&history).expect("serialize history"); + tampered["content"] = serde_json::Value::String("tampered".to_string()); + + let mut values = context_json(&[ + &root_a, + &history, + &history, + &trigger, + &cancelled, + &cross_root, + &wrong_channel_reply, + &malformed_shape, + &wrong_kind, + ]) + .as_array() + .expect("array") + .clone(); + values.push(tampered); + let excluded = HashSet::from([trigger.id, cancelled.id]); + + let context = parse_nostr_thread_response( + serde_json::Value::Array(values), + channel_id, + root_a.id, + 12, + &excluded, + ThreadContextType::Stream, + ) + .expect("root and one valid historical reply remain"); + let ConversationContext::Thread { + messages, + total, + truncated, + } = context + else { + panic!("expected thread context"); + }; + assert_eq!( + messages + .iter() + .map(|message| message.content.as_str()) + .collect::>(), + ["root-a", "history-a"] + ); + assert_eq!(total, 2); + assert!(!truncated); + assert!( + parse_nostr_thread_response( + context_json(&[&cross_root]), + channel_id, + cross_root.id, + 12, + &HashSet::new(), + ThreadContextType::Stream, + ) + .is_none(), + "an exact-ID lookup cannot promote a reply in another canonical thread to a root" + ); + } + + #[test] + fn forum_root_and_comment_context_use_canonical_shapes_without_repeating_trigger() { + let channel_id = Uuid::new_v4(); + let keys = Keys::generate(); + let post = buzz_sdk::build_forum_post(channel_id, "forum post", &[], &[]) + .expect("build forum post") + .custom_created_at(Timestamp::from(10)) + .sign_with_keys(&keys) + .expect("sign forum post"); + let history = buzz_sdk::build_forum_comment( + channel_id, + "older comment", + &buzz_sdk::ThreadRef { + root_event_id: post.id, + parent_event_id: post.id, + }, + &[], + &[], + ) + .expect("build forum comment") + .custom_created_at(Timestamp::from(20)) + .sign_with_keys(&keys) + .expect("sign forum comment"); + let trigger = buzz_sdk::build_forum_comment( + channel_id, + "current comment", + &buzz_sdk::ThreadRef { + root_event_id: post.id, + parent_event_id: history.id, + }, + &[], + &[], + ) + .expect("build nested forum comment") + .custom_created_at(Timestamp::from(30)) + .sign_with_keys(&keys) + .expect("sign nested forum comment"); + let wrong_type = signed_context_event( + buzz_core::kind::KIND_STREAM_MESSAGE as u16, + channel_id, + "stream message cannot enter forum context", + 25, + direct_reply_tags(post.id), + ); + + let comment_context = parse_nostr_thread_response( + context_json(&[&post, &history, &wrong_type, &trigger]), + channel_id, + post.id, + 12, + &HashSet::from([trigger.id]), + ThreadContextType::Forum, + ) + .expect("forum history"); + let ConversationContext::Thread { messages, .. } = comment_context else { + panic!("expected thread context"); + }; + assert_eq!( + messages + .iter() + .map(|message| message.content.as_str()) + .collect::>(), + ["forum post", "older comment"] + ); + + assert!( + parse_nostr_thread_response( + context_json(&[&post]), + channel_id, + post.id, + 12, + &HashSet::from([post.id]), + ThreadContextType::Forum, + ) + .is_none(), + "a forum-root trigger must not repeat itself as hydrated context" + ); + } + + #[test] + fn dm_reply_context_is_validated_and_current_reply_is_excluded() { + let channel_id = Uuid::new_v4(); + let root = signed_context_event(9, channel_id, "dm root", 10, vec![]); + let history = + signed_context_event(9, channel_id, "dm history", 20, direct_reply_tags(root.id)); + let trigger = signed_context_event( + 9, + channel_id, + "dm current", + 30, + nested_reply_tags(root.id, history.id), + ); + let forum_root = signed_context_event( + buzz_core::kind::KIND_FORUM_POST as u16, + channel_id, + "not a DM root", + 11, + vec![], + ); + let forum_comment = signed_context_event( + buzz_core::kind::KIND_FORUM_COMMENT as u16, + channel_id, + "not a DM reply", + 21, + direct_reply_tags(root.id), + ); + + let context = parse_nostr_thread_response( + context_json(&[&trigger, &forum_comment, &history, &root]), + channel_id, + root.id, + 12, + &HashSet::from([trigger.id]), + ThreadContextType::Dm, + ) + .expect("validated DM reply history"); + let ConversationContext::Thread { messages, .. } = context else { + panic!("expected thread context"); + }; + assert_eq!( + messages + .iter() + .map(|message| message.content.as_str()) + .collect::>(), + ["dm root", "dm history"] + ); + assert!( + parse_nostr_thread_response( + context_json(&[&forum_root]), + channel_id, + forum_root.id, + 12, + &HashSet::new(), + ThreadContextType::Dm, + ) + .is_none(), + "DM hydration must reject a forum root even when the relay returns it by exact ID" + ); + } + + #[test] + fn dm_context_preserves_chronological_limit_after_batch_exclusions() { + let channel_id = Uuid::new_v4(); + let oldest = signed_context_event(9, channel_id, "oldest", 10, vec![]); + let kept_a = signed_context_event(9, channel_id, "kept-a", 20, vec![]); + let kept_b = signed_context_event(40002, channel_id, "kept-b", 30, vec![]); + let cancelled = signed_context_event(9, channel_id, "cancelled", 40, vec![]); + let trigger = signed_context_event(9, channel_id, "trigger", 50, vec![]); + + let context = parse_nostr_dm_response( + context_json(&[&trigger, &cancelled, &kept_b, &kept_a, &oldest]), + channel_id, + 2, + &HashSet::from([trigger.id, cancelled.id]), + ) + .expect("DM history"); + let ConversationContext::Dm { + messages, + total, + truncated, + } = context + else { + panic!("expected DM context"); + }; + assert_eq!( + messages + .iter() + .map(|message| message.content.as_str()) + .collect::>(), + ["kept-a", "kept-b"] + ); + assert!(truncated); + assert_eq!(total, 3); + } + #[test] fn test_collect_prompt_pubkeys_includes_authors_mentions_and_context() { let keys = Keys::generate(); @@ -4198,7 +5665,7 @@ mod tests { .unwrap(); let author_hex = event.pubkey.to_hex(); let batch = FlushBatch { - channel_id: Uuid::new_v4(), + scope: ConversationScope::channel(Uuid::new_v4()), events: vec![crate::queue::BatchEvent { event, prompt_tag: "@mention".into(), @@ -4324,9 +5791,9 @@ mod tests { assert_eq!(pct_encode(" "), "%20"); } - fn make_state() -> (SessionState, Uuid, Uuid) { - let ch_a = Uuid::new_v4(); - let ch_b = Uuid::new_v4(); + fn make_state() -> (SessionState, ConversationScope, ConversationScope) { + let ch_a = ConversationScope::channel(Uuid::new_v4()); + let ch_b = ConversationScope::channel(Uuid::new_v4()); let mut s = SessionState::default(); s.sessions.insert(ch_a, "sess-a".into()); s.sessions.insert(ch_b, "sess-b".into()); @@ -4345,14 +5812,14 @@ mod tests { apply_completed_before_control_signal( &mut s, - &PromptSource::Channel(ch_a), + &PromptSource::Conversation(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!(!s.has_scope_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"); @@ -4366,7 +5833,7 @@ mod tests { apply_completed_before_control_signal( &mut s, - &PromptSource::Channel(ch_a), + &PromptSource::Conversation(ch_a), &ControlSignal::Cancel, ); @@ -4379,12 +5846,12 @@ mod tests { #[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)); + s.invalidate(&PromptSource::Conversation(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)); + assert!(!s.has_scope_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); @@ -4424,8 +5891,8 @@ mod tests { #[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)); + let ghost = ConversationScope::channel(Uuid::new_v4()); + s.invalidate(&PromptSource::Conversation(ghost)); // Everything still intact. assert_eq!(s.sessions.len(), 2); @@ -4446,13 +5913,13 @@ mod tests { } #[test] - fn test_invalidate_channel_returns_true_when_session_existed() { + fn test_invalidate_scope_returns_true_when_session_existed() { let (mut s, ch_a, ch_b) = make_state(); - assert!(s.invalidate_channel(&ch_a)); + assert!(s.invalidate_scope(&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)); + assert!(!s.has_scope_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); @@ -4463,10 +5930,10 @@ mod tests { } #[test] - fn test_invalidate_channel_returns_false_when_no_session() { + fn test_invalidate_scope_returns_false_when_no_session() { let (mut s, _ch_a, _ch_b) = make_state(); - let ghost = Uuid::new_v4(); - assert!(!s.invalidate_channel(&ghost)); + let ghost = ConversationScope::channel(Uuid::new_v4()); + assert!(!s.invalidate_scope(&ghost)); // Nothing changed. assert_eq!(s.sessions.len(), 2); assert_eq!(s.turn_counts.len(), 2); @@ -4477,14 +5944,14 @@ mod tests { // 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]; + let removed = vec![ch_a.channel_id]; 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!(!s.has_scope_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"); @@ -4500,11 +5967,11 @@ mod tests { // 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()), + &PromptSource::Conversation(ch_a), + &ControlSignal::SwitchModel { generation: 1 }, ); - assert!(!s.has_channel_state(&ch_a)); + assert!(!s.has_scope_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); @@ -4518,13 +5985,13 @@ mod tests { // `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 { + fn one_event_batch(scope: ConversationScope) -> FlushBatch { let keys = Keys::generate(); let event = EventBuilder::new(Kind::Custom(9), "test") .sign_with_keys(&keys) .unwrap(); FlushBatch { - channel_id, + scope, events: vec![crate::queue::BatchEvent { event, prompt_tag: "test".into(), @@ -4541,7 +6008,7 @@ mod tests { (ControlSignal::Steer, Some(CancelReason::Steer)), (ControlSignal::Interrupt, Some(CancelReason::Interrupt)), ( - ControlSignal::SwitchModel("gpt-5".into()), + ControlSignal::SwitchModel { generation: 1 }, Some(CancelReason::Interrupt), ), (ControlSignal::Cancel, None), @@ -4551,8 +6018,7 @@ mod tests { 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 batch = one_event_batch(ConversationScope::channel(Uuid::new_v4())); let result = requeue_cancelled_batch(&ctx, signal.clone(), Some(batch)); match expected_reason { Some(reason) => { @@ -4657,7 +6123,7 @@ mod tests { Case { name: "CancelDrainTimeout + SwitchModel preserves batch with Interrupt reason", error: || AcpError::CancelDrainTimeout(CONTROL_CANCEL_GRACE), - signal: ControlSignal::SwitchModel("gpt-5".to_string()), + signal: ControlSignal::SwitchModel { generation: 1 }, expected_outcome: "CancelDrainTimeout", batch_preserved: true, expected_reason: Some(CancelReason::Interrupt), @@ -4704,8 +6170,7 @@ mod tests { ]; for case in cases { - let channel_id = Uuid::new_v4(); - let batch = one_event_batch(channel_id); + let batch = one_event_batch(ConversationScope::channel(Uuid::new_v4())); let failure = classify_control_cancel_failure( &ctx, (case.error)(), @@ -5073,7 +6538,9 @@ mod tests { acp, state: SessionState::default(), model_capabilities: None, + baseline_model: None, desired_model: None, + desired_model_generation: None, model_overridden: false, agent_name: "unknown".into(), goose_system_prompt_supported: None, @@ -5131,7 +6598,9 @@ mod tests { acp, state: SessionState::default(), model_capabilities: None, + baseline_model: None, desired_model: None, + desired_model_generation: None, model_overridden: false, agent_name: "unknown".into(), goose_system_prompt_supported: None, @@ -5388,6 +6857,7 @@ mod tests { memory_enabled: false, harness_name: "goose".to_string(), relay_url: "ws://127.0.0.1:3000".to_string(), + model_controls: ChannelModelControls::default(), } } @@ -5437,14 +6907,14 @@ mod tests { // ── canvas_sections cache invalidation ─────────────────────────────────── #[test] - fn test_invalidate_channel_clears_canvas_section() { - let ch = Uuid::new_v4(); + fn test_invalidate_scope_clears_canvas_section() { + let ch = ConversationScope::channel(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); + s.invalidate_scope(&ch); assert!(!s.canvas_sections.contains_key(&ch)); assert!(!s.sessions.contains_key(&ch)); @@ -5452,8 +6922,8 @@ mod tests { #[test] fn test_invalidate_all_clears_canvas_sections() { - let ch_a = Uuid::new_v4(); - let ch_b = Uuid::new_v4(); + let ch_a = ConversationScope::channel(Uuid::new_v4()); + let ch_b = ConversationScope::channel(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()); @@ -5467,26 +6937,26 @@ mod tests { #[test] fn test_invalidate_channel_leaves_other_channels_canvas_intact() { - let ch_a = Uuid::new_v4(); - let ch_b = Uuid::new_v4(); + let ch_a = ConversationScope::channel(Uuid::new_v4()); + let ch_b = ConversationScope::channel(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); + s.invalidate_channel(&ch_a.channel_id); 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(); + fn test_has_scope_state_true_when_only_canvas_section_present() { + let ch = ConversationScope::channel(Uuid::new_v4()); let mut s = SessionState::default(); s.canvas_sections.insert(ch, "canvas".into()); - assert!(s.has_channel_state(&ch)); + assert!(s.has_scope_state(&ch)); } // ── canvas_section_from_query_response ─────────────────────────────────── @@ -5837,3 +7307,180 @@ mod tests { server.abort(); } } + +#[cfg(test)] +mod thread_scope_session_tests { + use super::*; + use crate::scope::ConversationScope; + use nostr::EventId; + use uuid::Uuid; + + fn thread_scope(channel_id: Uuid, root_byte: char) -> ConversationScope { + let root = EventId::from_hex(&root_byte.to_string().repeat(64)).expect("valid hex"); + ConversationScope::thread(channel_id, root) + } + + fn context_batch(scope: ConversationScope, tagged_root: Option) -> FlushBatch { + let tags = tagged_root + .map(|root| { + nostr::Tag::parse(&[ + "e".to_string(), + root.to_string().repeat(64), + String::new(), + "reply".to_string(), + ]) + .expect("reply tag") + }) + .into_iter() + .collect::>(); + let event = nostr::EventBuilder::new(nostr::Kind::Custom(9), "context") + .tags(tags) + .sign_with_keys(&nostr::Keys::generate()) + .expect("sign context event"); + FlushBatch { + scope, + events: vec![crate::queue::BatchEvent { + event, + prompt_tag: "test".into(), + received_at: std::time::Instant::now(), + }], + cancelled_events: vec![], + cancel_reason: None, + } + } + + /// Hydration must follow the same canonical root that keys the queue and + /// session. DM replies are the exception: their session remains channel + /// scoped while their last event's root still selects reply context. + #[test] + fn context_hydration_uses_typed_root_and_preserves_dm_reply_context() { + let channel_id = Uuid::new_v4(); + let typed_scope = thread_scope(channel_id, 'a'); + + let conflicting_tag = context_batch(typed_scope, Some('b')); + assert_eq!( + context_thread_root(&conflicting_tag, false).map(|root| root.to_hex()), + Some("a".repeat(64)), + "an event tag may not redirect context away from the typed session root" + ); + + let forum_root_without_reply_tags = context_batch(typed_scope, None); + assert_eq!( + context_thread_root(&forum_root_without_reply_tags, false).map(|root| root.to_hex()), + Some("a".repeat(64)), + "a forum root hydrates by its typed own-event scope" + ); + + let dm_reply = context_batch(ConversationScope::channel(channel_id), Some('b')); + assert_eq!( + context_thread_root(&dm_reply, true).map(|root| root.to_hex()), + Some("b".repeat(64)), + "DM replies preserve thread-context hydration" + ); + assert_eq!( + context_thread_root(&dm_reply, false), + None, + "an ordinary channel-scoped message must not import a tagged root" + ); + } + + /// Two threads in one channel — and the channel-level conversation — hold + /// distinct session entries; replies in one thread reuse that thread's + /// session key. + #[test] + fn threads_and_channel_hold_distinct_sessions() { + let ch = Uuid::new_v4(); + let thread_a = thread_scope(ch, 'a'); + let thread_b = thread_scope(ch, 'b'); + let channel = ConversationScope::channel(ch); + + let mut s = SessionState::default(); + s.sessions.insert(thread_a, "sess-thread-a".into()); + s.sessions.insert(thread_b, "sess-thread-b".into()); + s.sessions.insert(channel, "sess-channel".into()); + + // A reply in thread A resolves to thread A's session (reuse), not the + // channel session and not thread B's. + assert_eq!(s.sessions.get(&thread_a).unwrap(), "sess-thread-a"); + assert_eq!(s.sessions.get(&thread_b).unwrap(), "sess-thread-b"); + assert_eq!(s.sessions.get(&channel).unwrap(), "sess-channel"); + assert_eq!(s.sessions.len(), 3); + } + + /// Rotating one thread's scope leaves the sibling thread and the + /// channel-level conversation untouched. + #[test] + fn invalidate_scope_targets_only_that_thread() { + let ch = Uuid::new_v4(); + let thread_a = thread_scope(ch, 'a'); + let thread_b = thread_scope(ch, 'b'); + let channel = ConversationScope::channel(ch); + + let mut s = SessionState::default(); + for (scope, sid) in [(thread_a, "a"), (thread_b, "b"), (channel, "c")] { + s.sessions.insert(scope, sid.into()); + s.turn_counts.insert(scope, 2); + s.core_sections.insert(scope, "core".into()); + s.canvas_sections.insert(scope, "canvas".into()); + } + + assert!(s.invalidate_scope(&thread_a)); + + assert!(!s.sessions.contains_key(&thread_a)); + assert!(!s.turn_counts.contains_key(&thread_a)); + assert!(!s.core_sections.contains_key(&thread_a)); + assert!(!s.canvas_sections.contains_key(&thread_a)); + assert_eq!(s.sessions.get(&thread_b).unwrap(), "b"); + assert_eq!(s.sessions.get(&channel).unwrap(), "c"); + assert_eq!(*s.turn_counts.get(&thread_b).unwrap(), 2); + } + + /// Channel removal invalidates every conversation scope under that + /// channel — channel-level and all threads — and nothing else. + #[test] + fn invalidate_channel_sweeps_channel_and_thread_scopes() { + let ch = Uuid::new_v4(); + let other = Uuid::new_v4(); + let thread_a = thread_scope(ch, 'a'); + let thread_b = thread_scope(ch, 'b'); + let channel = ConversationScope::channel(ch); + let other_scope = ConversationScope::channel(other); + let other_thread = thread_scope(other, 'e'); + + let mut s = SessionState::default(); + for scope in [thread_a, thread_b, channel, other_scope, other_thread] { + s.sessions.insert(scope, "sess".into()); + s.turn_counts.insert(scope, 1); + s.core_sections.insert(scope, "core".into()); + s.canvas_sections.insert(scope, "canvas".into()); + } + + assert!(s.invalidate_channel(&ch)); + + for scope in [thread_a, thread_b, channel] { + assert!(!s.has_scope_state(&scope), "{scope} must be swept"); + } + for scope in [other_scope, other_thread] { + assert!(s.has_scope_state(&scope), "{scope} must survive"); + } + // Idempotent: nothing left to remove. + assert!(!s.invalidate_channel(&ch)); + } + + /// Proactive rotation counters are per-conversation: turns in one thread + /// must not advance the rotation clock of siblings. + #[test] + fn turn_counts_are_per_conversation() { + let ch = Uuid::new_v4(); + let thread_a = thread_scope(ch, 'a'); + let channel = ConversationScope::channel(ch); + + let mut s = SessionState::default(); + *s.turn_counts.entry(thread_a).or_insert(0) += 1; + *s.turn_counts.entry(thread_a).or_insert(0) += 1; + *s.turn_counts.entry(channel).or_insert(0) += 1; + + assert_eq!(*s.turn_counts.get(&thread_a).unwrap(), 2); + assert_eq!(*s.turn_counts.get(&channel).unwrap(), 1); + } +} diff --git a/crates/buzz-acp/src/queue.rs b/crates/buzz-acp/src/queue.rs index 029bf86dbf..4316819515 100644 --- a/crates/buzz-acp/src/queue.rs +++ b/crates/buzz-acp/src/queue.rs @@ -1,16 +1,19 @@ //! Event queue state machine for buzz-acp. //! -//! Manages per-channel event queues with per-channel in-flight tracking. -//! When the harness is ready to prompt the agent, it flushes the channel with -//! the oldest pending event, draining ALL events for that channel into a single -//! batch. Multiple channels can be in-flight simultaneously; each channel is -//! independent. +//! Manages per-conversation event queues with per-conversation in-flight +//! tracking, keyed by [`ConversationScope`] — channel-level for unthreaded +//! messages and DMs, thread-level for thread replies in regular channels. +//! When the harness is ready to prompt the agent, it flushes the conversation +//! with the oldest pending event, draining ALL events for that conversation +//! into a single batch — events from different scopes are never combined. +//! Multiple conversations (including multiple threads in one channel) can be +//! in-flight simultaneously; each conversation is independent. //! //! ## Dedup modes //! -//! - **Drop** (default) — while a prompt is in-flight for channel C, new events -//! for channel C are silently dropped (debug-logged). Events for other channels -//! still queue normally. +//! - **Drop** (default) — while a prompt is in-flight for conversation C, new +//! events for C are silently dropped (debug-logged). Events for other +//! conversations still queue normally. //! - **Queue** — all events accumulate; batched on the next flush cycle. use nostr::{Event, ToBech32}; @@ -19,8 +22,9 @@ use std::time::{Duration, Instant}; use uuid::Uuid; use crate::config::DedupMode; +use crate::scope::ConversationScope; -/// Maximum events queued per channel before oldest events are dropped. +/// Maximum events queued per conversation before oldest events are dropped. const MAX_PENDING_PER_CHANNEL: usize = 500; /// Maximum events drained into a single batch. @@ -44,7 +48,8 @@ const DEFAULT_IN_FLIGHT_DEADLINE_SECS: u64 = 7300; /// An event waiting in the queue. #[derive(Debug, Clone)] pub struct QueuedEvent { - pub channel_id: Uuid, + /// Conversation this event belongs to (queue lane + session key). + pub scope: ConversationScope, pub event: Event, pub received_at: Instant, /// Tag identifying which rule (or mode) matched this event. @@ -73,9 +78,13 @@ pub enum CancelReason { } /// A batch of events to prompt the agent with. +/// +/// All events in a batch share one [`ConversationScope`] — the flush path +/// drains a single scope's queue, so distinct threads (or a thread and the +/// surrounding channel conversation) are never merged into one prompt. #[derive(Debug, Clone)] pub struct FlushBatch { - pub channel_id: Uuid, + pub scope: ConversationScope, pub events: Vec, /// Events from a cancelled batch that triggered this re-prompt. /// Empty for normal (non-cancel) batches. When non-empty, `format_prompt()` @@ -89,70 +98,85 @@ pub struct FlushBatch { pub cancel_reason: Option, } -/// Per-channel event queue with per-channel in-flight enforcement. +impl FlushBatch { + /// The channel this batch's conversation lives in. + pub fn channel_id(&self) -> Uuid { + self.scope.channel_id + } +} + +/// Per-conversation event queue with per-conversation in-flight enforcement. +/// +/// Every map below is keyed by [`ConversationScope`], so two threads in one +/// channel occupy independent queue lanes, in-flight slots, retry throttles, +/// and cancel/steer side tables — they cannot batch, steer, cancel, or queue +/// into each other. Channel-wide teardown (membership removal) goes through +/// [`drain_channel`](Self::drain_channel), which sweeps every scope under the +/// channel. /// /// # State Machine /// /// ```text /// State: -/// queues: Map> (capped at MAX_PENDING_PER_CHANNEL) -/// in_flight_channels: HashSet -/// in_flight_deadlines: Map (auto-expire after in_flight_deadline) -/// retry_after: Map -/// retry_counts: Map (dead-letter after MAX_RETRIES) +/// queues: Map> (capped at MAX_PENDING_PER_CHANNEL) +/// in_flight_scopes: HashSet +/// in_flight_deadlines: Map (auto-expire after in_flight_deadline) +/// retry_after: Map +/// retry_counts: Map (dead-letter after MAX_RETRIES) /// dedup_mode: DedupMode /// /// Transitions: /// push(event): -/// if dedup_mode == Drop AND in_flight_channels.contains(event.channel_id): +/// if dedup_mode == Drop AND in_flight_scopes.contains(event.scope): /// debug log + discard -/// else if queues[channel].len() >= MAX_PENDING_PER_CHANNEL: +/// else if queues[scope].len() >= MAX_PENDING_PER_CHANNEL: /// drop oldest (pop_front), warn, push_back new event /// else: -/// queues[event.channel_id].push_back(event) +/// queues[event.scope].push_back(event) /// /// flush_next() → Option: /// expire any stuck in-flight entries past their deadline -/// candidates = channels where queue non-empty -/// AND NOT in in_flight_channels -/// AND (no retry_after OR retry_after[c] <= now) +/// candidates = scopes where queue non-empty +/// AND NOT in in_flight_scopes +/// AND (no retry_after OR retry_after[s] <= now) /// if candidates empty: return None -/// channel = pick candidate with oldest head event (min received_at) -/// events = drain up to MAX_BATCH_EVENTS from queues[channel] -/// in_flight_channels.insert(channel) -/// in_flight_deadlines.insert(channel, now + in_flight_deadline) -/// return Some(FlushBatch { channel, events }) +/// scope = pick candidate with oldest head event (min received_at) +/// events = drain up to MAX_BATCH_EVENTS from queues[scope] +/// in_flight_scopes.insert(scope) +/// in_flight_deadlines.insert(scope, now + in_flight_deadline) +/// return Some(FlushBatch { scope, events }) /// -/// mark_complete(channel_id): -/// in_flight_channels.remove(channel_id) -/// in_flight_deadlines.remove(channel_id) -/// retry_counts.remove(channel_id) +/// mark_complete(scope): +/// in_flight_scopes.remove(scope) +/// in_flight_deadlines.remove(scope) +/// retry_counts.remove(scope) /// clean up expired retry_after entry if present /// /// requeue(batch): -/// increment retry_counts[channel] -/// if retry_counts[channel] > MAX_RETRIES: dead-letter (log ERROR, return batch to caller) +/// increment retry_counts[scope] +/// if retry_counts[scope] > MAX_RETRIES: dead-letter (log ERROR, return batch to caller) /// else: push_front with original received_at, set exponential backoff retry_after with jitter /// ``` pub struct EventQueue { - queues: HashMap>, - in_flight_channels: HashSet, - /// Per-channel deadline for auto-expiring stuck in-flight entries. - in_flight_deadlines: HashMap, + queues: HashMap>, + in_flight_scopes: HashSet, + /// Per-conversation deadline for auto-expiring stuck in-flight entries. + in_flight_deadlines: HashMap, /// Number of events in each in-flight batch (for expiry logging). - in_flight_batch_sizes: HashMap, - retry_after: HashMap, - /// Per-channel retry attempt counter for exponential backoff / dead-lettering. - retry_counts: HashMap, + in_flight_batch_sizes: HashMap, + retry_after: HashMap, + /// Per-conversation retry attempt counter for exponential backoff / dead-lettering. + retry_counts: HashMap, dedup_mode: DedupMode, - /// Events from cancelled batches, keyed by channel. Merged into the next - /// `FlushBatch` for that channel as `cancelled_events` so `format_prompt()` - /// can produce annotated "[Previous request — interrupted]" sections. - cancelled_batches: HashMap>, - /// Why each channel's cancelled batch was cancelled (steer vs interrupt). - /// Set by `requeue_as_cancelled`, consumed by `flush_next` to set - /// `FlushBatch::cancel_reason`. Keyed by channel, cleared on flush. - cancel_reasons: HashMap, + /// Events from cancelled batches, keyed by conversation. Merged into the + /// next `FlushBatch` for that conversation as `cancelled_events` so + /// `format_prompt()` can produce annotated + /// "[Previous request — interrupted]" sections. + cancelled_batches: HashMap>, + /// Why each conversation's cancelled batch was cancelled (steer vs + /// interrupt). Set by `requeue_as_cancelled`, consumed by `flush_next` to + /// set `FlushBatch::cancel_reason`. Keyed by conversation, cleared on flush. + cancel_reasons: HashMap, /// Events withheld from `queues` while a goose-native steer is in flight /// for that event. Invisible to `flush_next` / `has_flushable_work` / /// `drain` (the events have been moved out of `queues`), so the queue's @@ -163,10 +187,11 @@ pub struct EventQueue { /// at line 453). Bulk recovery on in-flight deadline expiry is performed /// by `flush_next` / `has_flushable_work` (recover, not log-and-drop — /// the events were never delivered to the agent). - withheld_native_steer: HashMap>, - /// Duration after which an in-flight channel is auto-expired as orphaned. - /// Must be strictly greater than `max_turn_duration` so a turn running to - /// the hard cap returns via `mark_complete` before the backstop fires. + withheld_native_steer: HashMap>, + /// Duration after which an in-flight conversation is auto-expired as + /// orphaned. Must be strictly greater than `max_turn_duration` so a turn + /// running to the hard cap returns via `mark_complete` before the + /// backstop fires. in_flight_deadline: Duration, } @@ -179,7 +204,7 @@ impl EventQueue { pub fn new(dedup_mode: DedupMode) -> Self { Self { queues: HashMap::new(), - in_flight_channels: HashSet::new(), + in_flight_scopes: HashSet::new(), in_flight_deadlines: HashMap::new(), in_flight_batch_sizes: HashMap::new(), retry_after: HashMap::new(), @@ -200,20 +225,20 @@ impl EventQueue { self } - /// Monotonically extend an existing in-flight deadline for `channel_id`. + /// Monotonically extend an existing in-flight deadline for `scope`. /// /// Called when a successful steer grants a fresh turn budget. The new /// deadline is `max(current, now + max_turn_secs + buffer)` — it never - /// moves backward. If the channel is not in-flight (already completed + /// moves backward. If the conversation is not in-flight (already completed /// via `mark_complete`), this is a no-op: a late ack never resurrects /// a deadline. - pub fn extend_in_flight_deadline(&mut self, channel_id: Uuid, max_turn_secs: u64) { - if let Some(current) = self.in_flight_deadlines.get_mut(&channel_id) { + pub fn extend_in_flight_deadline(&mut self, scope: ConversationScope, max_turn_secs: u64) { + if let Some(current) = self.in_flight_deadlines.get_mut(&scope) { let extended = Instant::now() + Duration::from_secs(max_turn_secs + IN_FLIGHT_DEADLINE_BUFFER_SECS); if extended > *current { tracing::info!( - %channel_id, + %scope, "extending in-flight deadline by {max_turn_secs}s + {IN_FLIGHT_DEADLINE_BUFFER_SECS}s buffer" ); *current = extended; @@ -221,28 +246,28 @@ impl EventQueue { } } - /// Push an event into the queue for its channel. + /// Push an event into the queue for its conversation. /// - /// In [`DedupMode::Drop`], events for any currently in-flight channel are - /// silently discarded (debug-logged). + /// In [`DedupMode::Drop`], events for any currently in-flight conversation + /// are silently discarded (debug-logged). /// /// Returns `true` if the event was accepted, `false` if dropped. pub fn push(&mut self, event: QueuedEvent) -> bool { if matches!(self.dedup_mode, DedupMode::Drop) - && self.in_flight_channels.contains(&event.channel_id) + && self.in_flight_scopes.contains(&event.scope) { tracing::debug!( - channel_id = %event.channel_id, - "dropping event for in-flight channel (drop mode)" + scope = %event.scope, + "dropping event for in-flight conversation (drop mode)" ); return false; } - let queue = self.queues.entry(event.channel_id).or_default(); - // Enforce per-channel depth cap: drop oldest to make room. + let queue = self.queues.entry(event.scope).or_default(); + // Enforce per-conversation depth cap: drop oldest to make room. if queue.len() >= MAX_PENDING_PER_CHANNEL { queue.pop_front(); tracing::warn!( - channel_id = %event.channel_id, + scope = %event.scope, limit = MAX_PENDING_PER_CHANNEL, "queue depth cap reached — dropped oldest event" ); @@ -254,14 +279,15 @@ impl EventQueue { /// Try to flush the next batch. /// /// Returns `None` if all non-in-flight, non-throttled queues are empty. - /// Otherwise picks the channel with the oldest pending event (FIFO fairness - /// across channels), drains ALL events for that channel into a single batch, - /// inserts into `in_flight_channels`, and returns the batch. + /// Otherwise picks the conversation with the oldest pending event (FIFO + /// fairness across conversations), drains ALL events for that conversation + /// into a single batch, inserts into `in_flight_scopes`, and returns the + /// batch. pub fn flush_next(&mut self) -> Option { let now = Instant::now(); // Auto-expire any stuck in-flight entries that missed mark_complete. - let expired: Vec = self + let expired: Vec = self .in_flight_deadlines .iter() .filter(|(_, deadline)| now >= **deadline) @@ -270,45 +296,45 @@ impl EventQueue { for id in expired { let lost_events = self.in_flight_batch_sizes.remove(&id).unwrap_or(0); tracing::error!( - channel_id = %id, + scope = %id, lost_events, deadline_secs = self.in_flight_deadline.as_secs(), - "BUG: in-flight channel expired without mark_complete — \ + "BUG: in-flight conversation expired without mark_complete — \ auto-releasing; {lost_events} dispatched event(s) orphaned" ); - self.in_flight_channels.remove(&id); + self.in_flight_scopes.remove(&id); self.in_flight_deadlines.remove(&id); // Recover any withheld goose-native steer events for the expired - // channel back to the queue front so normal dispatch delivers + // conversation back to the queue front so normal dispatch delivers // them. Unlike the in-flight batch above (already delivered to a // now-hung prompt — nothing to recover), these events were never // delivered to the agent. - self.recover_withheld_for_expired_channel(id); + self.recover_withheld_for_expired_scope(id); } - // Find the channel whose head event has the oldest received_at, - // excluding in-flight channels and throttled channels. - let channel_id = self + // Find the conversation whose head event has the oldest received_at, + // excluding in-flight conversations and throttled conversations. + let scope = self .queues .iter() .filter(|(id, q)| { !q.is_empty() - && !self.in_flight_channels.contains(id) + && !self.in_flight_scopes.contains(id) && self.retry_after.get(id).is_none_or(|&t| t <= now) }) .min_by_key(|(_, q)| q.front().unwrap().received_at) .map(|(id, _)| *id); - // Fallback: if no queued events are ready but a channel has cancelled - // events waiting (e.g., explicit !cancel with no new @mention), flush - // those as a regular batch (re-dispatch unchanged). - let channel_id = match channel_id { + // Fallback: if no queued events are ready but a conversation has + // cancelled events waiting (e.g., explicit !cancel with no new + // @mention), flush those as a regular batch (re-dispatch unchanged). + let scope = match scope { Some(id) => id, None => { let cancelled_id = self .cancelled_batches .keys() - .find(|id| !self.in_flight_channels.contains(id)) + .find(|id| !self.in_flight_scopes.contains(id)) .copied(); match cancelled_id { Some(id) => { @@ -316,12 +342,12 @@ impl EventQueue { // No new events to merge — re-dispatch the original batch. let cancelled = self.cancelled_batches.remove(&id).unwrap_or_default(); let cancel_reason = self.cancel_reasons.remove(&id); - self.in_flight_channels.insert(id); + self.in_flight_scopes.insert(id); self.in_flight_deadlines .insert(id, now + self.in_flight_deadline); self.in_flight_batch_sizes.insert(id, cancelled.len()); return Some(FlushBatch { - channel_id: id, + scope: id, events: cancelled, cancelled_events: vec![], cancel_reason, @@ -333,7 +359,7 @@ impl EventQueue { }; // Drain up to MAX_BATCH_EVENTS; leave any remainder in the queue. - let queue = self.queues.entry(channel_id).or_default(); + let queue = self.queues.entry(scope).or_default(); let drain_count = MAX_BATCH_EVENTS.min(queue.len()); let mut events: Vec = queue .drain(..drain_count) @@ -350,103 +376,102 @@ impl EventQueue { events.sort_by_key(|be| be.event.created_at); // Remove the queue entry if now empty. - if self.queues.get(&channel_id).is_some_and(|q| q.is_empty()) { - self.queues.remove(&channel_id); + if self.queues.get(&scope).is_some_and(|q| q.is_empty()) { + self.queues.remove(&scope); } - self.in_flight_channels.insert(channel_id); + self.in_flight_scopes.insert(scope); self.in_flight_deadlines - .insert(channel_id, now + self.in_flight_deadline); - self.in_flight_batch_sizes.insert(channel_id, events.len()); + .insert(scope, now + self.in_flight_deadline); + self.in_flight_batch_sizes.insert(scope, events.len()); // Merge any cancelled events stored by requeue_as_cancelled(). - let cancelled_events = self - .cancelled_batches - .remove(&channel_id) - .unwrap_or_default(); + let cancelled_events = self.cancelled_batches.remove(&scope).unwrap_or_default(); let cancel_reason = if cancelled_events.is_empty() { - self.cancel_reasons.remove(&channel_id); + self.cancel_reasons.remove(&scope); None } else { - self.cancel_reasons.remove(&channel_id) + self.cancel_reasons.remove(&scope) }; Some(FlushBatch { - channel_id, + scope, events, cancelled_events, cancel_reason, }) } - /// Mark the prompt for `channel_id` as complete. + /// Mark the prompt for `scope` as complete. /// - /// Removes the channel from `in_flight_channels` and `in_flight_deadlines`. + /// Removes the conversation from `in_flight_scopes` and + /// `in_flight_deadlines`. /// - /// If the channel was NOT requeued (no active `retry_after` throttle), the - /// retry counter is reset — the channel is healthy and the next failure - /// starts fresh. If the channel WAS requeued, `retry_counts` is left intact - /// so the backoff sequence continues on the next attempt. + /// If the conversation was NOT requeued (no active `retry_after` + /// throttle), the retry counter is reset — the conversation is healthy and + /// the next failure starts fresh. If the conversation WAS requeued, + /// `retry_counts` is left intact so the backoff sequence continues on the + /// next attempt. /// /// Also cleans up any already-expired `retry_after` entry. - pub fn mark_complete(&mut self, channel_id: Uuid) { - self.in_flight_channels.remove(&channel_id); - self.in_flight_deadlines.remove(&channel_id); - self.in_flight_batch_sizes.remove(&channel_id); + pub fn mark_complete(&mut self, scope: ConversationScope) { + self.in_flight_scopes.remove(&scope); + self.in_flight_deadlines.remove(&scope); + self.in_flight_batch_sizes.remove(&scope); let now = Instant::now(); - match self.retry_after.get(&channel_id) { - // Active throttle → channel was requeued; keep retry_counts intact. + match self.retry_after.get(&scope) { + // Active throttle → conversation was requeued; keep retry_counts. Some(&deadline) if deadline > now => {} // Expired or absent throttle → successful completion; reset counter // and clean up the stale retry_after entry. Some(_) => { - self.retry_after.remove(&channel_id); - self.retry_counts.remove(&channel_id); + self.retry_after.remove(&scope); + self.retry_counts.remove(&scope); } None => { - self.retry_counts.remove(&channel_id); + self.retry_counts.remove(&scope); } } } /// Re-queue a batch of events that failed to process. /// - /// Events are pushed back to the **front** of the channel's queue so they - /// are processed first on the next flush cycle. This prevents event loss - /// when session creation or `session/prompt` fails transiently. + /// Events are pushed back to the **front** of the conversation's queue so + /// they are processed first on the next flush cycle. This prevents event + /// loss when session creation or `session/prompt` fails transiently. /// - /// Original `received_at` timestamps are preserved so the channel retains - /// its fairness position. The retry delay comes from exponential backoff, - /// not from resetting received_at. + /// Original `received_at` timestamps are preserved so the conversation + /// retains its fairness position. The retry delay comes from exponential + /// backoff, not from resetting received_at. /// /// After [`MAX_RETRIES`] attempts the batch is dead-lettered: logged at /// ERROR and returned to the caller (rather than requeued) so a visible /// failure notice can be posted to the channel. Returns `None` when the /// batch was requeued for another attempt. /// - /// Note: does NOT remove from `in_flight_channels` — caller must call + /// Note: does NOT remove from `in_flight_scopes` — caller must call /// `mark_complete` separately. pub fn requeue(&mut self, batch: FlushBatch) -> Option { - let channel_id = batch.channel_id; + let scope = batch.scope; let attempt = { - let count = self.retry_counts.entry(channel_id).or_insert(0); + let count = self.retry_counts.entry(scope).or_insert(0); *count += 1; *count }; if attempt > MAX_RETRIES { tracing::error!( - channel_id = %channel_id, + scope = %scope, attempt, events = batch.events.len(), "dead-lettering batch after {} retries — discarding {} events", MAX_RETRIES, batch.events.len(), ); - self.retry_counts.remove(&channel_id); - // Also clear retry_after so fresh traffic on this channel isn't - // throttled by stale backoff from the discarded poison batch. - self.retry_after.remove(&channel_id); + self.retry_counts.remove(&scope); + // Also clear retry_after so fresh traffic on this conversation + // isn't throttled by stale backoff from the discarded poison batch. + self.retry_after.remove(&scope); return Some(batch); } @@ -464,7 +489,7 @@ impl EventQueue { let delay = Duration::from_secs_f64(capped_secs as f64 * jitter); tracing::warn!( - channel_id = %channel_id, + scope = %scope, attempt, max = MAX_RETRIES, delay_secs = delay.as_secs_f64(), @@ -472,56 +497,56 @@ impl EventQueue { "requeueing failed batch with backoff" ); - let queue = self.queues.entry(channel_id).or_default(); + let queue = self.queues.entry(scope).or_default(); // Push to front in reverse order so original order is preserved. for be in batch.events.into_iter().rev() { queue.push_front(QueuedEvent { - channel_id, + scope, event: be.event, prompt_tag: be.prompt_tag, received_at: be.received_at, // preserve original timestamp (#46) }); } - // Enforce per-channel cap: trim oldest (back) events if requeue pushed - // the queue over the limit. Without this, repeated requeue+push cycles - // can grow the queue unboundedly. + // Enforce per-conversation cap: trim oldest (back) events if requeue + // pushed the queue over the limit. Without this, repeated requeue+push + // cycles can grow the queue unboundedly. while queue.len() > MAX_PENDING_PER_CHANNEL { queue.pop_back(); tracing::warn!( - channel_id = %channel_id, + scope = %scope, limit = MAX_PENDING_PER_CHANNEL, "requeue overflow — dropped oldest event to enforce cap" ); } - self.retry_after.insert(channel_id, Instant::now() + delay); + self.retry_after.insert(scope, Instant::now() + delay); None } /// Re-queue a batch preserving original `received_at` timestamps. /// /// Used when a batch was flushed but no agent was available — we want to - /// retry without penalizing the channel's position in the fairness queue - /// and without imposing a retry throttle. + /// retry without penalizing the conversation's position in the fairness + /// queue and without imposing a retry throttle. /// - /// Does NOT set `retry_after`. Does NOT remove from `in_flight_channels` — + /// Does NOT set `retry_after`. Does NOT remove from `in_flight_scopes` — /// caller must call `mark_complete` separately. pub fn requeue_preserve_timestamps(&mut self, batch: FlushBatch) { - let channel_id = batch.channel_id; - let queue = self.queues.entry(channel_id).or_default(); + let scope = batch.scope; + let queue = self.queues.entry(scope).or_default(); // Push to front in reverse order so original order is preserved. for be in batch.events.into_iter().rev() { queue.push_front(QueuedEvent { - channel_id, + scope, event: be.event, prompt_tag: be.prompt_tag, received_at: be.received_at, }); } - // Enforce per-channel cap: trim newest (back) events if over limit. + // Enforce per-conversation cap: trim newest (back) events if over limit. while queue.len() > MAX_PENDING_PER_CHANNEL { queue.pop_back(); tracing::warn!( - channel_id = %channel_id, + scope = %scope, limit = MAX_PENDING_PER_CHANNEL, "requeue_preserve overflow — dropped newest event to enforce cap" ); @@ -529,7 +554,7 @@ impl EventQueue { } /// Requeue a cancelled batch so its events appear as `cancelled_events` - /// in the next `FlushBatch` for this channel (enabling the annotated + /// in the next `FlushBatch` for this conversation (enabling the annotated /// merged-prompt format in `format_prompt()`). /// /// `reason` records why the turn was cancelled (steer vs interrupt) so the @@ -540,15 +565,15 @@ impl EventQueue { /// the generic queue — they are stored separately and merged by /// `flush_next()`. No retry throttle, no backoff. pub fn requeue_as_cancelled(&mut self, batch: FlushBatch, reason: CancelReason) { - let entry = self.cancelled_batches.entry(batch.channel_id).or_default(); + let entry = self.cancelled_batches.entry(batch.scope).or_default(); // Preserve any already-cancelled events from a prior cancel (double-cancel). entry.extend(batch.cancelled_events); entry.extend(batch.events); - self.cancel_reasons.insert(batch.channel_id, reason); + self.cancel_reasons.insert(batch.scope, reason); } - /// Returns `true` if any channel has pending events that are not in-flight - /// and not throttled by `retry_after`. + /// Returns `true` if any conversation has pending events that are not + /// in-flight and not throttled by `retry_after`. /// /// Also auto-expires any stuck in-flight entries whose deadline has passed. /// This is a `&mut self` method so expiry can happen without requiring a @@ -557,7 +582,7 @@ impl EventQueue { let now = Instant::now(); // Auto-expire stuck in-flight entries (same logic as flush_next). - let expired: Vec = self + let expired: Vec = self .in_flight_deadlines .iter() .filter(|(_, deadline)| now >= **deadline) @@ -566,84 +591,92 @@ impl EventQueue { for id in expired { let lost_events = self.in_flight_batch_sizes.remove(&id).unwrap_or(0); tracing::error!( - channel_id = %id, + scope = %id, lost_events, deadline_secs = self.in_flight_deadline.as_secs(), - "BUG: in-flight channel expired without mark_complete — \ + "BUG: in-flight conversation expired without mark_complete — \ auto-releasing; {lost_events} dispatched event(s) orphaned" ); - self.in_flight_channels.remove(&id); + self.in_flight_scopes.remove(&id); self.in_flight_deadlines.remove(&id); // Symmetric with the flush_next expiry block: recover withheld - // goose-native steer events for the expired channel so they are - // not permanently orphaned in the side table. - self.recover_withheld_for_expired_channel(id); + // goose-native steer events for the expired conversation so they + // are not permanently orphaned in the side table. + self.recover_withheld_for_expired_scope(id); } self.queues.iter().any(|(id, q)| { !q.is_empty() - && !self.in_flight_channels.contains(id) + && !self.in_flight_scopes.contains(id) && self.retry_after.get(id).is_none_or(|&t| t <= now) }) || self .cancelled_batches .keys() - .any(|id| !self.in_flight_channels.contains(id)) + .any(|id| !self.in_flight_scopes.contains(id)) } - /// Number of channels with pending events. - pub fn pending_channels(&self) -> usize { + /// Number of conversations with pending events. + pub fn pending_conversations(&self) -> usize { self.queues.len() } - /// Number of queued events for a specific channel. Test-only. + /// Number of queued events for a specific conversation. Test-only. #[cfg(test)] - pub fn queued_event_count(&self, channel_id: &Uuid) -> usize { - self.queues.get(channel_id).map_or(0, |q| q.len()) + pub fn queued_event_count(&self, scope: &ConversationScope) -> usize { + self.queues.get(scope).map_or(0, |q| q.len()) } - /// Force a channel's retry-attempt counter to `count`, simulating `count` - /// prior failed attempts without needing to drive fake flush/requeue - /// cycles through the queue (which would leave artifact events behind). - /// Test-only — lets integration tests outside this module exercise - /// `requeue()`'s dead-letter threshold directly. + /// Force a conversation's retry-attempt counter to `count`, simulating + /// `count` prior failed attempts without needing to drive fake + /// flush/requeue cycles through the queue (which would leave artifact + /// events behind). Test-only — lets integration tests outside this module + /// exercise `requeue()`'s dead-letter threshold directly. #[cfg(test)] - pub fn set_retry_count_for_test(&mut self, channel_id: Uuid, count: u32) { - self.retry_counts.insert(channel_id, count); + pub fn set_retry_count_for_test(&mut self, scope: ConversationScope, count: u32) { + self.retry_counts.insert(scope, count); } - /// Drop all queued (non-in-flight) events for a channel. + /// Drop all queued (non-in-flight) events for every conversation under a + /// channel — the channel-level conversation and all of its threads. /// /// Used when the agent is removed from a channel — any pending events /// for that channel are stale and should not be prompted. Does NOT /// affect in-flight prompts (those will complete normally; the agent /// may fail to act if it lost access, but that's handled by the relay). /// - /// Also clears any `retry_after` throttle for the channel. + /// Also clears any `retry_after` throttle for those conversations. /// /// Returns the event IDs of dropped events so the caller can clean up /// any reactions (👀) that were added at queue-push time. pub fn drain_channel(&mut self, channel_id: Uuid) -> Vec { - let ids = self - .queues - .remove(&channel_id) - .map(|q| q.into_iter().map(|e| e.event.id.to_hex()).collect()) - .unwrap_or_default(); - self.retry_after.remove(&channel_id); - self.retry_counts.remove(&channel_id); - self.cancelled_batches.remove(&channel_id); - self.cancel_reasons.remove(&channel_id); - self.withheld_native_steer.remove(&channel_id); - // Preserve in_flight_channels AND in_flight_deadlines: the in-flight + let mut ids = Vec::new(); + self.queues.retain(|scope, q| { + if scope.channel_id == channel_id { + ids.extend(q.iter().map(|e| e.event.id.to_hex())); + false + } else { + true + } + }); + self.retry_after.retain(|s, _| s.channel_id != channel_id); + self.retry_counts.retain(|s, _| s.channel_id != channel_id); + self.cancelled_batches + .retain(|s, _| s.channel_id != channel_id); + self.cancel_reasons + .retain(|s, _| s.channel_id != channel_id); + self.withheld_native_steer + .retain(|s, _| s.channel_id != channel_id); + // Preserve in_flight_scopes AND in_flight_deadlines: the in-flight // task will eventually complete (calling mark_complete) or the deadline - // will expire (auto-cleaning the channel). Removing deadlines without - // removing in_flight_channels would disable auto-expiry and leave a - // wedged task permanently blocking the channel. + // will expire (auto-cleaning the conversation). Removing deadlines + // without removing in_flight_scopes would disable auto-expiry and + // leave a wedged task permanently blocking the conversation. ids } - /// Whether a prompt is currently in-flight for the given channel. - pub fn is_channel_in_flight(&self, channel_id: Uuid) -> bool { - self.in_flight_channels.contains(&channel_id) + /// Whether a prompt is currently in-flight for the given conversation. + pub fn is_scope_in_flight(&self, scope: ConversationScope) -> bool { + self.in_flight_scopes.contains(&scope) } // ── Goose-native steer withhold (side table) ────────────────────────── @@ -652,46 +685,47 @@ impl EventQueue { // for a specific queued event, that event is moved out of `queues` into // `withheld_native_steer` so `flush_next` / `has_flushable_work` / the // contiguous drain at line 285 cannot see it — closing the race window - // between `mark_complete` (which clears `in_flight_channels`) and the + // between `mark_complete` (which clears `in_flight_scopes`) and the // ack arriving on the main loop. On `Success` the event is consumed // (`remove_event`); on `Err` / `PromptCompletedNeutral` it is released // back to the queue front (`release_native_steer`), preserving its // original `received_at` for FIFO fairness. - /// Move a queued event out of `queues[channel_id]` into the side table + /// Move a queued event out of `queues[scope]` into the side table /// to withhold it from `flush_next` while a goose-native steer is in /// flight. /// /// Returns `true` if the event was found and withheld, `false` if the - /// event id was not present in `queues[channel_id]` (race-safe no-op: + /// event id was not present in `queues[scope]` (race-safe no-op: /// the event may have already been drained, removed, or never queued). /// /// Must be called synchronously from the mode-gate fork immediately /// after `pool.send_steer` returns `Ok(())` and before any watcher task /// is spawned, so the withhold is established before `mark_complete` / /// any subsequent `flush_next` tick can run. - pub fn mark_native_steer_pending(&mut self, channel_id: Uuid, event_id: &str) -> bool { - let Some(q) = self.queues.get_mut(&channel_id) else { + pub fn mark_native_steer_pending(&mut self, scope: ConversationScope, event_id: &str) -> bool { + let Some(q) = self.queues.get_mut(&scope) else { return false; }; let Some(pos) = q.iter().position(|qe| qe.event.id.to_hex() == event_id) else { return false; }; - let qe = q - .remove(pos) - .expect("position came from iter so remove must succeed"); + let Some(qe) = q.remove(pos) else { + // Unreachable: position came from iter, so remove must succeed. + return false; + }; if q.is_empty() { - self.queues.remove(&channel_id); + self.queues.remove(&scope); } self.withheld_native_steer - .entry(channel_id) + .entry(scope) .or_default() .push(qe); true } /// Release a single withheld event back to the front of - /// `queues[channel_id]`, preserving its original `received_at`. + /// `queues[scope]`, preserving its original `received_at`. /// /// Called on `SteerAck::Err(_)` and `SteerAck::PromptCompletedNeutral` /// (delivery unknown after prompt completion; restoring queued event @@ -699,9 +733,9 @@ impl EventQueue { /// removed or never withheld. /// /// Push-to-front matches the discipline of `requeue_preserve_timestamps` - /// at line 453, preserving fairness across channels. - pub fn release_native_steer(&mut self, channel_id: Uuid, event_id: &str) { - let Some(entries) = self.withheld_native_steer.get_mut(&channel_id) else { + /// at line 453, preserving fairness across conversations. + pub fn release_native_steer(&mut self, scope: ConversationScope, event_id: &str) { + let Some(entries) = self.withheld_native_steer.get_mut(&scope) else { return; }; let Some(pos) = entries @@ -712,17 +746,17 @@ impl EventQueue { }; let qe = entries.remove(pos); if entries.is_empty() { - self.withheld_native_steer.remove(&channel_id); + self.withheld_native_steer.remove(&scope); } // Push to FRONT so original `received_at` keeps the event at the head - // of the channel's queue. Per-channel cap is enforced below in case - // a flood of events arrived during the ack window. - let queue = self.queues.entry(channel_id).or_default(); + // of the conversation's queue. Per-conversation cap is enforced below + // in case a flood of events arrived during the ack window. + let queue = self.queues.entry(scope).or_default(); queue.push_front(qe); while queue.len() > MAX_PENDING_PER_CHANNEL { queue.pop_back(); tracing::warn!( - channel_id = %channel_id, + scope = %scope, limit = MAX_PENDING_PER_CHANNEL, "release_native_steer overflow — dropped newest event to enforce cap" ); @@ -735,22 +769,22 @@ impl EventQueue { /// Called on `SteerAck::Success` — the agent received the steer, so the /// event has been "delivered" via the non-cancelling path and must not /// be redelivered via normal dispatch. Idempotent across both stores. - pub fn remove_event(&mut self, channel_id: Uuid, event_id: &str) { - if let Some(entries) = self.withheld_native_steer.get_mut(&channel_id) { + pub fn remove_event(&mut self, scope: ConversationScope, event_id: &str) { + if let Some(entries) = self.withheld_native_steer.get_mut(&scope) { entries.retain(|qe| qe.event.id.to_hex() != event_id); if entries.is_empty() { - self.withheld_native_steer.remove(&channel_id); + self.withheld_native_steer.remove(&scope); } } - if let Some(q) = self.queues.get_mut(&channel_id) { + if let Some(q) = self.queues.get_mut(&scope) { q.retain(|qe| qe.event.id.to_hex() != event_id); if q.is_empty() { - self.queues.remove(&channel_id); + self.queues.remove(&scope); } } } - /// Bulk-release every withheld event for `channel_id` back to the queue + /// Bulk-release every withheld event for `scope` back to the queue /// front, preserving relative FIFO order. /// /// Called from the `in_flight_deadline` expiry blocks in @@ -763,25 +797,25 @@ impl EventQueue { /// Iterates the stored entries in reverse so per-entry `push_front` /// composes to original-FIFO order at the queue front (same discipline /// as `requeue_preserve_timestamps` at line 453). - fn recover_withheld_for_expired_channel(&mut self, channel_id: Uuid) { - let Some(entries) = self.withheld_native_steer.remove(&channel_id) else { + fn recover_withheld_for_expired_scope(&mut self, scope: ConversationScope) { + let Some(entries) = self.withheld_native_steer.remove(&scope) else { return; }; let n = entries.len(); - let queue = self.queues.entry(channel_id).or_default(); + let queue = self.queues.entry(scope).or_default(); for qe in entries.into_iter().rev() { queue.push_front(qe); } while queue.len() > MAX_PENDING_PER_CHANNEL { queue.pop_back(); tracing::warn!( - channel_id = %channel_id, + scope = %scope, limit = MAX_PENDING_PER_CHANNEL, "withheld-steer recovery overflow — dropped newest event to enforce cap" ); } tracing::warn!( - channel_id = %channel_id, + scope = %scope, recovered = n, "in-flight expiry recovered withheld steer event(s) — \ steer ack never arrived; normal dispatch will deliver" @@ -791,13 +825,13 @@ impl EventQueue { /// Compact expired metadata entries to prevent unbounded map growth. /// /// Removes `retry_after` entries whose deadline has already passed, and - /// cleans up orphaned `retry_counts` entries for channels that have no - /// queued events, no active throttle, and no in-flight prompt. Without - /// this, channels that completed their retry cycle but never received + /// cleans up orphaned `retry_counts` entries for conversations that have + /// no queued events, no active throttle, and no in-flight prompt. Without + /// this, conversations that completed their retry cycle but never received /// fresh traffic would leak a `u32` entry in `retry_counts` indefinitely. /// - /// The in-flight guard is critical: a channel whose throttle expired and - /// whose queue is empty because it was flushed may still have a retry + /// The in-flight guard is critical: a conversation whose throttle expired + /// and whose queue is empty because it was flushed may still have a retry /// attempt in flight. Removing its `retry_counts` would reset the /// backoff sequence if that attempt fails and requeues. /// @@ -807,13 +841,13 @@ impl EventQueue { pub fn compact_expired_state(&mut self) { let now = Instant::now(); self.retry_after.retain(|_, deadline| *deadline > now); - // Remove retry_counts for channels with no active throttle, no + // Remove retry_counts for conversations with no active throttle, no // queued events, AND no in-flight prompt — they completed their // retry cycle and are truly idle. - self.retry_counts.retain(|ch, _| { - self.retry_after.contains_key(ch) - || self.queues.get(ch).is_some_and(|q| !q.is_empty()) - || self.in_flight_channels.contains(ch) + self.retry_counts.retain(|s, _| { + self.retry_after.contains_key(s) + || self.queues.get(s).is_some_and(|q| !q.is_empty()) + || self.in_flight_scopes.contains(s) }); } } @@ -847,22 +881,11 @@ pub struct ThreadTags { /// positional format (no markers, `["e", id, relay_url]`) is not supported — /// Buzz always generates marker-based tags (see relay messages.rs:762-783). pub fn parse_thread_tags(event: &Event) -> ThreadTags { - let mut root = None; - let mut reply = None; let mut mentions = Vec::new(); for tag in event.tags.iter() { let parts = tag.as_slice(); match parts.first().map(|s| s.as_str()) { - Some("e") if parts.len() >= 4 => { - let id = &parts[1]; - let marker = &parts[3]; - match marker.as_str() { - "root" => root = Some(id.clone()), - "reply" => reply = Some(id.clone()), - _ => {} - } - } Some("p") if parts.len() >= 2 => { mentions.push(parts[1].clone()); } @@ -870,14 +893,9 @@ pub fn parse_thread_tags(event: &Event) -> ThreadTags { } } - // For direct replies to root: single "reply" tag, no "root" tag. - // In that case, root == parent. - let (root_event_id, parent_event_id) = match (root, reply) { - (Some(r), Some(p)) => (Some(r), Some(p)), - (Some(r), None) => (Some(r.clone()), Some(r)), - (None, Some(p)) => (Some(p.clone()), Some(p)), - (None, None) => (None, None), - }; + let thread = buzz_core::thread::parse_nip10_thread(event); + let root_event_id = thread.map(|parsed| parsed.root_event_id.to_hex()); + let parent_event_id = thread.map(|parsed| parsed.parent_event_id.to_hex()); ThreadTags { root_event_id, @@ -1479,7 +1497,7 @@ pub fn format_prompt(batch: &FlushBatch, args: &FormatPromptArgs<'_>) -> Vec) -> Vec) -> Vec) -> Vec QueuedEvent { QueuedEvent { - channel_id, + scope: ConversationScope::channel(channel_id), event: make_event(content), received_at: Instant::now(), prompt_tag: "test".into(), @@ -1653,7 +1691,7 @@ mod tests { /// Build a QueuedEvent with a specific `received_at` offset from now. fn make_queued_at(channel_id: Uuid, content: &str, age: Duration) -> QueuedEvent { QueuedEvent { - channel_id, + scope: ConversationScope::channel(channel_id), event: make_event(content), received_at: Instant::now() - age, prompt_tag: "test".into(), @@ -1673,7 +1711,7 @@ mod tests { .sign_with_keys(&keys) .unwrap(); QueuedEvent { - channel_id, + scope: ConversationScope::channel(channel_id), event, received_at: Instant::now(), prompt_tag: "test".into(), @@ -1685,7 +1723,7 @@ mod tests { } fn any_in_flight(q: &EventQueue) -> bool { - !q.in_flight_channels.is_empty() + !q.in_flight_scopes.is_empty() } #[test] @@ -1706,7 +1744,7 @@ mod tests { q.push(make_queued(ch, "hello")); let batch = q.flush_next().expect("should return a batch"); - assert_eq!(batch.channel_id, ch); + assert_eq!(batch.channel_id(), ch); assert_eq!(batch.events.len(), 1); assert_eq!(batch.events[0].event.content, "hello"); @@ -1745,12 +1783,12 @@ mod tests { assert!(q.flush_next().is_none()); // Complete the in-flight prompt. - q.mark_complete(ch); + q.mark_complete(ConversationScope::channel(ch)); assert!(!any_in_flight(&q)); // Now flush should succeed. let batch = q.flush_next().expect("should flush after mark_complete"); - assert_eq!(batch.channel_id, ch); + assert_eq!(batch.channel_id(), ch); assert_eq!(batch.events.len(), 1); assert_eq!(batch.events[0].event.content, "second"); } @@ -1767,7 +1805,7 @@ mod tests { assert_eq!(pending_count(&q), 3); let batch = q.flush_next().expect("should return batch"); - assert_eq!(batch.channel_id, ch); + assert_eq!(batch.channel_id(), ch); assert_eq!(batch.events.len(), 3); assert_eq!(batch.events[0].event.content, "msg1"); assert_eq!(batch.events[1].event.content, "msg2"); @@ -1818,7 +1856,7 @@ mod tests { let batch = q.flush_next().expect("should return batch"); // A is older, so it should be picked first. - assert_eq!(batch.channel_id, ch_a); + assert_eq!(batch.channel_id(), ch_a); assert_eq!(batch.events[0].event.content, "from A"); } @@ -1834,18 +1872,18 @@ mod tests { // First flush picks A. let batch_a = q.flush_next().expect("first flush"); - assert_eq!(batch_a.channel_id, ch_a); + assert_eq!(batch_a.channel_id(), ch_a); assert!(any_in_flight(&q)); // B still pending. assert_eq!(pending_count(&q), 1); assert_eq!(q.queues.len(), 1); - q.mark_complete(ch_a); + q.mark_complete(ConversationScope::channel(ch_a)); // Second flush picks B. let batch_b = q.flush_next().expect("second flush"); - assert_eq!(batch_b.channel_id, ch_b); + assert_eq!(batch_b.channel_id(), ch_b); assert_eq!(batch_b.events[0].event.content, "B-event"); assert_eq!(pending_count(&q), 0); @@ -1867,7 +1905,7 @@ mod tests { .unwrap_or_else(|_| event.pubkey.to_hex()); let batch = FlushBatch { - channel_id: ch, + scope: ConversationScope::channel(ch), events: vec![BatchEvent { event, prompt_tag: "@mention".into(), @@ -1897,7 +1935,7 @@ mod tests { fn make_merged_batch(reason: Option) -> FlushBatch { let ch = Uuid::new_v4(); FlushBatch { - channel_id: ch, + scope: ConversationScope::channel(ch), events: vec![BatchEvent { event: make_event("the new message"), prompt_tag: "@mention".into(), @@ -1992,7 +2030,7 @@ mod tests { // The mode gate fires Steer → cancel → requeue as cancelled, carrying // the steer reason (exactly the lib.rs requeue path). q.requeue_as_cancelled(batch, CancelReason::Steer); - q.mark_complete(ch); + q.mark_complete(ConversationScope::channel(ch)); // The re-prompt the agent actually receives. let merged = q.flush_next().unwrap(); @@ -2028,7 +2066,7 @@ mod tests { // Multi-event header path must also branch on reason. let ch = Uuid::new_v4(); let batch = FlushBatch { - channel_id: ch, + scope: ConversationScope::channel(ch), events: vec![ BatchEvent { event: make_event("new one"), @@ -2085,7 +2123,7 @@ mod tests { let _steering_id = steering.id.to_hex(); let batch = FlushBatch { - channel_id: ch, + scope: ConversationScope::channel(ch), events: vec![BatchEvent { event: steering, prompt_tag: "@mention".into(), @@ -2134,10 +2172,10 @@ mod tests { // Simulate failure — requeue the batch. queue.requeue(batch); - queue.mark_complete(ch); + queue.mark_complete(ConversationScope::channel(ch)); // retry_after is set, so manually clear it for this test. - queue.retry_after.remove(&ch); + queue.retry_after.remove(&ConversationScope::channel(ch)); // Should be able to flush again and get the same events in order. let batch2 = queue.flush_next().unwrap(); @@ -2158,15 +2196,15 @@ mod tests { // Flush ch_a first (older). let batch_a = queue.flush_next().unwrap(); - assert_eq!(batch_a.channel_id, ch_a); + assert_eq!(batch_a.channel_id(), ch_a); // Requeue ch_a (simulating failure) and complete. queue.requeue(batch_a); - queue.mark_complete(ch_a); + queue.mark_complete(ConversationScope::channel(ch_a)); // After requeue, ch_a has retry_after set (5s), so ch_b goes first. let next_batch = queue.flush_next().unwrap(); - assert_eq!(next_batch.channel_id, ch_b); + assert_eq!(next_batch.channel_id(), ch_b); } #[test] @@ -2177,7 +2215,7 @@ mod tests { let e3 = make_event("third message"); let batch = FlushBatch { - channel_id: ch, + scope: ConversationScope::channel(ch), events: vec![ BatchEvent { event: e1, @@ -2217,7 +2255,7 @@ mod tests { let event = make_event("hello"); let batch = FlushBatch { - channel_id: ch, + scope: ConversationScope::channel(ch), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -2240,7 +2278,7 @@ mod tests { let ch = Uuid::new_v4(); let event = make_event("hi"); let batch = FlushBatch { - channel_id: ch, + scope: ConversationScope::channel(ch), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -2272,7 +2310,7 @@ mod tests { let ch = Uuid::new_v4(); let event = make_event("hi"); let batch = FlushBatch { - channel_id: ch, + scope: ConversationScope::channel(ch), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -2302,7 +2340,7 @@ mod tests { let ch = Uuid::new_v4(); let event = make_event("hi"); let batch = FlushBatch { - channel_id: ch, + scope: ConversationScope::channel(ch), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -2329,7 +2367,7 @@ mod tests { let event = make_event("hello"); let batch = FlushBatch { - channel_id: ch, + scope: ConversationScope::channel(ch), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -2353,7 +2391,7 @@ mod tests { let event = make_event("hello"); let batch = FlushBatch { - channel_id: ch, + scope: ConversationScope::channel(ch), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -2409,7 +2447,7 @@ mod tests { let event = make_event("hello"); let batch = FlushBatch { - channel_id: ch, + scope: ConversationScope::channel(ch), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -2447,7 +2485,7 @@ mod tests { let ch = Uuid::new_v4(); let event = make_event("hello"); let batch = FlushBatch { - channel_id: ch, + scope: ConversationScope::channel(ch), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -2513,7 +2551,7 @@ mod tests { q.push(make_queued(ch, "dropped")); assert_eq!(pending_count(&q), 0, "event should be dropped"); - q.mark_complete(ch); + q.mark_complete(ConversationScope::channel(ch)); // Nothing to flush. assert!(q.flush_next().is_none()); } @@ -2532,9 +2570,9 @@ mod tests { q.push(make_queued(ch_b, "B-event")); assert_eq!(pending_count(&q), 1); - q.mark_complete(ch_a); + q.mark_complete(ConversationScope::channel(ch_a)); let batch_b = q.flush_next().expect("flush B"); - assert_eq!(batch_b.channel_id, ch_b); + assert_eq!(batch_b.channel_id(), ch_b); } #[test] @@ -2548,22 +2586,22 @@ mod tests { // Flush A — now A is in-flight. let batch_a = q.flush_next().expect("flush A"); - assert_eq!(batch_a.channel_id, ch_a); + assert_eq!(batch_a.channel_id(), ch_a); assert!(any_in_flight(&q)); // Flush B — B should also be flushable (different channel). let batch_b = q.flush_next().expect("flush B while A in-flight"); - assert_eq!(batch_b.channel_id, ch_b); + assert_eq!(batch_b.channel_id(), ch_b); // Both in-flight. - assert_eq!(q.in_flight_channels.len(), 2); + assert_eq!(q.in_flight_scopes.len(), 2); // Complete A only. - q.mark_complete(ch_a); + q.mark_complete(ConversationScope::channel(ch_a)); assert!(any_in_flight(&q)); // B still in-flight. // Complete B. - q.mark_complete(ch_b); + q.mark_complete(ConversationScope::channel(ch_b)); assert!(!any_in_flight(&q)); } @@ -2583,7 +2621,7 @@ mod tests { // flush_next should pick ch2, not ch (ch is in-flight). let batch2 = q.flush_next().expect("should flush ch2"); - assert_eq!(batch2.channel_id, ch2); + assert_eq!(batch2.channel_id(), ch2); // ch still in-flight — no more candidates. assert!(q.flush_next().is_none()); @@ -2607,8 +2645,8 @@ mod tests { q.push(make_queued(ch_b, "B-dropped")); assert_eq!(pending_count(&q), 0); - q.mark_complete(ch_a); - q.mark_complete(ch_b); + q.mark_complete(ConversationScope::channel(ch_a)); + q.mark_complete(ConversationScope::channel(ch_b)); } #[test] @@ -2625,22 +2663,22 @@ mod tests { // Flush A (oldest). let batch = q.flush_next().expect("flush A"); - assert_eq!(batch.channel_id, ch_a); + assert_eq!(batch.channel_id(), ch_a); // A is in-flight; next oldest non-in-flight is B. let batch2 = q.flush_next().expect("flush B"); - assert_eq!(batch2.channel_id, ch_b); + assert_eq!(batch2.channel_id(), ch_b); // A and B in-flight; only C left. let batch3 = q.flush_next().expect("flush C"); - assert_eq!(batch3.channel_id, ch_c); + assert_eq!(batch3.channel_id(), ch_c); // All in-flight. assert!(q.flush_next().is_none()); - q.mark_complete(ch_a); - q.mark_complete(ch_b); - q.mark_complete(ch_c); + q.mark_complete(ConversationScope::channel(ch_a)); + q.mark_complete(ConversationScope::channel(ch_b)); + q.mark_complete(ConversationScope::channel(ch_c)); } #[test] @@ -2655,18 +2693,22 @@ mod tests { let _batch_a = q.flush_next().expect("flush A"); let _batch_b = q.flush_next().expect("flush B"); - assert_eq!(q.in_flight_channels.len(), 2); + assert_eq!(q.in_flight_scopes.len(), 2); // Complete only A. - q.mark_complete(ch_a); - assert_eq!(q.in_flight_channels.len(), 1); - assert!(q.in_flight_channels.contains(&ch_b)); - assert!(!q.in_flight_channels.contains(&ch_a)); + q.mark_complete(ConversationScope::channel(ch_a)); + assert_eq!(q.in_flight_scopes.len(), 1); + assert!(q + .in_flight_scopes + .contains(&ConversationScope::channel(ch_b))); + assert!(!q + .in_flight_scopes + .contains(&ConversationScope::channel(ch_a))); // B still in-flight. assert!(any_in_flight(&q)); - q.mark_complete(ch_b); + q.mark_complete(ConversationScope::channel(ch_b)); assert!(!any_in_flight(&q)); } @@ -2677,7 +2719,7 @@ mod tests { let old_time = Instant::now() - Duration::from_secs(10); q.push(QueuedEvent { - channel_id: ch, + scope: ConversationScope::channel(ch), event: make_event("old-msg"), received_at: old_time, prompt_tag: "test".into(), @@ -2688,7 +2730,7 @@ mod tests { // requeue_preserve_timestamps should keep the original timestamp. q.requeue_preserve_timestamps(batch); - q.mark_complete(ch); + q.mark_complete(ConversationScope::channel(ch)); // No retry_after set — should be immediately flushable. let batch2 = q.flush_next().expect("flush after requeue_preserve"); @@ -2704,10 +2746,10 @@ mod tests { let batch = q.flush_next().expect("flush"); q.requeue_preserve_timestamps(batch); - q.mark_complete(ch); + q.mark_complete(ConversationScope::channel(ch)); // No retry_after — channel should be immediately flushable. - assert!(!q.retry_after.contains_key(&ch)); + assert!(!q.retry_after.contains_key(&ConversationScope::channel(ch))); assert!(q.flush_next().is_some()); } @@ -2771,7 +2813,7 @@ mod tests { // Requeue — older events go to front, overflow trims from back (newest). q.requeue_preserve_timestamps(batch); - q.mark_complete(ch); + q.mark_complete(ConversationScope::channel(ch)); // The requeued events should be at the front of the queue. let batch2 = q.flush_next().expect("should flush after requeue"); @@ -2798,22 +2840,24 @@ mod tests { assert!(!q.has_flushable_work()); // Complete — no pending events, no flushable work. - q.mark_complete(ch); + q.mark_complete(ConversationScope::channel(ch)); assert!(!q.has_flushable_work()); // Requeue with retry_after — throttled, no flushable work. q.push(make_queued(ch, "msg2")); let batch2 = q.flush_next().expect("flush2"); q.requeue(batch2); - q.mark_complete(ch); + q.mark_complete(ConversationScope::channel(ch)); assert!( !q.has_flushable_work(), "throttled channel should not be flushable" ); // Manually expire the retry_after to simulate time passing. - q.retry_after - .insert(ch, Instant::now() - Duration::from_secs(1)); + q.retry_after.insert( + ConversationScope::channel(ch), + Instant::now() - Duration::from_secs(1), + ); assert!( q.has_flushable_work(), "expired throttle should be flushable" @@ -2827,27 +2871,31 @@ mod tests { q.push(make_queued(ch, "poison")); for attempt in 1..=MAX_RETRIES { - q.retry_after - .insert(ch, Instant::now() - Duration::from_secs(1)); + q.retry_after.insert( + ConversationScope::channel(ch), + Instant::now() - Duration::from_secs(1), + ); let batch = q.flush_next().expect("flush"); assert!( q.requeue(batch).is_none(), "attempt {attempt} should requeue, not dead-letter" ); - q.mark_complete(ch); + q.mark_complete(ConversationScope::channel(ch)); } // The MAX_RETRIES+1'th failure dead-letters: batch is returned. - q.retry_after - .insert(ch, Instant::now() - Duration::from_secs(1)); + q.retry_after.insert( + ConversationScope::channel(ch), + Instant::now() - Duration::from_secs(1), + ); let batch = q.flush_next().expect("flush"); let dead = q.requeue(batch).expect("should dead-letter"); - assert_eq!(dead.channel_id, ch); + assert_eq!(dead.channel_id(), ch); assert_eq!(dead.events.len(), 1); - q.mark_complete(ch); + q.mark_complete(ConversationScope::channel(ch)); // Retry state is cleared so fresh traffic isn't throttled. - assert!(!q.retry_counts.contains_key(&ch)); - assert!(!q.retry_after.contains_key(&ch)); + assert!(!q.retry_counts.contains_key(&ConversationScope::channel(ch))); + assert!(!q.retry_after.contains_key(&ConversationScope::channel(ch))); } #[test] @@ -2861,7 +2909,7 @@ mod tests { // Requeue sets retry_after. q.requeue(batch); - q.mark_complete(ch); + q.mark_complete(ConversationScope::channel(ch)); // Channel is throttled — flush_next should return None (no other channels). assert!(q.flush_next().is_none()); @@ -2869,16 +2917,18 @@ mod tests { // Add a different channel — it should be flushable. q.push(make_queued(ch2, "other")); let batch2 = q.flush_next().expect("ch2 should be flushable"); - assert_eq!(batch2.channel_id, ch2); + assert_eq!(batch2.channel_id(), ch2); // After retry_after expires, ch should be flushable again. - q.retry_after - .insert(ch, Instant::now() - Duration::from_secs(1)); - q.mark_complete(ch2); + q.retry_after.insert( + ConversationScope::channel(ch), + Instant::now() - Duration::from_secs(1), + ); + q.mark_complete(ConversationScope::channel(ch2)); let batch3 = q .flush_next() .expect("ch should be flushable after throttle expires"); - assert_eq!(batch3.channel_id, ch); + assert_eq!(batch3.channel_id(), ch); } /// Build an event with specific tags for thread testing. @@ -2909,28 +2959,31 @@ mod tests { #[test] fn test_parse_thread_tags_direct_reply() { // Direct reply to root: single "reply" tag. + let root = "a".repeat(64); let event = make_event_with_tags( "reply to root", - vec![vec!["e".into(), "abc123".into(), "".into(), "reply".into()]], + vec![vec!["e".into(), root.clone(), "".into(), "reply".into()]], ); let tags = parse_thread_tags(&event); - assert_eq!(tags.root_event_id.as_deref(), Some("abc123")); - assert_eq!(tags.parent_event_id.as_deref(), Some("abc123")); + assert_eq!(tags.root_event_id.as_deref(), Some(root.as_str())); + assert_eq!(tags.parent_event_id.as_deref(), Some(root.as_str())); } #[test] fn test_parse_thread_tags_nested_reply() { // Nested reply: root + reply tags. + let root = "a".repeat(64); + let parent = "b".repeat(64); let event = make_event_with_tags( "nested reply", vec![ - vec!["e".into(), "root123".into(), "".into(), "root".into()], - vec!["e".into(), "parent456".into(), "".into(), "reply".into()], + vec!["e".into(), root.clone(), "".into(), "root".into()], + vec!["e".into(), parent.clone(), "".into(), "reply".into()], ], ); let tags = parse_thread_tags(&event); - assert_eq!(tags.root_event_id.as_deref(), Some("root123")); - assert_eq!(tags.parent_event_id.as_deref(), Some("parent456")); + assert_eq!(tags.root_event_id.as_deref(), Some(root.as_str())); + assert_eq!(tags.parent_event_id.as_deref(), Some(parent.as_str())); } #[test] @@ -2949,14 +3002,14 @@ mod tests { #[test] fn test_parse_thread_tags_root_only() { - // Only root marker, no reply marker — root == parent. + // Relay-canonical shape rules reject root without reply. let event = make_event_with_tags( "reply", - vec![vec!["e".into(), "root123".into(), "".into(), "root".into()]], + vec![vec!["e".into(), "a".repeat(64), "".into(), "root".into()]], ); let tags = parse_thread_tags(&event); - assert_eq!(tags.root_event_id.as_deref(), Some("root123")); - assert_eq!(tags.parent_event_id.as_deref(), Some("root123")); + assert!(tags.root_event_id.is_none()); + assert!(tags.parent_event_id.is_none()); } #[test] @@ -2964,7 +3017,7 @@ mod tests { let ch = Uuid::new_v4(); let event = make_event("hello"); let batch = FlushBatch { - channel_id: ch, + scope: ConversationScope::channel(ch), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -2995,7 +3048,7 @@ mod tests { let ch = Uuid::new_v4(); let event = make_event("hey"); let batch = FlushBatch { - channel_id: ch, + scope: ConversationScope::channel(ch), events: vec![BatchEvent { event, prompt_tag: "dm".into(), @@ -3023,17 +3076,13 @@ mod tests { #[test] fn test_format_prompt_thread_scope() { let ch = Uuid::new_v4(); + let root = "a".repeat(64); let event = make_event_with_tags( "yes go ahead", - vec![vec![ - "e".into(), - "root123".into(), - "".into(), - "reply".into(), - ]], + vec![vec!["e".into(), root.clone(), "".into(), "reply".into()]], ); let batch = FlushBatch { - channel_id: ch, + scope: ConversationScope::channel(ch), events: vec![BatchEvent { event, prompt_tag: "@mention".into(), @@ -3045,23 +3094,19 @@ mod tests { let prompt = format_prompt(&batch, &FormatPromptArgs::default()).join("\n\n"); assert!(prompt.contains("Scope: thread")); - assert!(prompt.contains("Thread root: root123")); + assert!(prompt.contains(&format!("Thread root: {root}"))); } #[test] fn test_format_prompt_with_thread_context() { let ch = Uuid::new_v4(); + let root = "b".repeat(64); let event = make_event_with_tags( "yes go ahead", - vec![vec![ - "e".into(), - "root123".into(), - "".into(), - "reply".into(), - ]], + vec![vec!["e".into(), root, "".into(), "reply".into()]], ); let batch = FlushBatch { - channel_id: ch, + scope: ConversationScope::channel(ch), events: vec![BatchEvent { event, prompt_tag: "@mention".into(), @@ -3105,7 +3150,7 @@ mod tests { let ch = Uuid::new_v4(); let event = make_event("ok do that"); let batch = FlushBatch { - channel_id: ch, + scope: ConversationScope::channel(ch), events: vec![BatchEvent { event, prompt_tag: "dm".into(), @@ -3154,7 +3199,7 @@ mod tests { ); let author_hex = event.pubkey.to_hex(); let batch = FlushBatch { - channel_id: ch, + scope: ConversationScope::channel(ch), events: vec![BatchEvent { event, prompt_tag: "@mention".into(), @@ -3350,18 +3395,14 @@ mod tests { #[test] fn test_format_prompt_dm_reply_hints_get_thread() { let ch = Uuid::new_v4(); + let root = "c".repeat(64); // DM reply event — has thread e-tags. let event = make_event_with_tags( "sounds good, do it", - vec![vec![ - "e".into(), - "root123".into(), - "".into(), - "reply".into(), - ]], + vec![vec!["e".into(), root.clone(), "".into(), "reply".into()]], ); let batch = FlushBatch { - channel_id: ch, + scope: ConversationScope::channel(ch), events: vec![BatchEvent { event, prompt_tag: "dm".into(), @@ -3406,7 +3447,7 @@ mod tests { ); // Thread structural info should be present. assert!( - prompt.contains("Thread root: root123"), + prompt.contains(&format!("Thread root: {root}")), "DM reply should include thread root" ); // Thread context should be included. @@ -3418,7 +3459,7 @@ mod tests { let ch = Uuid::new_v4(); let event = make_event("hey there"); let batch = FlushBatch { - channel_id: ch, + scope: ConversationScope::channel(ch), events: vec![BatchEvent { event, prompt_tag: "dm".into(), @@ -3458,7 +3499,7 @@ mod tests { let event = make_event("test"); let event_id = event.id.to_hex(); let batch = FlushBatch { - channel_id: ch, + scope: ConversationScope::channel(ch), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -3482,7 +3523,7 @@ mod tests { let hex = event.pubkey.to_hex(); let npub = event.pubkey.to_bech32().unwrap(); let batch = FlushBatch { - channel_id: ch, + scope: ConversationScope::channel(ch), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -3505,7 +3546,7 @@ mod tests { // Kind 9 (stream message) — tags were previously stripped. let event = make_event_with_tags("hello", vec![vec!["h".into(), ch.to_string()]]); let batch = FlushBatch { - channel_id: ch, + scope: ConversationScope::channel(ch), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -3558,7 +3599,7 @@ mod tests { q.push(make_queued(ch, "msg")); let batch = q.flush_next().unwrap(); q.requeue(batch); // sets retry_after - q.mark_complete(ch); + q.mark_complete(ConversationScope::channel(ch)); // Channel is throttled — verify drain clears it. assert!(!q.has_flushable_work()); @@ -3602,26 +3643,28 @@ mod tests { q.push(make_queued(ch, "msg1")); let batch = q.flush_next().unwrap(); q.requeue(batch); - q.mark_complete(ch); - assert!(q.retry_after.contains_key(&ch)); - assert!(q.retry_counts.contains_key(&ch)); + q.mark_complete(ConversationScope::channel(ch)); + assert!(q.retry_after.contains_key(&ConversationScope::channel(ch))); + assert!(q.retry_counts.contains_key(&ConversationScope::channel(ch))); // The requeued event is back in the queue. Flush it again so the // queue is empty (simulating a successful retry dispatch). // We need to wait for retry_after to expire first. - q.retry_after - .insert(ch, Instant::now() - Duration::from_secs(1)); + q.retry_after.insert( + ConversationScope::channel(ch), + Instant::now() - Duration::from_secs(1), + ); let _batch2 = q.flush_next().unwrap(); // Now mark_complete with no active throttle — clears retry_counts. - q.mark_complete(ch); - assert!(!q.retry_counts.contains_key(&ch)); + q.mark_complete(ConversationScope::channel(ch)); + assert!(!q.retry_counts.contains_key(&ConversationScope::channel(ch))); // Re-create the orphan scenario: manually insert stale retry_counts // with no queue, no throttle, and no in-flight. - q.retry_counts.insert(ch, 3); + q.retry_counts.insert(ConversationScope::channel(ch), 3); q.compact_expired_state(); assert!( - !q.retry_counts.contains_key(&ch), + !q.retry_counts.contains_key(&ConversationScope::channel(ch)), "orphaned retry_counts should be removed" ); } @@ -3635,21 +3678,26 @@ mod tests { q.push(make_queued(ch, "msg1")); let batch = q.flush_next().unwrap(); q.requeue(batch); - q.mark_complete(ch); + q.mark_complete(ConversationScope::channel(ch)); // Expire the throttle so the requeued event can be flushed. - q.retry_after - .insert(ch, Instant::now() - Duration::from_secs(1)); + q.retry_after.insert( + ConversationScope::channel(ch), + Instant::now() - Duration::from_secs(1), + ); let _batch2 = q.flush_next().unwrap(); // Channel is now in-flight with empty queue and expired throttle. - assert!(q.in_flight_channels.contains(&ch)); - assert!(q.queues.get(&ch).is_none_or(|q| q.is_empty())); + assert!(q.in_flight_scopes.contains(&ConversationScope::channel(ch))); + assert!(q + .queues + .get(&ConversationScope::channel(ch)) + .is_none_or(|q| q.is_empty())); // compact must NOT remove retry_counts — the in-flight attempt // may fail and requeue, which needs the existing count. q.compact_expired_state(); assert!( - q.retry_counts.contains_key(&ch), + q.retry_counts.contains_key(&ConversationScope::channel(ch)), "retry_counts must survive while channel is in-flight" ); } @@ -3661,11 +3709,11 @@ mod tests { // Manually set up: retry_counts exists, queue is non-empty, no throttle. q.push(make_queued(ch, "msg1")); - q.retry_counts.insert(ch, 2); + q.retry_counts.insert(ConversationScope::channel(ch), 2); q.compact_expired_state(); assert!( - q.retry_counts.contains_key(&ch), + q.retry_counts.contains_key(&ConversationScope::channel(ch)), "retry_counts should survive when queue is non-empty" ); } @@ -3686,7 +3734,7 @@ mod tests { // Cancel the original batch and release the channel. q.requeue_as_cancelled(batch, CancelReason::Interrupt); - q.mark_complete(ch); + q.mark_complete(ConversationScope::channel(ch)); // flush_next should merge: events=[new-1], cancelled_events=[old-1, old-2]. let next = q.flush_next().unwrap(); @@ -3708,27 +3756,27 @@ mod tests { let batch = q.flush_next().unwrap(); q.push(make_queued(ch, "new")); q.requeue_as_cancelled(batch, CancelReason::Steer); - q.mark_complete(ch); + q.mark_complete(ConversationScope::channel(ch)); let merged = q.flush_next().unwrap(); assert_eq!( merged.cancel_reason, Some(CancelReason::Steer), "steer reason should reach the merged batch" ); - q.mark_complete(ch); + q.mark_complete(ConversationScope::channel(ch)); // Fallback path (no new event): reason still rides through. q.push(make_queued(ch, "only")); let batch = q.flush_next().unwrap(); q.requeue_as_cancelled(batch, CancelReason::Interrupt); - q.mark_complete(ch); + q.mark_complete(ConversationScope::channel(ch)); let fallback = q.flush_next().unwrap(); assert_eq!( fallback.cancel_reason, Some(CancelReason::Interrupt), "interrupt reason should reach the re-dispatched batch" ); - q.mark_complete(ch); + q.mark_complete(ConversationScope::channel(ch)); // A normal (non-cancel) flush carries no reason. q.push(make_queued(ch, "plain")); @@ -3744,12 +3792,12 @@ mod tests { let batch1 = q.flush_next().unwrap(); q.push(make_queued(ch, "new-1")); q.requeue_as_cancelled(batch1, CancelReason::Interrupt); - q.mark_complete(ch); + q.mark_complete(ConversationScope::channel(ch)); let batch2 = q.flush_next().unwrap(); // Second cancel with a different reason — the latest reason wins. q.requeue_as_cancelled(batch2, CancelReason::Steer); q.push(make_queued(ch, "new-2")); - q.mark_complete(ch); + q.mark_complete(ConversationScope::channel(ch)); let batch3 = q.flush_next().unwrap(); assert_eq!(batch3.cancel_reason, Some(CancelReason::Steer)); } @@ -3767,7 +3815,7 @@ mod tests { // Cancel the batch (no new events pushed) and release the channel. q.requeue_as_cancelled(batch, CancelReason::Interrupt); - q.mark_complete(ch); + q.mark_complete(ConversationScope::channel(ch)); // Fallback path: cancelled events become regular events, cancelled_events is empty. let next = q.flush_next().unwrap(); @@ -3791,7 +3839,7 @@ mod tests { q.push(make_queued(ch, "msg")); let batch = q.flush_next().unwrap(); q.requeue_as_cancelled(batch, CancelReason::Interrupt); - q.mark_complete(ch); + q.mark_complete(ConversationScope::channel(ch)); // Channel has only cancelled events — should still be considered flushable. assert!( @@ -3809,7 +3857,7 @@ mod tests { q.push(make_queued(ch, "msg")); let batch = q.flush_next().unwrap(); q.requeue_as_cancelled(batch, CancelReason::Interrupt); - q.mark_complete(ch); + q.mark_complete(ConversationScope::channel(ch)); // drain_channel should clear cancelled_batches for the channel. q.drain_channel(ch); @@ -3837,7 +3885,7 @@ mod tests { // First cancel: store 2 cancelled events. q.requeue_as_cancelled(batch1, CancelReason::Interrupt); - q.mark_complete(ch); + q.mark_complete(ConversationScope::channel(ch)); // Second flush: events=[new-1], cancelled_events=[orig-1, orig-2]. let batch2 = q.flush_next().unwrap(); @@ -3850,7 +3898,7 @@ mod tests { // Push 1 more new event and release channel. q.push(make_queued(ch, "new-2")); - q.mark_complete(ch); + q.mark_complete(ConversationScope::channel(ch)); // Third flush: events=[new-2], cancelled_events=[orig-1, orig-2, new-1]. let batch3 = q.flush_next().unwrap(); @@ -3871,7 +3919,7 @@ mod tests { vec![vec!["e".into(), root_id.clone(), "".into(), "reply".into()]], ); let batch = FlushBatch { - channel_id: ch, + scope: ConversationScope::channel(ch), events: vec![BatchEvent { event, prompt_tag: "@mention".into(), @@ -3913,7 +3961,7 @@ mod tests { ); let event_id = event.id.to_hex(); let batch = FlushBatch { - channel_id: ch, + scope: ConversationScope::channel(ch), events: vec![BatchEvent { event, prompt_tag: "@mention".into(), @@ -3947,7 +3995,7 @@ mod tests { let event = make_event("hello world"); let event_id = event.id.to_hex(); let batch = FlushBatch { - channel_id: ch, + scope: ConversationScope::channel(ch), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -3976,7 +4024,7 @@ mod tests { let ch = Uuid::new_v4(); let event = make_event("hey there"); let batch = FlushBatch { - channel_id: ch, + scope: ConversationScope::channel(ch), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -4018,7 +4066,7 @@ mod tests { ); let event_id = event.id.to_hex(); let batch = FlushBatch { - channel_id: ch, + scope: ConversationScope::channel(ch), events: vec![BatchEvent { event, prompt_tag: "@mention".into(), @@ -4054,7 +4102,7 @@ mod tests { vec![vec!["e".into(), root_id.clone(), "".into(), "reply".into()]], ); let batch = FlushBatch { - channel_id: ch, + scope: ConversationScope::channel(ch), events: vec![BatchEvent { event, prompt_tag: "@mention".into(), @@ -4089,7 +4137,7 @@ mod tests { vec![vec!["e".into(), root_id.clone(), "".into(), "reply".into()]], ); let batch = FlushBatch { - channel_id: ch, + scope: ConversationScope::channel(ch), events: vec![ BatchEvent { event: plain, @@ -4126,7 +4174,7 @@ mod tests { let plain = make_event("latest top-level"); let plain_id = plain.id.to_hex(); let batch = FlushBatch { - channel_id: ch, + scope: ConversationScope::channel(ch), events: vec![ BatchEvent { event: threaded, @@ -4159,7 +4207,7 @@ mod tests { /// Build a single-event FlushBatch with the given content. fn make_single_batch(content: &str) -> FlushBatch { FlushBatch { - channel_id: Uuid::new_v4(), + scope: ConversationScope::channel(Uuid::new_v4()), events: vec![BatchEvent { event: make_event(content), prompt_tag: "test".into(), @@ -4283,7 +4331,7 @@ mod tests { let event_id = qe.event.id.to_hex(); q.push(qe); - assert!(q.mark_native_steer_pending(ch, &event_id)); + assert!(q.mark_native_steer_pending(ConversationScope::channel(ch), &event_id)); assert!( q.flush_next().is_none(), @@ -4294,7 +4342,12 @@ mod tests { "withheld-only channel must not register as flushable work" ); assert_eq!(pending_count(&q), 0); - assert_eq!(q.withheld_native_steer.get(&ch).map(|v| v.len()), Some(1)); + assert_eq!( + q.withheld_native_steer + .get(&ConversationScope::channel(ch)) + .map(|v| v.len()), + Some(1) + ); } /// Earlier events on the same channel must flush normally during the @@ -4320,25 +4373,25 @@ mod tests { q.push(e3); // Steer in flight for e3 — withhold it from normal dispatch. - assert!(q.mark_native_steer_pending(ch, &e3_id)); + assert!(q.mark_native_steer_pending(ConversationScope::channel(ch), &e3_id)); // Earlier events flush as a normal batch; e3 is invisible. let batch = q .flush_next() .expect("e1+e2 should flush during ack window"); - assert_eq!(batch.channel_id, ch); + assert_eq!(batch.channel_id(), ch); assert_eq!(batch.events.len(), 2); assert_eq!(batch.events[0].event.id.to_hex(), e1_id); assert_eq!(batch.events[1].event.id.to_hex(), e2_id); // Earlier batch completes; channel is no longer in flight. - q.mark_complete(ch); + q.mark_complete(ConversationScope::channel(ch)); // Ack arrives as Err or PromptCompletedNeutral → release e3. - q.release_native_steer(ch, &e3_id); + q.release_native_steer(ConversationScope::channel(ch), &e3_id); let next = q.flush_next().expect("released e3 should now flush"); - assert_eq!(next.channel_id, ch); + assert_eq!(next.channel_id(), ch); assert_eq!(next.events.len(), 1); assert_eq!(next.events[0].event.id.to_hex(), e3_id); @@ -4362,17 +4415,21 @@ mod tests { // Simulate a prompt in flight for `ch`, then withhold the queued // event for an in-flight goose-native steer. - q.in_flight_channels.insert(ch); - q.in_flight_deadlines.insert(ch, Instant::now()); - q.in_flight_batch_sizes.insert(ch, 1); - assert!(q.mark_native_steer_pending(ch, &event_id)); + q.in_flight_scopes.insert(ConversationScope::channel(ch)); + q.in_flight_deadlines + .insert(ConversationScope::channel(ch), Instant::now()); + q.in_flight_batch_sizes + .insert(ConversationScope::channel(ch), 1); + assert!(q.mark_native_steer_pending(ConversationScope::channel(ch), &event_id)); // Force the in-flight deadline to be in the past, simulating the // steer ack never arriving and the read loop hanging long enough // for `in_flight_deadline` to elapse. Same expiry-simulation // trick used by `test_retry_throttle_blocks_requeue_channel`. - q.in_flight_deadlines - .insert(ch, Instant::now() - Duration::from_secs(1)); + q.in_flight_deadlines.insert( + ConversationScope::channel(ch), + Instant::now() - Duration::from_secs(1), + ); // `has_flushable_work` runs the expiry block first; it must recover // the withheld event so the channel registers as flushable. @@ -4389,7 +4446,7 @@ mod tests { let batch = q .flush_next() .expect("recovered event should flush via normal dispatch"); - assert_eq!(batch.channel_id, ch); + assert_eq!(batch.channel_id(), ch); assert_eq!(batch.events.len(), 1); assert_eq!(batch.events[0].event.id.to_hex(), event_id); } @@ -4420,24 +4477,32 @@ mod tests { // tests. What matters here is that the bulk-recovery path // (reverse iter + push_front) composes to original FIFO at the // queue front. - assert!(q.mark_native_steer_pending(ch, &e1_id)); - assert!(q.mark_native_steer_pending(ch, &e2_id)); - assert!(q.mark_native_steer_pending(ch, &e3_id)); + assert!(q.mark_native_steer_pending(ConversationScope::channel(ch), &e1_id)); + assert!(q.mark_native_steer_pending(ConversationScope::channel(ch), &e2_id)); + assert!(q.mark_native_steer_pending(ConversationScope::channel(ch), &e3_id)); assert_eq!(pending_count(&q), 0); - assert_eq!(q.withheld_native_steer.get(&ch).map(|v| v.len()), Some(3)); + assert_eq!( + q.withheld_native_steer + .get(&ConversationScope::channel(ch)) + .map(|v| v.len()), + Some(3) + ); // Trigger expiry → bulk-release path. - q.in_flight_channels.insert(ch); - q.in_flight_deadlines - .insert(ch, Instant::now() - Duration::from_secs(1)); - q.in_flight_batch_sizes.insert(ch, 3); + q.in_flight_scopes.insert(ConversationScope::channel(ch)); + q.in_flight_deadlines.insert( + ConversationScope::channel(ch), + Instant::now() - Duration::from_secs(1), + ); + q.in_flight_batch_sizes + .insert(ConversationScope::channel(ch), 3); assert!(q.has_flushable_work()); // After recovery, the queue front-to-back order must match the // original FIFO: e1, e2, e3. let recovered: Vec = q .queues - .get(&ch) + .get(&ConversationScope::channel(ch)) .expect("queue restored") .iter() .map(|qe| qe.event.id.to_hex()) @@ -4453,7 +4518,7 @@ mod tests { let canvas = "[Channel Canvas]\nCanvas revision (event ID): abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234\nLast modified: 2024-01-15T10:30:00+00:00\nFetch current content with: buzz canvas get --channel 00f1ccaf-1506-4dd7-9a0e-fa67e9e486ae"; let ch = Uuid::new_v4(); let batch = FlushBatch { - channel_id: ch, + scope: ConversationScope::channel(ch), events: vec![BatchEvent { event: make_event("hi"), prompt_tag: "test".into(), @@ -4482,7 +4547,7 @@ mod tests { let canvas = "[Channel Canvas]\nCanvas revision (event ID): abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234\nLast modified: 2024-01-15T10:30:00+00:00\nFetch current content with: buzz canvas get --channel 00f1ccaf-1506-4dd7-9a0e-fa67e9e486ae"; let ch = Uuid::new_v4(); let batch = FlushBatch { - channel_id: ch, + scope: ConversationScope::channel(ch), events: vec![BatchEvent { event: make_event("hi"), prompt_tag: "test".into(), @@ -4510,7 +4575,7 @@ mod tests { fn test_format_prompt_no_canvas_produces_no_canvas_section() { let ch = Uuid::new_v4(); let batch = FlushBatch { - channel_id: ch, + scope: ConversationScope::channel(ch), events: vec![BatchEvent { event: make_event("hi"), prompt_tag: "test".into(), @@ -4559,11 +4624,15 @@ mod tests { let mut q = EventQueue::new(DedupMode::Queue); let ch = Uuid::new_v4(); let old_deadline = Instant::now() + Duration::from_secs(100); - q.in_flight_channels.insert(ch); - q.in_flight_deadlines.insert(ch, old_deadline); + q.in_flight_scopes.insert(ConversationScope::channel(ch)); + q.in_flight_deadlines + .insert(ConversationScope::channel(ch), old_deadline); - q.extend_in_flight_deadline(ch, 7200); - let new = *q.in_flight_deadlines.get(&ch).unwrap(); + q.extend_in_flight_deadline(ConversationScope::channel(ch), 7200); + let new = *q + .in_flight_deadlines + .get(&ConversationScope::channel(ch)) + .unwrap(); assert!( new > old_deadline, "extended deadline must be past the original" @@ -4575,11 +4644,15 @@ mod tests { let mut q = EventQueue::new(DedupMode::Queue); let ch = Uuid::new_v4(); let far_future = Instant::now() + Duration::from_secs(999_999); - q.in_flight_channels.insert(ch); - q.in_flight_deadlines.insert(ch, far_future); + q.in_flight_scopes.insert(ConversationScope::channel(ch)); + q.in_flight_deadlines + .insert(ConversationScope::channel(ch), far_future); - q.extend_in_flight_deadline(ch, 7200); - let after = *q.in_flight_deadlines.get(&ch).unwrap(); + q.extend_in_flight_deadline(ConversationScope::channel(ch), 7200); + let after = *q + .in_flight_deadlines + .get(&ConversationScope::channel(ch)) + .unwrap(); assert_eq!(after, far_future, "deadline must never move backward"); } @@ -4587,17 +4660,23 @@ mod tests { fn extend_in_flight_deadline_noop_after_mark_complete() { let mut q = EventQueue::new(DedupMode::Queue); let ch = Uuid::new_v4(); - q.in_flight_channels.insert(ch); - q.in_flight_deadlines - .insert(ch, Instant::now() + Duration::from_secs(100)); - q.in_flight_batch_sizes.insert(ch, 1); + q.in_flight_scopes.insert(ConversationScope::channel(ch)); + q.in_flight_deadlines.insert( + ConversationScope::channel(ch), + Instant::now() + Duration::from_secs(100), + ); + q.in_flight_batch_sizes + .insert(ConversationScope::channel(ch), 1); - q.mark_complete(ch); - assert!(!q.in_flight_deadlines.contains_key(&ch)); + q.mark_complete(ConversationScope::channel(ch)); + assert!(!q + .in_flight_deadlines + .contains_key(&ConversationScope::channel(ch))); - q.extend_in_flight_deadline(ch, 7200); + q.extend_in_flight_deadline(ConversationScope::channel(ch), 7200); assert!( - !q.in_flight_deadlines.contains_key(&ch), + !q.in_flight_deadlines + .contains_key(&ConversationScope::channel(ch)), "extend after mark_complete must not resurrect a deadline" ); } @@ -4607,17 +4686,21 @@ mod tests { let mut q = EventQueue::new(DedupMode::Queue); let ch = Uuid::new_v4(); let extended = Instant::now() + Duration::from_secs(9999); - q.in_flight_channels.insert(ch); - q.in_flight_deadlines.insert(ch, extended); + q.in_flight_scopes.insert(ConversationScope::channel(ch)); + q.in_flight_deadlines + .insert(ConversationScope::channel(ch), extended); q.compact_expired_state(); assert!( - q.in_flight_deadlines.contains_key(&ch), + q.in_flight_deadlines + .contains_key(&ConversationScope::channel(ch)), "compaction must not touch in-flight deadlines" ); assert_eq!( - *q.in_flight_deadlines.get(&ch).unwrap(), + *q.in_flight_deadlines + .get(&ConversationScope::channel(ch)) + .unwrap(), extended, "compaction must leave extended deadline intact" ); @@ -4636,9 +4719,11 @@ mod tests { // Insert the channel as in-flight with a deadline already in the past // (Instant::now() — by the time flush_next runs, now >= deadline). - q.in_flight_channels.insert(ch); - q.in_flight_deadlines.insert(ch, Instant::now()); - q.in_flight_batch_sizes.insert(ch, 1); + q.in_flight_scopes.insert(ConversationScope::channel(ch)); + q.in_flight_deadlines + .insert(ConversationScope::channel(ch), Instant::now()); + q.in_flight_batch_sizes + .insert(ConversationScope::channel(ch), 1); // Also push an event so flush_next has something to do after expiry. q.push(make_queued(ch, "after-expiry")); @@ -4648,7 +4733,7 @@ mod tests { let batch = q .flush_next() .expect("channel should be dispatchable after auto-expiry"); - assert_eq!(batch.channel_id, ch); + assert_eq!(batch.channel_id(), ch); assert_eq!(batch.events[0].event.content, "after-expiry"); } @@ -4664,10 +4749,13 @@ mod tests { let ch = Uuid::new_v4(); // Put the channel in-flight with an extended deadline far in the future. - q.in_flight_channels.insert(ch); - q.in_flight_deadlines - .insert(ch, Instant::now() + Duration::from_secs(9999)); - q.in_flight_batch_sizes.insert(ch, 1); + q.in_flight_scopes.insert(ConversationScope::channel(ch)); + q.in_flight_deadlines.insert( + ConversationScope::channel(ch), + Instant::now() + Duration::from_secs(9999), + ); + q.in_flight_batch_sizes + .insert(ConversationScope::channel(ch), 1); // Push an event for another channel so flush_next has work to do. let ch2 = Uuid::new_v4(); @@ -4675,17 +4763,19 @@ mod tests { let batch = q.flush_next().expect("other channel should flush"); assert_eq!( - batch.channel_id, ch2, + batch.channel_id(), + ch2, "only ch2 should be flushed; ch is still in-flight with extended deadline" ); // ch must still be in-flight — the extended deadline did not expire. assert!( - q.in_flight_channels.contains(&ch), + q.in_flight_scopes.contains(&ConversationScope::channel(ch)), "ch must remain in-flight after flush_next with an extended deadline" ); assert!( - q.in_flight_deadlines.contains_key(&ch), + q.in_flight_deadlines + .contains_key(&ConversationScope::channel(ch)), "in-flight deadline for ch must not be removed by flush_next" ); } @@ -4702,10 +4792,13 @@ mod tests { let ch = Uuid::new_v4(); // In-flight channel with extended (far-future) deadline. - q.in_flight_channels.insert(ch); - q.in_flight_deadlines - .insert(ch, Instant::now() + Duration::from_secs(9999)); - q.in_flight_batch_sizes.insert(ch, 1); + q.in_flight_scopes.insert(ConversationScope::channel(ch)); + q.in_flight_deadlines.insert( + ConversationScope::channel(ch), + Instant::now() + Duration::from_secs(9999), + ); + q.in_flight_batch_sizes + .insert(ConversationScope::channel(ch), 1); // No other channels — nothing flushable. assert!( @@ -4713,7 +4806,7 @@ mod tests { "has_flushable_work must return false when the only channel is in-flight with extended deadline" ); assert!( - q.in_flight_channels.contains(&ch), + q.in_flight_scopes.contains(&ConversationScope::channel(ch)), "ch must remain in-flight after has_flushable_work with extended deadline" ); @@ -4726,7 +4819,7 @@ mod tests { ); // ch still in-flight and not expired. assert!( - q.in_flight_channels.contains(&ch), + q.in_flight_scopes.contains(&ConversationScope::channel(ch)), "ch must still be in-flight after has_flushable_work finds ch2 work" ); } @@ -4741,15 +4834,23 @@ mod tests { let mut q = EventQueue::new(DedupMode::Queue); let ch = Uuid::new_v4(); - q.in_flight_channels.insert(ch); - q.in_flight_deadlines - .insert(ch, Instant::now() + Duration::from_secs(100)); + q.in_flight_scopes.insert(ConversationScope::channel(ch)); + q.in_flight_deadlines.insert( + ConversationScope::channel(ch), + Instant::now() + Duration::from_secs(100), + ); - q.extend_in_flight_deadline(ch, 7200); - let after_first = *q.in_flight_deadlines.get(&ch).unwrap(); + q.extend_in_flight_deadline(ConversationScope::channel(ch), 7200); + let after_first = *q + .in_flight_deadlines + .get(&ConversationScope::channel(ch)) + .unwrap(); - q.extend_in_flight_deadline(ch, 7200); - let after_second = *q.in_flight_deadlines.get(&ch).unwrap(); + q.extend_in_flight_deadline(ConversationScope::channel(ch), 7200); + let after_second = *q + .in_flight_deadlines + .get(&ConversationScope::channel(ch)) + .unwrap(); assert!( after_second >= after_first, @@ -4757,3 +4858,144 @@ mod tests { ); } } + +#[cfg(test)] +mod thread_scope_isolation_tests { + use super::*; + use crate::config::DedupMode; + use nostr::{EventBuilder, EventId, Keys, Kind}; + + fn make_event(content: &str) -> Event { + let keys = Keys::generate(); + EventBuilder::new(Kind::Custom(9), content) + .tags([]) + .sign_with_keys(&keys) + .unwrap() + } + + fn thread_scope(channel_id: Uuid, root_byte: char) -> ConversationScope { + let root = EventId::from_hex(&root_byte.to_string().repeat(64)).expect("valid hex"); + ConversationScope::thread(channel_id, root) + } + + fn queued(scope: ConversationScope, content: &str) -> QueuedEvent { + QueuedEvent { + scope, + event: make_event(content), + received_at: Instant::now(), + prompt_tag: "test".into(), + } + } + + /// Events for two threads and the channel-level conversation of the SAME + /// channel must never be combined into one batch. + #[test] + fn distinct_scopes_in_one_channel_never_batch_together() { + let mut q = EventQueue::new(DedupMode::Queue); + let ch = Uuid::new_v4(); + let thread_a = thread_scope(ch, 'a'); + let thread_b = thread_scope(ch, 'b'); + let channel = ConversationScope::channel(ch); + + q.push(queued(thread_a, "in thread A")); + q.push(queued(thread_b, "in thread B")); + q.push(queued(channel, "top-level")); + + let mut seen = Vec::new(); + while let Some(batch) = q.flush_next() { + assert_eq!( + batch.events.len(), + 1, + "each scope must flush as its own single-event batch" + ); + assert_eq!(batch.channel_id(), ch); + seen.push(batch.scope); + } + seen.sort_by_key(|s| format!("{s}")); + let mut expected = vec![thread_a, thread_b, channel]; + expected.sort_by_key(|s| format!("{s}")); + assert_eq!(seen, expected); + } + + /// An in-flight thread must not block a sibling thread or the channel + /// conversation, and its in-flight state is invisible to their scopes. + #[test] + fn in_flight_thread_does_not_block_siblings() { + let mut q = EventQueue::new(DedupMode::Queue); + let ch = Uuid::new_v4(); + let thread_a = thread_scope(ch, 'a'); + let thread_b = thread_scope(ch, 'b'); + let channel = ConversationScope::channel(ch); + + q.push(queued(thread_a, "first")); + let batch_a = q.flush_next().expect("thread A flushes"); + assert_eq!(batch_a.scope, thread_a); + assert!(q.is_scope_in_flight(thread_a)); + assert!(!q.is_scope_in_flight(thread_b)); + assert!(!q.is_scope_in_flight(channel)); + + // Sibling scopes still dispatch while thread A is in flight. + q.push(queued(thread_b, "second")); + q.push(queued(channel, "third")); + let batch_b = q.flush_next().expect("thread B flushes concurrently"); + assert_eq!(batch_b.scope, thread_b); + let batch_c = q.flush_next().expect("channel scope flushes concurrently"); + assert_eq!(batch_c.scope, channel); + + // Thread A itself stays blocked until mark_complete. + q.push(queued(thread_a, "queued behind in-flight")); + assert!(q.flush_next().is_none()); + q.mark_complete(thread_a); + let batch_a2 = q.flush_next().expect("thread A resumes after completion"); + assert_eq!(batch_a2.scope, thread_a); + } + + /// Drop-mode dedup is scope-local: an in-flight thread drops only its own + /// follow-ups, not events for sibling threads in the same channel. + #[test] + fn drop_mode_dedup_is_scope_local() { + let mut q = EventQueue::new(DedupMode::Drop); + let ch = Uuid::new_v4(); + let thread_a = thread_scope(ch, 'a'); + let thread_b = thread_scope(ch, 'b'); + + q.push(queued(thread_a, "first")); + q.flush_next().expect("thread A in flight"); + + assert!( + !q.push(queued(thread_a, "same thread — dropped")), + "drop mode must drop events for the in-flight scope" + ); + assert!( + q.push(queued(thread_b, "sibling thread — accepted")), + "drop mode must NOT drop events for a sibling thread" + ); + assert!( + q.push(queued(ConversationScope::channel(ch), "channel — accepted")), + "drop mode must NOT drop channel-scope events while a thread is in flight" + ); + } + + /// Channel removal must drain every conversation under the channel — + /// channel scope and all thread scopes — and nothing from other channels. + #[test] + fn drain_channel_sweeps_all_scopes_under_channel() { + let mut q = EventQueue::new(DedupMode::Queue); + let ch = Uuid::new_v4(); + let other = Uuid::new_v4(); + let thread_a = thread_scope(ch, 'a'); + let thread_b = thread_scope(ch, 'b'); + let other_scope = ConversationScope::channel(other); + + q.push(queued(ConversationScope::channel(ch), "one")); + q.push(queued(thread_a, "two")); + q.push(queued(thread_b, "three")); + q.push(queued(other_scope, "keep")); + + let drained = q.drain_channel(ch); + assert_eq!(drained.len(), 3, "all three conversations drained"); + assert_eq!(q.queued_event_count(&other_scope), 1); + assert!(q.flush_next().is_some(), "other channel still flushable"); + assert!(q.flush_next().is_none()); + } +} diff --git a/crates/buzz-acp/src/relay.rs b/crates/buzz-acp/src/relay.rs index c8312cc61e..d8beedd005 100644 --- a/crates/buzz-acp/src/relay.rs +++ b/crates/buzz-acp/src/relay.rs @@ -153,6 +153,8 @@ pub(crate) fn channel_type_from_tags(tags: &[serde_json::Value]) -> String { "dm".to_string() } else if declared_type == Some("private") || is_private { "private".to_string() + } else if declared_type == Some("forum") { + "forum".to_string() } else { "stream".to_string() } @@ -4098,6 +4100,14 @@ mod tests { assert_eq!(map[&channel].channel_type, "dm"); } + #[test] + fn merge_discovered_channels_preserves_declared_forum_type() { + let channel = Uuid::new_v4(); + let meta = serde_json::json!([meta_event(channel, "forum", &["t", "forum"])]); + let map = merge_discovered_channels(vec![channel], &meta); + assert_eq!(map[&channel].channel_type, "forum"); + } + #[test] fn merge_discovered_channels_skips_archived_metadata() { let live = Uuid::new_v4(); diff --git a/crates/buzz-acp/src/scope.rs b/crates/buzz-acp/src/scope.rs new file mode 100644 index 0000000000..145af78e64 --- /dev/null +++ b/crates/buzz-acp/src/scope.rs @@ -0,0 +1,334 @@ +//! Conversation scope — the typed key for ACP session routing. +//! +//! The harness keys session affinity, turn counters, queueing, in-flight +//! tracking, and invalidation by [`ConversationScope`] instead of the bare +//! channel UUID, so separate threads inside one channel get isolated +//! ACP/Hermes sessions (Discord-style thread isolation) while unthreaded +//! channel traffic and DMs keep channel-level continuity. +//! +//! This is agent conversation routing only: channel identity, subscriptions, +//! and NIP-29 authorization remain keyed by the `h`-tag channel UUID. + +use nostr::EventId; +use uuid::Uuid; + +/// The conversation a Buzz event belongs to. +/// +/// Two shapes exist within a channel: +/// +/// - **Channel scope** (`thread_root == None`) — unthreaded channel messages +/// and all DM traffic. One continuous conversation per channel, matching +/// the pre-thread-scoping behavior. +/// - **Thread scope** (`thread_root == Some(root)`) — thread replies in a +/// regular channel. Each canonical NIP-10 thread root gets its own +/// isolated conversation: its own ACP session, turn counter, queue lane, +/// and in-flight slot. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct ConversationScope { + /// The channel this conversation lives in (NIP-29 `h` tag UUID). + pub channel_id: Uuid, + /// Canonical thread root event ID, when the conversation is a thread. + pub thread_root: Option, +} + +impl ConversationScope { + /// Channel-level scope: unthreaded messages, DMs, control fallback. + pub fn channel(channel_id: Uuid) -> Self { + Self { + channel_id, + thread_root: None, + } + } + + /// Thread-level scope for a specific canonical root event. + pub fn thread(channel_id: Uuid, root: EventId) -> Self { + Self { + channel_id, + thread_root: Some(root), + } + } + + /// Derive the scope for an inbound event. + /// + /// `thread_scoped` must be `false` for DM channels — and for channels + /// whose type could not be determined — so those keep channel-level + /// continuity. When `true`: + /// + /// - A forum post (kind 45001) IS a thread root: it scopes to its own + /// event ID, so the root post and its later comments share one + /// conversation, while separate forum posts in the same channel stay + /// isolated from each other. + /// - Otherwise the canonical NIP-10 root from the event's `e` tags (via + /// [`buzz_core::thread::parse_nip10_thread`]) selects the thread scope — + /// this covers stream thread replies (kind + /// 9) and forum comments (kind 45003), whose root marker carries the + /// forum post's ID and therefore resolves to the same scope as the + /// post itself. + /// - Events with no root tag, or with a root that is not a valid event + /// ID, fall back to channel scope. + pub fn for_event(channel_id: Uuid, event: &nostr::Event, thread_scoped: bool) -> Self { + if !thread_scoped { + return Self::channel(channel_id); + } + if event.kind.as_u16() as u32 == buzz_core::kind::KIND_FORUM_POST { + return Self::thread(channel_id, event.id); + } + let root = buzz_core::thread::parse_nip10_thread(event).map(|thread| thread.root_event_id); + match root { + Some(root) => Self::thread(channel_id, root), + None => Self::channel(channel_id), + } + } + + /// Whether this scope identifies a thread conversation. + #[cfg(test)] + pub fn is_thread(&self) -> bool { + self.thread_root.is_some() + } +} + +impl std::fmt::Display for ConversationScope { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self.thread_root { + Some(root) => write!(f, "{}#{}", self.channel_id, root.to_hex()), + None => write!(f, "{}", self.channel_id), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use nostr::{EventBuilder, Keys, Kind, Tag}; + + fn make_event_kind(kind: u16, tags: Vec>) -> nostr::Event { + let keys = Keys::generate(); + let tags: Vec = tags + .into_iter() + .map(|t| Tag::parse(&t).expect("valid tag")) + .collect(); + EventBuilder::new(Kind::Custom(kind), "hello") + .tags(tags) + .sign_with_keys(&keys) + .expect("sign test event") + } + + fn make_event(tags: Vec>) -> nostr::Event { + make_event_kind(9, tags) + } + + fn root_hex() -> String { + "a".repeat(64) + } + + #[test] + fn unthreaded_event_gets_channel_scope() { + let ch = Uuid::new_v4(); + let event = make_event(vec![]); + let scope = ConversationScope::for_event(ch, &event, true); + assert_eq!(scope, ConversationScope::channel(ch)); + assert!(!scope.is_thread()); + } + + #[test] + fn root_only_marker_is_unthreaded() { + let ch = Uuid::new_v4(); + let event = make_event(vec![vec!["e".into(), root_hex(), "".into(), "root".into()]]); + let scope = ConversationScope::for_event(ch, &event, true); + assert_eq!(scope, ConversationScope::channel(ch)); + assert!(!scope.is_thread()); + } + + #[test] + fn reply_marker_only_uses_reply_as_canonical_root() { + // parse_thread_tags treats a lone "reply" marker as root == parent. + let ch = Uuid::new_v4(); + let event = make_event(vec![vec![ + "e".into(), + root_hex(), + "".into(), + "reply".into(), + ]]); + let scope = ConversationScope::for_event(ch, &event, true); + let expected_root = EventId::from_hex(&root_hex()).expect("valid hex"); + assert_eq!(scope, ConversationScope::thread(ch, expected_root)); + } + + #[test] + fn root_and_reply_markers_use_root() { + let ch = Uuid::new_v4(); + let parent = "b".repeat(64); + let event = make_event(vec![ + vec!["e".into(), root_hex(), "".into(), "root".into()], + vec!["e".into(), parent, "".into(), "reply".into()], + ]); + let scope = ConversationScope::for_event(ch, &event, true); + let expected_root = EventId::from_hex(&root_hex()).expect("valid hex"); + assert_eq!(scope.thread_root, Some(expected_root)); + } + + #[test] + fn dm_or_unknown_channel_stays_channel_scoped_even_with_thread_tags() { + let ch = Uuid::new_v4(); + let event = make_event(vec![vec![ + "e".into(), + root_hex(), + "".into(), + "reply".into(), + ]]); + let scope = ConversationScope::for_event(ch, &event, false); + assert_eq!(scope, ConversationScope::channel(ch)); + } + + #[test] + fn malformed_root_hex_falls_back_to_channel_scope() { + let ch = Uuid::new_v4(); + let event = make_event(vec![vec![ + "e".into(), + "not-hex".into(), + "".into(), + "root".into(), + ]]); + let scope = ConversationScope::for_event(ch, &event, true); + assert_eq!(scope, ConversationScope::channel(ch)); + } + + #[test] + fn same_thread_same_scope_distinct_threads_distinct_scopes() { + let ch = Uuid::new_v4(); + let root_a = vec!["e".into(), "a".repeat(64), "".into(), "reply".into()]; + let root_b = vec!["e".into(), "b".repeat(64), "".into(), "reply".into()]; + let a1 = ConversationScope::for_event(ch, &make_event(vec![root_a.clone()]), true); + let a2 = ConversationScope::for_event(ch, &make_event(vec![root_a]), true); + let b = ConversationScope::for_event(ch, &make_event(vec![root_b]), true); + assert_eq!(a1, a2); + assert_ne!(a1, b); + assert_eq!(a1.channel_id, b.channel_id); + } + + #[test] + fn forum_post_scopes_to_its_own_event_id() { + let ch = Uuid::new_v4(); + let post = make_event_kind(45001, vec![]); + let scope = ConversationScope::for_event(ch, &post, true); + assert_eq!(scope, ConversationScope::thread(ch, post.id)); + } + + #[test] + fn forum_comment_resolves_to_the_posts_scope() { + let ch = Uuid::new_v4(); + let post = make_event_kind(45001, vec![]); + let post_scope = ConversationScope::for_event(ch, &post, true); + // A direct comment uses the SDK's canonical reply-only encoding. + let comment = buzz_sdk::build_forum_comment( + ch, + "comment", + &buzz_sdk::ThreadRef { + root_event_id: post.id, + parent_event_id: post.id, + }, + &[], + &[], + ) + .expect("build forum comment") + .sign_with_keys(&Keys::generate()) + .expect("sign forum comment"); + let comment_scope = ConversationScope::for_event(ch, &comment, true); + assert_eq!( + comment_scope, post_scope, + "forum root and its comments must share one conversation" + ); + } + + #[test] + fn separate_forum_posts_are_isolated() { + let ch = Uuid::new_v4(); + let post_a = make_event_kind(45001, vec![]); + let post_b = make_event_kind(45001, vec![]); + let scope_a = ConversationScope::for_event(ch, &post_a, true); + let scope_b = ConversationScope::for_event(ch, &post_b, true); + assert_ne!(scope_a, scope_b); + assert_eq!(scope_a.channel_id, scope_b.channel_id); + } + + #[test] + fn forum_post_in_unscoped_channel_stays_channel_scoped() { + let ch = Uuid::new_v4(); + let post = make_event_kind(45001, vec![]); + let scope = ConversationScope::for_event(ch, &post, false); + assert_eq!(scope, ConversationScope::channel(ch)); + } + + #[test] + fn display_includes_root_for_threads() { + let ch = Uuid::new_v4(); + let root = EventId::from_hex(&"c".repeat(64)).expect("valid hex"); + assert_eq!(ConversationScope::channel(ch).to_string(), ch.to_string()); + assert_eq!( + ConversationScope::thread(ch, root).to_string(), + format!("{}#{}", ch, "c".repeat(64)) + ); + } + + #[test] + fn sdk_direct_and_nested_replies_preserve_canonical_scope() { + let ch = Uuid::new_v4(); + let keys = Keys::generate(); + let root = buzz_sdk::build_message(ch, "root", None, &[], false, &[]) + .expect("build root") + .sign_with_keys(&keys) + .expect("sign root"); + let direct = buzz_sdk::build_message( + ch, + "direct", + Some(&buzz_sdk::ThreadRef { + root_event_id: root.id, + parent_event_id: root.id, + }), + &[], + false, + &[], + ) + .expect("build direct reply") + .sign_with_keys(&keys) + .expect("sign direct reply"); + let nested = buzz_sdk::build_message( + ch, + "nested", + Some(&buzz_sdk::ThreadRef { + root_event_id: root.id, + parent_event_id: direct.id, + }), + &[], + false, + &[], + ) + .expect("build nested reply") + .sign_with_keys(&keys) + .expect("sign nested reply"); + + let expected = ConversationScope::thread(ch, root.id); + assert_eq!(ConversationScope::for_event(ch, &direct, true), expected); + assert_eq!(ConversationScope::for_event(ch, &nested, true), expected); + } + + #[test] + fn malformed_later_duplicate_does_not_erase_valid_scope_marker() { + let ch = Uuid::new_v4(); + let parent = "b".repeat(64); + let event = make_event(vec![ + vec!["e".into(), root_hex(), "".into(), "root".into()], + vec!["e".into(), parent, "".into(), "reply".into()], + vec![ + "e".into(), + "not-an-event-id".into(), + "".into(), + "root".into(), + ], + ]); + assert_eq!( + ConversationScope::for_event(ch, &event, true), + ConversationScope::thread(ch, EventId::from_hex(&root_hex()).expect("valid root")) + ); + } +} diff --git a/crates/buzz-core/src/lib.rs b/crates/buzz-core/src/lib.rs index 66b7708f1d..c7e9e7303e 100644 --- a/crates/buzz-core/src/lib.rs +++ b/crates/buzz-core/src/lib.rs @@ -36,6 +36,8 @@ pub mod presence; pub mod relay; /// Tenant identity — the server-resolved community key carried on scoped paths. pub mod tenant; +/// Canonical marker-based NIP-10 thread parsing. +pub mod thread; /// Schnorr signature and event ID verification. pub mod verification; diff --git a/crates/buzz-core/src/thread.rs b/crates/buzz-core/src/thread.rs new file mode 100644 index 0000000000..ce5c2b4636 --- /dev/null +++ b/crates/buzz-core/src/thread.rs @@ -0,0 +1,159 @@ +//! Canonical NIP-10 thread-reference parsing. +//! +//! Buzz emits marker-based NIP-10 tags only. This module deliberately does not +//! support the deprecated positional form. + +use nostr::{Event, EventId}; + +/// A validated canonical NIP-10 thread reference. +/// +/// Direct replies carry only a `reply` marker, in which case the root and +/// parent are the same event. Nested replies carry both `root` and `reply` +/// markers. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Nip10Thread { + /// Canonical root event for the thread. + pub root_event_id: EventId, + /// Immediate parent event being replied to. + pub parent_event_id: EventId, +} + +/// Parse Buzz's canonical marker-based NIP-10 thread shape. +/// +/// Each marker candidate is validated as an [`EventId`] before it can replace +/// an earlier candidate. Consequently, a malformed duplicate never erases a +/// valid marker, while later valid duplicates retain the relay's existing +/// last-valid-wins behavior. +/// +/// Accepted shapes: +/// +/// - `reply` only: a direct reply, where root equals parent; +/// - `root` plus `reply`: a nested reply. +/// +/// A `root` marker without a `reply` marker is not a thread reference and +/// returns `None`, matching relay ingestion. +pub fn parse_nip10_thread(event: &Event) -> Option { + let mut root = None; + let mut reply = None; + + for tag in event.tags.iter() { + let parts = tag.as_slice(); + if parts.len() < 4 || parts[0] != "e" { + continue; + } + let Ok(candidate) = EventId::from_hex(&parts[1]) else { + continue; + }; + match parts[3].as_str() { + "root" => root = Some(candidate), + "reply" => reply = Some(candidate), + _ => {} + } + } + + match (root, reply) { + (Some(root_event_id), Some(parent_event_id)) => Some(Nip10Thread { + root_event_id, + parent_event_id, + }), + (None, Some(parent_event_id)) => Some(Nip10Thread { + root_event_id: parent_event_id, + parent_event_id, + }), + (Some(_), None) | (None, None) => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use nostr::{EventBuilder, Keys, Kind, Tag}; + + fn event(tags: Vec>) -> Event { + let tags = tags + .into_iter() + .map(|parts| Tag::parse(parts).expect("valid tag structure")) + .collect::>(); + EventBuilder::new(Kind::Custom(9), "test") + .tags(tags) + .sign_with_keys(&Keys::generate()) + .expect("sign event") + } + + fn marker(id: char, marker: &str) -> Vec { + vec![ + "e".to_string(), + id.to_string().repeat(64), + String::new(), + marker.to_string(), + ] + } + + #[test] + fn no_markers_is_unthreaded() { + assert_eq!(parse_nip10_thread(&event(vec![])), None); + } + + #[test] + fn root_only_is_unthreaded() { + assert_eq!(parse_nip10_thread(&event(vec![marker('a', "root")])), None); + } + + #[test] + fn reply_only_is_a_direct_reply() { + let parsed = parse_nip10_thread(&event(vec![marker('a', "reply")])) + .expect("reply-only is canonical"); + assert_eq!(parsed.root_event_id.to_hex(), "a".repeat(64)); + assert_eq!(parsed.parent_event_id, parsed.root_event_id); + } + + #[test] + fn root_and_reply_form_a_nested_reply_in_either_order() { + for tags in [ + vec![marker('a', "root"), marker('b', "reply")], + vec![marker('b', "reply"), marker('a', "root")], + ] { + let parsed = parse_nip10_thread(&event(tags)).expect("canonical nested reply"); + assert_eq!(parsed.root_event_id.to_hex(), "a".repeat(64)); + assert_eq!(parsed.parent_event_id.to_hex(), "b".repeat(64)); + } + } + + #[test] + fn malformed_duplicate_does_not_erase_valid_candidate() { + let malformed_root = vec![ + "e".to_string(), + "not-an-event-id".to_string(), + String::new(), + "root".to_string(), + ]; + let malformed_reply = vec![ + "e".to_string(), + "also-invalid".to_string(), + String::new(), + "reply".to_string(), + ]; + let parsed = parse_nip10_thread(&event(vec![ + marker('a', "root"), + malformed_root, + marker('b', "reply"), + malformed_reply, + ])) + .expect("valid candidates survive malformed duplicates"); + assert_eq!(parsed.root_event_id.to_hex(), "a".repeat(64)); + assert_eq!(parsed.parent_event_id.to_hex(), "b".repeat(64)); + } + + #[test] + fn later_valid_duplicate_wins_for_each_marker() { + let parsed = parse_nip10_thread(&event(vec![ + marker('a', "root"), + marker('b', "reply"), + marker('c', "root"), + marker('d', "reply"), + ])) + .expect("canonical nested reply"); + assert_eq!(parsed.root_event_id.to_hex(), "c".repeat(64)); + assert_eq!(parsed.parent_event_id.to_hex(), "d".repeat(64)); + } +} diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index a30b0e714d..ec82023c1c 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -580,36 +580,10 @@ pub(crate) async fn resolve_nip10_thread_meta( channel_id: Uuid, state: &AppState, ) -> Result, String> { - let mut root_hex: Option = None; - let mut reply_hex: Option = None; - - for tag in event.tags.iter() { - let parts = tag.as_slice(); - if parts.len() >= 4 && parts[0] == "e" { - let hex_val = &parts[1]; - let marker = &parts[3]; - if hex_val.len() == 64 && hex_val.chars().all(|c| c.is_ascii_hexdigit()) { - match marker.as_str() { - "root" => root_hex = Some(hex_val.to_string()), - "reply" => reply_hex = Some(hex_val.to_string()), - _ => {} - } - } - } - } - - if root_hex.is_none() && reply_hex.is_none() { + let Some(thread) = buzz_core::thread::parse_nip10_thread(event) else { return Ok(None); - } - - let (root_hex, parent_hex) = match (root_hex, reply_hex) { - (Some(r), Some(p)) => (r, p), - (None, Some(p)) => (p.clone(), p), - (Some(_), None) | (None, None) => return Ok(None), }; - - let parent_bytes = - hex::decode(&parent_hex).map_err(|_| "invalid parent event ID hex".to_string())?; + let parent_bytes = thread.parent_event_id.as_bytes().to_vec(); let (parent_event_result, parent_meta_result) = tokio::join!( state.db.get_event_by_id(community_id, &parent_bytes), @@ -634,8 +608,7 @@ pub(crate) async fn resolve_nip10_thread_meta( chrono::DateTime::from_timestamp(parent_event.event.created_at.as_secs() as i64, 0) .unwrap_or_else(Utc::now); - let client_root_bytes = - hex::decode(&root_hex).map_err(|_| "invalid root event ID hex".to_string())?; + let client_root_bytes = thread.root_event_id.as_bytes().to_vec(); let parent_meta = parent_meta_result.map_err(|e| format!("db error looking up thread metadata: {e}"))?; @@ -663,28 +636,8 @@ pub(crate) async fn resolve_nip10_thread_meta( (effective_root, root_ts, depth) } None => { - let parent_root = parent_event - .event - .tags - .iter() - .find_map(|t| { - let parts = t.as_slice(); - if parts.len() >= 4 && parts[0] == "e" && parts[3] == "root" { - hex::decode(&parts[1]).ok().filter(|b| b.len() == 32) - } else { - None - } - }) - .or_else(|| { - parent_event.event.tags.iter().find_map(|t| { - let parts = t.as_slice(); - if parts.len() >= 4 && parts[0] == "e" && parts[3] == "reply" { - hex::decode(&parts[1]).ok().filter(|b| b.len() == 32) - } else { - None - } - }) - }) + let parent_root = buzz_core::thread::parse_nip10_thread(&parent_event.event) + .map(|thread| thread.root_event_id.as_bytes().to_vec()) .unwrap_or_else(|| parent_bytes.clone()); if client_root_bytes != parent_root { diff --git a/desktop/src/features/agents/lib/liveSwitchOutcome.test.mjs b/desktop/src/features/agents/lib/liveSwitchOutcome.test.mjs index 737d84b862..6719384928 100644 --- a/desktop/src/features/agents/lib/liveSwitchOutcome.test.mjs +++ b/desktop/src/features/agents/lib/liveSwitchOutcome.test.mjs @@ -4,6 +4,7 @@ import test from "node:test"; import { awaitLiveSwitchOutcome } from "./liveSwitchOutcome.ts"; const MODEL = "goose-claude-fable-5"; +const REQUEST = "request-current"; function frame(status, overrides = {}) { return { type: "switch_model", status, modelId: MODEL, ...overrides }; @@ -28,6 +29,7 @@ function harness(channelCount) { const outcome = awaitLiveSwitchOutcome({ channelCount, modelId: MODEL, + requestId: REQUEST, subscribe: (fn) => { listener = fn; return () => { @@ -128,6 +130,38 @@ test("awaitLiveSwitchOutcome ignores frames for a different model or control typ assert.equal(await h.outcome, "ok"); }); +test("awaitLiveSwitchOutcome ignores stale, superseded, and duplicate channel results", async () => { + const h = harness(2); + let settled = false; + void h.outcome.then(() => { + settled = true; + }); + + h.push( + frame("superseded", { + channelId: "channel-a", + requestId: REQUEST, + }), + ); + h.push( + frame("unsupported_model", { + channelId: "channel-a", + requestId: "request-stale", + }), + ); + h.push(frame("sent", { channelId: "channel-a", requestId: REQUEST })); + h.push(frame("sent", { channelId: "channel-a", requestId: REQUEST })); + await Promise.resolve(); + assert.equal( + settled, + false, + "one channel cannot satisfy two acknowledgements", + ); + + h.push(frame("sent", { channelId: "channel-b", requestId: REQUEST })); + assert.equal(await h.outcome, "ok"); +}); + test("awaitLiveSwitchOutcome resolves ok via the timeout fallback when the harness never replies", async () => { const h = harness(2); h.fireTimeout(); @@ -152,3 +186,31 @@ test("awaitLiveSwitchOutcome with zero channels resolves ok at the timeout (no a h.fireTimeout(); assert.equal(await h.outcome, "ok"); }); + +test("awaitLiveSwitchOutcome cleans up when a channel control send fails", async () => { + let listener = null; + let unsubscribeCalls = 0; + let cancelTimeoutCalls = 0; + const sendError = new Error("relay send failed"); + const outcome = awaitLiveSwitchOutcome({ + channelCount: 1, + modelId: MODEL, + requestId: REQUEST, + subscribe: (fn) => { + listener = fn; + return () => { + unsubscribeCalls += 1; + listener = null; + }; + }, + sendSwitches: () => Promise.reject(sendError), + scheduleTimeout: () => () => { + cancelTimeoutCalls += 1; + }, + }); + + await assert.rejects(outcome, sendError); + assert.equal(unsubscribeCalls, 1); + assert.equal(cancelTimeoutCalls, 1); + assert.equal(listener, null); +}); diff --git a/desktop/src/features/agents/lib/liveSwitchOutcome.ts b/desktop/src/features/agents/lib/liveSwitchOutcome.ts index d12261e596..c00a56e4b6 100644 --- a/desktop/src/features/agents/lib/liveSwitchOutcome.ts +++ b/desktop/src/features/agents/lib/liveSwitchOutcome.ts @@ -18,6 +18,7 @@ import type { ControlResultFrame } from "@/shared/api/types"; export async function awaitLiveSwitchOutcome({ channelCount, modelId, + requestId, subscribe, sendSwitches, scheduleTimeout, @@ -26,6 +27,9 @@ export async function awaitLiveSwitchOutcome({ channelCount: number; /** Model being switched to; frames for any other model are ignored. */ modelId: string; + /** Correlation id echoed by current harnesses. Missing ids remain accepted + * for updater compatibility with older running harnesses. */ + requestId?: string; /** Register a control-result listener; returns an unsubscribe function. */ subscribe: (listener: (frame: ControlResultFrame) => void) => () => void; /** Fire the per-channel `switch_model` sends. Resolves when all are sent. */ @@ -33,25 +37,49 @@ export async function awaitLiveSwitchOutcome({ /** Schedule the no-reply fallback; returns a cancel function. */ scheduleTimeout: (onTimeout: () => void) => () => void; }): Promise<"ok" | "unsupported"> { + let abandon = () => {}; const settled = new Promise<"ok" | "unsupported">((resolve) => { let unsubscribe = () => {}; let cancelTimeout = () => {}; let remaining = channelCount; - const finish = (outcome: "ok" | "unsupported") => { + let finished = false; + const resolvedChannels = new Set(); + const cleanup = () => { + if (finished) return false; + finished = true; cancelTimeout(); unsubscribe(); + return true; + }; + const finish = (outcome: "ok" | "unsupported") => { + if (!cleanup()) return; resolve(outcome); }; + // A failed relay send rejects the caller, but must still detach the + // already-installed result listener and timeout. + abandon = () => { + cleanup(); + }; cancelTimeout = scheduleTimeout(() => finish("ok")); unsubscribe = subscribe((frame) => { if (frame.type !== "switch_model" || frame.modelId !== modelId) { return; } + if (requestId && frame.requestId && frame.requestId !== requestId) { + return; + } + if (frame.status === "superseded") { + return; + } if (frame.status === "unsupported_model") { // Any single failure rejects the whole pick immediately. finish("unsupported"); return; } + if (frame.channelId) { + if (resolvedChannels.has(frame.channelId)) return; + resolvedChannels.add(frame.channelId); + } // sent / switched / turn_ending — count as success for this channel. remaining -= 1; if (remaining <= 0) { @@ -60,7 +88,12 @@ export async function awaitLiveSwitchOutcome({ }); }); - await sendSwitches(); + try { + await sendSwitches(); + } catch (error) { + abandon(); + throw error; + } return settled; } diff --git a/desktop/src/features/agents/observerRelayStore.ts b/desktop/src/features/agents/observerRelayStore.ts index 56c69f915a..b7d87ed728 100644 --- a/desktop/src/features/agents/observerRelayStore.ts +++ b/desktop/src/features/agents/observerRelayStore.ts @@ -58,8 +58,9 @@ const snapshotByAgent = new Map(); // TranscriptState once over the combined window. const archiveEventsByChannel = new Map(); -// Per-agent, per-channel latest-live-session-id. -// Key: `${normalizePubkey(agentPubkey)}:${channelId}`. +// Per-agent, per-channel/thread latest-live-session-id. +// Key includes canonical threadRoot so concurrent same-channel conversations +// remain independently current. // Set when a live relay observer event with a sessionId arrives. // Cleared in resetAgentObserverStore. // @@ -73,26 +74,93 @@ const archiveEventsByChannel = new Map(); // the parsed event sorts strictly AFTER the stored one, using the same // two-key ordering as `compareObserverEvents`: timestamp first, then seq on a // tie — so a higher-seq frame at equal timestamp still advances the entry. -type LatestLiveEntry = { sessionId: string; timestamp: string; seq: number }; -const latestLiveSessionByAgentChannel = new Map(); +type LatestLiveEntry = { + sessionId: string; + timestamp: string; + seq: number; + agentChannelKey: string; + conversationKey: string; +}; +const latestLiveSessionByConversation = new Map(); +const latestLiveSessionIdsByAgentChannel = new Map(); +const EMPTY_LIVE_SESSION_IDS: readonly string[] = Object.freeze([]); + +function liveAgentChannelKey( + agentPubkey: string, + channelId: string | null, +): string { + return `${normalizePubkey(agentPubkey)}\u0000${channelId ?? ""}`; +} -function liveSessionKey(agentPubkey: string, channelId: string | null): string { - return `${normalizePubkey(agentPubkey)}:${channelId ?? ""}`; +function liveConversationKey( + agentPubkey: string, + channelId: string | null, + threadRoot: string | null, +): string { + return `${liveAgentChannelKey(agentPubkey, channelId)}\u0000${threadRoot ?? ""}`; +} + +function refreshLatestLiveSessionIds(agentChannelKey: string) { + const next = [...latestLiveSessionByConversation.values()] + .filter((entry) => entry.agentChannelKey === agentChannelKey) + .sort((left, right) => + left.conversationKey.localeCompare(right.conversationKey), + ) + .map((entry) => entry.sessionId); + const current = + latestLiveSessionIdsByAgentChannel.get(agentChannelKey) ?? + EMPTY_LIVE_SESSION_IDS; + if ( + current.length === next.length && + current.every((sessionId, index) => sessionId === next[index]) + ) { + return; + } + latestLiveSessionIdsByAgentChannel.set(agentChannelKey, next); } -/** Read the latest-live-session-id for a (agent, channel) pair. */ -export function getLatestLiveSessionId( +/** Read stable latest-live session IDs for every conversation in a channel. */ +export function getLatestLiveSessionIds( agentPubkey: string | null | undefined, channelId: string | null | undefined, -): string | null { - if (!agentPubkey) return null; +): readonly string[] { + if (!agentPubkey) return EMPTY_LIVE_SESSION_IDS; return ( - latestLiveSessionByAgentChannel.get( - liveSessionKey(agentPubkey, channelId ?? null), - )?.sessionId ?? null + latestLiveSessionIdsByAgentChannel.get( + liveAgentChannelKey(agentPubkey, channelId ?? null), + ) ?? EMPTY_LIVE_SESSION_IDS ); } +/** + * Advance live-session state for one decoded relay frame. Exported from this + * module for deterministic store tests; production calls it only after the + * observer frame passes signature/ownership/decryption gates. + */ +export function recordLatestLiveSession( + agentPubkey: string, + event: ObserverEvent, +): void { + if (!event.sessionId || !event.channelId) return; + const threadRoot = event.threadRoot ?? null; + const agentChannelKey = liveAgentChannelKey(agentPubkey, event.channelId); + const conversationKey = liveConversationKey( + agentPubkey, + event.channelId, + threadRoot, + ); + const stored = latestLiveSessionByConversation.get(conversationKey); + if (stored && !isObserverEventAfter(event, stored)) return; + latestLiveSessionByConversation.set(conversationKey, { + sessionId: event.sessionId, + timestamp: event.timestamp, + seq: event.seq, + agentChannelKey, + conversationKey, + }); + refreshLatestLiveSessionIds(agentChannelKey); +} + // Per-agent listeners for `control_result` frames. The ModelPicker subscribes // here to learn the async outcome of a `switch_model` frame (the send is // fire-and-forget; the harness replies out-of-band over the observer relay). @@ -236,7 +304,7 @@ function appendAgentEvent(agentPubkey: string, event: ObserverEvent) { /** * Compose the map key for the channel-scoped archive transcript. * Separates agent identity from channel with `:` — the same delimiter used by - * liveSessionKey so all composite keys in this module are consistently shaped. + * liveAgentChannelKey so all composite keys in this module are consistently shaped. */ function archiveChannelKey(agentPubkey: string, channelId: string): string { return `${normalizePubkey(agentPubkey)}:${channelId}`; @@ -372,25 +440,7 @@ async function handleRelayObserverEvent( if (activeGeneration !== generation) { return; } - // Track the latest-live-session-id per (agent, channel) on the live path. - // Only set when the parsed event carries both a sessionId and channelId, - // so we never attribute a session to the wrong channel. - if (parsed.sessionId && parsed.channelId) { - const key = liveSessionKey(agentPubkey, parsed.channelId); - const stored = latestLiveSessionByAgentChannel.get(key); - // Advance only when this event sorts strictly AFTER the stored one via - // isObserverEventAfter (timestamp then seq — same ordering as - // compareObserverEvents). This prevents late-arriving live frames from - // older sessions from regressing the latest-live id, while also - // correctly advancing on a same-timestamp frame with a higher seq. - if (!stored || isObserverEventAfter(parsed, stored)) { - latestLiveSessionByAgentChannel.set(key, { - sessionId: parsed.sessionId, - timestamp: parsed.timestamp, - seq: parsed.seq, - }); - } - } + recordLatestLiveSession(agentPubkey, parsed); appendAgentEvent(agentPubkey, parsed); const managementRequest = parseAgentManagementRequest(parsed.payload); if (managementRequest) { @@ -748,7 +798,8 @@ export function resetAgentObserverStore() { knownAgentPubkeys.clear(); knownAgentsBySubscription.clear(); pendingUnknownAgentFrames.length = 0; - latestLiveSessionByAgentChannel.clear(); + latestLiveSessionByConversation.clear(); + latestLiveSessionIdsByAgentChannel.clear(); agentManagementListeners.clear(); onSessionConfigCaptured = null; connectionState = "idle"; diff --git a/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx index 29bfd7dbab..6871658bc4 100644 --- a/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx +++ b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx @@ -8,7 +8,7 @@ import { } from "@/features/agents/activeAgentTurnsStore"; import { subscribeAgentObserverStore, - getLatestLiveSessionId, + getLatestLiveSessionIds, } from "@/features/agents/observerRelayStore"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import { useAnchoredScroll } from "@/features/messages/ui/useAnchoredScroll"; @@ -141,20 +141,20 @@ export function AgentSessionTranscriptList({ [activeTurns, channelId], ); - // Subscribe to the observer relay store so we read the latest-live-session-id - // reactively. We don't need the full snapshot — only the key for boundary labeling. + // Subscribe to every live conversation session in this channel. The snapshot + // is reference-stable until one conversation advances. const getLatestLive = React.useCallback( - () => getLatestLiveSessionId(agentPubkey, channelId), + () => getLatestLiveSessionIds(agentPubkey, channelId), [agentPubkey, channelId], ); - const latestLiveSessionId = React.useSyncExternalStore( + const latestLiveSessionIds = React.useSyncExternalStore( subscribeAgentObserverStore, getLatestLive, ); const displayBlocks = React.useMemo( - () => buildTranscriptDisplayBlocks(items, latestLiveSessionId), - [items, latestLiveSessionId], + () => buildTranscriptDisplayBlocks(items, latestLiveSessionIds), + [items, latestLiveSessionIds], ); // Derive the same block keys the DOM renders as `data-message-id` so // useAnchoredScroll anchors on real DOM rows. Value-stabilized so the diff --git a/desktop/src/features/agents/ui/ModelPicker.tsx b/desktop/src/features/agents/ui/ModelPicker.tsx index f7bafde99b..cbe858b4bd 100644 --- a/desktop/src/features/agents/ui/ModelPicker.tsx +++ b/desktop/src/features/agents/ui/ModelPicker.tsx @@ -113,16 +113,27 @@ export function ModelPicker({ // from every channel before resolving success. const sendLiveSwitch = React.useCallback( (modelId: string) => { - const channelIds = activeTurns.map((turn) => turn.channelId); + // Model state is channel-wide even when several thread turns are live in + // that channel. Send one control per channel, not one per turn. + const channelIds = [ + ...new Set(activeTurns.map((turn) => turn.channelId)), + ]; + const requestId = crypto.randomUUID(); return awaitLiveSwitchOutcome({ channelCount: channelIds.length, modelId, + requestId, subscribe: (listener) => subscribeControlResults(agent.pubkey, listener), sendSwitches: async () => { await Promise.all( channelIds.map((channelId) => - switchManagedAgentModel(agent.pubkey, channelId, modelId), + switchManagedAgentModel( + agent.pubkey, + channelId, + modelId, + requestId, + ), ), ); }, diff --git a/desktop/src/features/agents/ui/agentSessionPermission.ts b/desktop/src/features/agents/ui/agentSessionPermission.ts new file mode 100644 index 0000000000..76316f1342 --- /dev/null +++ b/desktop/src/features/agents/ui/agentSessionPermission.ts @@ -0,0 +1,89 @@ +import { asRecord, asString } from "./agentSessionUtils"; + +export function describePermissionRequest(payload: Record) { + const params = asRecord(payload.params); + const title = + asString(params.title) ?? + asString(params.message) ?? + asString(params.reason) ?? + "Permission requested"; + const toolCallId = + asString(params.toolCallId) ?? asString(params.tool_call_id); + const options = Array.isArray(params.options) + ? params.options + .map((option) => { + const record = asRecord(option); + return ( + asString(record.name) ?? + asString(record.kind) ?? + asString(record.optionId) + ); + }) + .filter((option): option is string => Boolean(option)) + : []; + const detail: string[] = []; + if (title !== "Permission requested") detail.push(title); + if (toolCallId) detail.push(`Tool call: ${toolCallId}`); + if (options.length > 0) detail.push(`Options: ${options.join(", ")}`); + + const optionNames = new Map(); + if (Array.isArray(params.options)) { + for (const option of params.options) { + const record = asRecord(option); + const optionId = asString(record.optionId); + const kind = asString(record.kind); + if (optionId && kind) { + optionNames.set(optionId, kind); + } + } + } + + return { + title, + text: detail.join("\n"), + optionNames, + descriptor: { + renderClass: "permission" as const, + label: "Permission requested", + preview: title, + action: { verb: "Requested", object: title }, + tone: "admin" as const, + operation: "session/request_permission", + object: title, + source: "acp" as const, + groupKey: "permission:request", + }, + }; +} + +/** + * Format a human-readable outcome label from a permission response. + * ACP `reject_*` kinds are denials; other selected options are approvals. + */ +export function describePermissionOutcome( + outcome: string, + optionId: string | null, + optionNames: Map, +): string { + if (outcome === "cancelled") { + return "Cancelled"; + } + if (outcome === "selected" && optionId) { + const kind = optionNames.get(optionId) ?? optionId; + const isDenial = kind.startsWith("reject"); + const verb = isDenial ? "Denied" : "Approved"; + return `${verb} (${kind})`; + } + return outcome; +} + +/** + * Stable map key for a JSON-RPC id. JSON encoding keeps numeric and string ids + * distinct; non-scalar values are not valid request ids for correlation. + */ +export function jsonRpcId(value: unknown): string | null { + if (typeof value === "string") return JSON.stringify(value); + if (typeof value === "number" && Number.isFinite(value)) + return JSON.stringify(value); + return null; +} diff --git a/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs b/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs index cc6f0467d6..062d8ae583 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs +++ b/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs @@ -1879,3 +1879,183 @@ test("buildTranscript five-section system prompt card is standalone with all sec "prompt context must NOT contain system-prompt sections (Base/System/Team Instructions/Core Memory/Channel Canvas)", ); }); + +test("pre-resolution frames inherit sessions only within their thread conversation", () => { + const channelId = "11111111-1111-1111-1111-111111111111"; + const rootA = "a".repeat(64); + const rootB = "b".repeat(64); + const event = (seq, kind, threadRoot, turnId, sessionId, payload = {}) => ({ + seq, + timestamp: `2026-07-28T00:00:0${seq}Z`, + kind, + agentIndex: 0, + channelId, + threadRoot, + sessionId, + turnId, + payload, + }); + const items = buildTranscript([ + event(1, "session_resolved", rootA, "turn-a-1", "sess-A", { + sessionId: "sess-A", + isNewSession: true, + }), + // B has not resolved yet. It must not inherit A's latest session. + event(2, "turn_started", rootB, "turn-b-1", null), + event(3, "session_resolved", rootB, "turn-b-1", "sess-B", { + sessionId: "sess-B", + isNewSession: true, + }), + // A's next pre-resolution frame must still inherit A, not the later B. + event(4, "turn_started", rootA, "turn-a-2", null), + ]); + + const bStart = items.find( + (item) => item.acpSource === "turn_started" && item.turnId === "turn-b-1", + ); + const aStart = items.find( + (item) => item.acpSource === "turn_started" && item.turnId === "turn-a-2", + ); + assert.equal(bStart?.sessionId, null); + assert.equal(bStart?.threadRoot, rootB); + assert.equal(aStart?.sessionId, "sess-A"); + assert.equal(aStart?.threadRoot, rootA); +}); + +test("equal adapter-local message and tool IDs stay isolated across threads", () => { + const rootA = "a".repeat(64); + const rootB = "b".repeat(64); + const inThread = (threadRoot, sessionId, turnId) => ({ + threadRoot, + sessionId, + turnId, + }); + const events = [ + assistantChunk( + 1, + "message-1", + "answer A", + inThread(rootA, "sess-A", "turn-A"), + ), + sessionUpdate( + 2, + { + sessionUpdate: "tool_call", + toolCallId: "tool-1", + status: "executing", + title: "shell A", + kind: "shell", + }, + inThread(rootA, "sess-A", "turn-A"), + ), + assistantChunk( + 3, + "message-1", + "answer B", + inThread(rootB, "sess-B", "turn-B"), + ), + sessionUpdate( + 4, + { + sessionUpdate: "tool_call", + toolCallId: "tool-1", + status: "executing", + title: "shell B", + kind: "shell", + }, + inThread(rootB, "sess-B", "turn-B"), + ), + ]; + + const items = buildTranscript(events); + const messages = items.filter((item) => item.type === "message"); + const tools = items.filter((item) => item.type === "tool"); + assert.equal(messages.length, 2); + assert.deepEqual( + messages.map((item) => [item.threadRoot, item.text]), + [ + [rootA, "answer A"], + [rootB, "answer B"], + ], + ); + assert.equal(new Set(messages.map((item) => item.id)).size, 2); + assert.equal(tools.length, 2); + assert.deepEqual( + tools.map((item) => [item.threadRoot, item.title]), + [ + [rootA, "shell A"], + [rootB, "shell B"], + ], + ); + assert.equal(new Set(tools.map((item) => item.id)).size, 2); +}); + +test("interleaved lifecycle frames do not seal another thread's streamed message", () => { + const rootA = "a".repeat(64); + const rootB = "b".repeat(64); + const scopeA = { + threadRoot: rootA, + sessionId: "sess-A", + turnId: "turn-A", + }; + const events = [ + assistantChunk(1, "message-1", "hel", scopeA), + { + ...baseEvent, + seq: 2, + timestamp: "2026-07-28T00:00:02Z", + kind: "turn_started", + threadRoot: rootB, + sessionId: "sess-B", + turnId: "turn-B", + payload: { source: "channel" }, + }, + assistantChunk(3, "message-1", "lo", scopeA), + ]; + + const messages = buildTranscript(events).filter( + (item) => item.type === "message", + ); + assert.equal(messages.length, 1); + assert.equal(messages[0].threadRoot, rootA); + assert.equal(messages[0].text, "hello"); +}); + +test("equal permission request IDs correlate within their thread conversation", () => { + const rootA = "a".repeat(64); + const rootB = "b".repeat(64); + const inThread = (event, threadRoot, sessionId, turnId) => ({ + ...event, + threadRoot, + sessionId, + turnId, + }); + const events = [ + inThread(makePermissionRequest(1, 1, "turn-A"), rootA, "sess-A", "turn-A"), + inThread(makePermissionRequest(2, 1, "turn-B"), rootB, "sess-B", "turn-B"), + inThread( + makePermissionResponse(3, 1, "selected", "allow_once"), + rootA, + "sess-A", + "turn-A", + ), + inThread( + makePermissionResponse(4, 1, "selected", "reject_once"), + rootB, + "sess-B", + "turn-B", + ), + ]; + + const permissions = buildTranscript(events).filter( + (item) => item.type === "lifecycle" && item.renderClass === "permission", + ); + assert.equal(permissions.length, 2); + assert.deepEqual( + permissions.map((item) => [item.threadRoot, item.outcome]), + [ + [rootA, "Approved (allow_once)"], + [rootB, "Denied (reject_once)"], + ], + ); +}); diff --git a/desktop/src/features/agents/ui/agentSessionTranscript.ts b/desktop/src/features/agents/ui/agentSessionTranscript.ts index 962290c6ca..147222fe2c 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscript.ts +++ b/desktop/src/features/agents/ui/agentSessionTranscript.ts @@ -13,6 +13,11 @@ import { } from "./agentSessionToolCatalog"; import { classifyTool } from "./agentSessionToolClassifier"; import { asRecord, asString, titleCase } from "./agentSessionUtils"; +import { + describePermissionOutcome, + describePermissionRequest, + jsonRpcId, +} from "./agentSessionPermission"; import { describeTurnStarted, describeSessionResolved, @@ -38,17 +43,20 @@ export type TranscriptState = { sealedKeys: Set; triggeringEventIdsByTurn: Map; /** - * Maps JSON-RPC request id → { itemId, optionNames }. + * Maps conversation-scoped JSON-RPC request id → { itemId, optionNames }. * Populated when a `session/request_permission` request is ingested so the * matching response (which carries the same JSON-RPC id, no `method`) can - * correlate and append the outcome to the lifecycle item. + * correlate and append the outcome to the lifecycle item. ACP processes + * commonly allocate request ids from small local counters, so the canonical + * channel/thread scope is part of the key. */ pendingPermissions: Map< string, { itemId: string; optionNames: Map } >; continuationSeq: number; - latestSessionId: string | null; + /** Latest resolved session per channel/thread conversation identity. */ + latestSessionIdByConversation: Map; }; export function createEmptyTranscriptState(): TranscriptState { @@ -60,7 +68,7 @@ export function createEmptyTranscriptState(): TranscriptState { triggeringEventIdsByTurn: new Map(), pendingPermissions: new Map(), continuationSeq: 0, - latestSessionId: null, + latestSessionIdByConversation: new Map(), }; } @@ -80,7 +88,7 @@ type TranscriptDraft = { { itemId: string; optionNames: Map } >; continuationSeq: number; - latestSessionId: string | null; + latestSessionIdByConversation: Map; changed: boolean; }; @@ -93,7 +101,7 @@ function draftFrom(state: TranscriptState): TranscriptDraft { triggeringEventIdsByTurn: state.triggeringEventIdsByTurn, pendingPermissions: state.pendingPermissions, continuationSeq: state.continuationSeq, - latestSessionId: state.latestSessionId, + latestSessionIdByConversation: state.latestSessionIdByConversation, changed: false, }; } @@ -122,9 +130,17 @@ function pushItem(d: TranscriptDraft, item: TranscriptItem) { d.itemsById.set(item.id, item); } -function sealOpenMessages(d: TranscriptDraft) { +function sealOpenMessages(d: TranscriptDraft, ctx: TranscriptItemContext) { let copied = false; for (const [, currentKey] of d.activeMessageKey) { + const current = d.itemsById.get(currentKey); + if ( + !current || + current.channelId !== ctx.channelId || + (current.threadRoot ?? null) !== ctx.threadRoot + ) { + continue; + } if (!d.sealedKeys.has(currentKey)) { if (!copied) { d.sealedKeys = new Set(d.sealedKeys); @@ -171,98 +187,6 @@ function stringifyPayload(value: unknown) { } } -function describePermissionRequest(payload: Record) { - const params = asRecord(payload.params); - const title = - asString(params.title) ?? - asString(params.message) ?? - asString(params.reason) ?? - "Permission requested"; - const toolCallId = - asString(params.toolCallId) ?? asString(params.tool_call_id); - const options = Array.isArray(params.options) - ? params.options - .map((option) => { - const record = asRecord(option); - return ( - asString(record.name) ?? - asString(record.kind) ?? - asString(record.optionId) - ); - }) - .filter((option): option is string => Boolean(option)) - : []; - const detail: string[] = []; - if (title !== "Permission requested") detail.push(title); - if (toolCallId) detail.push(`Tool call: ${toolCallId}`); - if (options.length > 0) detail.push(`Options: ${options.join(", ")}`); - - // Build optionId → kind map for outcome labeling on the response. - const optionNames = new Map(); - if (Array.isArray(params.options)) { - for (const option of params.options) { - const record = asRecord(option); - const optionId = asString(record.optionId); - const kind = asString(record.kind); - if (optionId && kind) { - optionNames.set(optionId, kind); - } - } - } - - return { - title, - text: detail.join("\n"), - optionNames, - descriptor: { - renderClass: "permission" as const, - label: "Permission requested", - preview: title, - action: { verb: "Requested", object: title }, - tone: "admin" as const, - operation: "session/request_permission", - object: title, - source: "acp" as const, - groupKey: "permission:request", - }, - }; -} - -/** - * Format a human-readable outcome label from a permission response. - * kind values from ACP: allow_once, allow_always, reject_once, reject_always. - * "reject_*" kinds are denials; anything else that is selected is an approval. - */ -function describePermissionOutcome( - outcome: string, - optionId: string | null, - optionNames: Map, -): string { - if (outcome === "cancelled") { - return "Cancelled"; - } - if (outcome === "selected" && optionId) { - const kind = optionNames.get(optionId) ?? optionId; - const isDenial = kind.startsWith("reject"); - const verb = isDenial ? "Denied" : "Approved"; - return `${verb} (${kind})`; - } - return outcome; -} - -/** - * Stable map key for a JSON-RPC id, which may be a string or a finite number - * per the spec. Using JSON.stringify avoids collisions between the number 1 and - * the string "1". Returns null for null, undefined, or non-id values (objects, - * booleans) so callers can gate on presence without a separate type check. - */ -function jsonRpcId(value: unknown): string | null { - if (typeof value === "string") return JSON.stringify(value); - if (typeof value === "number" && Number.isFinite(value)) - return JSON.stringify(value); - return null; -} - function describeFreeformStatus(payload: Record) { const statusType = asString(payload.type) ?? asString(payload.status); const title = @@ -279,10 +203,18 @@ function rawPayloadTitle(payload: unknown) { type TranscriptItemContext = { channelId: string | null; + threadRoot: string | null; turnId: string | null; sessionId: string | null; }; +function transcriptConversationKey( + channelId: string | null, + threadRoot: string | null, +): string { + return `${channelId ?? "global"}\u0000${threadRoot ?? ""}`; +} + function upsertMessage( d: TranscriptDraft, id: string, @@ -304,6 +236,7 @@ function upsertMessage( ...existing, text: existing.text + text, channelId: ctx.channelId, + threadRoot: ctx.threadRoot ?? existing.threadRoot, turnId: ctx.turnId ?? existing.turnId, sessionId: ctx.sessionId ?? existing.sessionId, authorPubkey: authorPubkey ?? existing.authorPubkey, @@ -326,6 +259,7 @@ function upsertMessage( timestamp, messageId, channelId: ctx.channelId, + threadRoot: ctx.threadRoot, turnId: ctx.turnId, sessionId: ctx.sessionId, authorPubkey, @@ -354,13 +288,14 @@ function upsertTextItem( ? joinLifecycleText(existing.text, text) : existing.text + text, channelId: ctx.channelId, + threadRoot: ctx.threadRoot ?? existing.threadRoot, turnId: ctx.turnId ?? existing.turnId, sessionId: ctx.sessionId ?? existing.sessionId, acpSource: acpSource ?? existing.acpSource, }); return; } - sealOpenMessages(d); + sealOpenMessages(d, ctx); if (type === "thought") { pushItem(d, { id, @@ -370,6 +305,7 @@ function upsertTextItem( text, timestamp, channelId: ctx.channelId, + threadRoot: ctx.threadRoot, turnId: ctx.turnId, sessionId: ctx.sessionId, acpSource, @@ -418,6 +354,7 @@ function upsertLifecycleItem( text: joinLifecycleText(existing.text, text), descriptor: descriptor ?? existing.descriptor, channelId: ctx.channelId, + threadRoot: ctx.threadRoot ?? existing.threadRoot, turnId: ctx.turnId ?? existing.turnId, sessionId: ctx.sessionId ?? existing.sessionId, acpSource: acpSource ?? existing.acpSource, @@ -425,7 +362,7 @@ function upsertLifecycleItem( return; } - sealOpenMessages(d); + sealOpenMessages(d, ctx); pushItem(d, { id, type: "lifecycle", @@ -435,6 +372,7 @@ function upsertLifecycleItem( timestamp, descriptor, channelId: ctx.channelId, + threadRoot: ctx.threadRoot, turnId: ctx.turnId, sessionId: ctx.sessionId, acpSource, @@ -465,6 +403,7 @@ function replaceLifecycleItem( title, text, channelId: ctx.channelId, + threadRoot: ctx.threadRoot ?? existing.threadRoot, turnId: ctx.turnId ?? existing.turnId, sessionId: ctx.sessionId ?? existing.sessionId, acpSource: acpSource ?? existing.acpSource, @@ -472,7 +411,7 @@ function replaceLifecycleItem( return; } - sealOpenMessages(d); + sealOpenMessages(d, ctx); pushItem(d, { id, type: "lifecycle", @@ -481,6 +420,7 @@ function replaceLifecycleItem( text, timestamp, channelId: ctx.channelId, + threadRoot: ctx.threadRoot, turnId: ctx.turnId, sessionId: ctx.sessionId, acpSource, @@ -504,6 +444,7 @@ function upsertPlan( ...existing, text, channelId: ctx.channelId, + threadRoot: ctx.threadRoot ?? existing.threadRoot, turnId: ctx.turnId ?? existing.turnId, sessionId: ctx.sessionId ?? existing.sessionId, acpSource: acpSource ?? existing.acpSource, @@ -519,6 +460,7 @@ function upsertPlan( isUpdate: true, targetId: id, channelId: ctx.channelId, + threadRoot: ctx.threadRoot, turnId: ctx.turnId, sessionId: ctx.sessionId, acpSource, @@ -526,7 +468,7 @@ function upsertPlan( } return; } - sealOpenMessages(d); + sealOpenMessages(d, ctx); pushItem(d, { id, type: "plan", @@ -535,6 +477,7 @@ function upsertPlan( text, timestamp, channelId: ctx.channelId, + threadRoot: ctx.threadRoot, turnId: ctx.turnId, sessionId: ctx.sessionId, acpSource, @@ -571,13 +514,14 @@ function upsertMetadata( ...existing, sections, channelId: ctx.channelId, + threadRoot: ctx.threadRoot ?? existing.threadRoot, turnId: ctx.turnId ?? existing.turnId, sessionId: ctx.sessionId ?? existing.sessionId, acpSource: acpSource ?? existing.acpSource, }); return; } - sealOpenMessages(d); + sealOpenMessages(d, ctx); pushItem(d, { id, type: "metadata", @@ -586,6 +530,7 @@ function upsertMetadata( sections, timestamp, channelId: ctx.channelId, + threadRoot: ctx.threadRoot, turnId: ctx.turnId, sessionId: ctx.sessionId, acpSource, @@ -659,6 +604,7 @@ function upsertTool( ? timestamp : existing.completedAt, channelId: ctx.channelId, + threadRoot: ctx.threadRoot ?? existing.threadRoot, turnId: ctx.turnId ?? existing.turnId, sessionId: ctx.sessionId ?? existing.sessionId, acpSource: acpSource ?? existing.acpSource, @@ -674,7 +620,7 @@ function upsertTool( result, isError: isError || status === "failed", }); - sealOpenMessages(d); + sealOpenMessages(d, ctx); pushItem(d, { id, type: "tool", @@ -691,6 +637,7 @@ function upsertTool( startedAt: timestamp, completedAt: isTerminalToolStatus(status) ? timestamp : null, channelId: ctx.channelId, + threadRoot: ctx.threadRoot, turnId: ctx.turnId, sessionId: ctx.sessionId, acpSource, @@ -703,22 +650,35 @@ export function processTranscriptEvent( ): TranscriptState { const d = draftFrom(state); - if (event.sessionId && event.sessionId !== d.latestSessionId) { - d.latestSessionId = event.sessionId; - } - const channelId = event.channelId ?? null; + const threadRoot = event.threadRoot ?? null; + const conversationKey = transcriptConversationKey(channelId, threadRoot); + const latestSessionId = + d.latestSessionIdByConversation.get(conversationKey) ?? null; + if (event.sessionId && event.sessionId !== latestSessionId) { + d.latestSessionIdByConversation = new Map(d.latestSessionIdByConversation); + d.latestSessionIdByConversation.set(conversationKey, event.sessionId); + } const ch = channelId ?? "global"; + // ACP message/tool IDs are session-local for several adapters. Namespace + // every transcript identity by the canonical conversation so sequential + // A/B/A turns in one channel cannot merge equal IDs across thread sessions. + // Keep the legacy key shape exactly unchanged when threadRoot is absent. + const scopeKey = threadRoot ? `${ch}:thread:${threadRoot}` : ch; const ctx: TranscriptItemContext = { channelId, + threadRoot, turnId: event.turnId, - sessionId: event.sessionId ?? d.latestSessionId, + sessionId: + event.sessionId ?? + d.latestSessionIdByConversation.get(conversationKey) ?? + null, }; if (event.kind === "raw_json_rpc") { upsertMetadata( d, - `raw-json-rpc:${ch}:${event.seq}`, + `raw-json-rpc:${scopeKey}:${event.seq}`, "Raw ACP payload", [ { @@ -733,13 +693,13 @@ export function processTranscriptEvent( } else if (event.kind === "turn_started") { rememberTriggeringEventIds( d, - ch, + scopeKey, event.turnId ?? event.seq, extractTriggeringEventIds(event.payload), ); upsertTextItem( d, - `turn:${ch}:${event.turnId ?? event.seq}`, + `turn:${scopeKey}:${event.turnId ?? event.seq}`, "lifecycle", "Turn started", describeTurnStarted(event.payload), @@ -750,7 +710,7 @@ export function processTranscriptEvent( } else if (event.kind === "session_resolved") { upsertTextItem( d, - `session:${ch}:${event.turnId ?? event.seq}`, + `session:${scopeKey}:${event.turnId ?? event.seq}`, "lifecycle", "Session ready", describeSessionResolved(event.payload), @@ -761,7 +721,7 @@ export function processTranscriptEvent( } else if (event.kind === "acp_parse_error") { upsertTextItem( d, - `parse-error:${ch}:${event.seq}`, + `parse-error:${scopeKey}:${event.seq}`, "lifecycle", "Wire parse error", extractBlockText(event.payload), @@ -778,7 +738,7 @@ export function processTranscriptEvent( event.kind === "agent_panic" ? "Agent error (crash)" : "Turn error"; upsertTextItem( d, - `${event.kind}:${ch}:${event.turnId ?? event.seq}`, + `${event.kind}:${scopeKey}:${event.turnId ?? event.seq}`, "lifecycle", title, `${outcome}: ${displayError}`, @@ -792,7 +752,7 @@ export function processTranscriptEvent( if (method === "session/request_permission") { const request = describePermissionRequest(payload); - const itemId = `permission:${ch}:${event.turnId ?? event.seq}`; + const itemId = `permission:${scopeKey}:${event.turnId ?? event.seq}`; upsertLifecycleItem( d, itemId, @@ -809,7 +769,7 @@ export function processTranscriptEvent( const requestId = jsonRpcId(payload.id); if (requestId) { d.pendingPermissions = new Map(d.pendingPermissions); - d.pendingPermissions.set(requestId, { + d.pendingPermissions.set(`${scopeKey}\u0000${requestId}`, { itemId, optionNames: request.optionNames, }); @@ -819,7 +779,8 @@ export function processTranscriptEvent( const responseId = jsonRpcId(payload.id); const result = asRecord(asRecord(payload.result).outcome); const outcomeKind = asString(result.outcome); - const pending = responseId ? d.pendingPermissions.get(responseId) : null; + const pendingKey = responseId ? `${scopeKey}\u0000${responseId}` : null; + const pending = pendingKey ? d.pendingPermissions.get(pendingKey) : null; if (pending && outcomeKind && responseId) { const optionId = asString(result.optionId) ?? null; const outcomeText = describePermissionOutcome( @@ -835,7 +796,9 @@ export function processTranscriptEvent( }); // Remove from pending map — the outcome is now recorded. d.pendingPermissions = new Map(d.pendingPermissions); - d.pendingPermissions.delete(responseId); + if (pendingKey) { + d.pendingPermissions.delete(pendingKey); + } } } } else if (event.kind === "acp_write" && method === "session/prompt") { @@ -845,7 +808,7 @@ export function processTranscriptEvent( if (parsedPrompt.userText) { upsertMessage( d, - `prompt:${ch}:${event.turnId ?? event.seq}`, + `prompt:${scopeKey}:${event.turnId ?? event.seq}`, "user", parsedPrompt.userTitle, parsedPrompt.userText, @@ -854,13 +817,17 @@ export function processTranscriptEvent( parsedPrompt.userPubkey, "session/prompt:user", parsedPrompt.userEventId ?? - getSingleTriggeringEventId(d, ch, event.turnId ?? event.seq), + getSingleTriggeringEventId( + d, + scopeKey, + event.turnId ?? event.seq, + ), ); } if (parsedPrompt.sections.length > 0) { upsertMetadata( d, - `prompt-context:${ch}:${event.turnId ?? event.seq}`, + `prompt-context:${scopeKey}:${event.turnId ?? event.seq}`, "Prompt context", parsedPrompt.sections, event.timestamp, @@ -885,7 +852,7 @@ export function processTranscriptEvent( if (sections.length > 0) { upsertMetadata( d, - `system-prompt:${ch}:${event.seq}:${event.timestamp}`, + `system-prompt:${scopeKey}:${event.seq}:${event.timestamp}`, "System prompt", sections, event.timestamp, @@ -904,7 +871,7 @@ export function processTranscriptEvent( if (parsedPrompt.userText) { upsertMessage( d, - `steer:${ch}:${event.turnId ?? event.seq}`, + `steer:${scopeKey}:${event.turnId ?? event.seq}`, "user", parsedPrompt.userTitle, parsedPrompt.userText, @@ -918,7 +885,7 @@ export function processTranscriptEvent( if (parsedPrompt.sections.length > 0) { upsertMetadata( d, - `steer-context:${ch}:${event.turnId ?? event.seq}`, + `steer-context:${scopeKey}:${event.turnId ?? event.seq}`, "Prompt context", parsedPrompt.sections, event.timestamp, @@ -937,7 +904,7 @@ export function processTranscriptEvent( if (updateType === "agent_message_chunk") { upsertMessage( d, - `assistant:${ch}:${messageId ?? turnKey}`, + `assistant:${scopeKey}:${messageId ?? turnKey}`, "assistant", "Assistant", extractContentText(update.content), @@ -949,13 +916,13 @@ export function processTranscriptEvent( } else if (updateType === "user_message_chunk") { // Suppress user_message_chunk echo when a steer already rendered // the user message for this turn (Goose echoes steered content back). - const steerKey = `steer:${ch}:${event.turnId ?? event.seq}`; + const steerKey = `steer:${scopeKey}:${event.turnId ?? event.seq}`; const authorPubkey = asString(update.authorPubkey); if (!d.itemsById.has(steerKey)) { const channelMessageId = maybeNostrEventId(messageId); upsertMessage( d, - `user:${ch}:${messageId ?? turnKey}`, + `user:${scopeKey}:${messageId ?? turnKey}`, "user", "User", extractContentText(update.content), @@ -969,7 +936,7 @@ export function processTranscriptEvent( } else if (updateType === "agent_thought_chunk") { upsertTextItem( d, - `thinking:${ch}:${messageId ?? turnKey}`, + `thinking:${scopeKey}:${messageId ?? turnKey}`, "thought", "Thinking", extractContentText(update.content), @@ -982,7 +949,7 @@ export function processTranscriptEvent( const identity = extractToolIdentity(update); upsertTool( d, - `tool:${ch}:${toolId}`, + `tool:${scopeKey}:${toolId}`, identity.title, identity.toolName, identity.buzzToolName, @@ -1002,7 +969,7 @@ export function processTranscriptEvent( const identity = extractToolIdentity(update); upsertTool( d, - `tool:${ch}:${toolId}`, + `tool:${scopeKey}:${toolId}`, identity.title, identity.toolName, identity.buzzToolName, @@ -1017,20 +984,20 @@ export function processTranscriptEvent( } else if (updateType === "plan") { upsertPlan( d, - `plan:${ch}:${turnKey}`, + `plan:${scopeKey}:${turnKey}`, "Plan", extractPlanText(update), event.timestamp, ctx, updateType, - `plan-update:${ch}:${turnKey}:${event.seq}`, + `plan-update:${scopeKey}:${turnKey}:${event.seq}`, ); } else if (updateType === "current_mode_update") { const mode = asString(update.currentModeId) ?? ""; if (mode) { upsertLifecycleItem( d, - `mode:${ch}:${turnKey}`, + `mode:${scopeKey}:${turnKey}`, "status", "Mode", mode, @@ -1053,7 +1020,7 @@ export function processTranscriptEvent( : ""; replaceLifecycleItem( d, - `usage:${ch}:${turnKey}`, + `usage:${scopeKey}:${turnKey}`, "status", "Usage", `Tokens: ${used}/${size}${costStr}`, @@ -1068,7 +1035,7 @@ export function processTranscriptEvent( : []; upsertLifecycleItem( d, - `commands:${ch}:${turnKey}`, + `commands:${scopeKey}:${turnKey}`, "status", "Commands", `Commands available: ${cmds.length}`, @@ -1093,7 +1060,7 @@ export function processTranscriptEvent( if (optText) { upsertLifecycleItem( d, - `config:${ch}:${turnKey}`, + `config:${scopeKey}:${turnKey}`, "status", "Config", optText, @@ -1110,7 +1077,7 @@ export function processTranscriptEvent( if (status) { upsertLifecycleItem( d, - `status:${ch}:${event.turnId ?? event.seq}:${status.statusType}`, + `status:${scopeKey}:${event.turnId ?? event.seq}:${status.statusType}`, "status", status.title, status.text, @@ -1128,7 +1095,7 @@ export function processTranscriptEvent( if (status) { upsertLifecycleItem( d, - `status:${ch}:${event.turnId ?? event.seq}:${status.statusType}`, + `status:${scopeKey}:${event.turnId ?? event.seq}:${status.statusType}`, "status", status.title, status.text, @@ -1140,7 +1107,10 @@ export function processTranscriptEvent( } } - if (!d.changed && d.latestSessionId === state.latestSessionId) { + if ( + !d.changed && + d.latestSessionIdByConversation === state.latestSessionIdByConversation + ) { return state; } @@ -1152,7 +1122,7 @@ export function processTranscriptEvent( triggeringEventIdsByTurn: d.triggeringEventIdsByTurn, pendingPermissions: d.pendingPermissions, continuationSeq: d.continuationSeq, - latestSessionId: d.latestSessionId, + latestSessionIdByConversation: d.latestSessionIdByConversation, }; } diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.test.mjs b/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.test.mjs index 7fddb8a7f1..0f09631e9b 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.test.mjs +++ b/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.test.mjs @@ -7,7 +7,12 @@ import { formatTurnSetupLabel, getDisplayBlockKey, } from "./agentSessionTranscriptGrouping.ts"; -import { isObserverEventAfter } from "../observerRelayStore.ts"; +import { + getLatestLiveSessionIds, + isObserverEventAfter, + recordLatestLiveSession, + resetAgentObserverStore, +} from "../observerRelayStore.ts"; const baseTimestamp = "2026-06-14T22:20:23.000Z"; @@ -1096,6 +1101,120 @@ test("buildTranscriptDisplayBlocks_nonContiguousRunsSameSession_distinctBoundary ); }); +test("interleaved thread sessions are grouped once instead of fragmenting A-B-A", () => { + const rootA = "a".repeat(64); + const rootB = "b".repeat(64); + const firstA = { + ...sessionItem("a-1", "sess-A", "2026-07-08T00:00:01.000Z"), + threadRoot: rootA, + }; + const onlyB = { + ...sessionItem("b-1", "sess-B", "2026-07-08T00:00:02.000Z"), + threadRoot: rootB, + }; + const secondA = { + ...sessionItem("a-2", "sess-A", "2026-07-08T00:00:03.000Z"), + threadRoot: rootA, + }; + + const blocks = buildTranscriptDisplayBlocks([firstA, onlyB, secondA]); + const flatIds = flattenDisplayBlocks(blocks).map((item) => item.id); + assert.deepEqual( + flatIds, + ["b-1", "a-1", "a-2"], + "conversation groups are ordered by last activity and A remains contiguous", + ); + assert.equal( + blocks.filter((block) => block.kind === "session-boundary").length, + 1, + "two conversation sessions produce two runs, not A/B/A's three", + ); +}); + +test("each concurrent thread can label its latest session current", () => { + const rootA = "a".repeat(64); + const rootB = "b".repeat(64); + const items = [ + { + ...sessionItem("b-old", "sess-B-old", "2026-07-08T00:00:01.000Z"), + threadRoot: rootB, + }, + { + ...sessionItem("b-live", "sess-B-live", "2026-07-08T00:00:02.000Z"), + threadRoot: rootB, + }, + { + ...sessionItem("a-old", "sess-A-old", "2026-07-08T00:00:03.000Z"), + threadRoot: rootA, + }, + { + ...sessionItem("a-live", "sess-A-live", "2026-07-08T00:00:04.000Z"), + threadRoot: rootA, + }, + ]; + const boundaries = buildTranscriptDisplayBlocks(items, [ + "sess-A-live", + "sess-B-live", + ]).filter((block) => block.kind === "session-boundary"); + + assert.equal( + boundaries.find((block) => block.sessionId === "sess-B-live")?.labelState, + "current", + ); + assert.equal( + boundaries.find((block) => block.sessionId === "sess-A-live")?.labelState, + "current", + ); +}); + +test("latest-live store keeps same-channel conversations independently current", () => { + resetAgentObserverStore(); + const agent = "f".repeat(64); + const channelId = "channel-1"; + const frame = (seq, threadRoot, sessionId, timestamp) => ({ + seq, + timestamp, + kind: "session_resolved", + agentIndex: 0, + channelId, + threadRoot, + sessionId, + turnId: `turn-${seq}`, + payload: {}, + }); + + recordLatestLiveSession( + agent, + frame(1, "a".repeat(64), "sess-A", "2026-07-08T00:00:01.000Z"), + ); + recordLatestLiveSession( + agent, + frame(2, "b".repeat(64), "sess-B", "2026-07-08T00:00:02.000Z"), + ); + recordLatestLiveSession( + agent, + frame(3, "a".repeat(64), "stale-A", "2026-07-08T00:00:00.000Z"), + ); + + const firstSnapshot = getLatestLiveSessionIds(agent, channelId); + assert.deepEqual([...firstSnapshot].sort(), ["sess-A", "sess-B"]); + assert.equal( + getLatestLiveSessionIds(agent, channelId), + firstSnapshot, + "useSyncExternalStore snapshot stays reference-stable without a change", + ); + + recordLatestLiveSession( + agent, + frame(4, "a".repeat(64), "sess-A-2", "2026-07-08T00:00:04.000Z"), + ); + assert.deepEqual([...getLatestLiveSessionIds(agent, channelId)].sort(), [ + "sess-A-2", + "sess-B", + ]); + resetAgentObserverStore(); +}); + // ── session/new run-anchor: restart scenario ────────────────────────────────── /** diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts b/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts index 795f1fcd16..95be81a8f3 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts +++ b/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts @@ -23,8 +23,8 @@ export type TranscriptDisplayBlock = * newer session in reading order). * * `labelState` encodes three distinct states: - * - `"current"` — newest-visible session AND matches the live relay - * session id. Agent is actively running this session. + * - `"current"` — matches one conversation's live relay session id. + * Concurrent thread sessions can both be current. * - `"most-recent"` — newest-visible session but no live session match * (archived-only view or session ended). This is the * most recently observed session — not current context. @@ -424,7 +424,7 @@ function getRenderClass(item: TranscriptItem) { * * A new run begins whenever the sessionId changes to a distinct non-null value. */ -function splitIntoSessionRuns( +function splitConversationIntoSessionRuns( items: TranscriptItem[], ): Array<{ sessionId: string; items: TranscriptItem[] }> { const runs: Array<{ sessionId: string; items: TranscriptItem[] }> = []; @@ -506,6 +506,63 @@ function splitIntoSessionRuns( return runs; } +function transcriptConversationKey(item: TranscriptItem): string { + // Callers pass a channel-scoped stream, so the optional root is the only + // discriminator needed here. Keeping legacy items (which may omit channelId + // on individual rows) in one partition preserves pre-thread behavior. + return item.threadRoot ?? ""; +} + +/** + * Keep each channel/thread conversation contiguous before applying the + * session-boundary state machine. Concurrent thread sessions can arrive as + * A/B/A on one agent observer stream; treating the stream as one conversation + * would manufacture two A runs. Conversation groups are ordered by their last + * observed item so the globally most-recent conversation remains last. + * + * A legacy/unthreaded stream has one conversation key and follows the exact + * historical contiguous-run path. + */ +function splitIntoSessionRuns( + items: TranscriptItem[], +): Array<{ sessionId: string; items: TranscriptItem[] }> { + const conversations = new Map< + string, + { items: TranscriptItem[]; lastIndex: number } + >(); + items.forEach((item, index) => { + const key = transcriptConversationKey(item); + const conversation = conversations.get(key); + if (conversation) { + conversation.items.push(item); + conversation.lastIndex = index; + } else { + conversations.set(key, { items: [item], lastIndex: index }); + } + }); + + if (conversations.size <= 1) { + return splitConversationIntoSessionRuns(items); + } + + return [...conversations.values()] + .sort((left, right) => left.lastIndex - right.lastIndex) + .flatMap((conversation) => + splitConversationIntoSessionRuns(conversation.items), + ); +} + +type LatestLiveSessionIdentity = string | readonly string[] | null; + +function isLatestLiveSession( + sessionId: string, + latestLiveSessionIds: LatestLiveSessionIdentity, +): boolean { + return typeof latestLiveSessionIds === "string" + ? sessionId === latestLiveSessionIds + : (latestLiveSessionIds?.includes(sessionId) ?? false); +} + /** * Build presentation-only display blocks from normalized transcript items. * Raw observer order is preserved in the source items; this only reorders @@ -520,16 +577,16 @@ function splitIntoSessionRuns( * When items span multiple sessions (archived history + live session), a * `session-boundary` block is injected between consecutive session runs. * The newest-visible run is labeled distinctly when it equals - * `latestLiveSessionId`, signalling "current session" vs archived history. + * any `latestLiveSessionIds`, signalling "current session" vs archived history. * No boundary is emitted when only one session run is present. * - * @param latestLiveSessionId - The session ID that is currently live on the - * relay (from `observerRelayStore`). Used to distinguish "current session" - * from "most recent observed session". Pass `null` when the relay is idle. + * @param latestLiveSessionIds - Session IDs currently live per conversation + * on the relay (from `observerRelayStore`). A single string remains accepted + * for backwards compatibility with callers and archived tests. */ export function buildTranscriptDisplayBlocks( items: TranscriptItem[], - latestLiveSessionId: string | null = null, + latestLiveSessionIds: LatestLiveSessionIdentity = null, ): TranscriptDisplayBlock[] { const sessionRuns = splitIntoSessionRuns(items); @@ -539,7 +596,7 @@ export function buildTranscriptDisplayBlocks( sessionRuns[0]?.items ?? [], /* isNewestRun */ true, sessionRuns[0]?.sessionId ?? null, - latestLiveSessionId, + latestLiveSessionIds, /* emitBoundary */ false, ); } @@ -556,9 +613,7 @@ export function buildTranscriptDisplayBlocks( const sessionStartTimestamp = firstItem?.timestamp ?? new Date(0).toISOString(); const labelState: "current" | "most-recent" | "earlier" = - isNewestRun && - latestLiveSessionId !== null && - run.sessionId === latestLiveSessionId + isLatestLiveSession(run.sessionId, latestLiveSessionIds) ? "current" : isNewestRun ? "most-recent" @@ -582,7 +637,7 @@ export function buildTranscriptDisplayBlocks( run.items, isNewestRun, run.sessionId, - latestLiveSessionId, + latestLiveSessionIds, /* emitBoundary */ false, ); allBlocks.push(...runBlocks); @@ -600,7 +655,7 @@ function buildBlocksForRun( items: TranscriptItem[], _isNewestRun: boolean, _sessionId: string | null, - _latestLiveSessionId: string | null, + _latestLiveSessionIds: LatestLiveSessionIdentity, _emitBoundary: boolean, ): TranscriptDisplayBlock[] { const blocks: TranscriptDisplayBlock[] = []; diff --git a/desktop/src/features/agents/ui/agentSessionTypes.ts b/desktop/src/features/agents/ui/agentSessionTypes.ts index 578f98076c..8d34952cdc 100644 --- a/desktop/src/features/agents/ui/agentSessionTypes.ts +++ b/desktop/src/features/agents/ui/agentSessionTypes.ts @@ -6,6 +6,8 @@ export type ObserverEvent = { kind: string; agentIndex: number | null; channelId: string | null; + /** Canonical thread root. Absent on legacy, unthreaded, and DM frames. */ + threadRoot?: string | null; sessionId: string | null; turnId: string | null; startedAt?: string | null; @@ -66,6 +68,7 @@ export type TranscriptItemIdentity = { turnId?: string | null; sessionId?: string | null; channelId?: string | null; + threadRoot?: string | null; }; export type TranscriptItem = diff --git a/desktop/src/shared/api/agentControl.ts b/desktop/src/shared/api/agentControl.ts index 677f0ffad4..34bfb0e0a4 100644 --- a/desktop/src/shared/api/agentControl.ts +++ b/desktop/src/shared/api/agentControl.ts @@ -22,10 +22,12 @@ export async function switchManagedAgentModel( pubkey: string, channelId: string, modelId: string, + requestId?: string, ): Promise { await sendAgentObserverControl(pubkey, { type: "switch_model", channelId, modelId, + requestId, }); } diff --git a/desktop/src/shared/api/controlResultTypes.ts b/desktop/src/shared/api/controlResultTypes.ts new file mode 100644 index 0000000000..70cbdc85cd --- /dev/null +++ b/desktop/src/shared/api/controlResultTypes.ts @@ -0,0 +1,22 @@ +/** + * Outcome of a live `switch_model` control frame, surfaced asynchronously via + * the agent's `control_result` observer frame. Busy path: `sent` (cancel + + * requeue on the new model) or `turn_ending` (oneshot already consumed this + * turn). Idle path: `switched`, `unsupported_model`, or `no_active_turn`. + */ +export type SwitchManagedAgentModelStatus = + | "sent" + | "turn_ending" + | "switched" + | "unsupported_model" + | "superseded" + | "no_active_turn"; + +export type ControlResultFrame = { + type: "cancel_turn" | "switch_model"; + status: string; + modelId?: string; + channelId?: string; + generation?: number; + requestId?: string | null; +}; diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index 689c400b03..475520efbe 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -479,24 +479,10 @@ export type CancelManagedAgentTurnResult = { status: "sent" | "no_active_turn"; }; -/** - * Outcome of a live `switch_model` control frame, surfaced asynchronously via - * the agent's `control_result` observer frame. Busy path: `sent` (cancel + - * requeue on the new model) or `turn_ending` (oneshot already consumed this - * turn). Idle path: `switched`, `unsupported_model`, or `no_active_turn`. - */ -export type SwitchManagedAgentModelStatus = - | "sent" - | "turn_ending" - | "switched" - | "unsupported_model" - | "no_active_turn"; - -export type ControlResultFrame = { - type: "cancel_turn" | "switch_model"; - status: string; - modelId?: string; -}; +export type { + ControlResultFrame, + SwitchManagedAgentModelStatus, +} from "./controlResultTypes"; export type GitBashPrerequisite = { available: boolean;