diff --git a/docs/buzz-help.md b/docs/buzz-help.md index e66c6620d9..212c6c314b 100644 --- a/docs/buzz-help.md +++ b/docs/buzz-help.md @@ -511,7 +511,12 @@ available rather than silently discarding it. resends work already sent through the gateway without it. Don't say an agent uses the gateway's models unless its Settings shows Model gateway. - **Activity**, where the agent type provides it, shows sessions and their - transcripts in the agent's profile. A turn's **Thread** or **Channel** label + transcripts in the agent's profile. Once goose agents run through the agent + gateway, a goose turn woken in a conversation by a plugin, watch or timer + that ran without the gateway shows "Ran without the agent gateway" beside its + prompt only while the app keeps running; goose compacting the conversation + can remove the prompt and its note. A wake run outside a conversation shows + no note. A turn's **Thread** or **Channel** label opens that conversation. Choosing an agent in the activity under a message, or a Working now agent whose conversation is known, opens that conversation's transcript. Working indicators describe current work, diff --git a/docs/configuration.md b/docs/configuration.md index 0421ec6690..a5cee046da 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -150,7 +150,10 @@ configuration and none of the Buzz request context, and the turn is noted mention, and in the harness's own turn activity for other turns, which receive it as `delivery.gatewayFallback`. That choice is made before dispatch; a turn whose gateway access is revoked after dispatch fails visibly and is never -replayed. Memberships in other communities and hosted agents are unchanged. +replayed. Once goose runs through the gateway, a goose agent's wake anchored +in a conversation shows that note beside its prompt in Activity only while the +app that ran it keeps running; a reload, or goose compacting the prompt out of +its history, drops it. A standalone run (`runOnce`) shows none. Memberships in other communities and hosted agents are unchanged. See `src/features/agents/gateway/` for the shared route, session and request context contract that harnesses consume. diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 4b23bf34ed..23fef61852 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -179,6 +179,7 @@ fn main() { "plugin_host_forward_admission", "plugin_host_forward_open", "plugin_host_forward_associate", + "plugin_host_forward_register_child", "plugin_host_forward_seal", "plugin_host_forward_close", "plugin_host_read_file", diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 046f8ef44b..5101184f27 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -125,6 +125,7 @@ "allow-plugin-host-forward-admission", "allow-plugin-host-forward-open", "allow-plugin-host-forward-associate", + "allow-plugin-host-forward-register-child", "allow-plugin-host-forward-seal", "allow-plugin-host-forward-close", "allow-plugin-host-read-file", diff --git a/src-tauri/src/browser_permissions_tests.rs b/src-tauri/src/browser_permissions_tests.rs index 0204d6eea2..3f6cd7cdcb 100644 --- a/src-tauri/src/browser_permissions_tests.rs +++ b/src-tauri/src/browser_permissions_tests.rs @@ -166,6 +166,7 @@ fn native_command_permissions_isolate_webviews() { "plugin_host_forward_admission", "plugin_host_forward_open", "plugin_host_forward_associate", + "plugin_host_forward_register_child", "plugin_host_forward_seal", "plugin_host_forward_close", "plugin_host_read_file", diff --git a/src-tauri/src/host_forward.rs b/src-tauri/src/host_forward.rs index 0a4f21aa5a..8519a98890 100644 --- a/src-tauri/src/host_forward.rs +++ b/src-tauri/src/host_forward.rs @@ -22,6 +22,11 @@ //! - Inference must name the generation's harness session in the harness's own //! header. A Claude generation is opened with its session; a goose one opens //! for discovery only and is associated once, before its first prompt. +//! - A goose generation also admits the children its parent session delegates +//! to, each registered from a live delegate call that named it. +//! Inference naming an unregistered child parks until it is registered, at +//! most `PENDING_CAP` at once and `RENDEZVOUS_DEADLINE` each; expiry, +//! sealing, revocation and close refuse it, never admit it. //! - Sealing is terminal: later requests get 410, which harnesses don't retry, //! while admitted streams finish. Closing forgets the handle (404) and aborts //! its streams. Neither leaves a record behind. @@ -35,7 +40,7 @@ use hyper::header::{self, HeaderMap, HeaderName, HeaderValue}; use hyper::{server::conn::http1, service::service_fn, Method, Request, Response, StatusCode}; use hyper_util::rt::{TokioIo, TokioTimer}; use serde::{Deserialize, Serialize}; -use std::collections::{BTreeMap, HashMap}; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::convert::Infallible; use std::future::Future; use std::net::Ipv4Addr; @@ -45,7 +50,7 @@ use std::task::{Context, Poll}; use std::time::Duration; use tauri::ipc::Channel; use tokio::net::TcpListener; -use tokio::sync::{mpsc, Semaphore}; +use tokio::sync::{mpsc, oneshot, Semaphore}; use tokio_util::sync::{CancellationToken, WaitForCancellationFutureOwned}; /// Generations open at once across all plugins. @@ -59,6 +64,15 @@ const BODY_LIMIT: usize = 64 * 1024 * 1024; const CONNECT_TIMEOUT: Duration = Duration::from_secs(30); /// The whole reserved header block, counted as `name: value\r\n` lines. const HEADER_BUDGET: usize = 2048; +/// A goose generation's child rendezvous bounds: inference requests parked +/// for a child not yet registered, registered children, and the delegate +/// calls that named them (a resume adds a call, not a child). +const PENDING_CAP: usize = 5; +const CHILD_CAP: usize = 32; +const CALL_CAP: usize = 64; +/// How long a parked request waits for its child's registration, by +/// default. It only bounds resources: expiry rejects, never admits. +const RENDEZVOUS_DEADLINE: Duration = Duration::from_secs(5); /// Never forwarded: the connection's own, the address and anything a browser /// adds, plus whatever `Connection` names. Credentials and every other header /// pass through unchanged. @@ -217,8 +231,13 @@ fn origin(origin: &str) -> Option { #[derive(Clone, Debug, Serialize, PartialEq, Eq)] #[serde(tag = "type", rename_all = "camelCase")] pub(crate) enum ForwardEvent { - /// A request passed admission and was sent upstream. - Admitted { request: u64, class: Class }, + /// A request passed admission and was sent upstream. `child`: it named a + /// registered goose child, not the generation's own session. + Admitted { + request: u64, + class: Class, + child: bool, + }, /// An admitted request's upstream response ended: relayed whole, or /// aborted by the harness hanging up, a revocation or a close. `status` is /// what the harness was answered, if anything yet. @@ -267,6 +286,18 @@ pub(crate) enum Pushed { Stale, } +/// A goose child registration's outcome. Contract violations are errors. +#[derive(Clone, Copy, Debug, Serialize, PartialEq, Eq)] +#[serde(tag = "type", rename_all = "camelCase")] +pub(crate) enum Registered { + Registered, + /// `child_call_conflict`, `child_cap`, `call_cap`, `generation_ended` + /// (sealed, closed or unknown) or `generation_revoked`. + Refused { + reason: &'static str, + }, +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum State { Open, @@ -290,6 +321,30 @@ struct Generation { abort: CancellationToken, events: Arc, requests: u64, + /// goose children registered for this generation's parent session, + /// frozen to it; each delegate call names one child. + children: HashSet, + calls: HashMap, + /// Inference requests parked for a child, by their own waiter ID. + pending: HashMap, + next_waiter: u64, +} +impl Generation { + /// Answers every parked request; none can outlive admission. + fn release(&mut self, status: StatusCode, reason: &'static str) { + for (_, waiter) in self.pending.drain() { + let _ = waiter.wake.send(Wake::Released(status, reason)); + } + } +} + +struct Waiter { + session: String, + wake: oneshot::Sender, +} +enum Wake { + Registered, + Released(StatusCode, &'static str), } #[derive(Clone, Copy, Default)] @@ -319,6 +374,7 @@ impl Registry { if let Some(generation) = self.generations.get_mut(handle) { generation.state = State::Revoked; generation.abort.cancel(); + generation.release(StatusCode::GONE, "generation_revoked"); } } } @@ -334,6 +390,7 @@ pub(crate) struct HostForward { inner: Arc>, /// The shared listener's address, bound on first use for the app's life. listener: Arc>>, + rendezvous: Duration, } impl Default for HostForward { fn default() -> Self { @@ -346,6 +403,7 @@ impl HostForward { gateway: gateway.map(Arc::new), inner: Arc::default(), listener: Arc::default(), + rendezvous: RENDEZVOUS_DEADLINE, } } fn registry(&self) -> MutexGuard<'_, Registry> { @@ -479,6 +537,10 @@ impl HostForward { abort: CancellationToken::new(), events: Arc::new(Events(events)), requests: 0, + children: HashSet::new(), + calls: HashMap::new(), + pending: HashMap::new(), + next_waiter: 0, }, ); Ok(Opened::Opened { @@ -511,10 +573,71 @@ impl HostForward { let generation = registry.generations.get_mut(&handle).expect("found"); if generation.state == State::Open { generation.state = State::Sealed; + generation.release(StatusCode::GONE, "generation_ended"); } Ok(()) } + /// Registers the goose child `child` that delegate `call` named, waking + /// every request parked for it in the same critical section. + fn register_child( + &self, + plugin: &str, + id: u64, + call: String, + child: String, + ) -> Result { + let refused = |reason| Ok(Registered::Refused { reason }); + let mut registry = self.registry(); + let Ok(handle) = registry.handle_of(plugin, id) else { + return refused("generation_ended"); + }; + let generation = ®istry.generations[&handle]; + let current = registry + .routes + .get(&(generation.plugin.clone(), generation.route.clone())) + .copied() + .unwrap_or_default(); + let generation = registry.generations.get_mut(&handle).expect("found"); + if generation.harness != Harness::Goose { + return Err("Only a goose generation has children".into()); + } + let Some(parent) = generation.session.as_deref() else { + return Err("The generation's session is not associated".into()); + }; + if !opaque(&call) || !opaque(&child) || child == parent { + return Err("Invalid child session".into()); + } + match generation.state { + State::Open if current.admits && current.revision == generation.revision => {} + State::Open | State::Revoked => return refused("generation_revoked"), + State::Sealed => return refused("generation_ended"), + } + match generation.calls.get(&call) { + Some(named) if *named == child => return Ok(Registered::Registered), + Some(_) => return refused("child_call_conflict"), + None if generation.calls.len() >= CALL_CAP => return refused("call_cap"), + None => {} + } + if !generation.children.contains(&child) && generation.children.len() >= CHILD_CAP { + return refused("child_cap"); + } + generation.calls.insert(call, child.clone()); + generation.children.insert(child.clone()); + let woken: Vec = generation + .pending + .iter() + .filter(|(_, waiter)| waiter.session == child) + .map(|(waiter, _)| *waiter) + .collect(); + for waiter in woken { + if let Some(waiter) = generation.pending.remove(&waiter) { + let _ = waiter.wake.send(Wake::Registered); + } + } + Ok(Registered::Registered) + } + fn close(&self, plugin: &str, id: u64) -> Result<(), String> { let mut registry = self.registry(); let handle = registry.handle_of(plugin, id)?; @@ -553,8 +676,32 @@ impl HostForward { Ok(authority) } - /// Admits one request under the lock that revocation takes, or refuses it. - fn admit(&self, request: &Request) -> Result { + /// Admits one request, or refuses it. goose inference naming a child not + /// yet registered waits, once and bounded, then is admitted afresh. + async fn admission(&self, request: &Request) -> Result { + let mut parked = match self.admit(request, true)? { + Step::Admit(admitted) => return Ok(admitted), + Step::Park(parked) => parked, + }; + let refuse = |status, reason| Refusal { + status, + reason, + events: Some(parked.events.clone()), + }; + match tokio::time::timeout(self.rendezvous, &mut parked.wake).await { + Ok(Ok(Wake::Registered)) => match self.admit(request, false)? { + Step::Admit(admitted) => Ok(admitted), + Step::Park(_) => unreachable!("a re-check never parks"), + }, + Ok(Ok(Wake::Released(status, reason))) => Err(refuse(status, reason)), + Ok(Err(_)) => Err(refuse(StatusCode::GONE, "generation_ended")), + Err(_) => Err(refuse(StatusCode::GATEWAY_TIMEOUT, "child_unregistered")), + } + } + + /// Admits one request under the lock that revocation and registration + /// take, parks it for a goose child when `wait`, or refuses it. + fn admit(&self, request: &Request, wait: bool) -> Result { let authority = self .listener .lock() @@ -618,7 +765,15 @@ impl HostForward { (Class::Discovery, None, _) | (Class::Discovery, Some(_), None) => true, (_, Some(session), expected) => expected == Some(session), }; - if !matches { + // goose inference under another session may be a delegated child's. + let child = session.filter(|session| { + !matches + && class == Class::Inference + && generation.harness == Harness::Goose + && expected.is_some() + && opaque(session) + }); + if !matches && child.is_none() { let reason = if expected.is_none() { "session_unassociated" } else { @@ -629,26 +784,75 @@ impl HostForward { if !current.admits || current.revision != generation.revision { return Err(refuse(StatusCode::GONE, "generation_revoked")); } + if let Some(child) = child.filter(|child| !generation.children.contains(*child)) { + if !wait { + return Err(refuse(StatusCode::BAD_REQUEST, "session_mismatch")); + } + if generation.pending.len() >= PENDING_CAP { + return Err(refuse(StatusCode::TOO_MANY_REQUESTS, "child_pending_full")); + } + let (sender, wake) = oneshot::channel(); + let waiter = generation.next_waiter; + generation.next_waiter += 1; + generation.pending.insert( + waiter, + Waiter { + session: child.to_owned(), + wake: sender, + }, + ); + return Ok(Step::Park(Parked { + forward: self.clone(), + handle: handle.to_owned(), + waiter, + wake, + events: generation.events.clone(), + })); + } generation.requests += 1; let headers = match class { Class::Inference => generation.inference.clone(), Class::Discovery => generation.discovery.clone(), }; - Ok(Admitted { + Ok(Step::Admit(Admitted { request: generation.requests, class, + child: child.is_some(), harness: generation.harness, rest, headers, abort: generation.abort.clone(), events: generation.events.clone(), - }) + })) + } +} + +enum Step { + Admit(Admitted), + Park(Parked), +} + +/// A request parked for a goose child. However it ends, including hyper +/// dropping the request, it frees its own slot and no other. +struct Parked { + forward: HostForward, + handle: String, + waiter: u64, + wake: oneshot::Receiver, + events: Arc, +} +impl Drop for Parked { + fn drop(&mut self) { + if let Some(generation) = self.forward.registry().generations.get_mut(&self.handle) { + generation.pending.remove(&self.waiter); + } } } struct Admitted { request: u64, class: Class, + child: bool, harness: Harness, rest: String, headers: HeaderMap, @@ -738,7 +942,7 @@ fn error(status: StatusCode, reason: &str) -> Reply { } async fn forward(state: &HostForward, request: Request) -> Reply { - let admitted = match state.admit(&request) { + let admitted = match state.admission(&request).await { Ok(admitted) => admitted, Err(refusal) => { if let Some(events) = refusal.events { @@ -753,13 +957,18 @@ async fn forward(state: &HostForward, request: Request) -> Reply { let Admitted { request: id, class, + child, harness, rest, headers: bundle, abort, events, } = admitted; - events.send(ForwardEvent::Admitted { request: id, class }); + events.send(ForwardEvent::Admitted { + request: id, + class, + child, + }); let mut finished = Finished { events, request: id, @@ -1004,6 +1213,18 @@ pub(crate) fn plugin_host_forward_associate( forward.associate(&id, generation, session) } +/// Registers a goose child that a live delegate call named. +#[tauri::command] +pub(crate) fn plugin_host_forward_register_child( + forward: tauri::State<'_, HostForward>, + id: String, + generation: u64, + call: String, + child: String, +) -> Result { + forward.register_child(&id, generation, call, child) +} + /// Ends the generation's admission for good; admitted streams finish. #[tauri::command] pub(crate) fn plugin_host_forward_seal( diff --git a/src-tauri/src/host_forward/tests.rs b/src-tauri/src/host_forward/tests.rs index fff44610b2..90947da317 100644 --- a/src-tauri/src/host_forward/tests.rs +++ b/src-tauri/src/host_forward/tests.rs @@ -581,7 +581,7 @@ async fn forwards_inference_with_only_the_bundle_as_provenance() { ); assert_eq!( recv(&mut generation.events).await, - serde_json::json!({ "type": "admitted", "request": 1, "class": "inference" }) + serde_json::json!({ "type": "admitted", "request": 1, "class": "inference", "child": false }) ); assert_eq!( recv(&mut generation.events).await, @@ -766,7 +766,8 @@ async fn only_allowlisted_paths_reach_the_gateway() { #[tokio::test] async fn inference_must_name_the_generation_session() { let mut gateway = fake_gateway().await; - let forward = ready(gateway.origin); + let mut forward = ready(gateway.origin); + forward.rendezvous = Duration::from_millis(100); let mut claude = open(&forward, Harness::Claude, 1); let client = reqwest::Client::new(); let post = || { @@ -841,9 +842,11 @@ async fn inference_must_name_the_generation_session() { assert!(forward .associate(PLUGIN, claude.id, "20261104_1".into()) .is_err()); + // Another session may be a child not yet registered: it waits, then is + // refused, and nothing reaches the gateway. assert_eq!( invoke("20261104_2").send().await.unwrap().status(), - StatusCode::BAD_REQUEST + StatusCode::GATEWAY_TIMEOUT ); assert_eq!( invoke("20261104_1").send().await.unwrap().status(), @@ -1357,8 +1360,9 @@ fn ipc_shapes_match_the_typescript_bindings() { ForwardEvent::Admitted { request: 1, class: Class::Discovery, + child: false, }, - serde_json::json!({ "type": "admitted", "request": 1, "class": "discovery" }), + serde_json::json!({ "type": "admitted", "request": 1, "class": "discovery", "child": false }), ), ( ForwardEvent::Finished { @@ -1382,6 +1386,16 @@ fn ipc_shapes_match_the_typescript_bindings() { ] { assert_eq!(json(serde_json::to_value(event)), expected); } + assert_eq!( + json(serde_json::to_value(Registered::Registered)), + serde_json::json!({ "type": "registered" }) + ); + assert_eq!( + json(serde_json::to_value(Registered::Refused { + reason: "child_cap" + })), + serde_json::json!({ "type": "refused", "reason": "child_cap" }) + ); assert_eq!( serde_json::from_value::(serde_json::json!("claude")).unwrap(), Harness::Claude @@ -1391,3 +1405,348 @@ fn ipc_shapes_match_the_typescript_bindings() { Harness::Goose ); } + +const PARENT: &str = "20261104_1"; +const CHILD: &str = "20261104_2"; + +/// A goose generation associated with `PARENT`. +fn goose(forward: &HostForward) -> Opening { + let generation = open(forward, Harness::Goose, 1); + forward + .associate(PLUGIN, generation.id, PARENT.into()) + .unwrap(); + generation +} +fn invoke(url: &str, session: &str) -> reqwest::RequestBuilder { + reqwest::Client::new() + .post(format!("{url}/serving-endpoints/model-a/invocations")) + .header("agent-session-id", session) + .body("{}") +} +fn register(forward: &HostForward, id: u64, call: &str, child: &str) -> Registered { + forward + .register_child(PLUGIN, id, call.into(), child.into()) + .unwrap() +} +fn refused(reason: &'static str) -> Registered { + Registered::Refused { reason } +} +fn pending(forward: &HostForward) -> usize { + let registry = forward.registry(); + registry.generations.values().map(|g| g.pending.len()).sum() +} +/// Ready, with a deadline no test reaches, so a parked request stays parked +/// until the test releases it. +fn patient(origin: &str) -> HostForward { + let mut forward = ready(origin); + forward.rendezvous = Duration::from_secs(3600); + forward +} +/// Waits until `count` requests are parked: an observed state, not a delay. +async fn parked(forward: &HostForward, count: usize) { + tokio::time::timeout(Duration::from_secs(5), async { + while pending(forward) != count { + tokio::time::sleep(Duration::from_millis(1)).await; + } + }) + .await + .expect("parked in time"); +} +fn send(request: reqwest::RequestBuilder) -> tokio::task::JoinHandle { + tokio::spawn(async move { request.send().await.unwrap() }) +} +/// A parked request's response, bounded well inside `patient`'s deadline so +/// a lost wake fails the test in seconds instead of holding it for an hour. +async fn answered(response: tokio::task::JoinHandle) -> reqwest::Response { + tokio::time::timeout(Duration::from_secs(5), response) + .await + .expect("answered in time") + .unwrap() +} + +#[tokio::test] +async fn a_registered_child_is_admitted_under_its_own_session() { + let mut gateway = fake_gateway().await; + let forward = ready(gateway.origin); + let mut generation = goose(&forward); + assert_eq!( + register(&forward, generation.id, "call-1", CHILD), + Registered::Registered + ); + let response = invoke(&generation.url, CHILD).send().await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let seen = recv(&mut gateway.seen).await; + assert_eq!(seen.headers["agent-session-id"], CHILD); + assert_eq!(seen.headers["x-gw-ctx-message-id"], MESSAGE); + assert_eq!( + recv(&mut generation.events).await, + serde_json::json!({ "type": "admitted", "request": 1, "class": "inference", "child": true }) + ); + // The parent's own traffic is not a child's. + invoke(&generation.url, PARENT).send().await.unwrap(); + recv(&mut generation.events).await; + assert_eq!(recv(&mut generation.events).await["child"], false); +} + +#[tokio::test] +async fn a_request_first_child_waits_dispatching_nothing_until_registered() { + let mut gateway = fake_gateway().await; + let forward = patient(gateway.origin); + let generation = goose(&forward); + let early = send(invoke(&generation.url, CHILD)); + parked(&forward, 1).await; + assert!(gateway.seen.try_recv().is_err()); + assert_eq!( + register(&forward, generation.id, "call-1", CHILD), + Registered::Registered + ); + assert_eq!(answered(early).await.status(), StatusCode::OK); + assert_eq!( + recv(&mut gateway.seen).await.headers["agent-session-id"], + CHILD + ); + assert!(gateway.seen.try_recv().is_err()); + assert_eq!(pending(&forward), 0); +} + +#[tokio::test] +async fn an_unregistered_child_is_refused_at_the_deadline() { + let mut gateway = fake_gateway().await; + let mut forward = ready(gateway.origin); + forward.rendezvous = Duration::from_millis(300); + let mut generation = goose(&forward); + // Only the outcome is asserted, never the brief parked state; the + // request-first test proves parking. + let started = std::time::Instant::now(); + let response = answered(send(invoke(&generation.url, CHILD))).await; + assert!(started.elapsed() >= forward.rendezvous); + assert_eq!(response.status(), StatusCode::GATEWAY_TIMEOUT); + assert!(response + .text() + .await + .unwrap() + .contains("gateway_child_unregistered")); + assert_eq!( + recv(&mut generation.events).await, + serde_json::json!({ "type": "refused", "status": 504, "reason": "child_unregistered" }) + ); + assert!(gateway.seen.try_recv().is_err()); + assert_eq!(pending(&forward), 0); + // A registration after expiry still admits a retry; expiry never did. + register(&forward, generation.id, "call-1", CHILD); + assert_eq!( + answered(send(invoke(&generation.url, CHILD))) + .await + .status(), + StatusCode::OK + ); +} + +#[tokio::test] +async fn parked_requests_are_capped_and_free_only_their_own_slot() { + let gateway = fake_gateway().await; + let forward = patient(gateway.origin); + let mut generation = goose(&forward); + let waiting: Vec<_> = (0..PENDING_CAP) + .map(|n| send(invoke(&generation.url, &format!("child-{n}")))) + .collect(); + parked(&forward, PENDING_CAP).await; + let full = answered(send(invoke(&generation.url, "child-x"))).await; + assert_eq!(full.status(), StatusCode::TOO_MANY_REQUESTS); + assert_eq!( + recv(&mut generation.events).await, + serde_json::json!({ "type": "refused", "status": 429, "reason": "child_pending_full" }) + ); + // A second request for child-0 cannot park; drop one harness request and + // only its slot is freed. + let mut waiting = waiting.into_iter(); + waiting.next().unwrap().abort(); + parked(&forward, PENDING_CAP - 1).await; + let again = send(invoke(&generation.url, "child-1")); + parked(&forward, PENDING_CAP).await; + // Both requests for child-1 are woken by one registration. + register(&forward, generation.id, "call-1", "child-1"); + assert_eq!(answered(again).await.status(), StatusCode::OK); + assert_eq!( + answered(waiting.next().unwrap()).await.status(), + StatusCode::OK + ); + assert_eq!(pending(&forward), PENDING_CAP - 2); +} + +#[tokio::test] +async fn a_cancelled_request_leaves_its_same_child_sibling_parked() { + let mut gateway = fake_gateway().await; + let forward = patient(gateway.origin); + let generation = goose(&forward); + let cancelled = send(invoke(&generation.url, CHILD)); + parked(&forward, 1).await; + let survivor = send(invoke(&generation.url, CHILD)); + parked(&forward, 2).await; + cancelled.abort(); + parked(&forward, 1).await; + assert_eq!( + register(&forward, generation.id, "call-1", CHILD), + Registered::Registered + ); + assert_eq!(answered(survivor).await.status(), StatusCode::OK); + assert_eq!( + recv(&mut gateway.seen).await.headers["agent-session-id"], + CHILD + ); + assert_eq!(pending(&forward), 0); + assert!(gateway.seen.try_recv().is_err()); +} + +#[tokio::test] +async fn seal_revocation_and_close_release_every_parked_request() { + let gateway = fake_gateway().await; + let forward = patient(gateway.origin); + let generation = goose(&forward); + let early = send(invoke(&generation.url, CHILD)); + parked(&forward, 1).await; + forward.seal(PLUGIN, generation.id).unwrap(); + assert_eq!(answered(early).await.status(), StatusCode::GONE); + assert_eq!( + register(&forward, generation.id, "call-1", CHILD), + refused("generation_ended") + ); + assert_eq!( + answered(send(invoke(&generation.url, CHILD))) + .await + .status(), + StatusCode::GONE + ); + + let revoked = goose(&forward); + let early = send(invoke(&revoked.url, CHILD)); + parked(&forward, 1).await; + forward + .push(forward.page(), PLUGIN, "route-a", 2, true) + .unwrap(); + let response = answered(early).await; + assert_eq!(response.status(), StatusCode::GONE); + assert!(response + .text() + .await + .unwrap() + .contains("gateway_generation_revoked")); + assert_eq!( + register(&forward, revoked.id, "call-1", CHILD), + refused("generation_revoked") + ); + + let closed = open(&forward, Harness::Goose, 2); + forward.associate(PLUGIN, closed.id, PARENT.into()).unwrap(); + let early = send(invoke(&closed.url, CHILD)); + parked(&forward, 1).await; + forward.close(PLUGIN, closed.id).unwrap(); + assert_eq!(answered(early).await.status(), StatusCode::GONE); + assert_eq!( + register(&forward, closed.id, "call-1", CHILD), + refused("generation_ended") + ); + assert_eq!(pending(&forward), 0); +} + +#[tokio::test] +async fn registration_is_frozen_to_its_generation_and_parent() { + let gateway = fake_gateway().await; + let forward = ready(gateway.origin); + let claude = open(&forward, Harness::Claude, 1); + let unassociated = open(&forward, Harness::Goose, 1); + let a = goose(&forward); + let b = goose(&forward); + let contract = |id, call: &str, child: &str| { + forward + .register_child(PLUGIN, id, call.into(), child.into()) + .is_err() + }; + assert!(contract(claude.id, "call-1", CHILD)); + assert!(contract(unassociated.id, "call-1", CHILD)); + assert!(contract(a.id, "call-1", PARENT)); + assert!(contract(a.id, "call/1", CHILD)); + assert!(contract(a.id, "call-1", "")); + assert_eq!( + forward + .register_child("other.plugin", a.id, "call-1".into(), CHILD.into()) + .unwrap(), + refused("generation_ended") + ); + assert_eq!( + register(&forward, a.id, "call-1", CHILD), + Registered::Registered + ); + assert_eq!( + register(&forward, a.id, "call-1", CHILD), + Registered::Registered + ); + assert_eq!( + register(&forward, a.id, "call-1", "20261104_3"), + refused("child_call_conflict") + ); + // Generation B never learns A's child; unknown sessions are never + // admitted once B ends. + forward.seal(PLUGIN, b.id).unwrap(); + assert_eq!( + invoke(&b.url, CHILD).send().await.unwrap().status(), + StatusCode::GONE + ); + assert!(!forward + .registry() + .generations + .values() + .any(|g| g.id == b.id && g.children.contains(CHILD))); + // Discovery is never parked or widened to children. + let discovery = reqwest::Client::new() + .get(format!("{}/api/2.0/serving-endpoints/m", a.url)) + .header("agent-session-id", CHILD) + .send() + .await + .unwrap(); + assert_eq!(discovery.status(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn children_and_calls_have_their_own_bounds() { + let gateway = fake_gateway().await; + let forward = ready(gateway.origin); + let generation = goose(&forward); + for n in 0..CHILD_CAP { + assert_eq!( + register( + &forward, + generation.id, + &format!("call-{n}"), + &format!("child-{n}") + ), + Registered::Registered + ); + } + assert_eq!( + register(&forward, generation.id, "call-new", "child-new"), + refused("child_cap") + ); + // A resume under a new call adds a call, not a child. + for n in CHILD_CAP..CALL_CAP { + assert_eq!( + register(&forward, generation.id, &format!("call-{n}"), "child-0"), + Registered::Registered + ); + } + let sizes = |forward: &HostForward| { + let registry = forward.registry(); + let g = registry.generations.values().next().unwrap(); + (g.calls.len(), g.children.len()) + }; + assert_eq!(sizes(&forward), (CALL_CAP, CHILD_CAP)); + assert_eq!( + register(&forward, generation.id, "call-0", "child-0"), + Registered::Registered + ); + assert_eq!( + register(&forward, generation.id, "call-resume", "child-0"), + refused("call_cap") + ); + assert_eq!(sizes(&forward), (CALL_CAP, CHILD_CAP)); +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0fcef5f93d..00c1334fc5 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -112,7 +112,8 @@ use host_files::{ }; use host_forward::{ plugin_host_forward_admission, plugin_host_forward_associate, plugin_host_forward_close, - plugin_host_forward_open, plugin_host_forward_seal, HostForward, + plugin_host_forward_open, plugin_host_forward_register_child, plugin_host_forward_seal, + HostForward, }; use host_mcp::{ plugin_host_mcp_authorize, plugin_host_mcp_close, plugin_host_mcp_pairing, @@ -762,6 +763,7 @@ fn commands() -> impl Fn(tauri::ipc::Invoke) -> bool + Sen plugin_host_forward_admission, plugin_host_forward_open, plugin_host_forward_associate, + plugin_host_forward_register_child, plugin_host_forward_seal, plugin_host_forward_close, plugin_host_read_file, diff --git a/src/bundled/claude-code/forwarding.test.ts b/src/bundled/claude-code/forwarding.test.ts index a30fb2863f..0fff261751 100644 --- a/src/bundled/claude-code/forwarding.test.ts +++ b/src/bundled/claude-code/forwarding.test.ts @@ -8,6 +8,10 @@ import type { EligibleRoute, GatewayRoute, } from "../../features/agents/gateway/route"; +import { + type ForwardingHarness, + GatewayForwarding, +} from "../../features/agents/gateway/forwarding"; import { VECTOR_PROFILE } from "../../features/agents/gateway/vectors"; import { type FakeClaude, @@ -67,7 +71,11 @@ const OWNER = { id: "buzz.claude-code", revision: "r1" }; /** A route whose revision the test moves, and a native side that records * every call and answers `open` from `answers`. */ -function harness(answers: ForwardOpened[] = []) { +function harness( + answers: ForwardOpened[] = [], + /** Another harness than Claude, through the shared opener. */ + other?: ForwardingHarness, +) { let route: GatewayRoute = eligible(2); const listeners = new Set<() => void>(); const calls: string[] = []; @@ -88,6 +96,14 @@ function harness(answers: ForwardOpened[] = []) { } ); }), + associate: vi.fn( + async (_owner, id, session) => + void calls.push(`associate ${id} ${session}`), + ), + register: vi.fn(async (_owner, id, call, child) => { + calls.push(`register ${id} ${call} ${child}`); + return { type: "registered" } as const; + }), seal: vi.fn(async (_owner, id) => void calls.push(`seal ${id}`)), close: vi.fn(async (_owner, id) => { calls.push(`close ${id}`); @@ -102,7 +118,9 @@ function harness(answers: ForwardOpened[] = []) { return () => listeners.delete(listener); }, }; - const forwarding = new ClaudeForwarding(OWNER, native, routes); + const forwarding: GatewayForwarding = other + ? new GatewayForwarding(OWNER, other, native, routes) + : new ClaudeForwarding(OWNER, native, routes); return { forwarding, native, @@ -277,6 +295,20 @@ describe("ClaudeForwarding.open", () => { ]); }); + it("still fails with the cancellation when native could not close what opened after it", async () => { + const h = harness(); + const controller = new AbortController(); + vi.mocked(h.native.open).mockImplementationOnce(async () => { + controller.abort(new Error("cancelled")); + return { type: "opened", id: 1, url: "http://127.0.0.1:9/h1" }; + }); + vi.mocked(h.native.close).mockRejectedValueOnce(new Error("gone")); + await expect( + h.forwarding.open(AGENT, eligible(2), turn, controller.signal), + ).rejects.toThrow("cancelled"); + expect(h.native.close).toHaveBeenCalledOnce(); + }); + it("fails before dispatch when native forwarding can't be reached", async () => { for (const method of ["push", "open"] as const) { const h = harness(); @@ -289,7 +321,7 @@ describe("ClaudeForwarding.open", () => { } }); - it("seals once and never after closing; a close native already did resolves", async () => { + it("seals once and never after closing; a close native refused rejects, every time", async () => { const h = harness(); const generation = await h.forwarding.open( AGENT, @@ -300,7 +332,9 @@ describe("ClaudeForwarding.open", () => { generation.seal(); generation.seal(); vi.mocked(h.native.close).mockRejectedValueOnce(new Error("gone")); - await generation.close(); + await expect(generation.close()).rejects.toThrow("gone"); + await expect(generation.close()).rejects.toThrow("gone"); + expect(h.native.close).toHaveBeenCalledOnce(); generation.seal(); expect(h.calls.filter((call) => call.startsWith("seal"))).toEqual([ "seal 1", @@ -308,6 +342,44 @@ describe("ClaudeForwarding.open", () => { }); }); +describe("GatewayForwarding for goose", () => { + const GOOSE = { upstream: "goose", native: "goose" } as const; + + it("enables the harness it declares, and opens with no session", async () => { + const h = harness([], GOOSE); + expect(h.forwarding.control(AGENT)).toBeUndefined(); + h.setRoute({ ...eligible(2), harnesses: ["goose"] }); + expect(h.forwarding.control(AGENT)).toEqual({ + ...eligible(2), + harnesses: ["goose"], + }); + await h.forwarding.open(AGENT, eligible(2), { binding, context }, signal); + const [, options] = nth(vi.mocked(h.native.open).mock.calls); + expect(options.harness).toBe("goose"); + expect(options).not.toHaveProperty("session"); + }); + + it("names its session and registers children on its own generation", async () => { + const h = harness([], GOOSE); + await h.forwarding.open(AGENT, eligible(2), { binding, context }, signal); + const generation = await h.forwarding.open( + AGENT, + eligible(2), + { binding, context }, + signal, + ); + await generation.associate("goose-session"); + await expect( + generation.registerChild("call-1", "child-1"), + ).resolves.toEqual({ type: "registered" }); + expect(h.calls.slice(-2)).toEqual([ + "associate 2 goose-session", + "register 2 call-1 child-1", + ]); + expect(vi.mocked(h.native.associate).mock.calls[0]?.[0]).toBe(OWNER); + }); +}); + /** A generation that records seal and close, for the process lifecycle; * `revoke` moves its route on, and `closed` holds native `closed`. */ function generation( @@ -340,6 +412,8 @@ function generation( .then(() => void log.push(`closed ${name}`)); return closing; }, + associate: async () => {}, + registerChild: async () => ({ type: "registered" }), }; return Object.assign(value, { revoke }); } @@ -505,6 +579,35 @@ describe("forwarded turns in a conversation", () => { await second; }); + it("lets the next turn in when native could not close the last generation", async () => { + const { sessions, log, forward, claudes } = pool(); + let refuse!: (error: Error) => void; + const refused = new Promise((_, reject) => { + refuse = reject; + }); + const first = sessions.deliver("c", "one", 1, { + forward: async () => { + log.push("open refused"); + return { generation: generation("refused", log, refused), binding }; + }, + }); + await flush(); + nth(claudes()).finish(); + await first; + const second = sessions.deliver("c", "two", 2, { forward }); + await flush(); + refuse(new Error("gone")); + await flush(); + expect(log).toEqual([ + "open refused", + "seal refused", + "close refused", + "open g1", + ]); + nth(claudes(), 1).finish(); + expect(await second).toEqual({ ok: true }); + }); + it("restarts on settings saved during startup only once the first process has exited", async () => { // Read before launch, then changed by the time the process is up. let reads = 0; @@ -1134,6 +1237,25 @@ describe("forwarded turns in a conversation", () => { expect(all.size).toBe(0); }); + it("ends a one-off turn as it did when native could not close its generation", async () => { + const { sessions, log, claudes } = pool(); + const refused = Promise.reject(new Error("gone")); + refused.catch(() => undefined); + const done = sessions.runOnce( + "watch", + () => "wake", + undefined, + async () => ({ + generation: generation("refused", log, refused), + binding, + }), + ); + await flush(); + nth(claudes()).finish(); + expect(await done).toEqual({ ok: true }); + expect(log).toEqual(["seal refused", "close refused"]); + }); + it("ends a one-off turn's process when the route revokes its generation", async () => { const { sessions, log, claudes } = pool(); const g = generation("g1", log); diff --git a/src/bundled/claude-code/forwarding.ts b/src/bundled/claude-code/forwarding.ts index d73b6795c5..2d9682dc69 100644 --- a/src/bundled/claude-code/forwarding.ts +++ b/src/bundled/claude-code/forwarding.ts @@ -1,234 +1,29 @@ -// One Claude turn's gateway generation: a native forwarding handle opened at -// the route revision the turn was dispatched with, with the turn's frozen -// headers, given to exactly one Claude process, and never rebound. Native -// owns admission (`host_forward.rs`); this owns the caller's side of it: -// pushing the revision, opening only while it is still the route's, and -// sealing and closing. A route change pushes the new revision, which revokes -// the generation natively, and ends its process. Nothing here waits on -// readiness or re-reads a route to retry: a turn that can't open fails. +// Claude's side of gateway forwarding: the shared opener, for Claude. +import type { ForwardOwner } from "../../features/host/forward"; import { - closeForward, - openForward, - pushForwardAdmission, - sealForward, - type ForwardEvent, - type ForwardOpened, - type ForwardOwner, -} from "../../features/host/forward"; -import type { AgentKey } from "../../features/agents/membership"; -import { - bindingAdmits, - discoveryBundle, - inferenceBundle, - type GatewayBinding, - type TurnContext, -} from "../../features/agents/gateway/contract"; -import { - type EligibleRoute, - type GatewayRoute, - routeFor, - subscribeRoute, -} from "../../features/agents/gateway/route"; + type ForwardNative, + type ForwardRoutes, + GatewayForwarding, +} from "../../features/agents/gateway/forwarding"; + +export { + CHANGED, + type ForwardNative, + type ForwardRoutes, + type Generation, + UNAVAILABLE, +} from "../../features/agents/gateway/forwarding"; /** The harness Claude declares to the gateway, its descriptor upstream key. */ export const HARNESS = "claude-code"; -export type ForwardNative = Readonly<{ - push: typeof pushForwardAdmission; - open: typeof openForward; - seal: typeof sealForward; - close: typeof closeForward; -}>; -export type ForwardRoutes = Readonly<{ - routeFor(key: AgentKey): GatewayRoute; - subscribeRoute(key: AgentKey, listener: () => void): () => void; -}>; - -/** A generation's caller side. `seal` ends admission for good, at the - * qualifying idle; `close` forgets it once its process has exited, and - * resolves on native `closed`, after which nothing of it follows. Both are - * idempotent. */ -export type Generation = Readonly<{ - url: string; - seal(): void; - close(): Promise; - /** Settles once the route moves on, which revokes the generation - * natively: its process must end. Recorded from before the open, so an - * owner attaching late still sees it. */ - revoked: Promise; - isRevoked(): boolean; -}>; - -const NATIVE: ForwardNative = { - push: pushForwardAdmission, - open: openForward, - seal: sealForward, - close: closeForward, -}; -const ROUTES: ForwardRoutes = { routeFor, subscribeRoute }; -export const CHANGED = - "This agent's gateway access changed as the turn started, so it didn't run. Try again."; -export const UNAVAILABLE = - "Buzz couldn't start forwarding to the agent gateway on this device, so the turn didn't run."; - -export class ClaudeForwarding { - /** Native route keys must be short and opaque; agent keys need not be. */ - private readonly keys = new Map(); - +/** Opens Claude turns' generations, each with its spawn session. */ +export class ClaudeForwarding extends GatewayForwarding { constructor( - private readonly owner: ForwardOwner, - private readonly native: ForwardNative = NATIVE, - private readonly routes: ForwardRoutes = ROUTES, - ) {} - - /** The route a session control with no turn of its own (compaction) goes - * by, read now without waiting: undefined where the runner wouldn't send - * the agent's turns to the gateway, so it runs on Claude's own - * configuration; else the eligible route that enables Claude, or the - * blocked one its turns would fall back from. */ - control( - agent: AgentKey, - ): EligibleRoute | Extract | undefined { - const route = this.routes.routeFor(agent); - if (route.kind === "blocked") return route; - return route.kind === "eligible" && route.harnesses.includes(HARNESS) - ? route - : undefined; - } - - /** Opens the turn's generation for harness session `session` at `route`, - * the revision it was dispatched with. A route that moved on, a refusal or - * a native side that can't be reached throws, before anything is spawned; - * it is never retried. A generation opened after `signal` aborted is - * closed. */ - async open( - agent: AgentKey, - route: EligibleRoute, - turn: Readonly<{ - binding: GatewayBinding; - context: TurnContext; - session: string; - }>, - signal: AbortSignal, - ): Promise { - const inference = inferenceBundle( - route.profile, - turn.binding, - turn.context, - ); - if (!inference.ok) throw new Error(inference.message); - if (!bindingAdmits(route, turn.binding).ok) throw new Error(CHANGED); - const discovery = discoveryBundle(route.profile, route, turn.binding); - if (!discovery.ok) throw new Error(discovery.message); - // Watched from before the push: a change during it, the open or the - // process's startup is pushed (native revokes on it) and recorded. - let revoked = false; - let revoke!: () => void; - const revocation = new Promise((resolve) => { - revoke = resolve; - }); - const moved = () => - revoked || this.routes.routeFor(agent).revision !== route.revision; - const unsubscribe = this.routes.subscribeRoute(agent, () => { - const now = this.routes.routeFor(agent); - if (revoked || now.revision === route.revision) return; - revoked = true; - void this.push(agent, now).catch(() => undefined); - revoke(); - }); - let closed!: () => void; - const ended = new Promise((resolve) => { - closed = resolve; - }); - let opened: ForwardOpened | undefined; - try { - if (moved()) throw new Error(CHANGED); - await this.push(agent, route); - signal.throwIfAborted(); - // Open only at the acknowledged revision. - if (!moved()) - opened = await this.native.open(this.owner, { - route: this.key(agent), - routeRevision: route.revision, - harness: "claude", - session: turn.session, - inference: inference.value, - discovery: discovery.value, - onEvent: (event: ForwardEvent) => { - if (event.type === "closed") closed(); - }, - }); - } catch (error) { - unsubscribe(); - if (signal.aborted) throw signal.reason; - if (error instanceof Error && error.message === CHANGED) throw error; - console.warn("Gateway forwarding is unavailable", error); - throw new Error(UNAVAILABLE); - } - if (opened?.type !== "opened") { - unsubscribe(); - throw new Error(CHANGED); - } - const generation = this.generation(opened, ended, unsubscribe, { - revocation, - isRevoked: () => revoked, - }); - if (signal.aborted || revoked) { - await generation.close(); - signal.throwIfAborted(); - throw new Error(CHANGED); - } - return generation; - } - - private generation( - { id, url }: Readonly<{ id: number; url: string }>, - ended: Promise, - unsubscribe: () => void, - { - revocation, - isRevoked, - }: Readonly<{ revocation: Promise; isRevoked: () => boolean }>, - ): Generation { - let sealed = false; - let closing: Promise | undefined; - return Object.freeze({ - url, - revoked: revocation, - isRevoked, - seal: () => { - if (sealed || closing) return; - sealed = true; - void this.native.seal(this.owner, id).catch(() => undefined); - }, - close: () => { - if (closing) return closing; - unsubscribe(); - // A generation native already dropped (a page reload) sent `closed`. - closing = this.native.close(this.owner, id).then( - () => ended, - () => undefined, - ); - return closing; - }, - }); - } - - private push(agent: AgentKey, route: GatewayRoute) { - return this.native.push( - this.owner, - this.key(agent), - route.revision, - route.kind === "eligible", - ); - } - - private key(agent: AgentKey) { - let key = this.keys.get(agent); - if (!key) { - key = `route-${this.keys.size + 1}`; - this.keys.set(agent, key); - } - return key; + owner: ForwardOwner, + native?: ForwardNative, + routes?: ForwardRoutes, + ) { + super(owner, { upstream: HARNESS, native: "claude" }, native, routes); } } diff --git a/src/bundled/claude-code/prompt.ts b/src/bundled/claude-code/prompt.ts index 7f60eff4de..5f273107b7 100644 --- a/src/bundled/claude-code/prompt.ts +++ b/src/bundled/claude-code/prompt.ts @@ -208,6 +208,9 @@ export function turnPrompt(input: TurnInput) { const EVENT = "buzz-event"; const STEER = "new-message-arrived-while-you-were-working"; +/** What a steer ends with, after its turn. */ +export const STEER_NOTE = + "\n\nNote: A new message arrived while you were working. Continue your in-progress work and incorporate the new message if it's relevant; if it's unrelated, you may briefly acknowledge it and carry on."; /** A turn for a session that is still working on an earlier one. Its event's * section becomes the steer's, so the event stays a record of its own; a * turn without exactly one, such as a wake's, is wrapped whole. */ @@ -220,5 +223,5 @@ export function steerPrompt(turn: string) { once(open) && once(close) ? turn.replace(open, `\n<${STEER}>\n`).replace(close, `\n`) : section(STEER, turn); - return `${steered}\n\nNote: A new message arrived while you were working. Continue your in-progress work and incorporate the new message if it's relevant; if it's unrelated, you may briefly acknowledge it and carry on.`; + return `${steered}${STEER_NOTE}`; } diff --git a/src/bundled/claude-code/runtime.test.ts b/src/bundled/claude-code/runtime.test.ts index 4023cee771..585ac2a2ab 100644 --- a/src/bundled/claude-code/runtime.test.ts +++ b/src/bundled/claude-code/runtime.test.ts @@ -3015,6 +3015,8 @@ function gateway( url: "http://127.0.0.1:9/h", }; }), + associate: vi.fn(async () => {}), + register: vi.fn(async () => ({ type: "registered" }) as const), seal: vi.fn(async (_owner, id) => void calls.push(`seal ${id}`)), close: vi.fn(async (_owner, id) => { calls.push(`close ${id}`); diff --git a/src/bundled/claude-code/runtime.ts b/src/bundled/claude-code/runtime.ts index 9cdb4fb287..f70ab43388 100644 --- a/src/bundled/claude-code/runtime.ts +++ b/src/bundled/claude-code/runtime.ts @@ -66,10 +66,13 @@ import { AgentSessions, localSessions, Spare, type Forward } from "./sessions"; import { type ClaudeForwarding, UNAVAILABLE } from "./forwarding"; import { bindSession, - isBuzzUuid, - isHex64, type TurnContext, } from "../../features/agents/gateway/contract"; +import { + compactRefusal, + controlTurn, + FORWARDING_PROTECTED, +} from "../../features/agents/gateway/forwarding"; import { type EligibleRoute, fallbackNotice, @@ -78,9 +81,9 @@ import { protectionHistoryEntry } from "./security"; import { readSessionTitle, readTranscript, - withNotices, type Conversation, } from "./transcript"; +import { withNotices } from "../../features/agents/activity-transcript"; import { sessionPrompts } from "../../features/agents/session-prompts"; /** The effort levels Claude lists, which a session can switch to between @@ -1223,21 +1226,5 @@ export function modelName(model: string) { return id ? `${name} ${id[2]}${id[3] ? `.${id[3]}` : ""}` : name; } -/** A control's error for a fallback notice: it never runs unforwarded. */ -const compactRefusal = (notice: string) => - notice.replace( - /^Ran without the agent gateway: /, - "Couldn't compact through the agent gateway: ", - ); -/** A session control's own context: its channel, and its thread only for a - * thread's session; it carries no message. */ -const controlTurn = (place: SessionPlace, scope: string): TurnContext => - Object.freeze({ - channel_id: isBuzzUuid(place.channelId) ? place.channelId : null, - message_id: null, - thread_id: scope !== "channel" && isHex64(place.root) ? place.root : null, - }); -const FORWARDING_PROTECTED = - "Protected agents can't run through the agent gateway yet, so this turn didn't run."; const forwarded = (forward: Forward | undefined) => forward ? { forward } : {}; diff --git a/src/bundled/claude-code/sessions.ts b/src/bundled/claude-code/sessions.ts index 4950c5dc6d..0921bac2ef 100644 --- a/src/bundled/claude-code/sessions.ts +++ b/src/bundled/claude-code/sessions.ts @@ -586,7 +586,8 @@ export class AgentSessions { void spawned.kill(); await spawned.exited.catch(() => undefined); } - await forwarded?.generation.close(); + // Claude ends its turn the same either way; native drops it on reload. + await forwarded?.generation.close().catch(() => undefined); this.once.delete(key); this.options.onChange?.(); } @@ -658,6 +659,7 @@ export class AgentSessions { void exited .catch(() => undefined) .then(() => gate?.forwarded.generation.close()) + .catch(() => undefined) .then(end); const started = gateway ? gateway().then( diff --git a/src/bundled/claude-code/transcript.test.ts b/src/bundled/claude-code/transcript.test.ts index c41096e40d..6f7bf3c155 100644 --- a/src/bundled/claude-code/transcript.test.ts +++ b/src/bundled/claude-code/transcript.test.ts @@ -20,8 +20,8 @@ import { projectName, readTranscript, TURN_LIMIT, - withNotices, } from "./transcript"; +import { withNotices } from "../../features/agents/activity-transcript"; const root = "c".repeat(64); const homes: string[] = []; diff --git a/src/bundled/claude-code/transcript.ts b/src/bundled/claude-code/transcript.ts index 0cd93e61cd..993bbf7e3f 100644 --- a/src/bundled/claude-code/transcript.ts +++ b/src/bundled/claude-code/transcript.ts @@ -98,37 +98,6 @@ function prompt( }; } -/** `turns` with each notice right after the prompt Claude saved under the - * id of the message it came with. A notice whose message Claude never took - * matches no prompt, so it shows nowhere rather than on another turn. */ -export function withNotices( - turns: readonly TranscriptTurn[], - notices: ReadonlyMap, -): TranscriptTurn[] { - return turns.map((turn) => - turn.items.some((item) => item.type === "prompt" && notices.has(item.id)) - ? { - ...turn, - items: turn.items.flatMap((item): TranscriptItem[] => { - const text = - item.type === "prompt" ? notices.get(item.id) : undefined; - return text - ? [ - item, - { - id: `${item.id}:notice`, - at: item.at, - type: "status", - text, - }, - ] - : [item]; - }), - } - : turn, - ); -} - /** Transcript lines as turns, each opened by a prompt. Claude Code's own * entries (metadata, subagents, attachments) are left out. */ export function claudeTurns( diff --git a/src/bundled/goose/forward-children.test.ts b/src/bundled/goose/forward-children.test.ts new file mode 100644 index 0000000000..ef31c82b39 --- /dev/null +++ b/src/bundled/goose/forward-children.test.ts @@ -0,0 +1,252 @@ +// @vitest-environment node +import { describe, expect, it, vi } from "vitest"; +import type { ForwardChildRegistered } from "../../features/host/forward"; +import { forwardChildren } from "./forward-children"; +import { flush } from "./goose-testing"; + +const announced = (call: string, toolName = "delegate", title = toolName) => ({ + sessionUpdate: "tool_call", + toolCallId: call, + title, + _meta: { goose: { toolCall: { toolName } } }, +}); +const completed = (call: string, child: unknown = "20261104_2") => ({ + sessionUpdate: "tool_call_update", + toolCallId: call, + status: "completed", + _meta: { goose: { subagentSessionId: child } }, +}); + +function tracked( + outcome: () => Promise = async () => ({ + type: "registered", + }), +) { + const register = vi.fn((_call: string, _child: string) => outcome()); + const failed = vi.fn(); + const { observe, settled } = forwardChildren({ register, failed }); + return { observe, settled, register, failed }; +} + +describe("forwardChildren", () => { + it("registers a live delegate's child, and its resume under a new call", async () => { + const { observe, settled, register, failed } = tracked(); + observe(announced("call-1"), true); + observe({ ...completed("call-1"), status: "in_progress" }, true); + observe(completed("call-1"), true); + // Titled by its source, as goose titles a delegate that names one. + observe(announced("call-2", "delegate", "delegate ยท reviewer"), true); + observe(completed("call-2"), true); + await flush(); + expect(register.mock.calls).toEqual([ + ["call-1", "20261104_2"], + ["call-2", "20261104_2"], + ]); + expect(failed).not.toHaveBeenCalled(); + expect(await settled()).toBeUndefined(); + }); + + it("ignores replay, other tools and calls it never saw announced", async () => { + const { observe, register } = tracked(); + // A replay arrives outside any prompt, announcement included. + observe(announced("call-1"), false); + observe(completed("call-1"), false); + observe(completed("call-1"), true); + observe(announced("call-2", "shell"), true); + observe(completed("call-2"), true); + // Another tool titled as a delegate is not one. + observe(announced("call-4", "shell", "delegate"), true); + observe(completed("call-4"), true); + observe({ ...announced("call-5"), _meta: undefined }, true); + observe(completed("call-5"), true); + observe(announced("call-3"), true); + observe(completed("call-3", 7), true); + observe({ ...completed("call-3"), _meta: undefined }, true); + await flush(); + expect(register).not.toHaveBeenCalled(); + }); + + it("shows each refusal, and stops registering once revoked", async () => { + const outcomes: ForwardChildRegistered[] = [ + { type: "refused", reason: "child_cap" }, + { type: "refused", reason: "generation_revoked" }, + ]; + const { observe, settled, register, failed } = tracked( + async () => outcomes.shift() ?? { type: "registered" }, + ); + for (const call of ["call-1", "call-2", "call-3"]) { + observe(announced(call), true); + observe(completed(call), true); + await flush(); + } + expect(register).toHaveBeenCalledTimes(2); + expect(failed.mock.calls).toEqual([["child_cap"], ["generation_revoked"]]); + // The turn fails for the first. + expect(await settled()).toBe("child_cap"); + }); + + it("surfaces a rejected registration, leaving no promise unhandled", async () => { + const unhandled = vi.fn(); + process.on("unhandledRejection", unhandled); + try { + const { observe, failed } = tracked(() => + Promise.reject("The generation's session is not associated"), + ); + observe(announced("call-1"), true); + observe(completed("call-1"), true); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(failed).toHaveBeenCalledExactlyOnceWith( + "The generation's session is not associated", + ); + expect(unhandled).not.toHaveBeenCalled(); + } finally { + process.off("unhandledRejection", unhandled); + } + }); +}); + +it("surfaces a registration that throws instead of returning a promise", () => { + const failed = vi.fn(); + const { observe } = forwardChildren({ + register: () => { + throw new Error("ipc unavailable"); + }, + failed, + }); + observe(announced("call-1"), true); + expect(() => observe(completed("call-1"), true)).not.toThrow(); + expect(failed).toHaveBeenCalledExactlyOnceWith("Error: ipc unavailable"); +}); + +describe("a failure reporter that throws", () => { + const throwing = () => + vi.fn((_reason: string) => { + throw new Error("turn already ended"); + }); + + it("is called once on a synchronous failure, and stops registration", async () => { + const logged = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + const failed = throwing(); + const register = vi.fn((_call: string, _child: string) => { + throw new Error("ipc unavailable"); + }); + const { observe, settled } = forwardChildren({ register, failed }); + observe(announced("call-1"), true); + expect(() => observe(completed("call-1"), true)).not.toThrow(); + observe(announced("call-2"), true); + observe(completed("call-2"), true); + expect(failed).toHaveBeenCalledExactlyOnceWith("Error: ipc unavailable"); + expect(await settled()).toBe("Error: ipc unavailable"); + expect(register).toHaveBeenCalledOnce(); + expect(logged).toHaveBeenCalledOnce(); + } finally { + logged.mockRestore(); + } + }); + + it.each([ + [ + "a refusal", + async () => ({ type: "refused", reason: "child_cap" }) as const, + "child_cap", + ], + ["a rejection", () => Promise.reject("not associated"), "not associated"], + ] as const)( + "is called once on %s, leaving no promise unhandled", + async (_, outcome, reason) => { + const unhandled = vi.fn(); + const logged = vi.spyOn(console, "error").mockImplementation(() => {}); + process.on("unhandledRejection", unhandled); + try { + const failed = throwing(); + const register = vi.fn((_call: string, _child: string) => outcome()); + const { observe, settled } = forwardChildren({ register, failed }); + observe(announced("call-1"), true); + observe(completed("call-1"), true); + await new Promise((resolve) => setTimeout(resolve, 0)); + observe(announced("call-2"), true); + observe(completed("call-2"), true); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(failed).toHaveBeenCalledExactlyOnceWith(reason); + expect(await settled()).toBe(reason); + expect(register).toHaveBeenCalledOnce(); + expect(logged).toHaveBeenCalledOnce(); + expect(unhandled).not.toHaveBeenCalled(); + } finally { + process.off("unhandledRejection", unhandled); + logged.mockRestore(); + } + }, + ); +}); + +it("keeps a failure for the turn when the only child's report throws", async () => { + const logged = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + const { observe, settled } = forwardChildren({ + register: async () => ({ + type: "refused", + reason: "child_call_conflict", + }), + failed: () => { + throw new Error("turn already ended"); + }, + }); + observe(announced("call-1"), true); + observe(completed("call-1"), true); + await flush(); + // No later child request exists to be refused: only this signal remains. + expect(await settled()).toBe("child_call_conflict"); + } finally { + logged.mockRestore(); + } +}); + +it("fails the turn for a registration still unanswered when its prompt ends", async () => { + let answer!: (registered: ForwardChildRegistered) => void; + const { observe, settled, failed } = tracked( + () => new Promise((resolve) => (answer = resolve)), + ); + observe(announced("call-1"), true); + observe(completed("call-1"), true); + // The prompt has ended; the registration has not been answered. + let outcome: string | undefined | null = null; + const checking = settled().then((failure) => (outcome = failure)); + await flush(); + expect(outcome).toBeNull(); + answer({ type: "refused", reason: "generation_ended" }); + await checking; + expect(outcome).toBe("generation_ended"); + expect(failed).toHaveBeenCalledExactlyOnceWith("generation_ended"); +}); + +it("fails the turn for a held registration that rejects into a throwing notifier", async () => { + const logged = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + let reject!: (error: unknown) => void; + const failed = vi.fn(() => { + throw new Error("turn already ended"); + }); + const { observe, settled } = forwardChildren({ + register: () => new Promise((_, rejected) => (reject = rejected)), + failed, + }); + observe(announced("call-1"), true); + observe(completed("call-1"), true); + // The prompt settles while the IPC answer is held: success stays blocked. + let checked = false; + const checking = settled().then((failure) => { + checked = true; + return failure; + }); + await flush(); + expect(checked).toBe(false); + // No later child follows. + reject("ipc closed"); + await expect(checking).resolves.toBe("ipc closed"); + expect(failed).toHaveBeenCalledOnce(); + } finally { + logged.mockRestore(); + } +}); diff --git a/src/bundled/goose/forward-children.ts b/src/bundled/goose/forward-children.ts new file mode 100644 index 0000000000..dc291e2bf0 --- /dev/null +++ b/src/bundled/goose/forward-children.ts @@ -0,0 +1,91 @@ +import type { ForwardChildRegistered } from "../../features/host/forward"; + +/** goose's native delegate tool, as its `tool_call` names it in + * `_meta.goose.toolCall.toolName`; its title may add a source or be + * generated (goose 1.57.0 `conversion.rs`). */ +const DELEGATE = "delegate"; + +export type ForwardChildren = Readonly<{ + /** `registerForwardChild` bound to this generation's owner and id. */ + register: (call: string, child: string) => Promise; + /** Shows why a child could not be registered on the turn. */ + failed: (reason: string) => void; +}>; + +/** Registers the children goose delegates during one forwarded prompt, as + * its `observe` as the generation's parent session's. One per + * generation, so the delegate calls it trusts are this prompt's. Only a live + * `delegate` call announced earlier in the prompt registers: goose strips + * `subagentSessionId` from every other tool's result (goose 1.57.0 + * `lease.rs`), and replayed or out-of-prompt updates are ignored. */ +export function forwardChildren({ register, failed }: ForwardChildren) { + const delegates = new Set(); + let stopped = false; + /** The first failure, kept whatever `failed` does with it. */ + let failure: string | undefined; + /** Registrations not yet answered, none of which ever rejects. */ + const pending = new Set>(); + // Kept first, then shown once and never again from its own error. A + // `failed` that throws stops registration too: nothing more is trusted. + const report = (reason: string) => { + failure ??= reason; + try { + failed(reason); + } catch (error) { + stopped = true; + console.error("A goose child registration failure went unreported", { + reason, + error, + }); + } + }; + const observe = (update: Record, prompting: boolean) => { + if (!prompting || stopped) return; + const call = update.toolCallId; + if (typeof call !== "string") return; + if (update.sessionUpdate === "tool_call") { + const meta = update._meta as { + goose?: { toolCall?: { toolName?: unknown } }; + }; + if (meta?.goose?.toolCall?.toolName === DELEGATE) delegates.add(call); + return; + } + const meta = update._meta as { goose?: { subagentSessionId?: unknown } }; + const child = meta?.goose?.subagentSessionId; + if ( + update.sessionUpdate !== "tool_call_update" || + update.status !== "completed" || + typeof child !== "string" || + !delegates.has(call) + ) + return; + // Nothing escapes the update: a throw, rejection or bad result fails + // the turn visibly instead. + let registering: Promise; + try { + registering = register(call, child); + } catch (error) { + report(String(error)); + return; + } + const answered = registering.then( + (registered) => { + if (registered.type === "registered") return; + if (registered.reason === "generation_revoked") stopped = true; + report(registered.reason); + }, + (error) => report(String(error)), + ); + pending.add(answered); + void answered.finally(() => pending.delete(answered)); + }; + /** The turn's registration failure, once every registration started has + * been answered. The caller awaits it after the prompt and before + * reporting success, so a failed registration fails the turn even when + * nothing else would notice. */ + const settled = async () => { + while (pending.size) await Promise.all(pending); + return failure; + }; + return { observe, settled }; +} diff --git a/src/bundled/goose/forwarded.test.ts b/src/bundled/goose/forwarded.test.ts new file mode 100644 index 0000000000..bbaaa7c7ad --- /dev/null +++ b/src/bundled/goose/forwarded.test.ts @@ -0,0 +1,432 @@ +// @vitest-environment node +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { Generation } from "../../features/agents/gateway/forwarding"; +import type { ForwardChildRegistered } from "../../features/host/forward"; +import { + type ForwardedTurn, + forwardedTurn, + NOT_CLOSED, + NOT_KEPT, + NOT_STOPPED, +} from "./forwarded"; +import { GooseProcess, type ToolServer } from "./goose"; +import { fakeSpawn, flush } from "./goose-testing"; + +const CHANGED = "The route changed"; +const tools: ToolServer = async (_conversation, message) => ({ + jsonrpc: "2.0", + id: message.id, + result: {}, +}); +const deferred = () => { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + return { promise, resolve, reject }; +}; + +/** A generation native opened, recording what the turn asks of it. */ +function generation( + closes: () => Promise = async () => undefined, + registers: () => Promise = async () => ({ + type: "registered", + }), +) { + const calls: string[] = []; + let revoked = false; + let revoke!: () => void; + const revocation = new Promise((resolve) => (revoke = resolve)); + const value: Generation = { + url: "http://127.0.0.1:4100/g1", + seal: () => void calls.push("seal"), + close: vi.fn(() => { + calls.push("close"); + return closes(); + }), + revoked: revocation, + isRevoked: () => revoked, + associate: async (session) => void calls.push(`associate ${session}`), + registerChild: vi.fn(async (call: string, child: string) => { + calls.push(`register ${call} ${child}`); + return registers(); + }), + }; + return { + value, + calls, + revoke: () => { + revoked = true; + revoke(); + }, + }; +} + +/** A forwarded turn on a fake goose: what it ran, and its retirement. */ +function setup( + options: Parameters[0] = {}, + turn: Partial = {}, + native = generation(), +) { + const fake = fakeSpawn({ load: true, ...options }); + const controller = new AbortController(); + const retirements: Promise[] = []; + const exits: Promise[] = []; + const kept: string[] = []; + const order = native.calls; + const run = (changes: Partial = {}) => + forwardedTurn( + { + open: async () => native.value, + start: (env, onProcess) => + GooseProcess.start(fake.spawn, { + cwd: "/work", + env, + serve: fake.serve, + onProcess, + }), + launch: async () => ({ cwd: "/work", systemPrompt: "prompt" }), + tools, + choice: () => ({ provider: "databricks", model: "" }), + conversation: "chat", + prompt: async (fresh) => { + order.push(`prompt ${fresh}`); + return "hello"; + }, + keep: (session) => { + order.push(`keep ${session}`); + kept.push(session); + return true; + }, + outlives: (retired, exited) => { + retirements.push(retired); + exits.push(exited); + }, + signal: controller.signal, + ...turn, + ...changes, + }, + CHANGED, + ); + return { fake, controller, retirements, exits, kept, native, order, run }; +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("a forwarded goose turn", () => { + it("runs one prompt in a new session of its own goose on the generation, then retires both in order", async () => { + const started = vi.fn(); + const { fake, native, order, retirements, run } = setup({}, { started }); + await expect(run()).resolves.toMatchObject({ ok: true }); + // A new session's prompt is recorded once it is kept. + expect(started).toHaveBeenCalledExactlyOnceWith("session-1"); + const [goose] = fake.geese; + expect(fake.geese).toHaveLength(1); + expect(goose?.env).toMatchObject({ + DATABRICKS_HOST: "http://127.0.0.1:4100/g1", + GOOSE_DISABLE_SESSION_NAMING: "true", + // A delegate stays on the gateway's provider. + GOOSE_SUBAGENT_PROVIDER: "databricks", + }); + // On the provider's default model, a delegate takes the parent's. + expect(goose?.env).not.toHaveProperty("GOOSE_SUBAGENT_MODEL"); + expect(goose?.opened).toHaveLength(1); + expect(goose?.turns.map((turn) => turn.text)).toEqual(["hello"]); + // Saved and named before its prompt; closed only after its exit. + expect(order).toEqual([ + "keep session-1", + "associate session-1", + "prompt true", + "seal", + "close", + ]); + expect(goose?.closed).toEqual(["session-1"]); + expect(goose?.killed).toBe(true); + expect(retirements).toHaveLength(1); + await expect(retirements[0]).resolves.toBeUndefined(); + expect(native.value.close).toHaveBeenCalledOnce(); + }); + + it("pins a delegate's model to the turn's, else the agent's", async () => { + const choice = () => ({ provider: "databricks", model: "m-agent" }); + const { fake, run } = setup({}, { choice }); + await expect(run()).resolves.toMatchObject({ ok: true }); + await expect(run({ model: "m-turn" })).resolves.toMatchObject({ ok: true }); + expect(fake.geese.map((goose) => goose.env)).toEqual([ + expect.objectContaining({ + GOOSE_SUBAGENT_PROVIDER: "databricks", + GOOSE_SUBAGENT_MODEL: "m-agent", + }), + expect.objectContaining({ + GOOSE_SUBAGENT_PROVIDER: "databricks", + GOOSE_SUBAGENT_MODEL: "m-turn", + }), + ]); + }); + + it("resumes the saved session it is given, never a new one", async () => { + const started = vi.fn(); + const { fake, order, run } = setup({}, { load: "s-saved", started }); + await expect(run()).resolves.toMatchObject({ ok: true }); + const [goose] = fake.geese; + expect(goose?.loaded).toEqual(["s-saved"]); + // A resumed session's prompt was recorded when it was new. + expect(started).not.toHaveBeenCalled(); + expect(goose?.opened).toEqual([]); + // Put back on the gateway's provider, whatever it was saved with. + expect(goose?.configured).toEqual(["s-saved provider=databricks"]); + expect(goose?.prompts.get("s-saved")).toBe("prompt"); + expect(order.slice(0, 3)).toEqual([ + "keep s-saved", + "associate s-saved", + "prompt false", + ]); + }); + + it("fails a resumed turn goose can't put back on its provider, prompting nothing", async () => { + const { fake, native, run } = setup({}, { load: "s-saved" }); + const result = await run({ + launch: async () => { + const [goose] = fake.geese; + if (goose) goose.configError = "Unknown provider"; + return { cwd: "/work", systemPrompt: "prompt" }; + }, + }); + expect(result).toMatchObject({ ok: false }); + expect(result.error).toContain("goose could not switch its provider"); + expect(fake.geese[0]?.turns).toEqual([]); + expect(native.calls.slice(-2)).toEqual(["seal", "close"]); + }); + + it("fails a session goose can't resume, prompting nothing, and still retires", async () => { + const { fake, native, order, run } = setup({}, { load: "s-saved" }); + const turn = run(); + await flush(); + const [goose] = fake.geese; + if (goose) goose.loadError = "Session not found"; + await expect(turn).resolves.toEqual({ + ok: false, + error: "Session not found", + }); + expect(goose?.opened).toEqual([]); + expect(goose?.turns).toEqual([]); + expect(goose?.killed).toBe(true); + expect(order).toEqual(["seal", "close"]); + expect(native.value.close).toHaveBeenCalledOnce(); + }); + + it("refuses to prompt a session it could not save, recording no prompt", async () => { + const started = vi.fn(); + const { fake, order, run } = setup( + {}, + { + keep: () => false, + started, + }, + ); + await expect(run()).resolves.toEqual({ ok: false, error: NOT_KEPT }); + expect(started).not.toHaveBeenCalled(); + expect(fake.geese[0]?.turns).toEqual([]); + expect(order).toEqual(["seal", "close"]); + }); + + it("starts nothing when the generation can't open, and is cancelled when it was aborted", async () => { + const { fake, controller, run } = setup(); + await expect( + run({ + open: async () => { + throw new Error("Buzz couldn't start forwarding"); + }, + }), + ).resolves.toEqual({ ok: false, error: "Buzz couldn't start forwarding" }); + controller.abort(); + await expect( + run({ + open: async () => { + throw new Error("aborted"); + }, + }), + ).resolves.toEqual({ ok: true, undelivered: true }); + expect(fake.geese).toEqual([]); + }); + + it("closes the generation once a goose that could not start has exited", async () => { + const { fake, native, order, retirements, run } = setup({ http: false }); + await expect(run()).resolves.toMatchObject({ + ok: false, + error: expect.stringContaining("no HTTP MCP support"), + }); + expect(fake.geese).toHaveLength(1); + expect(order).toEqual(["seal", "close"]); + expect(retirements).toHaveLength(1); + expect(native.value.close).toHaveBeenCalledOnce(); + }); + + it("stops a goose that comes up after the turn was cancelled, closing the generation only once it exits", async () => { + const spawned = deferred(); + // A goose that never answers is stopped, not waited on. + const { fake, controller, native, run } = setup({ silent: ["initialize"] }); + const spawn = fake.spawn; + const turn = run({ + start: (env, onProcess) => + GooseProcess.start( + async (id, options) => { + await spawned.promise; + return spawn(id, options); + }, + { cwd: "/work", env, serve: fake.serve, onProcess }, + ), + }); + await flush(); + controller.abort(); + await flush(); + // Nothing can be closed before the late goose is known to have exited. + expect(native.value.close).not.toHaveBeenCalled(); + spawned.resolve(); + await expect(turn).resolves.toEqual({ ok: true, undelivered: true }); + expect(fake.geese[0]?.killed).toBe(true); + expect(fake.geese[0]?.opened).toEqual([]); + expect(native.value.close).toHaveBeenCalledOnce(); + }); + + it("stops its prompt on interrupt, then retires", async () => { + const { fake, controller, native, run } = setup({ hold: true }); + const turn = run(); + while (!fake.geese[0]?.turns.length) await flush(); + controller.abort(); + await expect(turn).resolves.toEqual({ ok: true, stopped: true }); + const [goose] = fake.geese; + expect(goose?.cancelled).toContain("session-1"); + expect(goose?.closed).toEqual(["session-1"]); + expect(goose?.killed).toBe(true); + expect(native.value.close).toHaveBeenCalledOnce(); + }); + + it("fails with the route change when the generation is revoked mid-prompt, whatever the prompt then says", async () => { + const native = generation(); + const { fake, controller, run } = setup({ hold: true }, {}, native); + const turn = run(); + while (!fake.geese[0]?.turns.length) await flush(); + native.revoke(); + await flush(); + // The interrupt its route change causes counts as no cancel. + controller.abort(); + await expect(turn).resolves.toEqual({ ok: false, error: CHANGED }); + expect(fake.geese[0]?.killed).toBe(true); + expect(native.value.close).toHaveBeenCalledOnce(); + }); + + it("fails a turn whose delegated child could not be registered, and waits for a registration before succeeding", async () => { + const answer = deferred(); + const native = generation(undefined, () => answer.promise); + const { fake, run } = setup({ hold: true }, {}, native); + const turn = run(); + while (!fake.geese[0]?.turns.length) await flush(); + const [goose] = fake.geese; + goose?.update("session-1", { + sessionUpdate: "tool_call", + toolCallId: "call-1", + title: "delegate ยท reviewer", + _meta: { goose: { toolCall: { toolName: "delegate" } } }, + }); + goose?.update("session-1", { + sessionUpdate: "tool_call_update", + toolCallId: "call-1", + status: "completed", + _meta: { goose: { subagentSessionId: "child-1" } }, + }); + await flush(); + goose?.turns[0]?.finish(); + await flush(30); + // The prompt has ended; its registration has not been answered. + expect(native.value.registerChild).toHaveBeenCalledWith( + "call-1", + "child-1", + ); + expect(native.value.close).not.toHaveBeenCalled(); + const warned = vi.spyOn(console, "warn").mockImplementation(() => {}); + answer.resolve({ type: "refused", reason: "child_cap" }); + await expect(turn).resolves.toEqual({ ok: false, error: "child_cap" }); + expect(warned).toHaveBeenCalled(); + }); + + it("ends an interrupt during a pending registration, retiring the goose", async () => { + const native = generation(undefined, () => new Promise(() => {})); + const { fake, controller, run } = setup({ hold: true }, {}, native); + const turn = run(); + while (!fake.geese[0]?.turns.length) await flush(); + const [goose] = fake.geese; + goose?.update("session-1", { + sessionUpdate: "tool_call", + toolCallId: "call-1", + title: "delegate ยท reviewer", + _meta: { goose: { toolCall: { toolName: "delegate" } } }, + }); + goose?.update("session-1", { + sessionUpdate: "tool_call_update", + toolCallId: "call-1", + status: "completed", + _meta: { goose: { subagentSessionId: "child-1" } }, + }); + await flush(); + goose?.turns[0]?.finish(); + await flush(30); + controller.abort(); + await expect(turn).resolves.toEqual({ ok: true, stopped: true }); + expect(goose?.killed).toBe(true); + expect(native.value.close).toHaveBeenCalledOnce(); + }); + + it("fails visibly, keeping the generation open, while its goose has not exited", async () => { + vi.useFakeTimers(); + try { + const { fake, native, retirements, run } = setup(); + const turn = run({ + start: (env, onProcess) => + GooseProcess.start(fake.spawn, { + cwd: "/work", + env, + serve: fake.serve, + onProcess: (started) => { + const [goose] = fake.geese; + if (goose) goose.holdExit = true; + onProcess(started); + }, + }), + }); + await vi.advanceTimersByTimeAsync(10_000); + const [goose] = fake.geese; + await expect(turn).resolves.toEqual({ ok: false, error: NOT_STOPPED }); + expect(native.value.close).not.toHaveBeenCalled(); + // The conversation waits on its exit, then native's close. + let retired = false; + void retirements[0]?.then(() => (retired = true)); + await vi.advanceTimersByTimeAsync(0); + expect(retired).toBe(false); + goose?.exit(null); + await vi.advanceTimersByTimeAsync(0); + expect(native.value.close).toHaveBeenCalledOnce(); + expect(retired).toBe(true); + } finally { + vi.useRealTimers(); + } + }); + + it("fails a turn that ran when native could not close its generation", async () => { + const warned = vi.spyOn(console, "warn").mockImplementation(() => {}); + const refused = new Error("close refused"); + const native = generation(() => Promise.reject(refused)); + const { fake, retirements, exits, run } = setup({}, {}, native); + await expect(run()).resolves.toEqual({ ok: false, error: NOT_CLOSED }); + expect(fake.geese[0]?.turns).toHaveLength(1); + // Its retirement fails too: nothing confirms the generation ended. Its + // process did exit, so the conversation is free. + await expect(retirements[0]).rejects.toBe(refused); + await expect(exits[0]).resolves.toBeUndefined(); + expect(warned).toHaveBeenCalled(); + }); +}); diff --git a/src/bundled/goose/forwarded.ts b/src/bundled/goose/forwarded.ts new file mode 100644 index 0000000000..b0b1bda1f2 --- /dev/null +++ b/src/bundled/goose/forwarded.ts @@ -0,0 +1,213 @@ +// One goose turn through the agent gateway: a generation of its own, opened at +// the route the turn was dispatched with, given to a dedicated `goose-acp` +// started on the generation's URL, never the shared one, and one session in +// it. Nothing is retried: a turn that can't open, start or load fails. Every +// exit retires it in order (seal, interrupt, close, stop), and the generation +// is closed only once its process has exited, so nothing of the turn runs on +// after it, and no other turn's runs on its generation. +import type { Generation } from "../../features/agents/gateway/forwarding"; +import { STOP_TIMEOUT_MS } from "../../features/agent-harness/acp/process"; +import type { AcpSession } from "../../features/agent-harness/acp/session"; +import { forwardChildren } from "./forward-children"; +import type { + ActivityReceipt, + Choice, + GooseProcess, + Settled, + ToolServer, +} from "./goose"; +import { abortable } from "./sessions"; + +export const NOT_KEPT = + "goose couldn't save this conversation's session, so the turn didn't run."; +export const NOT_STOPPED = + "goose didn't stop after the turn; this conversation waits until it has."; +export const NOT_CLOSED = + "Buzz couldn't confirm the agent gateway closed this turn's forwarding."; + +export type ForwardedTurn = Readonly<{ + /** Opens the turn's generation; it throws, spawning nothing, when it + * can't. */ + open(signal: AbortSignal): Promise; + /** Starts the turn's own goose with `env`, handing it to `onProcess` as + * soon as it runs, so one whose start fails is retired too. */ + start( + env: Readonly>, + onProcess: (goose: GooseProcess) => void, + ): Promise; + launch(): Promise>; + tools: ToolServer; + choice: () => Choice; + /** The conversation (or one-off label) its Buzz tools act for. */ + conversation: string; + title?: string; + /** The saved session to resume; it never falls back to a new one. */ + load?: string; + /** The turn's text once its session is open, `fresh` for a new session + * shown nothing yet; undefined sends nothing. */ + prompt(fresh: boolean): Promise; + /** Saves the session before its first prompt: whether it was. */ + keep?(session: string): boolean; + /** Told of a new session once it is kept, before its first prompt. */ + started?(session: string): void; + /** Told the turn's retirement before it ends: `retired` settles once the + * process has exited and native has closed the generation, `exited` once + * the process has, and never rejects. */ + outlives(retired: Promise, exited: Promise): void; + /** Ends the turn: interrupt or dispose. */ + signal: AbortSignal; + activity?: ActivityReceipt; + model?: string; + effort?: string; + kept?: string; +}>; + +/** Runs `turn`, and settles once it has retired, or as far as it could: + * a route change wins (`changed`), then a cancel, then the turn's own + * error, then a retirement that could not be confirmed. */ +export async function forwardedTurn( + turn: ForwardedTurn, + changed: string, +): Promise { + const { signal } = turn; + let generation: Generation; + try { + generation = await turn.open(signal); + } catch (error) { + if (signal.aborted) return { ok: true, undelivered: true }; + return { ok: false, error: message(error) }; + } + // Interrupted, or the route moved on, which revokes it natively. + const halt = new AbortController(); + const stop = () => halt.abort(new Error(changed)); + if (signal.aborted) stop(); + signal.addEventListener("abort", stop, { once: true }); + void generation.revoked.then(stop); + let process: GooseProcess | undefined; + let exit: Promise = Promise.resolve(); + let exited = true; + let session: AcpSession | undefined; + let sent = false; + let starting: Promise | undefined; + const kill = () => void process?.kill(); + let outcome: Settled; + try { + halt.signal.throwIfAborted(); + starting = turn.start( + { + DATABRICKS_HOST: generation.url, + // Its one prompt is the turn's: no model call names the session. + GOOSE_DISABLE_SESSION_NAMING: "true", + // A delegate runs on these, not a provider the model or config names + // (goose 1.57.0 `resolve_provider_config`); a recipe's own still wins. + ...subagent(turn), + }, + (goose) => { + process = goose; + exited = false; + exit = goose.exited.then(() => { + exited = true; + }); + // Ended while it starts, or before: it is stopped at once. Once + // started, it is retired in order instead. + if (halt.signal.aborted) void goose.kill(); + else halt.signal.addEventListener("abort", kill, { once: true }); + }, + ); + // Never abandoned: retirement waits for whatever it started. + void starting.catch(() => undefined); + const goose = await abortable(starting, halt.signal).finally(() => + halt.signal.removeEventListener("abort", kill), + ); + const { cwd, systemPrompt } = await abortable(turn.launch(), halt.signal); + session = await abortable( + goose.open({ + cwd, + systemPrompt, + tools: turn.tools, + choice: turn.choice, + ...(turn.load === undefined + ? turn.title + ? { title: turn.title } + : {} + : { load: turn.load }), + onSession: (opened) => { + session = opened; + }, + }), + halt.signal, + ); + session.conversation = turn.conversation; + outcome = await (async (open: AcpSession): Promise => { + if (turn.keep && !turn.keep(open.id)) + return { ok: false, error: NOT_KEPT }; + if (turn.load === undefined) turn.started?.(open.id); + await abortable(generation.associate(open.id), halt.signal); + const children = forwardChildren({ + register: (call, child) => generation.registerChild(call, child), + failed: (reason) => + console.warn("goose could not register a delegated session", reason), + }); + open.observe = children.observe; + const text = await abortable( + turn.prompt(turn.load === undefined), + halt.signal, + ); + if (text === undefined) return { ok: true, undelivered: true }; + sent = true; + const result = await abortable( + open.send(text, turn.activity, turn.model, turn.effort, turn.kept), + halt.signal, + ); + const failure = await abortable(children.settled(), halt.signal); + if (!result.ok) return result; + return failure === undefined ? result : { ok: false, error: failure }; + })(session); + } catch (error) { + outcome = { ok: false, error: message(error) }; + } + // Retired in order, each step whatever the one before did. + signal.removeEventListener("abort", stop); + generation.seal(); + // A start still running is waited out: what it starts is retired too. + await starting?.then(noop, noop); + const live = session; + if (live?.working) live.interrupt(); + if (process && live) + await process + .close(live, true) + .catch((error) => + console.warn("goose did not close a forwarded session", error), + ); + if (process) await process.stop(STOP_TIMEOUT_MS); + const retired = exit.then(() => generation.close()); + turn.outlives(retired, exit); + let retirement: string | undefined; + if (!exited) retirement = NOT_STOPPED; + else + await retired.catch((error) => { + console.warn("The agent gateway did not close a generation", error); + retirement = NOT_CLOSED; + }); + if (generation.isRevoked()) return { ok: false, error: changed }; + if (signal.aborted) + return sent ? { ok: true, stopped: true } : { ok: true, undelivered: true }; + if (!outcome.ok || !retirement) return outcome; + return { ok: false, error: retirement }; +} + +const noop = () => {}; + +/** Pins a delegate's provider and model to the turn's. */ +function subagent(turn: ForwardedTurn): Record { + const { provider, model } = turn.choice(); + const chosen = turn.model || model; + return { + ...(provider ? { GOOSE_SUBAGENT_PROVIDER: provider } : {}), + ...(chosen ? { GOOSE_SUBAGENT_MODEL: chosen } : {}), + }; +} + +function message(error: unknown) { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/bundled/goose/goose-testing.ts b/src/bundled/goose/goose-testing.ts index dc37021796..6d78569d03 100644 --- a/src/bundled/goose/goose-testing.ts +++ b/src/bundled/goose/goose-testing.ts @@ -37,6 +37,12 @@ export type FakeGoose = { /** Turns, in the order they began. */ turns: FakeTurn[]; closed: string[]; + /** Sessions `session/load` resumed. */ + loaded: string[]; + /** Every `session/load` is answered with this error. */ + loadError?: string; + /** Kill asks it to stop, and it never exits on its own. */ + holdExit: boolean; cancelled: string[]; deleted: string[]; /** Each `session/set_config_option`, as `sessionId configId=value`. */ @@ -91,6 +97,8 @@ export function fakeSpawn( options: Readonly<{ /** Whether goose says it can reach HTTP MCP servers. */ http?: boolean; + /** Whether goose says it can resume a saved session. */ + load?: boolean; home?: string; hold?: boolean; /** What a turn writes before it ends. */ @@ -204,6 +212,8 @@ export function fakeSpawn( prompts: new Map(), turns: [], closed: [], + loaded: [], + holdExit: false, cancelled: [], deleted: [], configured: [], @@ -267,6 +277,7 @@ export function fakeSpawn( protocolVersion: 1, agentCapabilities: { mcpCapabilities: { http: options.http ?? true, sse: false }, + ...(options.load && { loadSession: true }), }, }); case "session/new": { @@ -452,6 +463,15 @@ export function fakeSpawn( case "session/close": fake.closed.push(sessionId); return reply(id, {}); + case "session/load": + if (fake.loadError) + return send({ + jsonrpc: "2.0", + ...(id !== undefined && { id }), + error: { code: -32603, message: fake.loadError }, + }); + fake.loaded.push(sessionId); + return reply(id, { configOptions: configOptions(sessionId) }); default: return send({ jsonrpc: "2.0", @@ -476,7 +496,7 @@ export function fakeSpawn( end: async () => undefined, kill: async () => { fake.killed = true; - fake.exit(null); + if (!fake.holdExit) fake.exit(null); }, exited, }; diff --git a/src/bundled/goose/index.ts b/src/bundled/goose/index.ts index 6030288d3a..0828b660ec 100644 --- a/src/bundled/goose/index.ts +++ b/src/bundled/goose/index.ts @@ -6,6 +6,8 @@ import { repositoriesContext } from "../../features/communities/repositories"; import { isTauri } from "@tauri-apps/api/core"; import type { PluginModule } from "../../plugins/api"; import { GooseLogoIcon } from "../../shared/design-system/icons"; +import { GatewayForwarding } from "../../features/agents/gateway/forwarding"; +import { upstreamFor } from "../../features/agents/gateway/route"; import { type Config, config, DEFAULT_CONFIG, GooseRuntime } from "./runtime"; import { gooseRun } from "./run"; import { GooseSetup, REVIEW_REFUSED } from "./setup"; @@ -14,6 +16,12 @@ import { GooseHistory } from "./transcript"; import { readUsage } from "./usage"; export const TYPE_ID = "goose"; +/** The harness goose declares to the gateway, its descriptor upstream key. */ +export const HARNESS = "goose"; +/** Whether goose declares its harness to the gateway, which forwards its + * agents' turns where their membership routes through it. Off until a pinned + * goose has run forwarded turns end to end. */ +const GATEWAY = false; export const inject = ["agents", "host", "agentProtection"]; export const apply: PluginModule["apply"] = (ctx) => { @@ -29,6 +37,16 @@ export const apply: PluginModule["apply"] = (ctx) => { () => agents.tools(), repositoriesContext, agents.storage, + // Without a goose upstream in this build, the runner sends every turn + // without the gateway, so nothing forwards. + GATEWAY && ctx.pluginOwner && upstreamFor(HARNESS) + ? new GatewayForwarding(ctx.pluginOwner, { + upstream: HARNESS, + native: "goose", + }) + : undefined, + // Read at a forwarded turn, after `setup` exists. + async () => (await setup.providers()).defaults.provider, ); const desktop = host.spawn && host.resolveDirectory @@ -92,6 +110,7 @@ export const apply: PluginModule["apply"] = (ctx) => { agents.register({ id: TYPE_ID, + ...(GATEWAY ? { gateway: { harness: HARNESS } } : {}), retire: (pubkey) => runtime.retire(pubkey), protection: true, title: "Goose", diff --git a/src/bundled/goose/runtime.test.ts b/src/bundled/goose/runtime.test.ts index 018f1013c3..e8979ebb08 100644 --- a/src/bundled/goose/runtime.test.ts +++ b/src/bundled/goose/runtime.test.ts @@ -2,11 +2,14 @@ import { describe, expect, it, vi } from "vitest"; import type { BuzzClient, Memories } from "../../buzz-mcp/client"; import { callTool, memoryVersion } from "../../buzz-mcp/tools"; import { createAgentMemories } from "../../features/agents/memory"; +import { localStore } from "../../features/agents/store"; import { promptMessage, promptSections, } from "../../features/agents/activity-transcript"; import { RESUMED_PROMPT, TOP_LEVEL_PROMPT } from "../../buzz-mcp/prompt"; +import { STEER_NOTE, steerPrompt } from "../claude-code/prompt"; +import { STEER } from "../../features/agents/activity-transcript"; import type { Agent, Delivery } from "../../features/agents/service"; import type { AgentProtection } from "../../features/agent-protection/service"; import type { Host } from "../../features/host/service"; @@ -15,7 +18,14 @@ import type { RelayData } from "../../features/relay/service"; import { fakeSpawn, flush, gated } from "./goose-testing"; import { REQUEST_TIMEOUT_MS } from "./goose"; import { AcpSession } from "../../features/agent-harness/acp/session"; -import { AgentSessions, SharedProcess } from "./sessions"; +import { AgentSessions, localSessions, SharedProcess } from "./sessions"; +import { gooseTurns, marked, unmarked } from "./transcript"; +import { SHARED_POOL } from "../../features/agent-harness/acp/agent-sessions"; +import type { ForwardNative } from "../../features/agents/gateway/forwarding"; +import { GatewayForwarding } from "../../features/agents/gateway/forwarding"; +import type { GatewayRoute } from "../../features/agents/gateway/route"; +import { VECTOR_PROFILE } from "../../features/agents/gateway/vectors"; +import type { ForwardOpen } from "../../features/host/forward"; import { DEFAULT_CONFIG, GooseRuntime, @@ -84,10 +94,15 @@ function setup( thinking?: NonNullable[0]>["thinking"]; /** Wraps the fake's `spawn`, as `gated(โ€ฆ).wrap` does. */ wrap?: (spawn: ReturnType["spawn"]) => Host["spawn"]; + forwarding?: GatewayForwarding; + provider?: string; + /** goose's own default provider. */ + defaultProvider?: () => Promise; }> = {}, ) { const fake = fakeSpawn({ hold: options.hold ?? false, + load: true, ...(options.protection && { history: [ { @@ -190,6 +205,9 @@ function setup( storage, undefined, options.repositories, + localStore(storage), + options.forwarding, + options.defaultProvider, ); const published: { kind: number; content: string; tags: string[][] }[] = []; const agent = { @@ -213,7 +231,11 @@ function setup( trigger, channelId: channel, agent, - config: { ...DEFAULT_CONFIG, scope: options.scope ?? "thread" }, + config: { + ...DEFAULT_CONFIG, + scope: options.scope ?? "thread", + ...(options.provider ? { provider: options.provider } : {}), + }, signal: new AbortController().signal, workingUntil: () => undefined, ...extra, @@ -235,6 +257,7 @@ function setup( return { agent, core, + storage, deliver, run, runOnce, @@ -2035,6 +2058,193 @@ it("refreshes a queued subscription's instructions when its policy wait ends", a } }); +it("marks each fallback wake's prompt with its own turn, however rebuilt, and keeps its notice until the agent retires", async () => { + const f = setup({ hold: true, scope: "channel" }); + const fallback = { + gatewayFallback: { reason: "disabled", notice: "Ran without the gateway." }, + } as unknown as Partial; + const MARK = /\n\n$/; + let instructions = "Old Interest instructions"; + try { + await f.deliver(message("1", "threaded work")); + await vi.waitFor(() => expect(f.goose()?.turns).toHaveLength(1)); + await f.run({ ...pluginWake(), topLevel: true, text: instructions }, { + ...fallback, + wakeText: async () => instructions, + } as Partial); + instructions = "Updated Interest instructions"; + f.goose()?.turns[0]?.finish(); + await vi.waitFor(() => expect(f.goose()?.turns).toHaveLength(2)); + // Rebuilt once its wait ends, under the turn it was given. + const first = f.goose()?.turns[1]?.text.match(MARK)?.[1]; + expect(f.goose()?.turns[1]?.text).toContain(instructions); + expect(f.runtime.notices(f.agent.key)).toEqual( + new Map([[first, "Ran without the gateway."]]), + ); + expect(f.goose()?.turns[0]?.text).not.toContain("buzz-turn"); + f.goose()?.turns[1]?.finish(); + // A wake through the gateway, or none, is never marked. + await f.run({ ...pluginWake(), topLevel: true, text: "routed" }); + await vi.waitFor(() => expect(f.goose()?.turns).toHaveLength(3)); + expect(f.goose()?.turns[2]?.text).not.toContain("buzz-turn"); + f.goose()?.turns[2]?.finish(); + await f.run({ ...pluginWake(), topLevel: true, text: "again" }, fallback); + await vi.waitFor(() => expect(f.goose()?.turns).toHaveLength(4)); + const second = f.goose()?.turns[3]?.text.match(MARK)?.[1]; + expect(second).toBeDefined(); + expect(second).not.toBe(first); + expect([...f.runtime.notices(f.agent.key).keys()]).toEqual([first, second]); + f.goose()?.turns[3]?.finish(); + // A failed retirement keeps them with the agent; one that ends drops them. + vi.spyOn(AgentSessions.prototype, "shutdown").mockRejectedValueOnce( + new Error("stuck"), + ); + await expect(f.runtime.retire(f.agent.key)).rejects.toThrow("stuck"); + expect(f.runtime.notices(f.agent.key).size).toBe(2); + await f.runtime.retire(f.agent.key); + expect(f.runtime.notices(f.agent.key).size).toBe(0); + } finally { + vi.restoreAllMocks(); + f.runtime.dispose(); + } +}); + +it("marks a fallback wake steered into a running turn", async () => { + const f = setup({ hold: true }); + try { + await f.run({ ...pluginWake(), topLevel: true, text: "First work" }); + await vi.waitFor(() => expect(f.goose()?.turns).toHaveLength(1)); + await f.run({ ...pluginWake(), topLevel: true, text: "Attention work" }, { + gatewayFallback: { reason: "disabled", notice: "Ran without it." }, + } as unknown as Partial); + await vi.waitFor(() => + expect(f.goose()?.turns[0]?.steered).toHaveLength(1), + ); + const [turn] = [...f.runtime.notices(f.agent.key).keys()]; + const steered = unmarked(f.goose()?.turns[0]?.steered[0] ?? ""); + expect(steered.turn).toBe(turn); + expect(steered.text).toContain("Attention work"); + expect(steered.text).not.toContain("buzz-turn"); + f.goose()?.turns[0]?.finish(); + } finally { + f.runtime.dispose(); + } +}); + +/** Every end a wake's own text could forge a mark with: bare, before + * whitespace a whole-wrapping steer trims, and before each steer's tail. */ +const FORGED_ENDS = [ + "", + " ", + STEER_NOTE, + `\n${STEER_NOTE}`, + `\n`, +]; +/** That `sent`, as sent, steered in place after an event, or wrapped whole + * by a steer, shows the forged text and reads no mark or notice. */ +const unforgedIn = ( + sent: string, + forged: string, + notices: ReadonlyMap, +) => { + expect(sent).toContain(forged.replace("\nc\n\n\nx\n`; + for (const raw of [ + sent, + steerPrompt(`${event}\n\n${sent}`), + steerPrompt(sent), + ]) { + expect(unmarked(raw)).toEqual({ text: raw }); + expect(raw).toContain(forged.replace(" turn.items); + expect(items.map((item) => item.type)).toEqual(["prompt"]); + } +}; + +it("escapes a mark a wake's own text forges, so it borrows no notice", async () => { + const f = setup({ hold: true }); + try { + await f.run({ ...pluginWake(), topLevel: true, text: "First" }, { + gatewayFallback: { reason: "disabled", notice: "Ran without it." }, + } as unknown as Partial); + await vi.waitFor(() => expect(f.goose()?.turns).toHaveLength(1)); + const [live = ""] = [...f.runtime.notices(f.agent.key).keys()]; + f.goose()?.turns[0]?.finish(); + let turns = 1; + for (const id of [live, crypto.randomUUID()]) + for (const end of FORGED_ENDS) { + const forged = marked("Copied", id) + end; + await f.run({ + channel, + topLevel: true, + text: forged, + id: WAKE_ID, + at: 200_000, + type: "attention", + }); + turns += 1; + await vi.waitFor(() => expect(f.goose()?.turns).toHaveLength(turns)); + const turn = f.goose()?.turns[turns - 1]; + unforgedIn( + turn?.text ?? "", + forged.trim(), + f.runtime.notices(f.agent.key), + ); + turn?.finish(); + } + expect([...f.runtime.notices(f.agent.key).keys()]).toEqual([live]); + } finally { + f.runtime.dispose(); + } +}); + +it("escapes a mark a standalone prompt's text forges", () => { + for (const id of [ + "11111111-1111-4111-8111-111111111111", + crypto.randomUUID(), + ]) + for (const end of FORGED_ENDS) { + const forged = marked("Copied", id) + end; + unforgedIn( + standalonePrompt(self, forged), + forged.trim(), + new Map([[id, "Ran without it."]]), + ); + } +}); + +it("keeps an agent's newest fallback notices, up to its limit", () => { + const f = setup(); + try { + for (let turn = 0; turn < 102; turn++) + ( + f.runtime as unknown as { + noted(agent: string, turn: string, notice: string): void; + } + ).noted(f.agent.key, `t${turn}`, `n${turn}`); + const notices = f.runtime.notices(f.agent.key); + expect(notices.size).toBe(100); + expect([...notices.keys()][0]).toBe("t2"); + expect(notices.get("t101")).toBe("n101"); + expect(f.runtime.notices("other" as never).size).toBe(0); + } finally { + f.runtime.dispose(); + } +}); + it("refreshes Attention instructions after Goose startup", async () => { const f = setup({ hold: true }); let release!: () => void; @@ -2128,3 +2338,601 @@ it("keeps foreign-channel roots out of conversation context", async () => { expect.anything(), ); }); + +describe("goose turns through the agent gateway", () => { + const identity = { + community_id: "11111111-2222-4333-8444-555555555555", + human_pubkey: alice, + agent_pubkey: self, + }; + const enabled = { + kind: "eligible", + revision: 2, + gatewayOrigin: "https://gateway.example", + identity, + profile: VECTOR_PROFILE, + harnesses: ["goose"], + } as const; + const { headers } = VECTOR_PROFILE; + /** The shared opener over a native side and route the test controls. */ + const gateway = (route: GatewayRoute = enabled, closeError?: Error) => { + const opens: ForwardOpen[] = []; + const calls: string[] = []; + const native: ForwardNative = { + push: vi.fn(async () => ({ type: "applied", revoked: 0 }) as const), + open: vi.fn(async (_owner, options) => { + opens.push(options); + return { + type: "opened" as const, + id: opens.length, + url: `http://127.0.0.1:9/g${opens.length}`, + }; + }), + associate: vi.fn( + async (_owner, id, session) => + void calls.push(`associate ${id} ${session}`), + ), + register: vi.fn(async () => ({ type: "registered" }) as const), + seal: vi.fn(async (_owner, id) => void calls.push(`seal ${id}`)), + close: vi.fn(async (_owner, id) => { + calls.push(`close ${id}`); + if (closeError) throw closeError; + opens[id - 1]?.onEvent({ type: "closed" }); + }), + }; + const forwarding = new GatewayForwarding( + { id: "buzz.goose", revision: "r1" }, + { upstream: "goose", native: "goose" }, + native, + { routeFor: () => route, subscribeRoute: () => () => {} }, + ); + return { forwarding, opens, calls, native }; + }; + /** The runner's frozen `gateway` for a turn at `route`. */ + const dispatched = (route: typeof enabled = enabled) => + ({ + gateway: { + route, + turn: { channel_id: null, message_id: "1".repeat(64), thread_id: null }, + upstream: { base: "/goose" }, + }, + }) as unknown as Partial; + const root = message("1", "Howzit"); + const key = `${channel}/${root.id}`; + + it("runs each turn in a goose of its own on its generation, resuming the session and binding it saved", async () => { + const g = gateway(); + const f = setup({ forwarding: g.forwarding, provider: "databricks" }); + try { + await f.deliver(root, dispatched()); + await vi.waitFor(() => expect(g.calls).toContain("close 1")); + const saved = localSessions(f.storage, self).get(key); + expect(saved?.sessions).toEqual(["session-1"]); + expect(saved?.binding).toMatchObject({ + session: "session-1", + value: { v: 1, identity }, + }); + await f.deliver( + message("2", "Again", { + tags: [ + ["h", channel], + ["e", root.id, "", "reply"], + ], + }), + dispatched(), + ); + await vi.waitFor(() => expect(g.calls).toContain("close 2")); + const [first, second] = f.fake.geese; + // Never the shared goose: one per generation, each on its own URL. + expect(f.fake.geese).toHaveLength(2); + expect(first?.env.DATABRICKS_HOST).toBe("http://127.0.0.1:9/g1"); + expect(second?.env.DATABRICKS_HOST).toBe("http://127.0.0.1:9/g2"); + expect(first?.turns).toHaveLength(1); + expect(second?.loaded).toEqual(["session-1"]); + expect(second?.opened).toEqual([]); + expect(second?.turns).toHaveLength(1); + expect(g.opens[1]?.inference[headers.launcher_session]).toBe( + g.opens[0]?.inference[headers.launcher_session], + ); + expect(g.calls).toEqual([ + "associate 1 session-1", + "seal 1", + "close 1", + "associate 2 session-1", + "seal 2", + "close 2", + ]); + expect(first?.killed && second?.killed).toBe(true); + expect(f.published).toEqual([]); + // The new session's prompt is kept once; the resumed one adds none. + const prompts = f.runtime.prompts(self); + await vi.waitFor(() => + expect(prompts.sessions()).toEqual([ + { + id: "session-1", + at: expect.any(Number), + title: expect.any(String), + conversation: key, + }, + ]), + ); + expect(prompts.get("session-1")).toEqual([ + { label: "", text: expect.stringContaining("Buzz") }, + ]); + } finally { + f.runtime.dispose(); + } + }); + + it("refuses a turn it can't forward, visibly and before anything spawns", async () => { + const refusals: [Parameters[0], string][] = [ + [{ provider: "databricks" }, "Buzz couldn't start forwarding"], + ]; + for (const [options, error] of refusals) { + const f = setup(options); + try { + await f.deliver(root, dispatched()); + await vi.waitFor(() => expect(f.published).toHaveLength(1)); + expect(f.published[0]?.content).toContain(error); + expect(f.fake.geese).toEqual([]); + } finally { + f.runtime.dispose(); + } + } + }); + + it("forwards on goose's default provider when the agent names none", async () => { + const g = gateway(); + const f = setup({ + forwarding: g.forwarding, + defaultProvider: async () => "databricks", + }); + try { + await f.deliver(root, dispatched()); + await vi.waitFor(() => expect(g.calls).toContain("close 1")); + expect(g.opens).toHaveLength(1); + expect(f.fake.geese[0]?.env.DATABRICKS_HOST).toBe( + "http://127.0.0.1:9/g1", + ); + expect(f.published).toEqual([]); + } finally { + f.runtime.dispose(); + } + }); + + it.each<[string, Parameters[0]]>([ + ["another provider", { provider: "openai" }], + [ + "goose's default, another provider", + { defaultProvider: async () => "openai" }, + ], + ["goose's default, unread", { defaultProvider: async () => undefined }], + [ + "goose's default, unreadable", + { defaultProvider: () => Promise.reject(new Error("no goose")) }, + ], + ])( + "runs a dispatched turn on %s as it would without the gateway", + async (_, options) => { + const g = gateway(); + const f = setup({ ...options, forwarding: g.forwarding }); + try { + await f.deliver(root, dispatched()); + await vi.waitFor(() => expect(f.goose()?.turns).toHaveLength(1)); + expect(g.opens).toEqual([]); + expect(f.goose()?.env.DATABRICKS_HOST).toBeUndefined(); + expect(f.published.map((event) => event.content)).not.toContainEqual( + expect.stringContaining("gateway"), + ); + } finally { + f.runtime.dispose(); + } + }, + ); + + it("compacts as without the gateway, refusing nothing, once the agent's provider is another", async () => { + const g = gateway(); + const f = setup({ + forwarding: g.forwarding, + provider: "databricks", + reply: "Compaction complete", + }); + const place = { channelId: channel, root: root.id }; + try { + await f.deliver(root, dispatched()); + await vi.waitFor(() => expect(g.calls).toContain("close 1")); + const agent = { + ...f.agent, + config: { ...DEFAULT_CONFIG, provider: "openai" }, + } as unknown as Agent; + f.runtime.sync([agent]); + await f.runtime.session(agent, place, "compact"); + // goose's own compaction, which has no open session to compact. + expect(g.opens).toHaveLength(1); + expect(f.fake.geese).toHaveLength(1); + } finally { + f.runtime.dispose(); + } + }); + + it("refuses a saved binding it can't read, keeping it, until New session", async () => { + const g = gateway(); + const f = setup({ forwarding: g.forwarding, provider: "databricks" }); + try { + const agent = { ...f.agent, config: DEFAULT_CONFIG } as unknown as Agent; + const place = { channelId: channel, root: root.id }; + // Not a binding, no value at all, and no saved wrapper. + for (const binding of [ + { session: "session-9", value: { v: 2 } }, + { session: "session-9", value: null }, + { session: 9 }, + ]) { + const damaged = { at: 1, sessions: ["session-9"], binding }; + localSessions(f.storage, self).set(key, damaged); + const before = f.published.length; + await f.deliver(root, dispatched()); + await vi.waitFor(() => expect(f.published).toHaveLength(before + 1)); + expect(f.published.at(-1)?.content).toContain("unreadable"); + expect(localSessions(f.storage, self).get(key)).toEqual(damaged); + await vi.waitFor(() => + expect(f.runtime.sessionState(agent, place)).toBe("idle"), + ); + } + expect(f.fake.geese).toEqual([]); + expect(g.native.open).not.toHaveBeenCalled(); + await f.runtime.session(agent, place, "new"); + await f.deliver(root, dispatched()); + await vi.waitFor(() => expect(g.calls).toContain("close 1")); + expect(f.fake.geese[0]?.opened).toHaveLength(1); + expect(localSessions(f.storage, self).get(key)?.sessions).toEqual([ + "session-1", + "session-9", + ]); + } finally { + f.runtime.dispose(); + } + }); + + it("compacts a forwarded conversation's saved session by a turn of its own through the gateway, or refuses", async () => { + const blocked: GatewayRoute = { + kind: "blocked", + revision: 3, + gatewayOrigin: "https://gateway.example", + reason: "network", + message: "", + }; + let route: GatewayRoute = enabled; + const g = gateway(); + const forwarding = new GatewayForwarding( + { id: "buzz.goose", revision: "r1" }, + { upstream: "goose", native: "goose" }, + g.native, + { routeFor: () => route, subscribeRoute: () => () => {} }, + ); + const f = setup({ + forwarding, + provider: "databricks", + reply: "Compaction complete", + }); + const config = { ...DEFAULT_CONFIG, provider: "databricks" }; + const agent = { ...f.agent, config } as unknown as Agent; + const place = { channelId: channel, root: root.id }; + try { + await f.deliver(root, dispatched()); + await vi.waitFor(() => expect(g.calls).toContain("close 1")); + await f.runtime.session(agent, place, "compact"); + const compacting = f.fake.geese[1]; + expect(compacting?.loaded).toEqual(["session-1"]); + expect(compacting?.turns.map((turn) => turn.text)).toEqual(["/compact"]); + // The control's own context: its thread, and never a message. + expect(g.opens[1]?.inference[headers.thread_id]).toBe(root.id); + expect(g.opens[1]?.inference).not.toHaveProperty(headers.message_id); + route = blocked; + await expect(f.runtime.session(agent, place, "compact")).rejects.toThrow( + "Couldn't compact through the agent gateway: Buzz couldn't reach the gateway.", + ); + route = { kind: "external", revision: 4 }; + await expect(f.runtime.session(agent, place, "compact")).rejects.toThrow( + "which this agent's turns don't use now", + ); + expect(f.fake.geese).toHaveLength(2); + } finally { + f.runtime.dispose(); + } + }); + + it("stops a forwarded turn on interrupt, posting nothing", async () => { + const g = gateway(); + const f = setup({ + forwarding: g.forwarding, + provider: "databricks", + hold: true, + }); + const agent = { ...f.agent, config: DEFAULT_CONFIG } as unknown as Agent; + const place = { channelId: channel, root: root.id }; + try { + await f.deliver(root, dispatched()); + await vi.waitFor(() => expect(f.fake.geese[0]?.turns).toHaveLength(1)); + expect(f.runtime.sessionState(agent, place)).toBe("active"); + await f.runtime.session(agent, place, "interrupt"); + await vi.waitFor(() => expect(g.calls).toContain("close 1")); + expect(f.fake.geese[0]?.killed).toBe(true); + await vi.waitFor(() => + expect(f.runtime.sessionState(agent, place)).toBe("idle"), + ); + expect(f.published).toEqual([]); + } finally { + f.runtime.dispose(); + } + }); + + it("runs a standalone turn on a binding of its own, saving nothing, and the agent retires only once its goose has exited and closed", async () => { + const g = gateway(); + const f = setup({ forwarding: g.forwarding, hold: true }); + try { + const turn = f.runtime.runOnce({ + delivery: { + agent: f.agent, + channelId: channel, + config: { ...DEFAULT_CONFIG, provider: "databricks" }, + ...dispatched(), + }, + key: "Timer", + signal: new AbortController().signal, + prompt: async () => "Check in", + } as unknown as Parameters[0]); + await vi.waitFor(() => expect(f.fake.geese[0]?.turns).toHaveLength(1)); + const [goose] = f.fake.geese; + if (goose) goose.holdExit = true; + vi.useFakeTimers(); + try { + goose?.turns[0]?.finish(); + await vi.advanceTimersByTimeAsync(10_000); + await expect(turn).resolves.toEqual({ + ok: false, + error: expect.stringContaining("didn't stop"), + }); + } finally { + vi.useRealTimers(); + } + await expect(f.runtime.retire(self)).rejects.toThrow("didn't stop"); + expect(g.calls).not.toContain("close 1"); + goose?.exit(null); + await vi.waitFor(() => expect(g.calls).toContain("close 1")); + await f.runtime.retire(self); + expect(localSessions(f.storage, self).list()).toEqual([]); + expect(f.runtime.prompts(self).sessions()).toMatchObject([ + { id: "session-1", conversation: null }, + ]); + } finally { + f.runtime.dispose(); + } + }); + + /** A standalone forwarded turn of the agent's. */ + const standalone = (f: ReturnType) => + f.runtime.runOnce({ + delivery: { + agent: f.agent, + channelId: channel, + config: { ...DEFAULT_CONFIG, provider: "databricks" }, + ...dispatched(), + }, + key: "Timer", + signal: new AbortController().signal, + prompt: async () => "Check in", + } as unknown as Parameters[0]); + /** A spawn whose goose comes up only once released. */ + const late = () => { + let release!: () => void; + const released = new Promise((resolve) => (release = resolve)); + const wrap = + (spawn: ReturnType["spawn"]): Host["spawn"] => + async (id, options) => { + if (id === "goose") await released; + return spawn(id, options); + }; + return { wrap, release }; + }; + + it("ends a standalone turn still starting when the agent retires, and waits for its goose to exit and close", async () => { + const g = gateway(); + const gate = late(); + const f = setup({ forwarding: g.forwarding, wrap: gate.wrap }); + const turn = standalone(f); + await vi.waitFor(() => expect(g.opens).toHaveLength(1)); + let retired = false; + const retiring = f.runtime.retire(self).then(() => (retired = true)); + await flush(30); + expect(retired).toBe(false); + gate.release(); + await retiring; + expect(f.fake.geese[0]?.killed).toBe(true); + expect(f.fake.geese[0]?.opened).toEqual([]); + expect(g.calls).toEqual(["seal 1", "close 1"]); + await expect(turn).resolves.toEqual({ ok: true, undelivered: true }); + }); + + it("ends a standalone turn's prompt when the agent retires, and waits for its goose to exit and close", async () => { + const g = gateway(); + const f = setup({ forwarding: g.forwarding, hold: true }); + const turn = standalone(f); + await vi.waitFor(() => expect(f.fake.geese[0]?.turns).toHaveLength(1)); + await f.runtime.retire(self); + expect(f.fake.geese[0]?.cancelled).toContain("session-1"); + expect(f.fake.geese[0]?.killed).toBe(true); + expect(g.calls).toContain("close 1"); + await expect(turn).resolves.toEqual({ ok: true, stopped: true }); + }); + + it("stops a goose that comes up after the runtime was disposed", async () => { + const g = gateway(); + const gate = late(); + const f = setup({ forwarding: g.forwarding, wrap: gate.wrap }); + const turn = standalone(f); + await vi.waitFor(() => expect(g.opens).toHaveLength(1)); + f.runtime.dispose(); + gate.release(); + await expect(turn).resolves.toEqual({ ok: true, undelivered: true }); + expect(f.fake.geese[0]?.killed).toBe(true); + expect(f.fake.geese[0]?.opened).toEqual([]); + expect(g.calls).toEqual(["seal 1", "close 1"]); + }); + + it("refuses to retire an agent until a conversation's goose that did not stop has exited and closed", async () => { + const g = gateway(); + const f = setup({ + forwarding: g.forwarding, + provider: "databricks", + hold: true, + }); + await f.deliver(root, dispatched()); + await vi.waitFor(() => expect(f.fake.geese[0]?.turns).toHaveLength(1)); + const [goose] = f.fake.geese; + if (goose) goose.holdExit = true; + vi.useFakeTimers(); + try { + goose?.turns[0]?.finish(); + await vi.advanceTimersByTimeAsync(10_000); + } finally { + vi.useRealTimers(); + } + await vi.waitFor(() => expect(f.published).toHaveLength(1)); + expect(f.published[0]?.content).toContain("didn't stop"); + // Refused, never forgotten as clean: the entry and its barrier stay. + await expect(f.runtime.retire(self)).rejects.toThrow("didn't stop"); + expect(g.calls).not.toContain("close 1"); + goose?.exit(null); + await vi.waitFor(() => expect(g.calls).toContain("close 1")); + await f.runtime.retire(self); + expect(g.calls).toEqual(["associate 1 session-1", "seal 1", "close 1"]); + }); + + it("keeps failing to retire an agent whose conversation's generation native could not close, without blocking the conversation", async () => { + const warned = vi.spyOn(console, "warn").mockImplementation(() => {}); + const g = gateway(enabled, new Error("close refused")); + const again = message("2", "Again", { + tags: [ + ["h", channel], + ["e", root.id, "", "reply"], + ], + }); + const f = setup({ + forwarding: g.forwarding, + provider: "databricks", + thread: [root, again], + }); + await f.deliver(root, dispatched()); + await vi.waitFor(() => expect(f.published).toHaveLength(1)); + expect(f.published[0]?.content).toContain("couldn't confirm"); + // Its goose exited, so the conversation's next message still runs, + // resuming its session. + await f.deliver(again, dispatched()); + await vi.waitFor(() => expect(g.calls).toContain("close 2")); + expect(f.fake.geese[1]?.loaded).toEqual(["session-1"]); + expect(f.fake.geese[1]?.turns).toHaveLength(1); + await expect(f.runtime.retire(self)).rejects.toThrow("close refused"); + // Never forgotten as clean: a later retirement fails the same way. + await expect(f.runtime.retire(self)).rejects.toThrow("close refused"); + warned.mockRestore(); + }); + + it("shows a resumed turn only what its saved session has not been shown", async () => { + const g = gateway(); + const reply = (id: string, content: string) => + message(id, content, { + tags: [ + ["h", channel], + ["e", root.id, "", "reply"], + ], + }); + const aside = reply("2", "An aside"); + const again = reply("3", "Again"); + const f = setup({ + forwarding: g.forwarding, + provider: "databricks", + thread: [root, aside, again, reply("4", "Once more")], + }); + try { + await f.deliver(root, dispatched()); + await vi.waitFor(() => expect(g.calls).toContain("close 1")); + await f.deliver(again, dispatched()); + await vi.waitFor(() => expect(g.calls).toContain("close 2")); + expect(f.fake.geese[1]?.turns[0]?.text).toContain("An aside"); + await f.deliver(reply("4", "Once more"), dispatched()); + await vi.waitFor(() => expect(g.calls).toContain("close 3")); + const third = f.fake.geese[2]?.turns[0]?.text ?? ""; + expect(promptMessage(promptSections(third)).text).toBe("Once more"); + expect(third).not.toContain("An aside"); + expect(third).not.toContain("Again"); + } finally { + f.runtime.dispose(); + } + }); + + it("holds a server in the shared pool for a forwarded turn's Buzz tools until it closes", async () => { + const g = gateway(); + const f = setup({ forwarding: g.forwarding, hold: true }); + const before = SHARED_POOL.size; + const turn = standalone(f); + await vi.waitFor(() => expect(f.fake.geese[0]?.turns).toHaveLength(1)); + expect(SHARED_POOL.size).toBe(before + 1); + f.fake.geese[0]?.turns[0]?.finish(); + await turn; + await vi.waitFor(() => expect(SHARED_POOL.size).toBe(before)); + f.runtime.dispose(); + }); + + it("gives up a forwarded message still waiting for its conversation at its deadline", async () => { + const g = gateway(); + const f = setup({ forwarding: g.forwarding, provider: "databricks" }); + try { + // The agent's entry, from a first turn elsewhere. + await f.deliver(message("9", "Elsewhere"), dispatched()); + await vi.waitFor(() => expect(g.calls).toContain("close 1")); + const { agents } = f.runtime as unknown as { + agents: Map; + }; + let release!: () => void; + const holding = agents + .get(self) + ?.sessions.exclusive( + key, + () => new Promise((resolve) => (release = resolve)), + ); + await flush(10); + const handover = new AbortController(); + await f.deliver(root, { ...dispatched(), signal: handover.signal }); + await flush(10); + handover.abort(new Error("Handover expired")); + await flush(10); + release(); + await holding; + await flush(30); + expect(g.opens).toHaveLength(1); + expect(f.fake.geese).toHaveLength(1); + // As an ordinary message that waited too long says. + expect(f.published).toHaveLength(1); + expect(f.published[0]?.content).toContain("goose did not start in time"); + } finally { + f.runtime.dispose(); + } + }); + + it("acknowledges a forwarded message once goose has taken it", async () => { + const g = gateway(); + const f = setup({ forwarding: g.forwarding, provider: "databricks" }); + try { + const acknowledged = vi.fn(); + await f.deliver(root, { + ...dispatched(), + step: vi.fn(), + acknowledged, + } as Partial); + await vi.waitFor(() => expect(g.calls).toContain("close 1")); + expect(acknowledged).toHaveBeenCalledTimes(1); + } finally { + f.runtime.dispose(); + } + }); +}); diff --git a/src/bundled/goose/runtime.ts b/src/bundled/goose/runtime.ts index dee5ae178e..610cd28916 100644 --- a/src/bundled/goose/runtime.ts +++ b/src/bundled/goose/runtime.ts @@ -57,13 +57,39 @@ import { } from "../claude-code/prompt"; import { readAuthorNames } from "../../features/agent-harness/author-names"; import { ownerCanvas, readProject } from "../../features/agent-harness/project"; +import type { Exclusive } from "../../features/agent-harness/acp/agent-sessions"; import { - type GooseProcess, + bindSession, + type TurnContext, +} from "../../features/agents/gateway/contract"; +import { + CHANGED, + compactRefusal, + controlTurn, + FORWARDING_PROTECTED, + type GatewayForwarding, + UNAVAILABLE, +} from "../../features/agents/gateway/forwarding"; +import { + type EligibleRoute, + fallbackNotice, +} from "../../features/agents/gateway/route"; +import { + type ActivityReceipt, + GooseProcess, REQUEST_TIMEOUT_MS, type ServeMcp, + type Settled, type ToolServer, } from "./goose"; -import { AgentSessions, localSessions, SharedProcess } from "./sessions"; +import { forwardedTurn } from "./forwarded"; +import { marked, unforged } from "./transcript"; +import { + absolute, + AgentSessions, + localSessions, + SharedProcess, +} from "./sessions"; export type Config = Readonly<{ /** A model the provider offers; empty uses the provider's default, or the @@ -99,7 +125,9 @@ const TITLE_LIMIT = 80; /** A watch's or timer's prompt, after a context naming the agent as a * conversation's first prompt does, so its session is listed as the agent's. */ export function standalonePrompt(agent: string, text: string) { - return `\nScope: standalone\nAgent: ${agent}\n\n\n${text}`; + return unforged( + `\nScope: standalone\nAgent: ${agent}\n\n\n${text}`, + ); } /** A session's title in goose, as harness agents name theirs: @@ -126,6 +154,12 @@ type Entry = { /** Every replaced provider's worker, which its successor waits out. */ stopping?: Promise; sessions: AgentSessions; + /** The Buzz tools its sessions serve, forwarded ones' included. */ + tools: ToolServer; + /** Forwarded turns, each from before it starts until it has retired, + * which retiring the agent ends and waits for; one whose retirement failed + * stays. */ + retiring: Set; config: Config; /** The team sections of the latest run; a change reopens idle sessions. */ team: string; @@ -177,12 +211,34 @@ export class GooseRuntime { "", /** Agent storage, where what each session started with is kept. */ private readonly agentStore: AgentStore = localStore(), + /** Opens forwarded turns' generations; absent without a plugin owner. */ + private readonly forwarding?: GatewayForwarding, + /** goose's own default provider, which an agent naming none runs on. */ + private readonly defaultProvider: () => Promise< + string | undefined + > = async () => undefined, ) {} /** What each of the agent's sessions started with. */ prompts(agent: AgentKey) { return sessionPrompts(this.agentStore, "goose", agent); } + /** Fallback notices, by the turn whose prompt was marked with each, kept + * only while the app runs: a reload, or goose compacting the prompt away, + * loses them. */ + private readonly noticed = new Map>(); + notices(agent: AgentKey): ReadonlyMap { + return this.noticed.get(agent) ?? new Map(); + } + private noted(agent: AgentKey, turn: string, notice: string) { + const notices = this.noticed.get(agent) ?? new Map(); + notices.set(turn, notice); + for (const oldest of notices.keys()) { + if (notices.size <= NOTICE_LIMIT) break; + notices.delete(oldest); + } + this.noticed.set(agent, notices); + } /** The desktop host's methods goose agents run with. */ private get desktop() { @@ -228,13 +284,23 @@ export class GooseRuntime { } catch (error) { failure = { error }; } + // Each forwarded turn ends, retiring in order, within its own + // timeouts; one whose process has yet to exit, or whose generation + // native did not close, keeps the agent from retiring. + const owned = [...(entry?.retiring ?? [])]; + await Promise.allSettled(owned.map((turn) => turn.ended)); + const left = owned.find((turn) => entry?.retiring.has(turn)); + if (left) failure ??= { error: left.failed ?? new Error(NOT_RETIRED) }; try { await entry?.stopping; await entry?.worker?.shutdown(); } catch (error) { failure ??= { error }; } - if (!failure) return; + if (!failure) { + this.noticed.delete(pubkey); + return; + } // Back, stopped, until a later retirement closes what is left. if (entry) this.agents.set(pubkey, entry); throw failure.error; @@ -353,7 +419,21 @@ export class GooseRuntime { throw new Error("Wait for the active turn to finish first."); return this.controls.run(control, action, async (signal) => { signal.throwIfAborted(); - const result = await entry.sessions.reset(key, action); + // A saved session a forwarded turn left compacts through the gateway + // too, under the control's own context, or fails visibly; one whose + // agent's provider the gateway doesn't serve compacts as any other. + const through = + action === "compact" && + !!this.forwarding && + (await this.forwards(entry)); + if (through) signal.throwIfAborted(); + const result = await entry.sessions.reset( + key, + action, + through + ? (held) => this.compact(entry, agent, place, held, signal) + : undefined, + ); signal.throwIfAborted(); if (!result.ok) throw new Error(result.error); }); @@ -388,6 +468,8 @@ export class GooseRuntime { superseded, interruptible, named, + gateway: offered, + gatewayFallback, }: Delivery) { const settings = config(raw); // Unaddressed watches and timers come as standalone turns (`runOnce`). @@ -426,6 +508,8 @@ export class GooseRuntime { const entry = this.entry(agent.key, settings); entry.handle = agent; entry.team = team ?? ""; + const gateway = offered && (await this.routed(entry, offered)); + if (offered) signal.throwIfAborted(); const event = trigger.type !== "mention" ? wakeAnchor(trigger) : trigger.event; const channel = @@ -440,6 +524,12 @@ export class GooseRuntime { ); const topLevel = solo || wake?.topLevel === true; if (wake && !wakeLands(wake, topLevel)) return ignored?.(); + // A wake run without the gateway says so beside its prompt in Activity: + // its prompt ends with a mark naming the turn. A wake is always sent as + // `refresh` rebuilds it, steered or queued, so only that is marked. + const marking = wake && gatewayFallback ? crypto.randomUUID() : undefined; + if (marking && gatewayFallback) + this.noted(agent.key, marking, gatewayFallback.notice); // `fresh` is for a session that has been shown nothing yet. const prompt = async (fresh: boolean, admitted?: string) => { const turn = await this.turn( @@ -519,6 +609,36 @@ export class GooseRuntime { ? { channel } : { channel, root: threadReference(event)?.rootId ?? event.id }, ); + if (gateway) { + delivered = this.forwarded(entry, agent.key, key, gateway, { + prompt: async (fresh) => { + if (current && !current()) return undefined; + // A wake is rebuilt as it is sent, as a refresh would be. + if (!fresh && !wake) return { text, shown }; + const rebuilt = await prompt(fresh, key); + return { + text: this.review(entry, rebuilt.text, notes), + shown: rebuilt.shown, + }; + }, + title: sessionTitle(entry.name, place, key.split("/")[1]), + ...(!deferred && !wait ? { signal } : {}), + ...(model ? { model } : {}), + ...(effort ? { effort } : {}), + ...(synced?.effort && !model && !effort + ? { kept: synced.effort } + : {}), + activity: { + activityScope: topLevel + ? channel + : (threadReference(event)?.rootId ?? event.id), + ...(step ? { step } : {}), + ...(acknowledged ? { acknowledged } : {}), + ...(superseded ? { superseded } : {}), + }, + }); + return; + } delivered = entry.sessions.deliver(key, text, shown, { ...(wake ? { @@ -529,7 +649,7 @@ export class GooseRuntime { (await prompt(fresh, key)).text, notes, ); - return text; + return marking ? marked(text, marking) : text; }, } : {}), @@ -613,31 +733,269 @@ export class GooseRuntime { const entry = this.entry(agent.key, config(delivery.config)); entry.handle = agent; entry.team = team ?? ""; - const done = entry.sessions.runOnce( + const standalone = async () => { + const text = await prompt(); + return text === undefined ? undefined : standalonePrompt(agent.key, text); + }; + const gateway = + delivery.gateway && (await this.routed(entry, delivery.gateway)); + // Its own generation and process, never saved and never unforwarded. + if (gateway) { + const { current, synced } = delivery; + const done = this.forward(entry, agent.key, key, gateway, { + prompt: async () => { + if (current && !current()) return undefined; + const text = await standalone(); + return text === undefined ? undefined : { text }; + }, + title: sessionTitle(entry.name, key), + signal, + ...(synced?.effort ? { kept: synced.effort } : {}), + }); + this.notify(); + return done.finally(() => this.notify()); + } + const done = entry.sessions.runOnce(key, standalone, { + signal, + title: sessionTitle(entry.name, key), + current: delivery.current, + ...(delivery.synced?.effort ? { kept: delivery.synced.effort } : {}), + }); + this.notify(); + return done.finally(() => this.notify()); + } + + /** Whether the agent's turns can go through the gateway: on the provider + * it names, else goose's default, the gateway's. One whose default can't + * be read runs as it would without the gateway. */ + private async forwards(entry: Entry) { + const named = entry.config.provider.trim(); + const provider = + named || (await this.defaultProvider().catch(() => undefined)); + return provider === GATEWAY_PROVIDER; + } + + /** The dispatched route a turn runs through, or none: a turn on another + * provider runs as it would without the gateway, its settings untouched. */ + private async routed(entry: Entry, gateway: Gateway) { + return (await this.forwards(entry)) ? gateway : undefined; + } + + /** A conversation's turn through the gateway, holding the conversation: + * its messages and controls wait for it, and for its process to exit. */ + private forwarded( + entry: Entry, + agent: AgentKey, + key: string, + gateway: Gateway, + turn: ForwardTurn, + ): Promise { + // The delivery's deadline ends only its wait for the conversation, as + // an ordinary delivery's does; a turn that has it runs on. + return entry.sessions + .exclusive( + key, + (held) => this.forward(entry, agent, key, gateway, turn, held), + undefined, + turn.signal, + ) + .catch((error: unknown) => + message(error) === STOPPED + ? { ok: true, undelivered: true } + : message(error) === LATE + ? { ok: false, error: LATE, undelivered: true } + : { ok: false, error: message(error) }, + ); + } + + /** Compacts the saved session a forwarded turn left: by its own turn of + * `/compact` through the gateway, where the agent's turns go through it + * now, else not at all. */ + private async compact( + entry: Entry, + agent: Agent, + place: SessionPlace, + held: Exclusive, + signal: AbortSignal, + ): Promise { + const route = this.forwarding?.control(agent.key); + if (route?.kind === "blocked") + return { ok: false, error: compactRefusal(fallbackNotice(route)) }; + if (!route) return { ok: false, error: NOT_COMPACTED }; + const turn = controlTurn(place, config(agent.config).scope); + const key = this.controlKey(agent, place); + const result = await this.forward( + entry, + agent.key, key, - async () => { - const text = await prompt(); - return text === undefined + { route, turn }, + { prompt: async () => ({ text: "/compact" }), signal }, + held, + ); + // goose ends the turn either way, and says which in its text. + if (!result.ok || result.text === "Compaction complete") return result; + return { + ok: false, + error: result.text || "goose could not compact the session", + }; + } + + /** Runs `turn` through the gateway at the route it was dispatched with: + * in conversation `key` under `held`, resuming its saved session with the + * binding saved with it, or, without, as a one-off turn with a binding of + * its own. A turn that can't be forwarded fails visibly before anything + * spawns; it never runs on goose's own configuration instead, and is + * never retried. Unlike an ordinary session, which reopens when the + * agent's instructions, team or memory change, a conversation's forwarded + * turns keep resuming its saved session, history and all, until New + * session. Each turn sets its system prompt again, built from the + * agent's current settings. */ + private forward( + entry: Entry, + agent: AgentKey, + key: string, + gateway: Gateway, + turn: ForwardTurn, + held?: Exclusive, + ): Promise { + const refused = (error: string) => Promise.resolve({ ok: false, error }); + const { forwarding } = this; + if (!forwarding) return refused(UNAVAILABLE); + // A protected run through the gateway is not yet proven to work. + if (entry.protected || entry.handle?.protection) + return refused(FORWARDING_PROTECTED); + // What is saved, unread, is refused, never taken for none: a value that + // is no binding reads as malformed. + const saved = held?.binding; + let bound: ReturnType; + try { + bound = bindSession( + gateway.route, + saved === undefined ? undefined - : standalonePrompt(agent.key, text); - }, + : saved === "malformed" || saved.value == null + ? MALFORMED + : saved.value, + !held ? "standalone" : held.reset ? "reset" : "resume", + ); + } catch (error) { + return refused(message(error)); + } + if (!bound.ok) return refused(bound.message); + const { binding } = bound; + const { route } = gateway; + const { desktop } = this; + const workspace = () => + absolute(desktop.resolveDirectory, entry.config.workspace); + // The agent owns the turn from here until its process has exited and + // native has closed its generation: retiring the agent ends it and waits + // for that. One that could not be confirmed stays, failing every + // retirement after. + const owned: Owned = { ended: Promise.resolve() }; + entry.retiring.add(owned); + // What the prompt sent was built to have shown, noted once goose takes + // it, as an ordinary message is. + let shown: number | undefined; + let systemPrompt = ""; + const { activity } = turn; + const done = forwardedTurn( { - signal, - title: sessionTitle(entry.name, key), - current: delivery.current, - ...(delivery.synced?.effort ? { kept: delivery.synced.effort } : {}), + open: (signal) => + forwarding.open( + agent, + route, + { binding, context: gateway.turn }, + turn.signal && held + ? AbortSignal.any([signal, turn.signal]) + : signal, + ), + start: async (env, onProcess) => + GooseProcess.start(desktop.spawn, { + cwd: await workspace(), + env, + // Its Buzz tools' server counts in the pool sessions share. + serve: entry.sessions.pooled((handler) => this.serve(handler)), + onProcess, + }), + launch: async () => { + ({ systemPrompt } = await this.launch(agent, entry)); + return { cwd: await workspace(), systemPrompt }; + }, + tools: entry.tools, + choice: () => this.choice(entry), + conversation: key, + ...(turn.title ? { title: turn.title } : {}), + ...(held?.saved ? { load: held.saved } : {}), + prompt: async (fresh) => { + const prompt = await turn.prompt(fresh); + shown = prompt?.shown; + return prompt?.text; + }, + ...(held ? { keep: (id: string) => held.keep(id, binding) } : {}), + // Kept as an ordinary new session's is, for the Prompts tab. + started: (id) => + void this.prompts(agent).save( + id, + [{ label: "", text: systemPrompt }], + { + at: Date.now(), + title: turn.title ?? "", + conversation: held ? key : null, + }, + ), + outlives: (retirement, exited) => { + // Before the turn ends: by then, one whose process exited is + // confirmed retired or failed. + owned.retiring = true; + void retirement.then( + () => entry.retiring.delete(owned), + (error: unknown) => { + owned.failed = error; + }, + ); + // The conversation waits only for the process: a generation native + // didn't close fails this turn and the agent's retirement, but no + // later turn runs on it. + held?.outlives(exited); + }, + signal: AbortSignal.any([ + held?.signal ?? turn.signal ?? entry.lifetime.signal, + entry.lifetime.signal, + ]), + activity: { + ...activity, + delivered: () => { + if (shown !== undefined) held?.shown(shown); + activity?.delivered?.(); + }, + }, + ...(turn.model ? { model: turn.model } : {}), + ...(turn.effort ? { effort: turn.effort } : {}), + ...(turn.kept ? { kept: turn.kept } : {}), }, + CHANGED, ); - this.notify(); - return done.finally(() => this.notify()); + owned.ended = done; + // Ended with nothing left running: nothing more to wait for. + void done.then( + () => { + if (!owned.retiring) entry.retiring.delete(owned); + }, + // Unreached: it settles, unless `held.outlives` refused a hold + // already released, which work it holds cannot see. + (error: unknown) => { + owned.failed = error; + }, + ); + return done; } /** `text` with the notes plugins left for the turn and the Buzz tools' * end-of-turn note, when there are any. */ private review(entry: Entry, text: string, notes: readonly string[] = []) { - return [text, ...notes, turnNote(entry.handle)] - .filter(Boolean) - .join("\n\n"); + return unforged( + [text, ...notes, turnNote(entry.handle)].filter(Boolean).join("\n\n"), + ); } /** Says in the thread that a turn failed, so nobody waits on it. */ @@ -812,6 +1170,8 @@ export class GooseRuntime { config: initial, team: "", sessions: undefined as unknown as AgentSessions, + tools: undefined as unknown as ToolServer, + retiring: new Set(), contexts: new Map(), policies: new Map(), }; @@ -857,6 +1217,7 @@ export class GooseRuntime { const context = (conversation && entry.contexts.get(conversation)) || {}; return respond(client, context, message, this.added()); }; + entry.tools = tools; const get = (): Promise => { const protection = entry.handle?.protection; if (!protection) { @@ -900,11 +1261,7 @@ export class GooseRuntime { }), onChange: () => this.notify(), // Read as each turn starts, so a change needs no restart. - choice: () => ({ - provider: entry.config.provider.trim(), - model: entry.config.model.trim(), - effort: entry.config.effort, - }), + choice: () => this.choice(entry), fingerprint: () => JSON.stringify([ entry.config.provider.trim() ? 0 : this.defaultsRevision, @@ -915,31 +1272,38 @@ export class GooseRuntime { entry.name, addedKey(this.added()), ]), - launch: async () => { - const { workspace, instructions, scope } = entry.config; - const memory = await this.core(agent); - return { - workspace, - systemPrompt: systemPrompt({ - scope, - cwd: workspace, - repositories: this.repositories( - this.relay(agent)?.snapshot().scope, - ), - ...(entry.name - ? { agent: { name: entry.name, pubkey: pubkeyOf(agent) } } - : {}), - instructions, - team: entry.team, - ...(memory === undefined ? {} : { memory }), - }), - }; - }, + launch: () => this.launch(agent, entry), }); this.agents.set(agent, entry); return entry; } + private choice(entry: Entry) { + return { + provider: entry.config.provider.trim(), + model: entry.config.model.trim(), + effort: entry.config.effort, + }; + } + private async launch(agent: AgentKey, entry: Entry) { + const { workspace, instructions, scope } = entry.config; + const memory = await this.core(agent); + return { + workspace, + systemPrompt: systemPrompt({ + scope, + cwd: workspace, + repositories: this.repositories(this.relay(agent)?.snapshot().scope), + ...(entry.name + ? { agent: { name: entry.name, pubkey: pubkeyOf(agent) } } + : {}), + instructions, + team: entry.team, + ...(memory === undefined ? {} : { memory }), + }), + }; + } + /** The agent's memory, or only entry `slug`, as the owner's reads of its * community give it: it is encrypted to the owner, so no agent key is * needed. */ @@ -960,3 +1324,47 @@ export class GooseRuntime { if (reads) this.memoryReads.forget(reads.connection, pubkey); } } + +type Gateway = Readonly<{ route: EligibleRoute; turn: TurnContext }>; +/** What a forwarded turn runs, past what the gateway and its hold give it. */ +type ForwardTurn = Readonly<{ + /** The turn's text, and the created_at of the newest event it shows. */ + prompt( + fresh: boolean, + ): Promise | undefined>; + title?: string; + /** The delivery's or control's: it ends the turn's open, and a one-off + * turn whole. */ + signal?: AbortSignal; + activity?: ActivityReceipt; + model?: string; + effort?: string; + kept?: string; +}>; + +/** The provider goose forwards through: the gateway serves Databricks' + * API. */ +const GATEWAY_PROVIDER = "databricks"; +const NOT_COMPACTED = + "Couldn't compact: this conversation's session ran through the agent gateway, which this agent's turns don't use now."; +/** goose's error for held work it never ran. */ +const STOPPED = "goose was stopped"; +/** The most fallback notices an agent keeps, the oldest dropped first. */ +const NOTICE_LIMIT = 100; +const LATE = "goose did not start in time"; +const NOT_RETIRED = + "goose didn't stop after a forwarded turn; the agent retires once it has."; +/** A forwarded turn the agent owns, until its retirement is confirmed. */ +type Owned = { + /** Settles once the turn has, retiring as far as it could. */ + ended: Promise; + /** Told its retirement, which it then leaves with. */ + retiring?: boolean; + failed?: unknown; +}; +/** No binding, read as one: `bindSession` refuses it as malformed. */ +const MALFORMED = Object.freeze({}); + +function message(error: unknown) { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/bundled/goose/sessions.test.ts b/src/bundled/goose/sessions.test.ts index 0e10d291e7..0ceea2402b 100644 --- a/src/bundled/goose/sessions.test.ts +++ b/src/bundled/goose/sessions.test.ts @@ -15,6 +15,7 @@ import { type SavedConversation, ServerPool, SharedProcess, + type SessionStore, type SessionsOptions, } from "./sessions"; @@ -40,7 +41,7 @@ const setup = ( pool?: ServerPool; started?: SessionsOptions["started"]; onChange?: () => void; - store?: Map; + store?: SessionStore; /** Wraps the fake's `serve`. */ serve?: (serve: ServeMcp) => ServeMcp; silent?: readonly string[]; @@ -1160,6 +1161,87 @@ it("repeats a failed kill on the next retirement", async () => { await shutdown.work; }); +it("keeps what held work saved, and New session, as stored across every write", () => { + const data = new Map(); + const storage = { + getItem: (key: string) => data.get(key) ?? null, + setItem: (key: string, value: string) => void data.set(key, value), + } as Storage; + const damaged = { session: 7, junk: [1] }; + data.set( + "buzz.goose.sessions.v1", + JSON.stringify({ + one: { + broken: { at: 1, sessions: ["s1"], binding: damaged, next: "new" }, + }, + }), + ); + const one = localSessions(storage, "one"); + // Another conversation's write rewrites every record, leaving this raw. + one.set("other", { + at: 2, + sessions: ["g1"], + binding: { session: "g1", value: { v: 1 } }, + }); + const reloaded = localSessions(storage, "one"); + expect(reloaded.get("broken")).toEqual({ + at: 1, + sessions: ["s1"], + binding: damaged, + next: "new", + }); + expect(reloaded.get("other")).toEqual({ + at: 2, + sessions: ["g1"], + binding: { session: "g1", value: { v: 1 } }, + }); +}); + +it("refuses New session, keeping what was saved, when storage cannot save it", async () => { + const warned = vi.spyOn(console, "warn").mockImplementation(() => {}); + const data = new Map(); + let full = false; + const storage = { + getItem: (key: string) => data.get(key) ?? null, + setItem: (key: string, value: string) => { + if (full) throw new DOMException("quota", "QuotaExceededError"); + data.set(key, value); + }, + } as Storage; + const prior = { + at: 1, + sessions: ["g1", "g0"], + binding: { session: "g1", value: { v: 1 } }, + }; + localSessions(storage, "one").set("a", prior); + full = true; + const { sessions } = setup({}, false, { + store: localSessions(storage, "one"), + }); + await expect(sessions.reset("a", "new")).resolves.toEqual({ + ok: false, + error: "goose could not save New session", + }); + expect(warned).toHaveBeenCalledWith( + "goose sessions were not saved", + expect.any(DOMException), + ); + full = false; + expect(localSessions(storage, "one").get("a")).toEqual(prior); + // The next held work resumes what was left, with its value. + await expect( + sessions.exclusive("a", async ({ saved, reset, binding }) => ({ + saved, + reset, + binding, + })), + ).resolves.toEqual({ + saved: "g1", + reset: false, + binding: { value: { v: 1 } }, + }); +}); + it("keeps saved conversations per agent in storage, most recent first", () => { const data = new Map(); const storage = { @@ -1393,3 +1475,75 @@ it.each(["startup", "provider"] as const)( sessions.dispose(); }, ); + +describe("AgentSessions held work", () => { + it("stops waiting its turn at its deadline, but never ends work it started", async () => { + const { sessions } = setup(); + let release!: () => void; + const holding = sessions.exclusive( + "a", + () => new Promise((resolve) => (release = resolve)), + ); + await flush(10); + const deadline = new AbortController(); + const ran = vi.fn(async () => undefined); + const waiting = sessions.exclusive("a", ran, undefined, deadline.signal); + await flush(10); + deadline.abort(); + await expect(waiting).rejects.toThrow("goose did not start in time"); + expect(ran).not.toHaveBeenCalled(); + release(); + await holding; + // Past its deadline once it holds the conversation: it runs on. + const late = new AbortController(); + let finish!: () => void; + const running = sessions.exclusive( + "a", + async ({ signal }) => { + late.abort(); + await new Promise((resolve) => (finish = resolve)); + return signal.aborted; + }, + undefined, + late.signal, + ); + await flush(10); + finish(); + await expect(running).resolves.toBe(false); + }); + + it("notes what held work's session was shown only while it holds the conversation", async () => { + const { sessions } = setup(); + let note!: (seen: number) => void; + await sessions.exclusive("a", async ({ shown }) => { + note = shown; + shown(5); + shown(3); + }); + expect(sessions.seen("a")).toBe(5); + note(9); + expect(sessions.seen("a")).toBe(5); + }); + + it("holds a busy pooled server for a session outside its own, closing an idle one to make room", async () => { + const pool = new ServerPool(1); + const { fake, sessions } = setup({}, false, { pool }); + await sessions.deliver("a", "one", 1); + const handler = async () => undefined; + const server = await sessions.pooled(fake.serve)(handler as never); + expect(fake.geese[0]?.closed).toEqual(["session-1"]); + expect(pool.size).toBe(1); + // Busy until closed: the next session goes over rather than close it. + expect(pool.evict()).toBeUndefined(); + await server.close(); + await flush(10); + expect(pool.size).toBe(0); + // One that fails to start holds nothing. + await expect( + sessions.pooled(async () => { + throw new Error("Too many MCP servers are running"); + })(handler as never), + ).rejects.toThrow("Too many"); + expect(pool.size).toBe(0); + }); +}); diff --git a/src/bundled/goose/tabs.test.tsx b/src/bundled/goose/tabs.test.tsx index fdd19dab2a..f0ec682406 100644 --- a/src/bundled/goose/tabs.test.tsx +++ b/src/bundled/goose/tabs.test.tsx @@ -779,6 +779,7 @@ function activity( conversations: () => world.saved, channelLabel: (_: string, id: string) => world.names?.[id] ? { name: world.names[id], kind: "stream" } : undefined, + notices: () => new Map(), prompts: () => ({ shown: (turns: unknown) => turns, sessions: () => @@ -888,6 +889,34 @@ it("cancels reading goose's history when Activity is left", async () => { expect(signal?.aborted).toBe(true); }); +it("reads a transcript with the agent's fallback notices", async () => { + const fake = gooseHistory(); + const history = new GooseHistory(fake); + const transcript = vi.spyOn(history, "transcript"); + const notices = new Map([["turn", "Ran without the agent gateway."]]); + activity( + fake, + { + saved: [{ key: `chan/${ROOT}`, at: 1, sessions: ["s1"] }], + runtime: { notices: () => notices }, + }, + history, + ); + await userEvent.setup().click( + screen.getByRole("button", { + name: "View transcript: chan ยท thread 11111111", + }), + ); + await waitFor(() => + expect(transcript).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.any(Function), + notices, + ), + ); +}); + it("lists conversations by channel name, with running and standalone sessions, one row each", async () => { const fake = gooseHistory(["s1", "timer"]); const old = (key: string, at: number) => ({ key, at, sessions: [] }); diff --git a/src/bundled/goose/tabs.tsx b/src/bundled/goose/tabs.tsx index d33f4c8cdc..d2d840fb4c 100644 --- a/src/bundled/goose/tabs.tsx +++ b/src/bundled/goose/tabs.tsx @@ -830,6 +830,7 @@ export function createTabs({ }, abort.signal, (pubkey) => runtime.name(agent.key, pubkey), + runtime.notices(agent.key), ) .then( (transcript) => diff --git a/src/bundled/goose/transcript.test.ts b/src/bundled/goose/transcript.test.ts index 3125136b16..25b69aeb72 100644 --- a/src/bundled/goose/transcript.test.ts +++ b/src/bundled/goose/transcript.test.ts @@ -2,7 +2,14 @@ import { describe, expect, it } from "vitest"; import type { EventData } from "../../features/relay/events"; import { steerPrompt, turnPrompt } from "../claude-code/prompt"; import { type FakeSession, fakeSpawn } from "./goose-testing"; -import { GooseHistory, gooseTurns, startedFor } from "./transcript"; +import { + GooseHistory, + gooseTurns, + type Message, + marked, + startedFor, + unmarked, +} from "./transcript"; const alice = "a".repeat(64); const agent = "c".repeat(64); @@ -215,6 +222,161 @@ describe("gooseTurns", () => { }); }); +describe("wake notices", () => { + const FIRST = "11111111-1111-4111-8111-111111111111"; + const SECOND = "22222222-2222-4222-8222-222222222222"; + const place = { sessionId: "s1", channelId: CHANNEL }; + const wake = (id: string, content: string) => ask(agent, event(id, content)); + const shown = (messages: readonly Message[], notices?: Map) => + gooseTurns(messages, place, false, name, notices).map((turn) => + turn.items.map((item) => + item.type === "prompt" + ? ["prompt", item.id, item.text] + : [item.type, item.id, "text" in item ? item.text : ""], + ), + ); + + it("reads only Buzz's trailing mark, as a v4 id", () => { + expect(unmarked(marked("wake", FIRST))).toEqual({ + text: "wake", + turn: FIRST, + }); + for (const raw of [ + `wake`, + `${marked("wake", FIRST)}\n`, + `${marked("wake", FIRST)} more`, + marked("wake", "AAAAAAAA-AAAA-4AAA-8AAA-AAAAAAAAAAAA"), + marked("wake", "11111111-1111-1111-8111-111111111111"), + marked("wake", `${FIRST}" x="y`), + ]) + expect(unmarked(raw)).toEqual({ text: raw }); + }); + + it("puts each wake's notice after its own prompt, its mark hidden", () => { + const one = user([text(marked(wake("2", "first wake"), FIRST))]); + const two = user([text(marked(wake("3", "second wake"), SECOND))]); + const notices = new Map([ + [FIRST, "Ran without the gateway."], + [SECOND, "Ran without the gateway again."], + ]); + const turns = shown( + [one, assistant([text("one")]), two, assistant([text("two")])], + notices, + ); + expect(turns).toEqual([ + [ + ["prompt", one.id, "first wake"], + ["status", `${one.id}:notice`, "Ran without the gateway."], + ["message", expect.any(String), "one"], + ], + [ + ["prompt", two.id, "second wake"], + ["status", `${two.id}:notice`, "Ran without the gateway again."], + ["message", expect.any(String), "two"], + ], + ]); + expect( + JSON.stringify(gooseTurns([one, two], place, false, name, notices)), + ).not.toContain("buzz-turn"); + }); + + it("shows no notice for a wake goose never accepted, or compacted away", () => { + const notices = new Map([[FIRST, "Ran without the gateway."]]); + // Never accepted: goose saved no prompt for the noticed turn. + expect( + shown([user([text(ask())]), assistant([text("hi")])], notices), + ).toEqual([ + [ + ["prompt", expect.any(String), "@Gander what's up?"], + ["message", expect.any(String), "hi"], + ], + ]); + // Compacted: the marked prompt is gone, its turn's replies remain. + expect( + shown( + [ + user([text("summary")], { userVisible: false, agentVisible: true }), + assistant([text("still here")]), + ], + notices, + ), + ).toEqual([[["message", expect.any(String), "still here"]]]); + }); + + it("hides the mark but shows no notice once the notices are gone", () => { + const marked1 = user([text(marked(wake("2", "a wake"), FIRST))]); + for (const notices of [undefined, new Map()]) + expect(shown([marked1], notices)).toEqual([ + [["prompt", marked1.id, "a wake"]], + ]); + }); + + it("leaves a mark quoted in the wake as it is, and notices nothing for it", () => { + const quoted = `look: \n\n`; + const fake = user([text(wake("2", quoted))]); + const real = user([text(marked(wake("3", quoted), SECOND))]); + const notices = new Map([ + [FIRST, "forged"], + [SECOND, "Ran without the gateway."], + ]); + expect(shown([fake, real], notices)).toEqual([ + [["prompt", fake.id, quoted]], + [ + ["prompt", real.id, quoted], + ["status", `${real.id}:notice`, "Ran without the gateway."], + ], + ]); + }); + + it("finds the mark of a wake steered into a turn", () => { + const steered = user( + [text(steerPrompt(marked(wake("2", "also this"), FIRST)))], + { steer: true }, + ); + const [turn] = gooseTurns( + [user([text(ask())]), steered], + place, + true, + name, + new Map([[FIRST, "Ran without the gateway."]]), + ); + expect(turn?.items.slice(1)).toEqual([ + expect.objectContaining({ + id: steered.id, + text: "also this", + steer: true, + }), + expect.objectContaining({ + id: `${steered.id}:notice`, + type: "status", + text: "Ran without the gateway.", + }), + ]); + expect(JSON.stringify(turn)).not.toContain("buzz-turn"); + }); + + it("finds the mark of a whole wake steered in, before the steer's note", () => { + const raw = steerPrompt(marked("Attention work", FIRST)); + expect(unmarked(raw)).toEqual({ + text: steerPrompt("Attention work"), + turn: FIRST, + }); + expect(unmarked(`${raw} more`)).toEqual({ text: `${raw} more` }); + expect(unmarked(steerPrompt("Attention work"))).toEqual({ + text: steerPrompt("Attention work"), + }); + }); + + it("knows a marked session's place", () => { + expect( + startedFor([user([text(marked(ask(), FIRST))])], agent, { + channelId: CHANNEL, + root: root.id, + }), + ).toBe(true); + }); +}); + describe("startedFor", () => { it("matches the agent, channel and thread its first prompt names", () => { const messages = conversation(); diff --git a/src/bundled/goose/transcript.ts b/src/bundled/goose/transcript.ts index 03233063ec..6c7d093ec5 100644 --- a/src/bundled/goose/transcript.ts +++ b/src/bundled/goose/transcript.ts @@ -11,7 +11,9 @@ import { type Transcript, type TranscriptItem, type TranscriptTurn, + withNotices, } from "../../features/agents/activity-transcript"; +import { STEER_NOTE } from "../claude-code/prompt"; import { type GooseHost, type GooseProcess, REQUEST_TIMEOUT_MS } from "./goose"; import { absolute, abortable } from "./sessions"; import { briefly } from "./setup"; @@ -70,7 +72,35 @@ const KINDS: Record = { /** A person's display name, if known. */ type Names = (pubkey: string) => string | undefined; -/** A Buzz prompt, or a steer, as the turn's opening item. */ + +/** The suffix Buzz ends a fallback wake's prompt with, naming the turn only so + * its notice shows beside it: never a wake's or the gateway's id. */ +const MARK = + /\n\n$/; +export const marked = (text: string, id: string) => + `${text}\n\n`; +/** `text`, which Buzz didn't mark, with every mark-like tag in it escaped, as + * `unframed` escapes a section's: whatever a steer later adds or trims, no + * mark is read from it. */ +export const unforged = (text: string) => + text.replace(/<(?=buzz-turn)/g, "<\\"); +/** What a steer adds after a marked wake: its note, and, for a wake it wraps + * whole, the end of its section. */ +const STEERED = [`\n${STEER_NOTE}`, STEER_NOTE]; +/** `raw` without Buzz's suffix, and the turn it names. Only the very end, or + * what a steer adds after it, counts, so the same text quoted within the + * prompt stays as it is. */ +export function unmarked(raw: string): { text: string; turn?: string } { + const tail = STEERED.find((end) => raw.endsWith(end)) ?? ""; + const match = MARK.exec(raw.slice(0, raw.length - tail.length)); + const turn = match?.[1]; + return match && turn + ? { text: raw.slice(0, match.index) + tail, turn } + : { text: raw }; +} + +/** A Buzz prompt, or a steer, as the turn's opening item, and the turn Buzz + * marked it as, if any. */ function prompt( id: string, at: number, @@ -78,20 +108,24 @@ function prompt( steered: boolean, name?: Names, ) { - let sections = promptSections(raw); + const { text: shown, turn } = unmarked(raw); + let sections = promptSections(shown); const steer = sections.find((section) => section.tag === STEER); // An older steer wraps a whole turn. if (steer && !readEventBody(steer.body)) sections = promptSections(steer.body); const { text, author } = promptMessage(sections, name); return { - id, - at, - type: "prompt" as const, - text: text || raw, - ...(author ? { author } : {}), - sections, - steer: steered || !!steer, + turn, + item: { + id, + at, + type: "prompt" as const, + text: text || shown, + ...(author ? { author } : {}), + sections, + steer: steered || !!steer, + }, }; } @@ -112,8 +146,12 @@ export function gooseTurns( conversation: Place & { sessionId: string }, working: boolean, name?: Names, + /** Notices by the turn Buzz marked their prompts as. */ + notices?: ReadonlyMap, ): TranscriptTurn[] { const turns: TranscriptTurn[] = []; + /** Notices by the id goose gave their prompts. */ + const noticed = new Map(); const tools = new Map(); /** Messages sent with send, until their result says whether they posted. */ const sends = new Map< @@ -148,7 +186,15 @@ export function gooseTurns( if (message.role === "user" && !responses.length) { const raw = texts(content); if (!raw.trim()) return; - const item = prompt(id, at, raw, message.metadata?.steer === true, name); + const { item, turn: marked } = prompt( + id, + at, + raw, + message.metadata?.steer === true, + name, + ); + const notice = marked && notices?.get(marked); + if (notice) noticed.set(id, notice); const current = item.steer ? turns.at(-1) : undefined; (current ?? turn(id, at, false)).items.push(item); last = at; @@ -258,7 +304,7 @@ export function gooseTurns( if (current && !working) current.endedAt = Math.max(last, current.startedAt); // goose returns a session's every message, from its start. if (turns[0] && !turns[0].partial) turns[0].newSession = true; - return turns; + return noticed.size ? withNotices(turns, noticed) : turns; } /** A field of the context a session's first prompt carries, such as its @@ -444,6 +490,7 @@ export class GooseHistory { lookup: Lookup, signal: AbortSignal, name?: Names, + notices?: ReadonlyMap, ): Promise { return this.with(signal, async (goose, { resolveDirectory }) => { const saved = new Set(lookup.sessions); @@ -481,6 +528,7 @@ export class GooseHistory { { sessionId, ...(lookup.place ?? { channelId: "" }) }, sessionId === lookup.working, name, + notices, ), ); } diff --git a/src/bundled/goose/usage.test.ts b/src/bundled/goose/usage.test.ts index cf6ddcb173..4301412084 100644 --- a/src/bundled/goose/usage.test.ts +++ b/src/bundled/goose/usage.test.ts @@ -3,7 +3,7 @@ import type { EventData } from "../../features/relay/events"; import { turnPrompt } from "../claude-code/prompt"; import { REQUEST_TIMEOUT_MS } from "./goose"; import { type FakeSession, fakeSpawn } from "./goose-testing"; -import { GooseHistory } from "./transcript"; +import { GooseHistory, marked } from "./transcript"; import { readUsage, sessionUsage } from "./usage"; const agent = "c".repeat(64); @@ -100,6 +100,18 @@ it("observes each reply's tokens and goose's price", () => { ); }); +it("titles a session by its first prompt without Buzz's turn mark", () => { + const session = sessionUsage("s", [ + user(marked(ask(), "11111111-1111-4111-8111-111111111111")), + reply(start + 1, tokens(1, 0, 0)), + ]); + expect(session).toMatchObject({ + channelId: "chan", + root: mention.id, + title: "@Grok fix the build", + }); +}); + it("reads the agent's sessions in its workspace through goose, every page", async () => { const saved = ( sessionId: string, diff --git a/src/features/agent-harness/acp/agent-sessions.ts b/src/features/agent-harness/acp/agent-sessions.ts index ba32eb1dbd..aa2e4829c5 100644 --- a/src/features/agent-harness/acp/agent-sessions.ts +++ b/src/features/agent-harness/acp/agent-sessions.ts @@ -16,6 +16,7 @@ import type { ActivityReceipt, Choice, ResolveDirectory, + ServeMcp, Settled, ToolServer, } from "./types"; @@ -34,6 +35,12 @@ export const SAVED_SESSIONS = 10; export type SavedConversation = Readonly<{ at: number; sessions?: readonly string[]; + /** What held work saved with its session (`keep`), as stored: read only + * through `association`, so damage is never taken for none. */ + binding?: unknown; + /** New session was asked for, until a session replacing the newest one + * is saved. */ + next?: "new"; }>; /** The longest session id kept; longer ones are taken as damaged. */ const SESSION_ID_LIMIT = 256; @@ -50,15 +57,48 @@ export function savedConversation( value: unknown, ): SavedConversation | undefined { if (!value || typeof value !== "object") return undefined; - const { at, sessions } = value as { at?: unknown; sessions?: unknown }; + const { at, sessions, next } = value as { + at?: unknown; + sessions?: unknown; + next?: unknown; + }; if (!validTime(at)) return undefined; - if (sessions === undefined) return { at }; + const kept = { + at, + // Kept as stored, however damaged, for `association` to refuse. + ...("binding" in value ? { binding: value.binding } : {}), + ...(next === "new" ? { next: "new" as const } : {}), + } as const; + if (sessions === undefined) return kept; if (!Array.isArray(sessions)) return undefined; const ids = sessions.filter( (id): id is string => typeof id === "string" && id.length > 0 && id.length <= SESSION_ID_LIMIT, ); - return { at, sessions: [...new Set(ids)].slice(0, SAVED_SESSIONS) }; + return { ...kept, sessions: [...new Set(ids)].slice(0, SAVED_SESSIONS) }; +} + +/** The value held work saved with the conversation's newest session: + * none when nothing was saved or what was is another session's, and + * "malformed" when what is stored can't be read. */ +export function association( + saved: SavedConversation | undefined, +): Readonly<{ value: unknown }> | "malformed" | undefined { + if (!saved || !("binding" in saved)) return undefined; + const { binding } = saved; + if (!binding || typeof binding !== "object" || Array.isArray(binding)) + return "malformed"; + const keys = Object.keys(binding); + if (keys.length !== 2 || !("session" in binding) || !("value" in binding)) + return "malformed"; + const { session, value } = binding; + if ( + typeof session !== "string" || + !session || + session.length > SESSION_ID_LIMIT + ) + return "malformed"; + return session === saved.sessions?.[0] ? { value } : undefined; } export type SessionStore = { get(key: string): SavedConversation | undefined; @@ -156,6 +196,41 @@ export type SessionsOptions = Readonly<{ onChange?(): void; }>; +/** A conversation `exclusive` holds. */ +type Held = { controller: AbortController; done: Promise }; + +/** What `exclusive` gives the work it runs. */ +export type Exclusive = Readonly<{ + /** The conversation's newest saved session, if any, and none once New + * session was asked for. */ + saved: string | undefined; + /** New session was asked for since the newest session was saved. */ + reset: boolean; + /** What the work saved with `saved`, read by `association`. */ + binding: ReturnType; + /** Saves `id` as its newest session with `binding`, while the work holds + * the conversation: whether the store then reads it back so. */ + keep(id: string, binding: unknown): boolean; + /** Notes what the session it runs in has been shown, as a message's + * delivery does, while the work holds the conversation. */ + shown(seen: number): void; + /** Aborts on `interrupt` or `dispose`. */ + signal: AbortSignal; + /** A session the work opened outlives it until `done`, its process's + * `exited`: until then the conversation cannot be held again and its + * messages wait. Each call adds to the ones before, which are all waited + * for. Should any `done` reject, its owner is unknown, and both fail from + * then on. Throws once the work has settled. */ + outlives(done: Promise): void; +}>; + +/** A session left running by held work, until its process has exited. */ +type Outliving = { + exits: readonly Promise[]; + settled: Promise; + failed: boolean; +}; + type Live = { session: AcpSession; process: AcpProcess; @@ -182,6 +257,15 @@ export class AgentSessions { string, Promise<{ settled?: Promise }> >(); + /** Conversations `exclusive` holds, which messages wait for. */ + private readonly held = new Map(); + /** Conversations whose last held work left a session not yet retired. */ + private readonly outliving = new Map(); + /** Holds waiting their turn, by conversation, which `interrupt` ends. */ + private readonly waiting = new Map>(); + /** Resolves on `dispose`, ending waits on a session left running. */ + private readonly stopping: Promise; + private stop!: () => void; /** created_at of the newest event each conversation's session was shown. */ private readonly shown = new Map(); /** Conversations that have had a session, whose next new one may have been @@ -191,8 +275,12 @@ export class AgentSessions { private readonly stopped: Settled; private readonly late: Settled; + private get unconfirmed() { + return `${this.options.label} could not confirm this conversation's session ended`; + } constructor(private readonly options: SessionsOptions) { + this.stopping = new Promise((resolve) => (this.stop = resolve)); this.stopped = { ok: false, error: `${options.label} was stopped` }; this.late = { ok: false, @@ -206,7 +294,12 @@ export class AgentSessions { return this.shown.get(key) ?? 0; } active(key: string) { - return this.opening.has(key) || !!this.live.get(key)?.session.working; + return ( + this.opening.has(key) || + this.held.has(key) || + this.outliving.has(key) || + !!this.live.get(key)?.session.working + ); } /** The harness session serving a conversation or one-off turn, and the * process it lives in. */ @@ -275,6 +368,31 @@ export class AgentSessions { for (;;) { if (current && !current()) return { ok: true, undelivered: true }; if (this.disposed) return { ...this.stopped, undelivered: true }; + // One that arrives while `exclusive` holds the conversation waits. + const held = this.held.get(key); + if (held) { + try { + await abortable(held.done, signal); + } catch { + return this.late; + } + continue; + } + // Or while a session held work left running has yet to exit. + const outliving = this.outliving.get(key); + if (outliving) { + if (outliving.failed) + return { ok: false, error: this.unconfirmed, undelivered: true }; + try { + await abortable( + Promise.race([outliving.settled, this.stopping]), + signal, + ); + } catch { + return this.late; + } + continue; + } // A message that arrives while its conversation's session opens uses it. const opening = this.opening.get(key); if (opening) { @@ -378,27 +496,277 @@ export class AgentSessions { /** Stops the turn running in conversation `key`, if any, once a session * opening for it has taken its message. */ async interrupt(key: string) { + // Held work is told at once, never waited for, as is work waiting to be. + for (const waiting of this.waiting.get(key) ?? []) waiting.abort(); + const held = this.held.get(key); + if (held) { + held.controller.abort(); + this.live.get(key)?.session.interrupt(); + return; + } await this.opening.get(key)?.catch(() => undefined); this.live.get(key)?.session.interrupt(); } /** Includes the model call after a final send has answered the turn. */ async idle(key: string) { + await this.held.get(key)?.done; await this.opening.get(key)?.catch(() => undefined); await this.live.get(key)?.session.idle(); } + + /** Runs `work` with conversation `key` to itself: messages wait for it. + * It waits its turn behind a session opening, another hold and a session + * one left running, until `interrupt`, `signal` or `dispose`, and is + * refused, never queued, once a session left running could not be + * confirmed ended. Its session finishes its work and is closed first, and + * `work` starts only once the harness has acknowledged that close and + * every earlier close of the conversation's sessions, or their process has + * exited; it never starts after a close that was not, nor once interrupted + * or stopped, when it rejects with "