diff --git a/crates/pod/src/controller.rs b/crates/pod/src/controller.rs index 450ac4f3..fbb09bf2 100644 --- a/crates/pod/src/controller.rs +++ b/crates/pod/src/controller.rs @@ -7,6 +7,8 @@ use llm_worker::llm_client::client::LlmClient; use manifest::TicketFeatureAccessConfig; use pod_store::PodMetadataStore; use session_store::Store; +use ticket::LocalTicketBackend; +use ticket::config::TicketConfig; use tokio::sync::{broadcast, mpsc, oneshot}; use crate::discovery::{PodDiscovery, list_pods_tool, restore_pod_tool, send_to_peer_pod_tool}; @@ -25,6 +27,9 @@ use crate::shutdown_after_idle::{ use crate::spawn::comm_tools::{read_pod_output_tool, send_to_pod_tool, stop_pod_tool}; use crate::spawn::registry::SpawnedPodRegistry; use crate::spawn::tool::spawn_pod_tool; +use crate::ticket_event_notify::{ + TicketEventCompanionNotifyHook, companion_pod_name_for_workspace, +}; use protocol::{ AlertLevel, AlertSource, ErrorCode, Event, Method, PodStatus, RewindTargetId, RunResult, Segment, TurnResult, @@ -230,6 +235,12 @@ impl PodController { spawned_registry.clone(), )?; + install_ticket_event_companion_notify_hook( + &mut pod, + runtime_base.to_path_buf(), + spawned_registry.clone(), + ); + // Intake role Pods self-terminate only after a successful // TicketIntakeReady turn has fully settled back to Idle. The request // is transient controller state, not model-visible context or ticket @@ -494,6 +505,59 @@ fn wire_event_bridges_on_worker( // per-item commit channel is wired at the top of this function. } +fn install_ticket_event_companion_notify_hook( + pod: &mut Pod, + runtime_base: PathBuf, + spawned_registry: Arc, +) where + C: LlmClient + Clone + 'static, + St: Store + PodMetadataStore + Clone + Send + Sync + 'static, +{ + if !is_ticket_orchestrator_role(pod.runtime_ticket_role()) { + return; + } + + let ticket_feature = &pod.manifest().feature.ticket; + if !ticket_feature.enabled + || !matches!(ticket_feature.access, TicketFeatureAccessConfig::Lifecycle) + { + return; + } + + let Some(companion_pod_name) = companion_pod_name_for_workspace(pod.workspace_root()) else { + return; + }; + if companion_pod_name == pod.manifest().pod.name { + return; + } + + let Ok(ticket_config) = TicketConfig::load_workspace(pod.cwd()) else { + return; + }; + let backend_root = ticket_config.backend_root().to_path_buf(); + if !backend_root.is_dir() { + return; + } + + let discovery = PodDiscovery::new( + pod.pod_metadata_store(), + pod.manifest().pod.name.clone(), + runtime_base, + pod.cwd().to_path_buf(), + spawned_registry, + ); + pod.add_post_tool_call_hook(TicketEventCompanionNotifyHook::new( + LocalTicketBackend::new(backend_root), + discovery, + companion_pod_name, + )); +} + +fn is_ticket_orchestrator_role(role: Option<&str>) -> bool { + role.map(|role| role.eq_ignore_ascii_case("orchestrator")) + .unwrap_or(false) +} + /// Register the builtin file-manipulation tools, optional memory tools, /// and the Pod-orchestration tools (SpawnPod + comm) on the Pod's /// Worker. Returns the `ScopedFs` clone used to attach a `PodFsView` to diff --git a/crates/pod/src/discovery.rs b/crates/pod/src/discovery.rs index c5a8500a..9ca50323 100644 --- a/crates/pod/src/discovery.rs +++ b/crates/pod/src/discovery.rs @@ -354,6 +354,18 @@ where } } + pub async fn send_weak_notify_to_live_peer(&self, peer_name: &str, message: String) -> bool { + let Ok(detail) = self.inspect(peer_name).await else { + return false; + }; + if detail.visibility != VisibilityReason::Peer || !detail.live.reachable { + return false; + } + send_notify(&detail.live.socket_path, message, false) + .await + .is_ok() + } + async fn live_for_name(&self, pod_name: &str, socket_override: Option<&Path>) -> LiveInfo { let socket_path = socket_override .map(Path::to_path_buf) @@ -913,14 +925,11 @@ where } async fn send_peer_notify(socket_path: &Path, message: String) -> io::Result<()> { - connect_and_send( - socket_path, - &Method::Notify { - message, - auto_run: true, - }, - ) - .await + send_notify(socket_path, message, true).await +} + +async fn send_notify(socket_path: &Path, message: String, auto_run: bool) -> io::Result<()> { + connect_and_send(socket_path, &Method::Notify { message, auto_run }).await } fn json_content(value: &T) -> Result { @@ -1421,6 +1430,150 @@ mod tests { target.await.unwrap(); } + #[tokio::test(flavor = "current_thread")] + async fn weak_notify_to_live_peer_uses_notify_without_auto_run_and_noops_when_missing() { + let root = TempDir::new().unwrap(); + let store_dir = root.path().join("store"); + let runtime_base = root.path().join("runtime"); + std::fs::create_dir_all(runtime_base.join("target")).unwrap(); + let store = FsPodStore::new(&store_dir).unwrap(); + store + .write(&PodMetadata { + pod_name: "source".into(), + active: None, + spawned_children: Vec::new(), + reclaimed_children: Vec::new(), + peers: vec![pod_store::PodPeer { + pod_name: "target".into(), + }], + resolved_manifest_snapshot: None, + }) + .unwrap(); + store + .write(&PodMetadata { + pod_name: "target".into(), + active: None, + spawned_children: Vec::new(), + reclaimed_children: Vec::new(), + peers: vec![pod_store::PodPeer { + pod_name: "source".into(), + }], + resolved_manifest_snapshot: None, + }) + .unwrap(); + let runtime_dir = Arc::new(RuntimeDir::create(&runtime_base, "source").await.unwrap()); + let discovery = PodDiscovery::new( + store, + "source".into(), + runtime_base.clone(), + root.path().to_path_buf(), + SpawnedPodRegistry::new(runtime_dir), + ); + + let socket = runtime_base.join("target").join("sock"); + let listener = UnixListener::bind(&socket).unwrap(); + let (tx, mut rx) = tokio::sync::mpsc::channel(1); + let target = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut writer = JsonLineWriter::new(stream); + writer + .write(&Event::Snapshot { + entries: Vec::new(), + greeting: protocol::Greeting { + pod_name: "target".into(), + cwd: "/tmp".into(), + provider: "test".into(), + model: "test".into(), + scope_summary: String::new(), + tools: Vec::new(), + context_window: 0, + context_tokens: 0, + }, + status: PodStatus::Idle, + }) + .await + .unwrap(); + + let (stream, _) = listener.accept().await.unwrap(); + let (reader_half, writer_half) = stream.into_split(); + let mut reader = JsonLineReader::new(reader_half); + let mut writer = JsonLineWriter::new(writer_half); + writer + .write(&Event::Snapshot { + entries: Vec::new(), + greeting: protocol::Greeting { + pod_name: "target".into(), + cwd: "/tmp".into(), + provider: "test".into(), + model: "test".into(), + scope_summary: String::new(), + tools: Vec::new(), + context_window: 0, + context_tokens: 0, + }, + status: PodStatus::Idle, + }) + .await + .unwrap(); + let method = reader.next::().await.unwrap().unwrap(); + if let Method::Notify { message, auto_run } = method { + assert!(!auto_run); + tx.send(message).await.unwrap(); + } else { + panic!("expected Notify, got {method:?}"); + } + }); + + assert!( + discovery + .send_weak_notify_to_live_peer("target", "weak event".into()) + .await + ); + assert_eq!(rx.recv().await.unwrap(), "weak event"); + target.await.unwrap(); + + assert!( + !discovery + .send_weak_notify_to_live_peer("missing", "no-op".into()) + .await + ); + } + + #[tokio::test(flavor = "current_thread")] + async fn weak_notify_does_not_send_to_spawned_child_visibility() { + let root = TempDir::new().unwrap(); + let store_dir = root.path().join("store"); + let runtime_base = root.path().join("runtime"); + std::fs::create_dir_all(runtime_base.join("target")).unwrap(); + let store = FsPodStore::new(&store_dir).unwrap(); + let socket = runtime_base.join("target").join("sock"); + store + .write(&PodMetadata { + pod_name: "source".into(), + active: None, + spawned_children: vec![child("target", &socket)], + reclaimed_children: Vec::new(), + peers: Vec::new(), + resolved_manifest_snapshot: None, + }) + .unwrap(); + store.write(&PodMetadata::new("target", None)).unwrap(); + let runtime_dir = Arc::new(RuntimeDir::create(&runtime_base, "source").await.unwrap()); + let discovery = PodDiscovery::new( + store, + "source".into(), + runtime_base, + root.path().to_path_buf(), + SpawnedPodRegistry::new(runtime_dir), + ); + + assert!( + !discovery + .send_weak_notify_to_live_peer("target", "must not send".into()) + .await + ); + } + #[tokio::test(flavor = "current_thread")] async fn probe_socket_reads_status_after_replayed_alert() { let root = TempDir::new().unwrap(); diff --git a/crates/pod/src/lib.rs b/crates/pod/src/lib.rs index 647ebc94..efc44e94 100644 --- a/crates/pod/src/lib.rs +++ b/crates/pod/src/lib.rs @@ -17,6 +17,7 @@ pub mod workflow; mod interrupt_prep; mod permission; mod pod; +mod ticket_event_notify; pub use compact::token_counter::{EstimateSource, SplitPoint, TokenEstimate}; pub use controller::{PodController, PodHandle, ShutdownReceiver}; diff --git a/crates/pod/src/pod.rs b/crates/pod/src/pod.rs index c0809d65..d8a33863 100644 --- a/crates/pod/src/pod.rs +++ b/crates/pod/src/pod.rs @@ -728,6 +728,13 @@ impl Pod { &self.workspace_root } + pub(crate) fn pod_metadata_store(&self) -> St + where + St: Clone, + { + self.store.clone() + } + /// The Pod's directory scope, as a shared atomically-swappable /// handle. Clone it to share scope state with another consumer /// (e.g. a tool that needs to mutate scope dynamically). diff --git a/crates/pod/src/prompt/catalog.rs b/crates/pod/src/prompt/catalog.rs index 266760b8..1989c296 100644 --- a/crates/pod/src/prompt/catalog.rs +++ b/crates/pod/src/prompt/catalog.rs @@ -95,6 +95,8 @@ pub enum PodPrompt { /// Trailing Pod orchestration guidance, appended when registered tools /// include Pod-management capabilities. PodOrchestrationGuidanceSection, + /// Weak Companion Notify payload for explicit Orchestrator Ticket events. + TicketEventCompanionNotice, /// LLM-facing description for the SpawnPod tool, including discovered /// profile selectors. SpawnPodToolDescription, @@ -115,6 +117,7 @@ impl PodPrompt { Self::ResidentKnowledgeSection => "resident_knowledge_section", Self::ResidentWorkflowsSection => "resident_workflows_section", Self::PodOrchestrationGuidanceSection => "pod_orchestration_guidance_section", + Self::TicketEventCompanionNotice => "ticket_event_companion_notice", Self::SpawnPodToolDescription => "spawn_pod_tool_description", } } @@ -135,6 +138,7 @@ impl PodPrompt { PodPrompt::ResidentKnowledgeSection, PodPrompt::ResidentWorkflowsSection, PodPrompt::PodOrchestrationGuidanceSection, + PodPrompt::TicketEventCompanionNotice, PodPrompt::SpawnPodToolDescription, ]; @@ -151,6 +155,7 @@ impl PodPrompt { "resident_knowledge_section", "resident_workflows_section", "pod_orchestration_guidance_section", + "ticket_event_companion_notice", "spawn_pod_tool_description", ]; } diff --git a/crates/pod/src/ticket_event_notify.rs b/crates/pod/src/ticket_event_notify.rs new file mode 100644 index 00000000..aa9d1e96 --- /dev/null +++ b/crates/pod/src/ticket_event_notify.rs @@ -0,0 +1,453 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use minijinja::Value as TemplateValue; +use serde_json::Value; +use std::collections::BTreeMap; +use ticket::{LocalTicketBackend, TicketBackend, TicketIdOrSlug}; +use tracing::debug; + +use crate::discovery::PodDiscovery; +use crate::hook::{Hook, HookPostToolAction, PostToolCall, ToolResultSummary}; +use crate::prompt::catalog::{PodPrompt, PromptCatalog}; +use pod_store::PodMetadataStore; + +const MAX_TITLE_CHARS: usize = 96; +const MAX_SUMMARY_CHARS: usize = 160; +const MAX_EVENT_KIND_CHARS: usize = 80; +const MAX_MESSAGE_CHARS: usize = 768; + +#[derive(Clone)] +pub(crate) struct TicketEventCompanionNotifyHook< + St: PodMetadataStore + Clone + Send + Sync + 'static, +> { + backend: Arc, + discovery: PodDiscovery, + companion_pod_name: String, +} + +impl TicketEventCompanionNotifyHook { + pub(crate) fn new( + backend: LocalTicketBackend, + discovery: PodDiscovery, + companion_pod_name: impl Into, + ) -> Self { + Self { + backend: Arc::new(backend), + discovery, + companion_pod_name: companion_pod_name.into(), + } + } +} + +#[async_trait] +impl Hook + for TicketEventCompanionNotifyHook +{ + async fn call(&self, summary: &ToolResultSummary) -> HookPostToolAction { + let Some(notice) = build_ticket_event_notice(&self.backend, summary) else { + return HookPostToolAction::Continue; + }; + let delivered = self + .discovery + .send_weak_notify_to_live_peer(&self.companion_pod_name, notice.message) + .await; + if delivered { + debug!( + ticket = %notice.ticket_id, + event_kind = %notice.event_kind, + companion = %self.companion_pod_name, + "delivered weak Ticket event notification to Companion peer" + ); + } + HookPostToolAction::Continue + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct TicketEventNotice { + ticket_id: String, + event_kind: String, + message: String, +} + +fn build_ticket_event_notice( + backend: &LocalTicketBackend, + summary: &ToolResultSummary, +) -> Option { + if summary.is_error { + return None; + } + let output = &summary.output; + let content = output.content.as_deref()?; + let content: Value = serde_json::from_str(content).ok()?; + if !content.get("ok").and_then(Value::as_bool).unwrap_or(false) { + return None; + } + + let event_kind = explicit_ticket_event_kind(summary.tool_name.as_str(), &content)?; + let ticket_query = content.get("ticket").and_then(Value::as_str)?; + let ticket = backend + .show(TicketIdOrSlug::Query(ticket_query.to_string())) + .ok()?; + + let event_kind = sanitize_one_line(&event_kind, MAX_EVENT_KIND_CHARS); + let ticket_id = ticket.meta.id.as_str(); + let title = sanitize_one_line(&ticket.meta.title, MAX_TITLE_CHARS); + let state = ticket.meta.workflow_state.as_str(); + let output_summary = sanitize_one_line(&output.summary, MAX_SUMMARY_CHARS); + let ref_path = event_ref_path(ticket_id, summary.tool_name.as_str()); + let message = render_ticket_event_notice_message(TicketEventNoticeValues { + ticket_id, + title: &title, + state, + event_kind: &event_kind, + summary: &output_summary, + ref_path: &ref_path, + })?; + + Some(TicketEventNotice { + ticket_id: ticket_id.to_string(), + event_kind, + message: bound_chars(&message, MAX_MESSAGE_CHARS), + }) +} + +struct TicketEventNoticeValues<'a> { + ticket_id: &'a str, + title: &'a str, + state: &'a str, + event_kind: &'a str, + summary: &'a str, + ref_path: &'a str, +} + +fn render_ticket_event_notice_message(values: TicketEventNoticeValues<'_>) -> Option { + PromptCatalog::builtins_only() + .ok()? + .render(PodPrompt::TicketEventCompanionNotice, values.to_template()) + .ok() +} + +impl TicketEventNoticeValues<'_> { + fn to_template(&self) -> TemplateValue { + let mut values: BTreeMap<&'static str, TemplateValue> = BTreeMap::new(); + values.insert("ticket_id", TemplateValue::from(self.ticket_id)); + values.insert("title", TemplateValue::from(self.title)); + values.insert("state", TemplateValue::from(self.state)); + values.insert("event_kind", TemplateValue::from(self.event_kind)); + values.insert("summary", TemplateValue::from(self.summary)); + values.insert("ref_path", TemplateValue::from(self.ref_path)); + TemplateValue::from(values) + } +} + +fn explicit_ticket_event_kind(tool_name: &str, content: &Value) -> Option { + match tool_name { + "TicketComment" => content + .get("event") + .and_then(Value::as_str) + .map(|event| format!("comment/{event}")), + "TicketReview" => content + .get("review") + .and_then(Value::as_str) + .map(|review| format!("review/{review}")), + "TicketWorkflowState" => { + let from = content.get("from").and_then(Value::as_str).unwrap_or("?"); + let to = content.get("to").and_then(Value::as_str).unwrap_or("?"); + Some(format!("state/{from}->{to}")) + } + "TicketIntakeReady" => Some("state/planning->ready".to_string()), + "TicketClose" => Some("close/resolution".to_string()), + _ => None, + } +} + +fn event_ref_path(ticket_id: &str, tool_name: &str) -> String { + let leaf = match tool_name { + "TicketClose" => "resolution.md", + "TicketIntakeReady" | "TicketWorkflowState" => "item.md", + _ => "thread.md", + }; + format!(".yoi/tickets/{ticket_id}/{leaf}") +} + +fn sanitize_one_line(input: &str, limit: usize) -> String { + let collapsed = input.split_whitespace().collect::>().join(" "); + bound_chars(&collapsed, limit) +} + +fn bound_chars(input: &str, limit: usize) -> String { + let mut out = String::new(); + for (idx, ch) in input.chars().filter(|ch| !ch.is_control()).enumerate() { + if idx >= limit { + out.push('…'); + break; + } + out.push(ch); + } + out +} + +pub(crate) fn companion_pod_name_for_workspace(workspace_root: &std::path::Path) -> Option { + workspace_root + .file_name() + .and_then(|name| name.to_str()) + .map(str::trim) + .filter(|name| !name.is_empty()) + .map(ToOwned::to_owned) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::PodStatus; + use crate::runtime::dir::RuntimeDir; + use crate::spawn::registry::SpawnedPodRegistry; + use llm_worker::tool::ToolOutput; + use pod_store::FsPodStore; + use pod_store::PodMetadata; + use protocol::stream::{JsonLineReader, JsonLineWriter}; + use protocol::{Event, Method}; + use serde_json::json; + use std::sync::Arc; + use tempfile::tempdir; + use ticket::NewTicket; + use tokio::net::UnixListener; + + fn create_backend_with_ticket(title: &str) -> (tempfile::TempDir, LocalTicketBackend, String) { + let dir = tempdir().expect("tempdir"); + let backend = LocalTicketBackend::new(dir.path().to_path_buf()); + let mut input = NewTicket::new(title); + input.body = ticket::MarkdownText::new("body"); + let ticket = backend.create(input).expect("create ticket"); + (dir, backend, ticket.id) + } + + fn tool_summary(tool_name: &str, output: ToolOutput) -> ToolResultSummary { + ToolResultSummary { + call_id: "test-call".to_string(), + tool_name: tool_name.to_string(), + output, + is_error: false, + } + } + + #[test] + fn builds_bounded_event_scoped_notice_for_ticket_state_change() { + let (_dir, backend, ticket_id) = create_backend_with_ticket( + "A very long title that should be bounded but still identify the ticket precisely enough for Companion", + ); + let output = ToolOutput { + summary: "Changed ticket state from queued to inprogress with a deliberately long summary that should be bounded before entering the weak notification payload and should not contain large logs".into(), + content: Some( + json!({ + "ok": true, + "ticket": ticket_id, + "from": "queued", + "to": "inprogress", + }) + .to_string(), + ), + }; + + let notice = + build_ticket_event_notice(&backend, &tool_summary("TicketWorkflowState", output)) + .expect("notice"); + + assert_eq!(notice.ticket_id, ticket_id); + assert_eq!(notice.event_kind, "state/queued->inprogress"); + assert!(notice.message.contains("auto_run=false")); + assert!(notice.message.contains("event: state/queued->inprogress")); + assert!(notice.message.contains("ref: .yoi/tickets/")); + assert!(notice.message.chars().count() <= MAX_MESSAGE_CHARS + 1); + + let expected = PromptCatalog::builtins_only() + .expect("load prompt catalog") + .render( + PodPrompt::TicketEventCompanionNotice, + TicketEventNoticeValues { + ticket_id: ¬ice.ticket_id, + title: &sanitize_one_line( + "A very long title that should be bounded but still identify the ticket precisely enough for Companion", + MAX_TITLE_CHARS, + ), + state: "planning", + event_kind: "state/queued->inprogress", + summary: &sanitize_one_line( + "Changed ticket state from queued to inprogress with a deliberately long summary that should be bounded before entering the weak notification payload and should not contain large logs", + MAX_SUMMARY_CHARS, + ), + ref_path: &format!(".yoi/tickets/{}/item.md", ticket_id), + } + .to_template(), + ) + .expect("render prompt resource"); + assert_eq!(notice.message, bound_chars(&expected, MAX_MESSAGE_CHARS)); + } + + #[test] + fn ignores_passive_or_non_event_ticket_tools() { + let (_dir, backend, ticket_id) = create_backend_with_ticket("Passive list test"); + let output = ToolOutput { + summary: "Listed tickets".into(), + content: Some(json!({"ok": true, "ticket": ticket_id}).to_string()), + }; + + assert!(build_ticket_event_notice(&backend, &tool_summary("TicketList", output)).is_none()); + } + + #[test] + fn notice_does_not_include_tool_content_body_or_error_details() { + let (_dir, backend, ticket_id) = create_backend_with_ticket("Safe payload"); + let output = ToolOutput { + summary: "Appended implementation_report to ticket".into(), + content: Some( + json!({ + "ok": true, + "ticket": ticket_id, + "event": "implementation_report", + "body": "SECRET_TOKEN provider stack trace long diagnostic should not be copied", + "error": "provider error details should not be copied" + }) + .to_string(), + ), + }; + + let notice = build_ticket_event_notice(&backend, &tool_summary("TicketComment", output)) + .expect("notice"); + + assert!( + notice + .message + .contains("event: comment/implementation_report") + ); + assert!(!notice.message.contains("SECRET_TOKEN")); + assert!(!notice.message.contains("provider error details")); + } + + #[tokio::test(flavor = "current_thread")] + async fn ticket_event_hook_delivers_weak_companion_notification() { + let root = tempdir().expect("tempdir"); + let runtime_base = root.path().join("runtime"); + let store_dir = root.path().join("store"); + std::fs::create_dir_all(runtime_base.join("companion")).unwrap(); + let store = FsPodStore::new(&store_dir).unwrap(); + store + .write(&PodMetadata { + pod_name: "orchestrator".into(), + active: None, + spawned_children: Vec::new(), + reclaimed_children: Vec::new(), + peers: vec![pod_store::PodPeer { + pod_name: "companion".into(), + }], + resolved_manifest_snapshot: None, + }) + .unwrap(); + store + .write(&PodMetadata { + pod_name: "companion".into(), + active: None, + spawned_children: Vec::new(), + reclaimed_children: Vec::new(), + peers: vec![pod_store::PodPeer { + pod_name: "orchestrator".into(), + }], + resolved_manifest_snapshot: None, + }) + .unwrap(); + let (_ticket_dir, backend, ticket_id) = create_backend_with_ticket("Companion event hook"); + let runtime_dir = Arc::new( + RuntimeDir::create(&runtime_base, "orchestrator") + .await + .unwrap(), + ); + let hook = TicketEventCompanionNotifyHook::new( + backend, + PodDiscovery::new( + store, + "orchestrator".into(), + runtime_base.clone(), + root.path().to_path_buf(), + SpawnedPodRegistry::new(runtime_dir), + ), + "companion", + ); + + let socket = runtime_base.join("companion").join("sock"); + let listener = UnixListener::bind(&socket).unwrap(); + let (tx, mut rx) = tokio::sync::mpsc::channel(1); + let companion = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut writer = JsonLineWriter::new(stream); + writer + .write(&Event::Snapshot { + entries: Vec::new(), + greeting: protocol::Greeting { + pod_name: "companion".into(), + cwd: "/tmp".into(), + provider: "test".into(), + model: "test".into(), + scope_summary: String::new(), + tools: Vec::new(), + context_window: 0, + context_tokens: 0, + }, + status: PodStatus::Idle, + }) + .await + .unwrap(); + + let (stream, _) = listener.accept().await.unwrap(); + let (reader_half, writer_half) = stream.into_split(); + let mut reader = JsonLineReader::new(reader_half); + let mut writer = JsonLineWriter::new(writer_half); + writer + .write(&Event::Snapshot { + entries: Vec::new(), + greeting: protocol::Greeting { + pod_name: "companion".into(), + cwd: "/tmp".into(), + provider: "test".into(), + model: "test".into(), + scope_summary: String::new(), + tools: Vec::new(), + context_window: 0, + context_tokens: 0, + }, + status: PodStatus::Idle, + }) + .await + .unwrap(); + let method = reader.next::().await.unwrap().unwrap(); + if let Method::Notify { message, auto_run } = method { + assert!(!auto_run); + tx.send(message).await.unwrap(); + } else { + panic!("expected Notify, got {method:?}"); + } + }); + + let output = ToolOutput { + summary: "Changed ticket state from queued to inprogress".into(), + content: Some( + json!({ + "ok": true, + "ticket": ticket_id, + "from": "queued", + "to": "inprogress", + }) + .to_string(), + ), + }; + let action = hook + .call(&tool_summary("TicketWorkflowState", output)) + .await; + assert_eq!(action, HookPostToolAction::Continue); + let message = rx.recv().await.unwrap(); + assert!(message.contains("event: state/queued->inprogress")); + assert!(message.contains("title: Companion event hook")); + companion.await.unwrap(); + } +} diff --git a/resources/prompts/internal.toml b/resources/prompts/internal.toml index 466d9587..05837bfc 100644 --- a/resources/prompts/internal.toml +++ b/resources/prompts/internal.toml @@ -68,6 +68,8 @@ The following workflows are advertised resident. When a user request matches one pod_orchestration_guidance_section = "{% include \"$yoi/common/pod-orchestration\" %}" +ticket_event_companion_notice = "{% include \"$yoi/pod/ticket_event_companion_notice\" %}" + spawn_pod_tool_description = """\ Spawn a new Pod process to work on a delegated task. The spawner's write scope is reduced by the scope passed here; the spawned Pod receives its own socket and starts running `task` immediately. The spawned Pod outlives the spawner's current turn and can be contacted again through its socket path. diff --git a/resources/prompts/pod/ticket_event_companion_notice.md b/resources/prompts/pod/ticket_event_companion_notice.md new file mode 100644 index 00000000..1551bdcb --- /dev/null +++ b/resources/prompts/pod/ticket_event_companion_notice.md @@ -0,0 +1,7 @@ +Ticket event notice (weak; auto_run=false) +ticket: {{ ticket_id }} +title: {{ title }} +state: {{ state }} +event: {{ event_kind }} +summary: {{ summary }} +ref: {{ ref_path }}