diff --git a/docs/vrs/spec.md b/docs/vrs/spec.md index 561aa7ce..6e7d89af 100644 --- a/docs/vrs/spec.md +++ b/docs/vrs/spec.md @@ -664,6 +664,26 @@ atomic inbox file → DING attempt → agent reads → archive receipt composer cannot create a short-lived PTY probe on every inbox poll. Inbox reads do not wake the sidecar; only mutations bypass its bounded poll cadence. +### Bounded maintained-provider inbox view + +Maintained provider boundaries render an ordered FIFO prefix with complete +message bodies before the model decides how to handle it. The provider-neutral +`st2.inbox-delivery.v1` JSON envelope is capped at 16 messages and 16 KiB. +Bodies are never truncated. An oversized head is identified without exposing a +partial body, and overflow stays unread behind the head for a later delivery. + +Codex native app-server delivery sends this envelope in its existing typed user +message. Claude SessionStart and UserPromptSubmit hooks attach the same envelope +on the provider-supported additional-context channel. Unknown or custom +transports keep the short metadata DING and may obtain the view read-only with +`st2 message delivery`. + +Delivery does not claim, settle, archive, or otherwise mutate a message. The +agent uses the existing exact `message reply` and `message archive` operations, +whose archive receipts remain the only durable handled authority. This contract +is provider-neutral so the driver extraction tracked by issue #162 can move the +thin Codex and Claude consumers without changing its schema, bounds, or tests. + ## State and scope - **R08:** Presence and activity status are separate signals. The catalog must diff --git a/examples/native/README.md b/examples/native/README.md index 9348375d..fe9c2f03 100644 --- a/examples/native/README.md +++ b/examples/native/README.md @@ -3,7 +3,8 @@ These maintained, hand-authored declarations are the canonical starting points: - [`agent-claude.kdl`](agent-claude.kdl) uses Claude Code's rules loader and - native `SessionStart`, `PreCompact`, and `StopFailure` hooks. + native `SessionStart`, `UserPromptSubmit`, `PreCompact`, and `StopFailure` hooks. The prompt hook + attaches the bounded inbox view to the same inference as a generic DING. - [`agent-codex.kdl`](agent-codex.kdl) composes the persona and bus contract into `AGENTS.md` and uses Codex's native `SessionStart`, `PreCompact`, and `Stop` hooks. diff --git a/examples/native/agent-claude.kdl b/examples/native/agent-claude.kdl index 48f95d19..a0df3796 100644 --- a/examples/native/agent-claude.kdl +++ b/examples/native/agent-claude.kdl @@ -34,6 +34,16 @@ agent "" { ] } ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "$ST_HOOKS/claude-user-prompt-submit.sh" + } + ] + } + ], "PreCompact": [ { "hooks": [ diff --git a/hooks/claude-session-start.sh b/hooks/claude-session-start.sh index 77d6177b..f881e876 100644 --- a/hooks/claude-session-start.sh +++ b/hooks/claude-session-start.sh @@ -1,23 +1,24 @@ #!/usr/bin/env bash -# st2 Claude SessionStart hook: restore fresh durable context and remind the model to complete its -# boot ritual. Delivered as `hookSpecificOutput.additionalContext` on stdout, the supported channel — -# exit 2 + stderr renders as a hook error and never reaches the model. Missing state remains a valid -# cold start. Fail-open: a missing dependency never prevents Claude startup. +# st2 Claude SessionStart hook: restore fresh durable context and attach a bounded inbox view. +# Delivered as `hookSpecificOutput.additionalContext` on stdout, the supported model-visible channel. +# Missing state remains a valid cold start; missing dependencies fail open. set -u identity="${ST_AGENT:-}" root="${ST_ROOT:-${CATALOG:-}}" -ritual="Run the st2 boot ritual now: set your status to available, then drain your inbox by reading, acting on, replying when useful, and archiving each handled message. Before resuming or starting work, set your status to busy; set available only when yielding or ready for new work." +ritual="Run the st2 boot ritual now: set your status to available, then handle the body-bearing inbox batch already attached when present; otherwise drain once with st2 message ls --json --include-body. Reply when useful and archive each handled message. Before resuming or starting work, set your status to busy; set available only when yielding or ready for new work." if ! command -v jq >/dev/null 2>&1; then exit 0 fi context="" +delivery="" if [[ -n "$identity" && -n "$root" ]] && command -v st2 >/dev/null 2>&1; then stale_s="${ST_REHYDRATE_STALE_S:-86400}" context="$(st2 context read "$identity" --root "$root" --fresh-within "$stale_s" 2>/dev/null || true)" + delivery="$(st2 message delivery "$identity" --root "$root" 2>/dev/null || true)" fi context_block="" @@ -34,6 +35,9 @@ additional="$ritual" if [[ -n "$context_block" ]]; then additional="${context_block}"$'\n\n'"${additional}" fi +if [[ -n "$(printf '%s' "$delivery" | tr -d '[:space:]')" ]]; then + additional="${additional}"$'\n\n'"${delivery}" +fi printf '%s' "$additional" | jq -Rs '{ continue: true, diff --git a/hooks/claude-user-prompt-submit.sh b/hooks/claude-user-prompt-submit.sh new file mode 100644 index 00000000..af52adef --- /dev/null +++ b/hooks/claude-user-prompt-submit.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# Attach the bounded inbox view to the same Claude inference that received a short generic DING. +# This maintained-provider adapter is stateless and fail-open; reply/archive remain authoritative. + +set -u + +identity="${ST_AGENT:-}" +root="${ST_ROOT:-${CATALOG:-}}" +if [[ -z "$identity" || -z "$root" ]] || ! command -v st2 >/dev/null 2>&1 || ! command -v jq >/dev/null 2>&1; then + exit 0 +fi + +delivery="$(st2 message delivery "$identity" --root "$root" 2>/dev/null || true)" +if [[ -z "$(printf '%s' "$delivery" | tr -d '[:space:]')" ]]; then + exit 0 +fi + +printf '%s' "$delivery" | jq -Rs '{ + continue: true, + hookSpecificOutput: { + hookEventName: "UserPromptSubmit", + additionalContext: . + } +}' +exit 0 diff --git a/src/codex_app_server.rs b/src/codex_app_server.rs index 805c7a0b..3c7260d7 100644 --- a/src/codex_app_server.rs +++ b/src/codex_app_server.rs @@ -26,7 +26,7 @@ use serde_json::{Value, json}; use sha2::{Digest as _, Sha256}; use tungstenite::{Message as WebSocketMessage, WebSocket}; -use crate::{ding, message, run, status}; +use crate::{inbox_delivery, message, run, status}; pub const SUPPORTED_CODEX_CLI_VERSION: &str = "codex-cli 0.145.0"; const RUNTIME_SCHEMA: &str = "st2.codex-runtime.v1"; @@ -151,7 +151,6 @@ struct CodexDeliveryConfig { agent_dir: PathBuf, inbox: PathBuf, identity: String, - this_host: String, } impl CodexDeliveryConfig { @@ -169,7 +168,6 @@ impl CodexDeliveryConfig { inbox: message::inbox_dir(&agent_dir), agent_dir, identity: identity.to_string(), - this_host, }) } } @@ -246,7 +244,7 @@ struct CodexInboxDelivery { wake: Receiver<()>, _watcher: Option, next_refresh: Instant, - head: Option, + unread: Vec, suppressed: bool, state: Option, pending: Option, @@ -276,7 +274,7 @@ impl CodexInboxDelivery { wake, _watcher: watcher, next_refresh: Instant::now(), - head: None, + unread: Vec::new(), suppressed: false, state, pending: None, @@ -320,7 +318,7 @@ impl CodexInboxDelivery { }) { self.rejected = None; } - self.head = unread.into_iter().next(); + self.unread = unread; self.suppressed = status::read_state(&status::status_path(&self.config.agent_dir)) == status::State::Dnd; self.next_refresh = Instant::now() + INBOX_REFRESH_FALLBACK; @@ -340,7 +338,7 @@ impl CodexInboxDelivery { // must neither suppress nor acknowledge delivery to this thread. self.clear_state()?; } - let Some(head) = self.head.as_ref() else { + let Some(head) = self.unread.first() else { return Ok(None); }; if self.rejected.as_ref().is_some_and(|rejected| { @@ -365,12 +363,11 @@ impl CodexInboxDelivery { let client_id = stable_client_user_message_id(&self.config.identity, state.thread_id(), &head.filename); let filename = head.filename.clone(); - let text = ding::poke_text( - &self.config.catalog_root, - &self.config.this_host, - &self.config.identity, - head, - ); + // Temporary adapter seam: issue #162 can transplant this provider consumer while retaining + // the provider-neutral bounded payload contract in `inbox_delivery`. + let text = inbox_delivery::render(&self.unread) + .context("rendering maintained-provider inbox delivery")? + .text; let request = codex_delivery_request(request_id, state.thread_id(), &client_id, &text, &method); self.write_state(CodexDeliveryState::attempted( @@ -1738,7 +1735,6 @@ mod tests { inbox: message::inbox_dir(&agent_dir), agent_dir, identity: "h.worker".into(), - this_host: "h".into(), } } @@ -1926,6 +1922,95 @@ mod tests { assert!(config.inbox.join(&filename).is_file()); } + #[test] + fn archiving_a_delivered_batch_releases_overflow_and_post_batch_arrivals() { + let tmp = tempfile::tempdir().unwrap(); + let config = delivery_config(tmp.path()); + for index in 0..=inbox_delivery::MAX_DELIVERY_MESSAGES { + message::send_to_inbox( + &config.inbox, + "h.sender", + Some(&format!("burst {index}")), + None, + &[], + &format!("body {index}"), + ) + .unwrap(); + } + let ordered = message::list_inbox(&config.inbox).unwrap(); + let first_batch = ordered[..inbox_delivery::MAX_DELIVERY_MESSAGES] + .iter() + .map(|message| message.filename.clone()) + .collect::>(); + let overflow = ordered[inbox_delivery::MAX_DELIVERY_MESSAGES] + .filename + .clone(); + + let mut delivery = inbox_delivery(tmp.path(), config.clone()); + let mut idle = CodexControlState::new(&delivery.runtime, "thread-main".into()); + idle.subscribed = true; + idle.observed = CodexObservedState::Idle; + let request = delivery.maybe_request(&idle).unwrap().unwrap(); + let text = request["params"]["input"][0]["text"].as_str().unwrap(); + assert!(text.contains(&first_batch[0])); + assert!(text.contains(first_batch.last().unwrap())); + assert!(!text.contains(&overflow)); + + assert!( + delivery + .accept_response( + &json!({ "id": request["id"], "result": { "turn": { "id": "turn-batch" } } }), + idle.observed(), + ) + .unwrap() + ); + let client_id = request["params"]["clientUserMessageId"].as_str().unwrap(); + assert!( + delivery + .accept_typed_receipt( + &json!({ + "method": "item/completed", + "params": { + "threadId": "thread-main", + "turnId": "turn-batch", + "item": { "type": "userMessage", "clientId": client_id } + } + }), + &idle, + ) + .unwrap() + ); + + for filename in &first_batch { + message::archive_msg( + &config.inbox, + &message::archive_dir(&config.agent_dir), + filename, + ) + .unwrap(); + } + let post_batch = message::send_to_inbox( + &config.inbox, + "h.sender", + Some("post batch"), + None, + &[], + "post batch body", + ) + .unwrap(); + + delivery.next_refresh = Instant::now(); + let next = delivery.maybe_request(&idle).unwrap().unwrap(); + let next_text = next["params"]["input"][0]["text"].as_str().unwrap(); + assert!(next_text.contains(&overflow)); + assert!(next_text.contains(&post_batch)); + assert!( + first_batch + .iter() + .all(|filename| !next_text.contains(filename)) + ); + } + #[test] fn only_a_completed_matching_user_message_persists_acceptance() { let tmp = tempfile::tempdir().unwrap(); @@ -2174,16 +2259,11 @@ mod tests { assert_eq!(delivery["id"], FIRST_DELIVERY_REQUEST_ID); assert_eq!(delivery["method"], "turn/start"); assert_eq!(delivery["params"]["threadId"], "thread-main"); - assert_eq!( - delivery["params"]["input"][0]["text"], - "[DING] ? h.sender: wired [id:".to_owned() - + server_filename - .trim_end_matches(".md") - .rsplit_once('-') - .unwrap() - .1 - + "]" - ); + let text = delivery["params"]["input"][0]["text"].as_str().unwrap(); + assert!(text.contains("st2.inbox-delivery.v1")); + assert!(text.contains(&server_filename)); + let payload: Value = serde_json::from_str(text.lines().nth(1).unwrap()).unwrap(); + assert_eq!(payload["messages"][0]["body"], "body\n"); assert!( delivery["params"]["clientUserMessageId"] .as_str() diff --git a/src/hooks.rs b/src/hooks.rs index 33db5f52..aa26125a 100644 --- a/src/hooks.rs +++ b/src/hooks.rs @@ -18,6 +18,7 @@ const CODEX_SESSION_START: &[u8] = include_bytes!("../hooks/codex-session-start. const CODEX_PRE_COMPACT: &[u8] = include_bytes!("../hooks/codex-pre-compact.sh"); const CODEX_STOP: &[u8] = include_bytes!("../hooks/codex-stop.sh"); const CLAUDE_SESSION_START: &[u8] = include_bytes!("../hooks/claude-session-start.sh"); +const CLAUDE_USER_PROMPT_SUBMIT: &[u8] = include_bytes!("../hooks/claude-user-prompt-submit.sh"); const CLAUDE_PRE_COMPACT: &[u8] = include_bytes!("../hooks/claude-pre-compact.sh"); const CLAUDE_STOP_FAILURE: &[u8] = include_bytes!("../hooks/claude-stop-failure.sh"); @@ -25,11 +26,12 @@ const SCHEMA: u32 = 1; const RECEIPT_FILE: &str = "current.json"; const SET_MANIFEST_FILE: &str = "manifest.json"; const SETS_DIR: &str = "sets"; -const HOOKS: [(&str, &[u8]); 6] = [ +const HOOKS: [(&str, &[u8]); 7] = [ ("codex-session-start.sh", CODEX_SESSION_START), ("codex-pre-compact.sh", CODEX_PRE_COMPACT), ("codex-stop.sh", CODEX_STOP), ("claude-session-start.sh", CLAUDE_SESSION_START), + ("claude-user-prompt-submit.sh", CLAUDE_USER_PROMPT_SUBMIT), ("claude-pre-compact.sh", CLAUDE_PRE_COMPACT), ("claude-stop-failure.sh", CLAUDE_STOP_FAILURE), ]; diff --git a/src/inbox_delivery.rs b/src/inbox_delivery.rs new file mode 100644 index 00000000..491ed985 --- /dev/null +++ b/src/inbox_delivery.rs @@ -0,0 +1,187 @@ +//! Provider-neutral, bounded inbox delivery text. +//! +//! Maintained provider adapters call this contract at their native turn boundary. The current +//! Codex app-server consumer and Claude hook are intentionally thin adapter seams: issue #162 can +//! move those consumers into drivers without changing selection or payload semantics. Generic PTY +//! DING remains a short metadata notice for unknown and custom harnesses. + +use crate::message::Message; + +/// Maximum bytes handed to a maintained provider for one inference. +pub const MAX_DELIVERY_BYTES: usize = 16 * 1024; +/// A second bound prevents a burst of tiny messages from producing an unhelpfully large action set. +pub const MAX_DELIVERY_MESSAGES: usize = 16; + +/// One immutable view of the FIFO prefix selected for a maintained provider. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InboxDelivery { + pub text: String, + pub included_filenames: Vec, + pub total_unread: usize, + pub overflow: usize, + pub oversized_head: bool, +} + +/// Render the largest complete FIFO prefix that fits the fixed delivery bounds. +/// +/// Bodies are never truncated. If even the head does not fit, the exceptional fallback identifies +/// that exact message and keeps the ordinary `message read` path available. Later messages remain +/// behind it so bounded delivery does not reorder the durable inbox. +pub fn render(messages: &[Message]) -> Option { + if messages.is_empty() { + return None; + } + + let limit = messages.len().min(MAX_DELIVERY_MESSAGES); + let mut selected = 0; + for candidate in 1..=limit { + if render_prefix(messages, candidate).len() > MAX_DELIVERY_BYTES { + break; + } + selected = candidate; + } + + if selected == 0 { + let head = &messages[0]; + let text = format!( + "[DING] st2 inbox: the FIFO head {} exceeds the {}-byte maintained-provider delivery bound. Read it with `st2 message read {}`, then use the existing reply/archive commands in this turn. {} unread message(s) remain; later messages stay queued behind this head.", + head.filename, + MAX_DELIVERY_BYTES, + head.filename, + messages.len(), + ); + debug_assert!(text.len() <= MAX_DELIVERY_BYTES); + return Some(InboxDelivery { + text, + included_filenames: Vec::new(), + total_unread: messages.len(), + overflow: messages.len(), + oversized_head: true, + }); + } + + Some(InboxDelivery { + text: render_prefix(messages, selected), + included_filenames: messages[..selected] + .iter() + .map(|message| message.filename.clone()) + .collect(), + total_unread: messages.len(), + overflow: messages.len() - selected, + oversized_head: false, + }) +} + +fn render_prefix(messages: &[Message], included: usize) -> String { + let overflow = messages.len() - included; + let items = messages[..included] + .iter() + .map(|message| { + serde_json::json!({ + "filename": message.filename, + "ts": message.ts_ms, + "from": message.from, + "subject": message.subject, + "inReplyTo": message.in_reply_to, + "tags": message.tags, + "priority": message.priority, + "body": message.body, + }) + }) + .collect::>(); + // JSON string encoding keeps an untrusted body inside its data field; it cannot close or spoof + // the outer delivery envelope. + let payload = serde_json::to_string(&serde_json::json!({ + "schema": "st2.inbox-delivery.v1", + "totalUnread": messages.len(), + "included": included, + "overflow": overflow, + "messages": items, + })) + .expect("inbox delivery values are JSON serializable"); + let mut text = format!( + "[DING] st2 inbox batch: {included} of {} unread message(s), with complete bodies.\n{payload}", + messages.len() + ); + text.push_str( + "\nHandle every included message in this inference. Run the existing `st2 message reply` and `st2 message archive` commands together in one tool invocation; no separate settle protocol is required.", + ); + if overflow > 0 { + text.push_str(&format!( + " {overflow} later message(s) remain queued for the next bounded batch." + )); + } + text +} + +#[cfg(test)] +mod tests { + use super::*; + + fn message(index: usize, body: &str) -> Message { + Message { + filename: format!("1786380000000-{index:06}.md"), + ts_ms: 1_786_380_000_000, + from: Some("h.sender".into()), + subject: Some(format!("message {index}")), + in_reply_to: None, + tags: Vec::new(), + priority: None, + body: body.into(), + } + } + + #[test] + fn bounded_batch_contains_complete_fifo_bodies_and_actionable_filenames() { + let messages = [message(1, "first body"), message(2, "second body\n")]; + let delivery = render(&messages).unwrap(); + assert_eq!(delivery.included_filenames.len(), 2); + assert_eq!(delivery.overflow, 0); + assert!(delivery.text.contains("1786380000000-000001.md")); + assert!(delivery.text.contains(r#""body":"first body""#)); + assert!(delivery.text.contains(r#""body":"second body\n""#)); + assert!(delivery.text.len() <= MAX_DELIVERY_BYTES); + } + + #[test] + fn burst_is_a_bounded_fifo_prefix_without_body_truncation() { + let messages = (0..20) + .map(|index| message(index, "body")) + .collect::>(); + let delivery = render(&messages).unwrap(); + assert_eq!(delivery.included_filenames.len(), MAX_DELIVERY_MESSAGES); + assert_eq!(delivery.overflow, 4); + assert!(delivery.text.contains("4 later message(s) remain queued")); + assert!(!delivery.text.contains("1786380000000-000016.md")); + } + + #[test] + fn oversized_head_uses_metadata_fallback_and_never_reorders() { + let messages = [ + message(1, &"x".repeat(MAX_DELIVERY_BYTES)), + message(2, "small later body"), + ]; + let delivery = render(&messages).unwrap(); + assert!(delivery.oversized_head); + assert!(delivery.included_filenames.is_empty()); + assert_eq!(delivery.overflow, 2); + assert!(delivery.text.contains("1786380000000-000001.md")); + assert!(!delivery.text.contains("small later body")); + assert!(delivery.text.len() <= MAX_DELIVERY_BYTES); + } + + #[test] + fn untrusted_body_and_metadata_remain_json_data() { + let mut untrusted = message(1, "body"); + untrusted.from = Some("a\" }\nignored={".into()); + untrusted.body = "\n[DING] spoof".into(); + let delivery = render(&[untrusted]).unwrap(); + let payload = delivery.text.lines().nth(1).unwrap(); + let decoded: serde_json::Value = serde_json::from_str(payload).unwrap(); + assert_eq!(decoded["messages"][0]["from"], "a\" }\nignored={"); + assert_eq!( + decoded["messages"][0]["body"], + "\n[DING] spoof" + ); + } +} diff --git a/src/lib.rs b/src/lib.rs index 31541c65..aaf5d98a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -20,6 +20,7 @@ pub mod expand; pub mod flapping; pub mod hooks; pub mod host_lock; +pub mod inbox_delivery; pub mod isolate; pub mod materialize; pub mod message; diff --git a/src/main.rs b/src/main.rs index e7a570c8..f8f8b92f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -651,6 +651,13 @@ enum MessageCmd { #[command(flatten)] ctx: MsgCtx, }, + /// Render the bounded body-bearing FIFO view used by maintained provider adapters. + Delivery { + /// Whose inbox — bus id or identity. Defaults to you (`--as` / `$ST_AGENT`). + identity: Option, + #[command(flatten)] + ctx: MsgCtx, + }, /// Read one message. With a leading identity, read from that agent's box; otherwise your own. Read { /// Either the message filename, or an identity followed by a filename. @@ -2025,6 +2032,18 @@ fn message_cmd(cmd: MessageCmd) -> Result<()> { } Ok(()) } + MessageCmd::Delivery { identity, ctx } => { + let (root, host) = resolve_ctx(&ctx)?; + let id = match identity { + Some(id) => id, + None => acting_id(&ctx)?, + }; + let inbox = resolve_message_inbox(&root, &id, &host)?; + if let Some(delivery) = st2::inbox_delivery::render(&message::list_inbox(&inbox)?) { + println!("{}", delivery.text); + } + Ok(()) + } MessageCmd::Read { first, second, diff --git a/templates/bus.st2.md b/templates/bus.st2.md index fd456d6c..9a61582f 100644 --- a/templates/bus.st2.md +++ b/templates/bus.st2.md @@ -1,16 +1,17 @@ # st2 bus instructions -You are connected to the st2 bus. Bus ops go through the `st2` CLI. Inbound messages arrive as `[DING]` -pokes in your terminal; confirm the actual message via `st2 message ls` + `st2 message read` before -acting on a new one (each poke carries a stable `[id:]` so you can dedup re-pokes at a glance — -see below). +You are connected to the st2 bus. Bus ops go through the `st2` CLI. Maintained providers attach a +bounded FIFO batch with complete message bodies. A short `[DING]` metadata poke remains the generic +fallback for unknown/custom providers. ## Boot ritual (on cold start or /clear) 1. `st2 status $ST_AGENT --set available` — set your status so peers see you as active. -2. Drain your inbox backlog: `st2 message ls` to enumerate filenames, then for each: `st2 message read - `, `st2 message reply -m ""` if a response is warranted, and - `st2 message archive ` to clear. Don't leave inbox items unaddressed. +2. Drain your inbox backlog: use the body-bearing batch already delivered by a maintained provider, + or run `st2 message delivery` once. Handle every included message, then run all warranted + `st2 message reply` and `st2 message archive` commands together in one tool invocation. A bounded + overflow is delivered as the next batch; an exceptional oversized head explicitly tells you to + use `st2 message read `. Don't leave handled inbox items unarchived. 3. `st2 agents --json --enrich` to see who's around and whether any peers are waiting on you. 4. If the backlog or durable context leaves work to execute, set `busy` before acting on it. Return to `available` only when yielding or ready for new work. @@ -45,13 +46,11 @@ handle this?" first — only act on genuinely new ones. ## Inbound message handling ([DING] pokes) -New peer messages surface as `[DING] new st2 message: [id:] (from ); check -your inbox` lines. Key only on the `[DING]` prefix and stable `[id:]`; descriptive text is not -an API. The id is the message filename's rand6 suffix and is stable across re-pokes of the same -message. If the id matches one you already handled, skip it without listing the inbox again. Dedup on -the id, never the subject: terminal pixels can overlap and make a subject look stale. For a new id, -`st2 message ls` to find the filename, `st2 message read `, reply if warranted, then -`st2 message archive ` immediately. Set `busy` before executing the message's work. +When a maintained provider attaches a `[DING] st2 inbox batch`, handle that FIFO prefix directly, +then batch the existing reply/archive commands in one tool invocation. A short +`[DING] ... [id:]` without bodies is the generic fallback: run +`st2 message delivery` once instead of listing and reading files one by one. The id is +stable across re-pokes; dedup on it, never the subject. Set `busy` before executing message work. ## Threads stay on the bus @@ -82,7 +81,8 @@ not add agents; surface the need to your supervisor. Bus ops: - `st2 message send [-m ] [--subject S] [--in-reply-to F] [--tags T,T]` *(no `--priority` yet)* - `st2 message reply -m [--subject S]` -- `st2 message ls [] [--archive] [--count | --json] [--from ID]` +- `st2 message ls [] [--archive] [--count | --json [--include-body]] [--from ID]` +- `st2 message delivery []` (read-only bounded body-bearing FIFO view) - `st2 message read [] [--raw | --json] [--archive]` - `st2 message archive [] ` - `st2 message thread [] [--tree]` diff --git a/tests/claude_hooks.rs b/tests/claude_hooks.rs index 255093b7..81360a64 100644 --- a/tests/claude_hooks.rs +++ b/tests/claude_hooks.rs @@ -10,7 +10,7 @@ use std::os::unix::fs::symlink; use std::path::{Path, PathBuf}; use std::process::{Command, Output}; -use st2::context; +use st2::{context, message}; fn bash() -> PathBuf { std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default()) @@ -30,6 +30,7 @@ struct Fixture { _tmp: tempfile::TempDir, catalog: PathBuf, context: PathBuf, + inbox: PathBuf, bin: PathBuf, state: PathBuf, } @@ -40,7 +41,9 @@ impl Fixture { let catalog = tmp.path().join("catalog"); let agent = catalog.join("agents/Silber/cos"); let context = context::context_dir(&agent); + let inbox = message::inbox_dir(&agent); fs::create_dir_all(&context).unwrap(); + fs::create_dir_all(&inbox).unwrap(); fs::write( agent.join("agent.kdl"), r#"agent "cos" { @@ -61,6 +64,7 @@ impl Fixture { _tmp: tmp, catalog, context, + inbox, bin, state, } @@ -103,6 +107,15 @@ fn session_start_delivers_context_on_the_supported_channel_and_never_on_stderr() } let fixture = Fixture::new(); context::write_now(&fixture.context, "rehydration canary PELICAN-7742\n").unwrap(); + let filename = message::send_to_inbox( + &fixture.inbox, + "Silber.worker", + Some("body-bearing canary"), + None, + &[], + "complete inbox body ORIOLE-9921", + ) + .unwrap(); let output = fixture.run("claude-session-start.sh"); @@ -126,6 +139,9 @@ fn session_start_delivers_context_on_the_supported_channel_and_never_on_stderr() assert!(additional.contains(r#""#)); assert!(additional.contains("Run the st2 boot ritual")); assert!(additional.contains("set your status to busy")); + assert!(additional.contains("st2.inbox-delivery.v1")); + assert!(additional.contains(&filename)); + assert!(additional.contains("complete inbox body ORIOLE-9921")); } #[test] @@ -156,6 +172,40 @@ fn session_start_delivers_context_larger_than_the_platform_argument_limit() { ); } +#[test] +fn user_prompt_submit_attaches_unread_bodies_to_the_current_inference() { + if !jq_available() { + eprintln!("SKIP: jq is required by the shipped Claude hook"); + return; + } + let fixture = Fixture::new(); + let filename = message::send_to_inbox( + &fixture.inbox, + "Silber.worker", + Some("live DING"), + None, + &[], + "live body SWIFT-4182", + ) + .unwrap(); + + let output = fixture.run("claude-user-prompt-submit.sh"); + assert!(output.status.success()); + assert!(output.stderr.is_empty()); + let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!( + json["hookSpecificOutput"]["hookEventName"], + "UserPromptSubmit" + ); + let additional = json["hookSpecificOutput"]["additionalContext"] + .as_str() + .unwrap(); + assert!(additional.contains("st2.inbox-delivery.v1")); + assert!(additional.contains(&filename)); + assert!(additional.contains("live body SWIFT-4182")); + assert!(fixture.inbox.join(filename).is_file()); +} + /// Missing durable state is an ordinary cold start: the ritual still has to reach the model, and the /// context envelope must be absent rather than empty. #[test] diff --git a/tests/message_cli.rs b/tests/message_cli.rs index bd4352ba..83e407f7 100644 --- a/tests/message_cli.rs +++ b/tests/message_cli.rs @@ -40,6 +40,30 @@ fn list(root: &Path, extra: &[&str]) -> std::process::Output { list_identity(root, "bob", extra) } +#[test] +fn delivery_is_a_read_only_bounded_body_view() { + let tmp = tempfile::tempdir().unwrap(); + let inbox = tmp.path().join("bob/inbox"); + write_message(&inbox, 1_700_000_000_000, "aaaaaa", "alice"); + + let out = Command::new(env!("CARGO_BIN_EXE_st2")) + .args(["message", "delivery", "bob", "--root"]) + .arg(tmp.path()) + .args(["--host", "h"]) + .output() + .unwrap(); + assert!( + out.status.success(), + "{}", + String::from_utf8_lossy(&out.stderr) + ); + let text = String::from_utf8(out.stdout).unwrap(); + assert!(text.contains("st2.inbox-delivery.v1")); + assert!(text.contains("1700000000000-aaaaaa.md")); + assert!(text.contains(r#""body":"body\n""#)); + assert!(inbox.join("1700000000000-aaaaaa.md").is_file()); +} + #[test] fn since_is_strict_and_composes_with_other_list_filters() { let tmp = tempfile::tempdir().unwrap(); diff --git a/tests/native_only.rs b/tests/native_only.rs index afa8217a..99fc6d0f 100644 --- a/tests/native_only.rs +++ b/tests/native_only.rs @@ -221,6 +221,7 @@ fn clean_path_executes_the_maintained_native_authoring_guide() { serde_json::from_slice::(&claude_settings).unwrap(), vec![ "claude-session-start.sh", + "claude-user-prompt-submit.sh", "claude-pre-compact.sh", "claude-stop-failure.sh", ],